@skyramp/mcp 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/playwright/registerPlaywrightTools.js +42 -1
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +19 -0
- package/build/prompts/test-maintenance/actionsInstructions.js +2 -2
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -4
- package/build/prompts/test-recommendation/recommendationSections.d.ts +1 -1
- package/build/prompts/test-recommendation/recommendationSections.js +5 -5
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +13 -7
- package/build/prompts/testbot/testbot-prompts.js +16 -14
- package/build/recommendation/discriminators.d.ts +7 -1
- package/build/recommendation/discriminators.js +16 -3
- package/build/resources/testbotResource.js +0 -1
- package/build/services/ScenarioGenerationService.js +5 -2
- package/build/services/TestExecutionService.js +25 -1
- package/build/services/TestGenerationService.js +24 -9
- package/build/services/containerEnv.d.ts +12 -1
- package/build/services/containerEnv.js +94 -1
- package/build/tools/executeSkyrampTestTool.d.ts +9 -0
- package/build/tools/executeSkyrampTestTool.js +20 -6
- package/build/tools/execution-video-state.d.ts +21 -0
- package/build/tools/execution-video-state.js +51 -0
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +31 -11
- package/build/tools/generate-tests/planGuard.d.ts +5 -5
- package/build/tools/generate-tests/planGuard.js +5 -17
- package/build/tools/submitReportTool.d.ts +83 -10
- package/build/tools/submitReportTool.js +257 -30
- package/build/tools/test-management/actionsTool.js +40 -39
- package/build/tools/test-management/analyzeChangesTool.d.ts +11 -0
- package/build/tools/test-management/analyzeChangesTool.js +41 -35
- package/build/tools/test-management/analyzeTestHealthTool.js +3 -3
- package/build/tools/test-management/registerTestPlanTool.js +191 -41
- package/build/types/TestExecution.d.ts +14 -0
- package/build/types/TestTypes.js +3 -2
- package/build/types/TestbotPromptOptions.d.ts +0 -1
- package/build/types/TestbotReport.d.ts +24 -1
- package/build/types/TestbotReport.js +10 -1
- package/build/types/index.d.ts +1 -0
- package/build/types/index.js +1 -0
- package/build/utils/AnalysisStateManager.d.ts +48 -2
- package/build/utils/AnalysisStateManager.js +49 -13
- package/build/utils/reportLanguage.d.ts +43 -0
- package/build/utils/reportLanguage.js +125 -0
- package/build/utils/reportVerification.d.ts +10 -0
- package/build/utils/reportVerification.js +31 -0
- package/build/utils/scenarioDrafting.js +7 -1
- package/build/utils/skyrampMdContent.d.ts +1 -1
- package/build/utils/skyrampMdContent.js +1 -1
- package/build/utils/urlPath.d.ts +37 -0
- package/build/utils/urlPath.js +55 -0
- package/build/utils/utils.d.ts +45 -0
- package/build/utils/utils.js +50 -0
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/utils/workspaceAuth.d.ts +15 -15
- package/build/utils/workspaceAuth.js +32 -17
- package/build/workspace/queryParamResolution.d.ts +93 -0
- package/build/workspace/queryParamResolution.js +201 -0
- package/build/workspace/workspace.d.ts +104 -0
- package/build/workspace/workspace.js +24 -0
- package/package.json +3 -2
|
@@ -6,10 +6,11 @@ import * as path from "path";
|
|
|
6
6
|
import yaml from "js-yaml";
|
|
7
7
|
import { simpleGit } from "simple-git";
|
|
8
8
|
import { logger } from "../../utils/logger.js";
|
|
9
|
+
import { dualChannelResult } from "../../utils/utils.js";
|
|
9
10
|
import { parseWorkspaceAuthType, getDefaultAuthHeader, WorkspaceAuthType, readWorkspaceConfigRaw } from "../../utils/workspaceAuth.js";
|
|
10
11
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
11
12
|
import { makeProgressReporter } from "../../utils/progress.js";
|
|
12
|
-
import { StateManager, registerSession, storeSessionData,
|
|
13
|
+
import { StateManager, registerSession, storeSessionData, rememberTestsRepoDir, getActiveRunStatePath, setActiveRunStatePath, } from "../../utils/AnalysisStateManager.js";
|
|
13
14
|
import { buildRecommendationPrompt, computeScoredCandidates } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
|
|
14
15
|
import { hasFlutterSdkDep, isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
|
|
15
16
|
import { buildExternalCoverageSet } from "../../prompts/test-recommendation/recommendationShared.js";
|
|
@@ -367,6 +368,14 @@ These components have no DOM presence in the running app: no route, page, or oth
|
|
|
367
368
|
- Log an \`issuesFound\` entry (high severity) naming the unintegrated file(s) above and stating that the changed code is unreachable in the running app.
|
|
368
369
|
- Backend/other work in this PR is unaffected — proceed with it normally.`;
|
|
369
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* Assemble the tool result. See `dualChannelResult` for why the payload is
|
|
373
|
+
* carried on both result channels.
|
|
374
|
+
*/
|
|
375
|
+
export function buildAnalyzeChangesResult(parts) {
|
|
376
|
+
const executionPlan = `\`\`\`json\n${parts.structuredSummary}\n\`\`\`\n\n## UI Blueprint Capture — do this BEFORE writing UI recommendation reasoning\n${parts.uiInstructions}\n\n${parts.outputText}\n\n---\n\n## Pre-built Test Catalog — Fill in placeholders from source code, then display verbatim\n⚠️ Do NOT reformat, rename sections, or generate a new catalog. Replace \`<…from source>\` values, then show this output exactly as-is, grouped by test type.\n\n${parts.recommendationPrompt}`;
|
|
377
|
+
return dualChannelResult({ executionPlan });
|
|
378
|
+
}
|
|
370
379
|
export function registerAnalyzeChangesTool(server) {
|
|
371
380
|
server.registerTool(TOOL_NAME, {
|
|
372
381
|
annotations: {
|
|
@@ -376,8 +385,14 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
376
385
|
openWorldHint: true, // may fetch PR comments from GitHub
|
|
377
386
|
},
|
|
378
387
|
description: `Scan repository API endpoints and discover existing tests — first step of the unified Test Health Analysis Flow. Returns a stateFile path and ranked test recommendations. Pass stateFile to skyramp_analyze_test_health and skyramp_actions.`,
|
|
379
|
-
//
|
|
380
|
-
//
|
|
388
|
+
// SKYR-4182: the payload is returned via structuredContent, which requires a
|
|
389
|
+
// declared outputSchema. This also keeps the result out of the single-line
|
|
390
|
+
// `.json` spill shape that Read cannot paginate — see buildAnalyzeChangesResult.
|
|
391
|
+
outputSchema: {
|
|
392
|
+
executionPlan: z
|
|
393
|
+
.string()
|
|
394
|
+
.describe("Analysis summary, UI blueprint capture instructions, and the ranked Execution Plan. Read and follow this field."),
|
|
395
|
+
},
|
|
381
396
|
inputSchema: analyzeChangesInputSchema,
|
|
382
397
|
}, async (params, extra) => {
|
|
383
398
|
let errorResult;
|
|
@@ -419,12 +434,9 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
419
434
|
logger.info("All user-changed files are non-application — skipping analysis", {
|
|
420
435
|
changedFiles: filesToCheck,
|
|
421
436
|
});
|
|
422
|
-
return {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
text: `All ${filesToCheck.length} changed file(s) are non-application (CI/CD, docs, lock files, config). No test analysis needed for this diff.\n\nChanged files: ${filesToCheck.join(", ")}`,
|
|
426
|
-
}],
|
|
427
|
-
};
|
|
437
|
+
return dualChannelResult({
|
|
438
|
+
executionPlan: `All ${filesToCheck.length} changed file(s) are non-application (CI/CD, docs, lock files, config). No test analysis needed for this diff.\n\nChanged files: ${filesToCheck.join(", ")}`,
|
|
439
|
+
});
|
|
428
440
|
}
|
|
429
441
|
}
|
|
430
442
|
// ── Step 2: Scan endpoints ──
|
|
@@ -569,22 +581,17 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
569
581
|
logger.info("GraphQL-only diff detected — REST testing not supported", {
|
|
570
582
|
changedFiles: diffData.changedFiles,
|
|
571
583
|
});
|
|
572
|
-
return {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
"",
|
|
584
|
-
`Changed files: ${diffData.changedFiles.join(", ")}`,
|
|
585
|
-
].join("\n"),
|
|
586
|
-
}],
|
|
587
|
-
};
|
|
584
|
+
return dualChannelResult({ executionPlan: [
|
|
585
|
+
"**GraphQL-only diff detected.**",
|
|
586
|
+
"",
|
|
587
|
+
"The changed files appear to be GraphQL schema, artifact, or endpoint implementation files.",
|
|
588
|
+
"Skyramp currently supports REST API testing only — GraphQL introspection,",
|
|
589
|
+
"query validation, and type-name grounding are not yet supported.",
|
|
590
|
+
"",
|
|
591
|
+
"No test recommendations can be generated for this diff.",
|
|
592
|
+
"",
|
|
593
|
+
`Changed files: ${diffData.changedFiles.join(", ")}`,
|
|
594
|
+
].join("\n") });
|
|
588
595
|
}
|
|
589
596
|
}
|
|
590
597
|
await sendProgress(50, 100, "Discovering existing tests...");
|
|
@@ -729,7 +736,9 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
729
736
|
let discoveredRelevantExternalPaths = [];
|
|
730
737
|
try {
|
|
731
738
|
const testDiscoveryService = new TestDiscoveryService();
|
|
732
|
-
|
|
739
|
+
// Run-scoped: only the primary call carries testsRepoDir, so never
|
|
740
|
+
// let a related repo's call clear it (SKYR-4204).
|
|
741
|
+
rememberTestsRepoDir(params.testsRepoDir);
|
|
733
742
|
const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath, { changedResources, changedSymbols, preciseResources: (changedSymbols?.length ?? 0) > 0, changedFrontendFiles, changedSelectors });
|
|
734
743
|
existingTests = discoveryResult.tests.map((test) => ({
|
|
735
744
|
testFile: test.testFile,
|
|
@@ -1601,15 +1610,12 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1601
1610
|
unmatchedFiles: classifiedEndpoints?.unmatchedFiles,
|
|
1602
1611
|
nextTool: "skyramp_analyze_test_health",
|
|
1603
1612
|
});
|
|
1604
|
-
return {
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
],
|
|
1611
|
-
isError: false,
|
|
1612
|
-
};
|
|
1613
|
+
return buildAnalyzeChangesResult({
|
|
1614
|
+
structuredSummary,
|
|
1615
|
+
uiInstructions,
|
|
1616
|
+
outputText,
|
|
1617
|
+
recommendationPrompt,
|
|
1618
|
+
});
|
|
1613
1619
|
}
|
|
1614
1620
|
catch (error) {
|
|
1615
1621
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -62,7 +62,7 @@ export function registerAnalyzeTestHealthTool(server) {
|
|
|
62
62
|
inputSchema: {
|
|
63
63
|
stateFile: z
|
|
64
64
|
.string()
|
|
65
|
-
.describe("Path to the analyze-changes-state.json file returned by skyramp_analyze_changes (the stateFile field in its output). Do NOT pass
|
|
65
|
+
.describe("Path to the analyze-changes-state.json file returned by skyramp_analyze_changes (the stateFile field in its output). Do NOT pass testbot-result.txt here."),
|
|
66
66
|
blueprintCaptured: z
|
|
67
67
|
.boolean()
|
|
68
68
|
.optional()
|
|
@@ -88,10 +88,10 @@ export function registerAnalyzeTestHealthTool(server) {
|
|
|
88
88
|
return toolError(`State file is empty or invalid: ${args.stateFile}${args.repository ? ` (no section for repository ${args.repository})` : ""}. Call skyramp_analyze_changes first to generate a valid state file.`);
|
|
89
89
|
}
|
|
90
90
|
if (!repositoryPath || typeof repositoryPath !== "string") {
|
|
91
|
-
return toolError(`repositoryPath not found in state file metadata. The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not
|
|
91
|
+
return toolError(`repositoryPath not found in state file metadata. The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not testbot-result.txt. Re-run skyramp_analyze_changes to regenerate it.`);
|
|
92
92
|
}
|
|
93
93
|
if (!Array.isArray(stateData.existingTests)) {
|
|
94
|
-
return toolError(`stateFile does not contain test discovery data (existingTests missing). The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not
|
|
94
|
+
return toolError(`stateFile does not contain test discovery data (existingTests missing). The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not testbot-result.txt.`);
|
|
95
95
|
}
|
|
96
96
|
// External tests: UPDATE only — REGENERATE/DELETE are report-only (enforced in
|
|
97
97
|
// skyramp_actions). Already scoped to relevant ones by discovery.
|
|
@@ -10,6 +10,8 @@ import { SCENARIO_CATEGORIES, CATEGORY_PRIORITY, Novelty, PriorityTier } from ".
|
|
|
10
10
|
import { HttpMethod, TestType } from "../../types/TestTypes.js";
|
|
11
11
|
import { CandidateSource, computeCandidateId, scenarioMergeKey, DiscriminatorKind } from "../../types/Recommendation.js";
|
|
12
12
|
import { selectPlan } from "../../recommendation/planRanker.js";
|
|
13
|
+
import { reservedUISlots } from "../../recommendation/budgeters/shared.js";
|
|
14
|
+
import { inferScenarioType } from "../../recommendation/diversity.js";
|
|
13
15
|
import { validateDiscriminator } from "../../recommendation/discriminators.js";
|
|
14
16
|
import { isAttackSurfaceSecurityBoundary } from "../../prompts/test-recommendation/recommendationShared.js";
|
|
15
17
|
import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
|
|
@@ -84,10 +86,12 @@ const registerTestPlanSchema = {
|
|
|
84
86
|
.string()
|
|
85
87
|
.refine((p) => path.isAbsolute(p), { message: "stateFile must be an absolute path" })
|
|
86
88
|
.describe("Path to state file from skyramp_analyze_changes"),
|
|
87
|
-
repository
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
// No `repository` argument, deliberately (SKYR-4204 reopen). The approved
|
|
90
|
+
// plan is run-wide — one pooled registration covers every repository in the
|
|
91
|
+
// run — so unlike the per-repo tools (analyze_test_health, actions,
|
|
92
|
+
// execute_test) there is no section to select: the tool reads the root and
|
|
93
|
+
// derives run-wide facts (diff union, budget flags, scenario lookup) itself.
|
|
94
|
+
// A legacy caller still passing `repository` has it stripped harmlessly.
|
|
91
95
|
candidates: z
|
|
92
96
|
.array(registerCandidateSchema)
|
|
93
97
|
.default([])
|
|
@@ -128,38 +132,57 @@ function toDraftedScenario(input) {
|
|
|
128
132
|
testType: input.testType,
|
|
129
133
|
};
|
|
130
134
|
}
|
|
131
|
-
/** Build the agent-submitted candidates
|
|
132
|
-
*
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
/** Build the agent-submitted candidates. Discriminator claims are verified
|
|
136
|
+
* later, by {@link applyDiscriminatorClaims}, once the merge has settled which
|
|
137
|
+
* scenario each name resolves to. */
|
|
138
|
+
function buildAgentCandidates(candidates) {
|
|
139
|
+
return candidates.map((input) => {
|
|
136
140
|
const scenario = toDraftedScenario(input);
|
|
137
|
-
|
|
138
|
-
let verifiedDiscriminator;
|
|
139
|
-
if (input.discriminator) {
|
|
140
|
-
const claim = input.discriminator;
|
|
141
|
-
const result = validateDiscriminator(scenario, claim, diffText);
|
|
142
|
-
if (result.verified) {
|
|
143
|
-
verifiedDiscriminator = claim.kind;
|
|
144
|
-
}
|
|
145
|
-
else {
|
|
146
|
-
demotions.push({
|
|
147
|
-
candidateId,
|
|
148
|
-
reason: result.reason ?? `${claim.kind} discriminator claim could not be verified.`,
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
const candidate = {
|
|
141
|
+
return {
|
|
153
142
|
scenario,
|
|
154
143
|
priority: derivePriorityTier(scenario),
|
|
155
144
|
novelty: Novelty.NEW,
|
|
156
145
|
source: CandidateSource.AGENT,
|
|
157
|
-
candidateId,
|
|
158
|
-
...(verifiedDiscriminator ? { verifiedDiscriminator } : {}),
|
|
146
|
+
candidateId: computeCandidateId(scenario),
|
|
159
147
|
};
|
|
160
|
-
return candidate;
|
|
161
148
|
});
|
|
162
|
-
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Verify each declared discriminator against the candidate that actually won
|
|
152
|
+
* the merge, and mark it. Failed claims demote (never reject).
|
|
153
|
+
*
|
|
154
|
+
* Runs AFTER the merge, not while building the agent's candidates: a server
|
|
155
|
+
* pre-seeded candidate wins the name, so verifying the agent's copy first
|
|
156
|
+
* attaches the result to a candidate that is about to be discarded — silently
|
|
157
|
+
* stripping the verification. Verification is against the WINNER's
|
|
158
|
+
* steps, so a claim that only held for the agent's rewrite is correctly refused
|
|
159
|
+
* rather than carried across.
|
|
160
|
+
*
|
|
161
|
+
* The marker is cleared before the current claim is applied. Each call replaces
|
|
162
|
+
* the agent's previous submission (see the `candidates` schema), and
|
|
163
|
+
* recoverServerCandidates restores whatever the LAST plan recorded — so keeping
|
|
164
|
+
* it would let a boost earned by an earlier registration outlive the claim that
|
|
165
|
+
* earned it, on a submission that no longer makes that claim or whose new claim
|
|
166
|
+
* fails.
|
|
167
|
+
*/
|
|
168
|
+
function applyDiscriminatorClaims(candidates, claimsByKey, diffText, reposWithoutDiff = []) {
|
|
169
|
+
const demotions = [];
|
|
170
|
+
const marked = candidates.map((candidate) => {
|
|
171
|
+
const { verifiedDiscriminator: _stale, ...unmarked } = candidate;
|
|
172
|
+
const claim = claimsByKey.get(scenarioMergeKey(candidate.scenario.scenarioName) || candidate.candidateId);
|
|
173
|
+
if (!claim)
|
|
174
|
+
return unmarked;
|
|
175
|
+
const result = validateDiscriminator(candidate.scenario, claim, diffText, reposWithoutDiff);
|
|
176
|
+
if (result.verified) {
|
|
177
|
+
return { ...unmarked, verifiedDiscriminator: claim.kind };
|
|
178
|
+
}
|
|
179
|
+
demotions.push({
|
|
180
|
+
candidateId: candidate.candidateId,
|
|
181
|
+
reason: result.reason ?? `${claim.kind} discriminator claim could not be verified.`,
|
|
182
|
+
});
|
|
183
|
+
return unmarked;
|
|
184
|
+
});
|
|
185
|
+
return { candidates: marked, demotions };
|
|
163
186
|
}
|
|
164
187
|
/** Recover full Candidates for pre-seeded ("server") plan items by matching
|
|
165
188
|
* candidateId back to the analyzed scenarios (repositoryAnalysis.scenarios) —
|
|
@@ -190,15 +213,54 @@ function recoverServerCandidates(priorPlan, allScenarios) {
|
|
|
190
213
|
}
|
|
191
214
|
return recovered;
|
|
192
215
|
}
|
|
193
|
-
|
|
216
|
+
/** Whether a repo section's persisted diff classification contains any new,
|
|
217
|
+
* modified, or removed endpoint — the same signal `computeScoredCandidates`
|
|
218
|
+
* derives `hasApiChanges` from at analyze time. */
|
|
219
|
+
function sectionHasApiChanges(data) {
|
|
220
|
+
const diff = data?.repositoryAnalysis?.diff;
|
|
221
|
+
return ((diff?.newEndpoints?.length ?? 0) > 0 ||
|
|
222
|
+
(diff?.modifiedEndpoints?.length ?? 0) > 0 ||
|
|
223
|
+
(diff?.removedEndpoints?.length ?? 0) > 0);
|
|
224
|
+
}
|
|
225
|
+
function resolveBudgetContext(stateData, fullState) {
|
|
194
226
|
const pbc = stateData.planBudgetContext;
|
|
195
|
-
|
|
227
|
+
const base = {
|
|
196
228
|
maxGenerate: pbc?.maxGenerate ?? MAX_TESTS_TO_GENERATE,
|
|
197
229
|
maxTotal: pbc?.maxTotal ?? MAX_RECOMMENDATIONS,
|
|
198
230
|
isUIOnlyPR: pbc?.isUIOnlyPR ?? false,
|
|
199
231
|
hasFrontendChanges: pbc?.hasFrontendChanges ?? false,
|
|
200
232
|
externalCoverage: new Set(pbc?.externalCoverageKeys ?? []),
|
|
201
233
|
};
|
|
234
|
+
const relatedSections = Object.values(fullState?.relatedRepos ?? {}).map((section) => section.data);
|
|
235
|
+
if (relatedSections.length === 0)
|
|
236
|
+
return base;
|
|
237
|
+
// SKYR-4204: isUIOnlyPR/hasFrontendChanges are per-SECTION facts — each
|
|
238
|
+
// analyze call computes them from its own repo's diff. The approved plan is
|
|
239
|
+
// one run-wide decision, so a frontend-only PRIMARY diff must not put the
|
|
240
|
+
// budgeter on its all-UI branch (backendGenerateCount() = 0) while a related
|
|
241
|
+
// repo carries endpoint changes; that pinned GENERATE to UI placeholders and
|
|
242
|
+
// left every API candidate in ADDITIONAL regardless of maxGenerate. Derive
|
|
243
|
+
// the flags across ALL sections. A related repo checked out on its default
|
|
244
|
+
// branch classifies no endpoint changes, so it leaves a UI-only run UI-only.
|
|
245
|
+
// Budget numbers come from the root (primary) section: the testbot prompt
|
|
246
|
+
// passes topN/maxGenerate only to the primary analyze call, so related
|
|
247
|
+
// sections carry defaults.
|
|
248
|
+
const sections = [fullState ?? undefined, ...relatedSections];
|
|
249
|
+
const hasFrontendChanges = sections.some((s) => s?.planBudgetContext?.hasFrontendChanges ?? false);
|
|
250
|
+
const hasApiChanges = sections.some(sectionHasApiChanges);
|
|
251
|
+
const rootPbc = fullState?.planBudgetContext;
|
|
252
|
+
// SKYR-4211: external-test coverage is also a per-section fact — a backend
|
|
253
|
+
// candidate already covered by an external test recorded in a RELATED repo's
|
|
254
|
+
// section must dedup the same as one covered in the primary. Union the keys
|
|
255
|
+
// across all sections, like the flags above.
|
|
256
|
+
const externalCoverage = new Set(sections.flatMap((s) => s?.planBudgetContext?.externalCoverageKeys ?? []));
|
|
257
|
+
return {
|
|
258
|
+
maxGenerate: rootPbc?.maxGenerate ?? base.maxGenerate,
|
|
259
|
+
maxTotal: rootPbc?.maxTotal ?? base.maxTotal,
|
|
260
|
+
isUIOnlyPR: hasFrontendChanges && !hasApiChanges,
|
|
261
|
+
hasFrontendChanges,
|
|
262
|
+
externalCoverage,
|
|
263
|
+
};
|
|
202
264
|
}
|
|
203
265
|
function describeGenerationCall(item) {
|
|
204
266
|
switch (item.testType) {
|
|
@@ -306,20 +368,52 @@ export function registerRegisterTestPlanTool(server) {
|
|
|
306
368
|
}, async (params) => {
|
|
307
369
|
let errorResult;
|
|
308
370
|
try {
|
|
309
|
-
logger.info(
|
|
371
|
+
logger.info("Registering run-wide test plan", {
|
|
310
372
|
candidateCount: params.candidates?.length ?? 0,
|
|
311
373
|
});
|
|
312
374
|
const stateManager = StateManager.fromStatePath(params.stateFile);
|
|
313
|
-
|
|
314
|
-
|
|
375
|
+
// The plan is run-wide, so register always reads the ROOT (primary)
|
|
376
|
+
// section — there is no section selector (routing by one is what let a
|
|
377
|
+
// related-repo registration run with that section's context —
|
|
378
|
+
// SKYR-4204 reopen). Run-wide facts (diff union, budget flags,
|
|
379
|
+
// scenario lookup) come from fullState below.
|
|
380
|
+
const stateData = await stateManager.readData();
|
|
315
381
|
if (!stateData) {
|
|
316
382
|
errorResult = toolError(`State file is empty or invalid: ${params.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
|
|
317
383
|
return errorResult;
|
|
318
384
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
385
|
+
// SKYR-4203: one register call carries candidates for EVERY repo of the
|
|
386
|
+
// run, but `stateData` is a single repo's section — and the candidate
|
|
387
|
+
// schema has no per-candidate repository, so a related repo's anchors
|
|
388
|
+
// were unverifiable by construction. Verify anchors (and rank diff-hunk
|
|
389
|
+
// proximity) against the union of ALL sections' diffs: an anchor
|
|
390
|
+
// grounded in a related repo's change is as real as one in the
|
|
391
|
+
// primary's. Sections with no diff (a related repo checked out on its
|
|
392
|
+
// default branch) are collected so an unmatched anchor reads as
|
|
393
|
+
// uncheckable rather than ungrounded.
|
|
394
|
+
const fullState = await stateManager.readFullState();
|
|
395
|
+
const diffSections = [
|
|
396
|
+
{ repo: fullState?.metadata?.repository ?? "the primary repository", diff: fullState?.diffText },
|
|
397
|
+
...Object.entries(fullState?.relatedRepos ?? {}).map(([repo, section]) => ({
|
|
398
|
+
repo,
|
|
399
|
+
diff: section.data?.diffText,
|
|
400
|
+
})),
|
|
401
|
+
];
|
|
402
|
+
const diffText = diffSections.map((s) => s.diff).filter(Boolean).join("\n");
|
|
403
|
+
const reposWithoutDiff = diffSections.filter((s) => !s.diff).map((s) => s.repo);
|
|
404
|
+
// Scenario lookup for server-candidate recovery spans ALL sections —
|
|
405
|
+
// the primary's drafted scenarios must resolve even when `repository`
|
|
406
|
+
// targets a related repo (SKYR-4204 reopen: the same single-section
|
|
407
|
+
// shape as the diff union above).
|
|
408
|
+
const allScenarios = [
|
|
409
|
+
...(fullState?.repositoryAnalysis?.scenarios ?? []),
|
|
410
|
+
...Object.values(fullState?.relatedRepos ?? {}).flatMap((section) => section.data?.repositoryAnalysis?.scenarios ?? []),
|
|
411
|
+
];
|
|
412
|
+
const agentCandidates = buildAgentCandidates(params.candidates ?? []);
|
|
413
|
+
// The approved plan is run-wide and persists at the ROOT (see the
|
|
414
|
+
// persist step below), so prior-plan recovery reads the root first;
|
|
415
|
+
// the section fallback covers state written by older builds.
|
|
416
|
+
const serverCandidates = recoverServerCandidates(fullState?.approvedPlan ?? stateData.approvedPlan, allScenarios);
|
|
323
417
|
// Merge by scenario-name identity, NOT the full content-hashed candidateId:
|
|
324
418
|
// the server (analyze_changes) and the agent frequently draft their own
|
|
325
419
|
// independent steps[] for "the same" scenario (identical scenarioName),
|
|
@@ -341,7 +435,18 @@ export function registerRegisterTestPlanTool(server) {
|
|
|
341
435
|
merged.set(mergeKey(candidate), candidate);
|
|
342
436
|
for (const candidate of serverCandidates)
|
|
343
437
|
merged.set(mergeKey(candidate), candidate);
|
|
344
|
-
|
|
438
|
+
// The agent declares a claim per scenario NAME, so key the claims the
|
|
439
|
+
// same way the merge does and verify against whichever copy survived.
|
|
440
|
+
const claimsByKey = new Map();
|
|
441
|
+
// Keyed through mergeKey, not scenarioMergeKey alone: a name that
|
|
442
|
+
// slugifies to nothing (e.g. an all-non-ASCII name) would be stored
|
|
443
|
+
// under "" while the lookup falls back to the candidateId, so the claim
|
|
444
|
+
// would be neither verified nor demoted.
|
|
445
|
+
(params.candidates ?? []).forEach((input, i) => {
|
|
446
|
+
if (input.discriminator)
|
|
447
|
+
claimsByKey.set(mergeKey(agentCandidates[i]), input.discriminator);
|
|
448
|
+
});
|
|
449
|
+
const { candidates: allCandidates, demotions } = applyDiscriminatorClaims([...merged.values()], claimsByKey, diffText, reposWithoutDiff);
|
|
345
450
|
// An empty union would persist an authoritative plan with an empty
|
|
346
451
|
// GENERATE list, which the generation gate then enforces — bricking
|
|
347
452
|
// the run until re-registration. Reject instead so the agent can
|
|
@@ -351,7 +456,44 @@ export function registerRegisterTestPlanTool(server) {
|
|
|
351
456
|
"Submit your complete candidate list (every test you would generate or recommend) in `candidates` — an empty plan would block all generation.");
|
|
352
457
|
return errorResult;
|
|
353
458
|
}
|
|
354
|
-
|
|
459
|
+
// Review (mcp#774): enforce ONE run-wide plan at the tool boundary. A
|
|
460
|
+
// re-registration whose pool shares no scenario with the active plan
|
|
461
|
+
// is the per-repo-partial signature (the demoshop rig's second,
|
|
462
|
+
// backend-only call) — accepting it would silently REPLACE the run's
|
|
463
|
+
// plan with a subset and drop every earlier agent candidate. Genuine
|
|
464
|
+
// full-list updates overlap the active plan and replace it as before.
|
|
465
|
+
// The overlap test spans GENERATE ∪ ADDITIONAL: with maxGenerate=0 the
|
|
466
|
+
// active plan's GENERATE is empty while ADDITIONAL carries every
|
|
467
|
+
// candidate, and a disjoint partial could otherwise still replace it
|
|
468
|
+
// silently (Copilot review on #774).
|
|
469
|
+
const priorItems = [
|
|
470
|
+
...(fullState?.approvedPlan?.generate ?? []),
|
|
471
|
+
...(fullState?.approvedPlan?.additional ?? []),
|
|
472
|
+
];
|
|
473
|
+
if (priorItems.length > 0) {
|
|
474
|
+
const poolKeys = new Set(allCandidates.map(mergeKey));
|
|
475
|
+
const overlaps = priorItems.some((item) => poolKeys.has(scenarioMergeKey(item.scenarioName) || item.candidateId));
|
|
476
|
+
if (!overlaps) {
|
|
477
|
+
errorResult = toolError(`A run-wide plan is already active (${fullState.approvedPlan.planId}: ` +
|
|
478
|
+
`${priorItems.map((i) => i.scenarioName).join(", ")}) and this submission shares none of its ` +
|
|
479
|
+
"scenarios. skyramp_register_test_plan REPLACES the whole run's plan — resubmit ONE pooled candidate " +
|
|
480
|
+
"list for the entire run (your previous candidates plus these changes), not a per-repo subset.");
|
|
481
|
+
return errorResult;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const budgetContext = resolveBudgetContext(stateData, fullState);
|
|
485
|
+
// SKYR-4204 reopen: a reserved UI slot is only fillable from the
|
|
486
|
+
// candidate pool. A pool with no UI candidate on a run with frontend
|
|
487
|
+
// changes would silently plan zero UI tests (runs 32419025266,
|
|
488
|
+
// 32431042133, 32431106864) — reject so the agent self-corrects in one
|
|
489
|
+
// step, exactly like the empty-candidates rejection above.
|
|
490
|
+
if (reservedUISlots(budgetContext) > 0 &&
|
|
491
|
+
!allCandidates.some((c) => inferScenarioType(c.scenario) === TestType.UI)) {
|
|
492
|
+
errorResult = toolError("This run has frontend changes, so the plan reserves a UI slot — but no submitted or recovered " +
|
|
493
|
+
"candidate has testType 'ui'. Resubmit your full candidate list including at least one UI candidate " +
|
|
494
|
+
"for the changed frontend (see uiContext.changedFrontendFiles).");
|
|
495
|
+
return errorResult;
|
|
496
|
+
}
|
|
355
497
|
const result = selectPlan(allCandidates, { ...budgetContext, demotions, diffText });
|
|
356
498
|
const approvedPlan = {
|
|
357
499
|
planId: crypto.randomUUID(),
|
|
@@ -360,8 +502,16 @@ export function registerRegisterTestPlanTool(server) {
|
|
|
360
502
|
additional: result.additional.map(buildApprovedPlanItem),
|
|
361
503
|
demotions: result.demotions,
|
|
362
504
|
};
|
|
505
|
+
// The approved plan is the ONE run-wide authority (the prompt mandates
|
|
506
|
+
// a single pooled registration on multi-repo runs), so it persists at
|
|
507
|
+
// the state-file ROOT regardless of `repository`. Per-section plans
|
|
508
|
+
// blinded every root-reading consumer — planGuard let generation run
|
|
509
|
+
// unguarded, submitReportTool's reconciliation was inert, and the eval
|
|
510
|
+
// extractor saw "no plan" (SKYR-4204 reopen). writeData preserves the
|
|
511
|
+
// relatedRepos sections and the root metadata.
|
|
363
512
|
try {
|
|
364
|
-
|
|
513
|
+
const { metadata: _meta, relatedRepos: _sections, ...rootData } = fullState ?? {};
|
|
514
|
+
await stateManager.writeData({ ...rootData, approvedPlan }, { step: TOOL_NAME });
|
|
365
515
|
}
|
|
366
516
|
catch (error) {
|
|
367
517
|
errorResult = toolError(`Failed to persist approved test plan to state file: ${error.message}. Retry this call.`);
|
|
@@ -26,6 +26,20 @@ export interface TestExecutionResult extends TestExecutionData {
|
|
|
26
26
|
testFile: string;
|
|
27
27
|
videoPath?: string;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* One browser execution's recorded video, as persisted in run state by
|
|
31
|
+
* skyramp_execute_test and read back by skyramp_submit_report (SKYR-4156).
|
|
32
|
+
*
|
|
33
|
+
* `status`/`executedAt` are not rendered in the report — they identify WHICH
|
|
34
|
+
* execution the referenced recording belongs to, which matters because a retried
|
|
35
|
+
* test leaves a video directory behind for every attempt.
|
|
36
|
+
*/
|
|
37
|
+
export interface VideoRecord {
|
|
38
|
+
/** Host path to the recorded video.webm. */
|
|
39
|
+
videoPath: string;
|
|
40
|
+
status: TestExecutionStatus;
|
|
41
|
+
executedAt: string;
|
|
42
|
+
}
|
|
29
43
|
export interface BatchExecutionResult {
|
|
30
44
|
totalTests: number;
|
|
31
45
|
passed: number;
|
package/build/types/TestTypes.js
CHANGED
|
@@ -216,8 +216,9 @@ export const baseTestSchema = {
|
|
|
216
216
|
.string()
|
|
217
217
|
.default("")
|
|
218
218
|
.describe("MUST be string of comma separated values like 'id=1,name=John' for URL query parameters. "
|
|
219
|
-
+ "Workspace-configured api.defaultQueryParams (if set) are merged in automatically
|
|
220
|
-
+ "
|
|
219
|
+
+ "Workspace-configured api.defaultQueryParams (if set) are merged in automatically, with any "
|
|
220
|
+
+ "api.queryParamOverrides entry whose pathPattern matches this endpoint layered on top — "
|
|
221
|
+
+ "no need to repeat them here. An explicit value for the same key here overrides both."),
|
|
221
222
|
formParams: z
|
|
222
223
|
.string()
|
|
223
224
|
.default("")
|
|
@@ -2,10 +2,21 @@ import type { DriftAction } from "./TestAnalysis.js";
|
|
|
2
2
|
import type { TestExecutionStatus } from "./TestExecution.js";
|
|
3
3
|
import type { HttpMethod, TestType } from "./TestTypes.js";
|
|
4
4
|
import type { ReuseOutcome } from "./ReuseOutcome.js";
|
|
5
|
+
/** Classification of an issuesFound entry. Bug is a product/code defect; the
|
|
6
|
+
* other values are tooling noise that the Testbot report renders in its
|
|
7
|
+
* Configuration Errors section instead of Issues Found. */
|
|
8
|
+
export declare enum IssueFoundCategory {
|
|
9
|
+
Bug = "bug",
|
|
10
|
+
Lint = "lint",
|
|
11
|
+
Type = "type",
|
|
12
|
+
Config = "config"
|
|
13
|
+
}
|
|
5
14
|
/**
|
|
6
15
|
* Shape of the JSON report written by skyramp_submit_report and read by testbot
|
|
7
16
|
* for rendering as Markdown. All fields mirror the corresponding Zod schemas in
|
|
8
|
-
* submitReportTool.ts — keep the two in sync.
|
|
17
|
+
* submitReportTool.ts — keep the two in sync. This type describes what a READER
|
|
18
|
+
* may encounter across MCP versions; the Zod schemas define what the current
|
|
19
|
+
* producer must submit, so a field can be required on submit but optional here.
|
|
9
20
|
*/
|
|
10
21
|
export interface TestbotReport {
|
|
11
22
|
businessCaseAnalysis: string;
|
|
@@ -16,6 +27,9 @@ export interface TestbotReport {
|
|
|
16
27
|
fileName: string;
|
|
17
28
|
reasoning: string;
|
|
18
29
|
description: string;
|
|
30
|
+
/** `owner/repo` attribution in multi-repo runs (SKYR-3786). Absent = the
|
|
31
|
+
* primary repo, or a single-repo run. */
|
|
32
|
+
repository?: string;
|
|
19
33
|
scenarioFile?: string;
|
|
20
34
|
traceFile?: string;
|
|
21
35
|
frontendTrace?: string;
|
|
@@ -44,6 +58,8 @@ export interface TestbotReport {
|
|
|
44
58
|
status: "Pass" | "Fail" | "Skipped";
|
|
45
59
|
details: string;
|
|
46
60
|
videoPath?: string;
|
|
61
|
+
/** See newTestsCreated[].repository. */
|
|
62
|
+
repository?: string;
|
|
47
63
|
}[];
|
|
48
64
|
additionalRecommendations?: {
|
|
49
65
|
testId: string;
|
|
@@ -65,10 +81,17 @@ export interface TestbotReport {
|
|
|
65
81
|
openApiSpec?: string;
|
|
66
82
|
backendTrace?: string;
|
|
67
83
|
frontendTrace?: string;
|
|
84
|
+
/** See newTestsCreated[].repository. */
|
|
85
|
+
repository?: string;
|
|
68
86
|
}[];
|
|
69
87
|
issuesFound: {
|
|
70
88
|
description: string;
|
|
71
89
|
severity?: "critical" | "high" | "medium" | "low";
|
|
90
|
+
/** Required by the submit_report schema since 0.3.4; absent in reports
|
|
91
|
+
* written by older MCP versions. Readers treat absence as Bug. */
|
|
92
|
+
category?: IssueFoundCategory;
|
|
93
|
+
/** See newTestsCreated[].repository. */
|
|
94
|
+
repository?: string;
|
|
72
95
|
}[];
|
|
73
96
|
nextSteps: string[];
|
|
74
97
|
commitMessage: string;
|
|
@@ -1 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
/** Classification of an issuesFound entry. Bug is a product/code defect; the
|
|
2
|
+
* other values are tooling noise that the Testbot report renders in its
|
|
3
|
+
* Configuration Errors section instead of Issues Found. */
|
|
4
|
+
export var IssueFoundCategory;
|
|
5
|
+
(function (IssueFoundCategory) {
|
|
6
|
+
IssueFoundCategory["Bug"] = "bug";
|
|
7
|
+
IssueFoundCategory["Lint"] = "lint";
|
|
8
|
+
IssueFoundCategory["Type"] = "type";
|
|
9
|
+
IssueFoundCategory["Config"] = "config";
|
|
10
|
+
})(IssueFoundCategory || (IssueFoundCategory = {}));
|
package/build/types/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { TestExecutionStatus } from "./TestExecution.js";
|
|
|
2
2
|
export { DriftAction } from "./TestAnalysis.js";
|
|
3
3
|
export { TestType, HttpMethod } from "./TestTypes.js";
|
|
4
4
|
export type { TestbotReport } from "./TestbotReport.js";
|
|
5
|
+
export { IssueFoundCategory } from "./TestbotReport.js";
|
|
5
6
|
export { ReuseDeclinedBy, ReuseVerificationOutcome } from "./ReuseOutcome.js";
|
|
6
7
|
export type { ReuseOutcome, ReuseSkippedEntry } from "./ReuseOutcome.js";
|
|
7
8
|
export type { RelatedRepository, TestbotPromptOptions, } from "./TestbotPromptOptions.js";
|
package/build/types/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { TestExecutionStatus } from "./TestExecution.js";
|
|
2
2
|
export { DriftAction } from "./TestAnalysis.js";
|
|
3
3
|
export { TestType, HttpMethod } from "./TestTypes.js";
|
|
4
|
+
export { IssueFoundCategory } from "./TestbotReport.js";
|
|
4
5
|
export { ReuseDeclinedBy, ReuseVerificationOutcome } from "./ReuseOutcome.js";
|