@skyramp/mcp 0.3.1 → 0.3.2-rc.pom-2
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/index.js +2 -1
- package/build/prompts/code-reuse.d.ts +7 -1
- package/build/prompts/code-reuse.js +8 -5
- package/build/prompts/code-reuse.test.d.ts +1 -0
- package/build/prompts/code-reuse.test.js +62 -0
- package/build/prompts/pom-aware-code-reuse.d.ts +6 -1
- package/build/prompts/pom-aware-code-reuse.js +100 -53
- package/build/prompts/pom-aware-code-reuse.test.d.ts +1 -0
- package/build/prompts/pom-aware-code-reuse.test.js +11 -0
- package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +4 -2
- package/build/prompts/test-recommendation/diffExecutionPlan.js +11 -65
- package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +2 -2
- package/build/prompts/test-recommendation/recommendationSections.js +5 -2
- package/build/prompts/test-recommendation/scopeAssessment.js +1 -1
- package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +26 -1
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +68 -56
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +48 -11
- package/build/prompts/testbot/testbot-prompts.d.ts +1 -1
- package/build/prompts/testbot/testbot-prompts.js +67 -21
- package/build/prompts/testbot/testbot-prompts.test.js +44 -0
- package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +7 -0
- package/build/recommendation/budgeters/diversityBalancedBudgeter.js +71 -0
- package/build/recommendation/budgeters/diversityBalancedBudgeter.test.d.ts +1 -0
- package/build/recommendation/budgeters/diversityBalancedBudgeter.test.js +75 -0
- package/build/recommendation/budgeters/fixedNBudgeter.d.ts +7 -0
- package/build/recommendation/budgeters/fixedNBudgeter.js +11 -0
- package/build/recommendation/budgeters/fixedNBudgeter.test.d.ts +1 -0
- package/build/recommendation/budgeters/fixedNBudgeter.test.js +66 -0
- package/build/recommendation/budgeters/shared.d.ts +19 -0
- package/build/recommendation/budgeters/shared.js +66 -0
- package/build/recommendation/discriminators.d.ts +31 -0
- package/build/recommendation/discriminators.js +355 -0
- package/build/recommendation/discriminators.test.d.ts +1 -0
- package/build/recommendation/discriminators.test.js +324 -0
- package/build/recommendation/diversity.d.ts +47 -0
- package/build/recommendation/diversity.js +101 -0
- package/build/recommendation/diversity.test.d.ts +1 -0
- package/build/recommendation/diversity.test.js +77 -0
- package/build/recommendation/planRanker.d.ts +50 -0
- package/build/recommendation/planRanker.js +67 -0
- package/build/recommendation/planRanker.test.d.ts +1 -0
- package/build/recommendation/planRanker.test.js +110 -0
- package/build/recommendation/testFixtures.d.ts +25 -0
- package/build/recommendation/testFixtures.js +45 -0
- package/build/resources/testbotResource.js +4 -1
- package/build/services/ScenarioGenerationService.d.ts +5 -0
- package/build/services/ScenarioGenerationService.js +16 -1
- package/build/services/ScenarioGenerationService.test.js +44 -0
- package/build/services/TestExecutionService.d.ts +15 -1
- package/build/services/TestExecutionService.js +210 -55
- package/build/services/TestExecutionService.test.js +397 -0
- package/build/services/TestGenerationService.js +19 -1
- package/build/services/TestGenerationService.test.js +58 -0
- package/build/tool-phases.js +1 -0
- package/build/toolNames.d.ts +19 -0
- package/build/toolNames.js +19 -0
- package/build/tools/code-refactor/codeReuseTool.d.ts +7 -0
- package/build/tools/code-refactor/codeReuseTool.js +130 -4
- package/build/tools/code-refactor/codeReuseTool.test.d.ts +1 -0
- package/build/tools/code-refactor/codeReuseTool.test.js +290 -0
- package/build/tools/executeSkyrampTestTool.js +8 -2
- package/build/tools/generate-tests/generateBatchScenarioRestTool.d.ts +6 -1
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +110 -17
- package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +147 -0
- package/build/tools/generate-tests/generateContractRestTool.js +11 -1
- package/build/tools/generate-tests/generateIntegrationRestTool.js +24 -1
- package/build/tools/generate-tests/generateIntegrationRestTool.test.d.ts +1 -0
- package/build/tools/generate-tests/generateIntegrationRestTool.test.js +159 -0
- package/build/tools/generate-tests/planGuard.d.ts +13 -0
- package/build/tools/generate-tests/planGuard.js +78 -0
- package/build/tools/generate-tests/planGuard.test.d.ts +1 -0
- package/build/tools/generate-tests/planGuard.test.js +185 -0
- package/build/tools/generate-tests/scenarioFileIdentity.d.ts +10 -0
- package/build/tools/generate-tests/scenarioFileIdentity.js +46 -0
- package/build/tools/generate-tests/scenarioLint.d.ts +30 -0
- package/build/tools/generate-tests/scenarioLint.js +150 -0
- package/build/tools/generate-tests/scenarioLint.test.d.ts +1 -0
- package/build/tools/generate-tests/scenarioLint.test.js +100 -0
- package/build/tools/submitReportTool.js +78 -0
- package/build/tools/submitReportTool.test.js +255 -0
- package/build/tools/test-management/analyzeChangesTool.js +55 -2
- package/build/tools/test-management/analyzeChangesTool.test.js +12 -0
- package/build/tools/test-management/index.d.ts +1 -0
- package/build/tools/test-management/index.js +1 -0
- package/build/tools/test-management/registerTestPlanTool.d.ts +2 -0
- package/build/tools/test-management/registerTestPlanTool.js +329 -0
- package/build/tools/test-management/registerTestPlanTool.test.d.ts +1 -0
- package/build/tools/test-management/registerTestPlanTool.test.js +296 -0
- package/build/types/Recommendation.d.ts +97 -0
- package/build/types/Recommendation.js +48 -0
- package/build/types/RepositoryAnalysis.d.ts +14 -14
- package/build/types/TestExecution.d.ts +2 -0
- package/build/types/TestRecommendation.d.ts +12 -1
- package/build/types/TestRecommendation.js +26 -11
- package/build/types/TestTypes.js +1 -1
- package/build/utils/AnalysisStateManager.d.ts +47 -0
- package/build/utils/docker.test.js +1 -1
- package/build/utils/planMatchKeys.d.ts +61 -0
- package/build/utils/planMatchKeys.js +125 -0
- package/build/utils/pom-scope/import-expansion.d.ts +5 -0
- package/build/utils/pom-scope/import-expansion.js +32 -0
- package/build/utils/pom-scope/index.d.ts +39 -0
- package/build/utils/pom-scope/index.js +120 -0
- package/build/utils/pom-scope/index.test.d.ts +1 -0
- package/build/utils/pom-scope/index.test.js +239 -0
- package/build/utils/pom-scope/pom-files.d.ts +3 -0
- package/build/utils/pom-scope/pom-files.js +48 -0
- package/build/utils/pom-scope/pom-files.test.d.ts +1 -0
- package/build/utils/pom-scope/pom-files.test.js +29 -0
- package/build/utils/pom-scope/scoring.d.ts +20 -0
- package/build/utils/pom-scope/scoring.js +45 -0
- package/build/utils/pom-scope/scoring.test.d.ts +1 -0
- package/build/utils/pom-scope/scoring.test.js +39 -0
- package/build/utils/pom-scope/selector-extractor.d.ts +7 -0
- package/build/utils/pom-scope/selector-extractor.js +57 -0
- package/build/utils/pom-scope/selector-extractor.test.d.ts +1 -0
- package/build/utils/pom-scope/selector-extractor.test.js +67 -0
- package/build/utils/pom-verify/__fixtures__/af-style/asset-list.page.d.ts +5 -0
- package/build/utils/pom-verify/__fixtures__/af-style/asset-list.page.js +5 -0
- package/build/utils/pom-verify/__fixtures__/af-style/pageobjects/asset-list-page.d.ts +5 -0
- package/build/utils/pom-verify/__fixtures__/af-style/pageobjects/asset-list-page.js +9 -0
- package/build/utils/pom-verify/__fixtures__/af-style/report.iframe.page.d.ts +4 -0
- package/build/utils/pom-verify/__fixtures__/af-style/report.iframe.page.js +4 -0
- package/build/utils/pom-verify/__fixtures__/af-style/workflow-footer.page.d.ts +4 -0
- package/build/utils/pom-verify/__fixtures__/af-style/workflow-footer.page.js +4 -0
- package/build/utils/pom-verify/bindings.d.ts +19 -0
- package/build/utils/pom-verify/bindings.js +161 -0
- package/build/utils/pom-verify/bindings.test.d.ts +1 -0
- package/build/utils/pom-verify/bindings.test.js +164 -0
- package/build/utils/pom-verify/calls.d.ts +16 -0
- package/build/utils/pom-verify/calls.js +42 -0
- package/build/utils/pom-verify/calls.test.d.ts +1 -0
- package/build/utils/pom-verify/calls.test.js +61 -0
- package/build/utils/pom-verify/index.d.ts +4 -0
- package/build/utils/pom-verify/index.js +4 -0
- package/build/utils/pom-verify/resolve.d.ts +7 -0
- package/build/utils/pom-verify/resolve.js +27 -0
- package/build/utils/pom-verify/resolve.test.d.ts +1 -0
- package/build/utils/pom-verify/resolve.test.js +68 -0
- package/build/utils/pom-verify/strip.d.ts +9 -0
- package/build/utils/pom-verify/strip.js +89 -0
- package/build/utils/pom-verify/verify.d.ts +14 -0
- package/build/utils/pom-verify/verify.js +158 -0
- package/build/utils/pom-verify/verify.test.d.ts +1 -0
- package/build/utils/pom-verify/verify.test.js +325 -0
- package/build/utils/reportVerification.d.ts +61 -0
- package/build/utils/reportVerification.js +104 -0
- package/build/utils/reportVerification.test.d.ts +1 -0
- package/build/utils/reportVerification.test.js +185 -0
- package/build/utils/scenarioDrafting.js +5 -5
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/utils/workspaceAuth.d.ts +9 -1
- package/build/utils/workspaceAuth.js +25 -5
- package/build/utils/workspaceAuth.test.js +48 -0
- package/build/workspace/workspace.d.ts +20 -0
- package/build/workspace/workspace.js +4 -0
- package/build/workspace/workspace.test.js +10 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +77 -8
- package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/utils/isomorphic/volatileDate.js +101 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/utils.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-aszq5EdG.js → codeMirrorModule-Bzd72-bG.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-BxS7Jm4s.js → defaultSettingsView-DzxTioTK.js} +101 -101
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.D4JTTy4R.js → index.BGc30U3S.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.DaRMQKOI.js → uiMode.IaDrb29A.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/utils/isomorphic/volatileDate.ts +131 -0
- package/node_modules/playwright/node_modules/playwright-core/src/utils.ts +1 -0
- package/node_modules/playwright/package.json +1 -1
- package/package.json +3 -3
- package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TOOL_BATCH_SCENARIO_TEST_GENERATION, TOOL_CONTRACT_TEST_GENERATION, TOOL_E2E_TEST_GENERATION, TOOL_INTEGRATION_TEST_GENERATION, TOOL_REGISTER_TEST_PLAN, TOOL_UI_TEST_GENERATION, } from "../../toolNames.js";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as crypto from "crypto";
|
|
5
|
+
import { logger } from "../../utils/logger.js";
|
|
6
|
+
import { StateManager, } from "../../utils/AnalysisStateManager.js";
|
|
7
|
+
import { toolError } from "../../utils/utils.js";
|
|
8
|
+
import { buildApprovedPlanItem } from "../../utils/planMatchKeys.js";
|
|
9
|
+
import { SCENARIO_CATEGORIES, CATEGORY_PRIORITY, Novelty, PriorityTier } from "../../types/TestRecommendation.js";
|
|
10
|
+
import { HttpMethod, TestType } from "../../types/TestTypes.js";
|
|
11
|
+
import { CandidateSource, computeCandidateId, DiscriminatorKind } from "../../types/Recommendation.js";
|
|
12
|
+
import { selectPlan } from "../../recommendation/planRanker.js";
|
|
13
|
+
import { validateDiscriminator } from "../../recommendation/discriminators.js";
|
|
14
|
+
import { isAttackSurfaceSecurityBoundary } from "../../prompts/test-recommendation/recommendationShared.js";
|
|
15
|
+
import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
|
|
16
|
+
const TOOL_NAME = TOOL_REGISTER_TEST_PLAN;
|
|
17
|
+
// ── Zod schema ──────────────────────────────────────────────────────────────
|
|
18
|
+
/** Test types eligible for a registered test plan — a subset of TestType
|
|
19
|
+
* (excludes smoke/fuzz/load/mock/unit/unknown, which this checkpoint doesn't
|
|
20
|
+
* govern). */
|
|
21
|
+
const PLAN_TEST_TYPES = new Set([
|
|
22
|
+
TestType.CONTRACT,
|
|
23
|
+
TestType.INTEGRATION,
|
|
24
|
+
TestType.E2E,
|
|
25
|
+
TestType.UI,
|
|
26
|
+
]);
|
|
27
|
+
const chainingRefSchema = z.object({
|
|
28
|
+
sourceStep: z.number().int(),
|
|
29
|
+
sourceField: z.string(),
|
|
30
|
+
sourceLocation: z.enum(["body", "header", "cookie"]),
|
|
31
|
+
targetParam: z.string(),
|
|
32
|
+
targetLocation: z.enum(["path", "body", "query", "header", "cookie"]),
|
|
33
|
+
});
|
|
34
|
+
const registerStepSchema = z.object({
|
|
35
|
+
order: z.number().int().min(1).describe("Order of this step within the scenario, starting at 1."),
|
|
36
|
+
method: z.nativeEnum(HttpMethod).describe("HTTP method of this step's request."),
|
|
37
|
+
path: z.string().describe("Endpoint path, e.g. '/api/v1/products/{product_id}'."),
|
|
38
|
+
description: z.string().describe("What this step does."),
|
|
39
|
+
interactionType: z
|
|
40
|
+
.enum(["success", "error", "edge-case"])
|
|
41
|
+
.describe("Whether this step exercises the happy path, an expected error, or an edge case."),
|
|
42
|
+
requestBody: z
|
|
43
|
+
.record(z.any())
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("Request body fields with concrete values. Used to verify boundary_equality claims (two distinct fields with equal values)."),
|
|
46
|
+
queryParams: z
|
|
47
|
+
.record(z.any())
|
|
48
|
+
.optional()
|
|
49
|
+
.describe("Query parameters with concrete values. Also scanned for boundary_equality and negative_match verification."),
|
|
50
|
+
responseBody: z.record(z.any()).optional().describe("Expected response body shape/values, when asserted."),
|
|
51
|
+
expectedStatusCode: z.number().int().describe("HTTP status code this step asserts (e.g. 201, 400, 404)."),
|
|
52
|
+
expectedResponseFields: z
|
|
53
|
+
.array(z.string())
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("Response fields (optionally with expected values, e.g. 'account_count=2') this step asserts. Used to verify multi_record_aggregate claims."),
|
|
56
|
+
bodyMustInclude: z
|
|
57
|
+
.array(z.string())
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Fields that MUST appear in the request body for this step."),
|
|
60
|
+
chainsFrom: z.union([chainingRefSchema, z.array(chainingRefSchema)]).optional(),
|
|
61
|
+
});
|
|
62
|
+
const discriminatorClaimSchema = z.object({
|
|
63
|
+
kind: z.nativeEnum(DiscriminatorKind),
|
|
64
|
+
changedCodeAnchor: z
|
|
65
|
+
.string()
|
|
66
|
+
.min(1)
|
|
67
|
+
.describe("Verbatim snippet copied EXACTLY from the diff that this test probes. Must occur character-for-character in the PR diff to verify — a paraphrase or reconstruction will fail verification and demote the claim."),
|
|
68
|
+
});
|
|
69
|
+
const registerCandidateSchema = z.object({
|
|
70
|
+
scenarioName: z.string().min(1).describe("Name for this test/scenario. Reused as the match key against the generation tool's scenarioName — keep it identical when you later call the generation tool."),
|
|
71
|
+
description: z.string().min(1),
|
|
72
|
+
category: z.enum(SCENARIO_CATEGORIES).describe("Test category — drives ranking (never your own priority tag)."),
|
|
73
|
+
priority: z.enum(["high", "medium", "low"]).describe("Your own assessment — recorded, but ranking is derived from category, not this field."),
|
|
74
|
+
testType: z.nativeEnum(TestType).refine((t) => PLAN_TEST_TYPES.has(t), {
|
|
75
|
+
message: `testType must be one of ${[...PLAN_TEST_TYPES].join(", ")}`,
|
|
76
|
+
}),
|
|
77
|
+
steps: z.array(registerStepSchema).min(1),
|
|
78
|
+
discriminator: discriminatorClaimSchema
|
|
79
|
+
.optional()
|
|
80
|
+
.describe("Declare when this candidate specifically probes changed logic (a structural bug-shape). Verified against `steps[]` and the PR diff — a failed claim demotes (with a reason) but never rejects the candidate."),
|
|
81
|
+
});
|
|
82
|
+
const registerTestPlanSchema = {
|
|
83
|
+
stateFile: z
|
|
84
|
+
.string()
|
|
85
|
+
.refine((p) => path.isAbsolute(p), { message: "stateFile must be an absolute path" })
|
|
86
|
+
.describe("Path to state file from skyramp_analyze_changes"),
|
|
87
|
+
repository: z
|
|
88
|
+
.string()
|
|
89
|
+
.optional()
|
|
90
|
+
.describe("The owner/repo whose analysis section to register against (e.g. 'letsramp/api-insight'). In a multi-repo run, ALWAYS set it. Omit only for a single-repo run."),
|
|
91
|
+
candidates: z
|
|
92
|
+
.array(registerCandidateSchema)
|
|
93
|
+
.default([])
|
|
94
|
+
.describe("Your COMPLETE candidate list — every test you would generate OR recommend this run, including ones already suggested by skyramp_analyze_changes. Merged with any server pre-ranked candidates, re-ranked, and split into GENERATE vs ADDITIONAL. Call this again (with your full updated list) if you want to add or change candidates — each call replaces your previous submission."),
|
|
95
|
+
};
|
|
96
|
+
// ── Handler helpers ─────────────────────────────────────────────────────────
|
|
97
|
+
function derivePriorityTier(scenario) {
|
|
98
|
+
if (isAttackSurfaceSecurityBoundary(scenario))
|
|
99
|
+
return PriorityTier.CRITICAL;
|
|
100
|
+
return CATEGORY_PRIORITY[scenario.category] ?? PriorityTier.LOW;
|
|
101
|
+
}
|
|
102
|
+
function toScenarioStep(step) {
|
|
103
|
+
return {
|
|
104
|
+
order: step.order,
|
|
105
|
+
method: step.method,
|
|
106
|
+
path: step.path,
|
|
107
|
+
description: step.description,
|
|
108
|
+
interactionType: step.interactionType,
|
|
109
|
+
requestBody: step.requestBody,
|
|
110
|
+
queryParams: step.queryParams,
|
|
111
|
+
responseBody: step.responseBody,
|
|
112
|
+
expectedStatusCode: step.expectedStatusCode,
|
|
113
|
+
expectedResponseFields: step.expectedResponseFields,
|
|
114
|
+
bodyMustInclude: step.bodyMustInclude,
|
|
115
|
+
chainsFrom: step.chainsFrom,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function toDraftedScenario(input) {
|
|
119
|
+
return {
|
|
120
|
+
scenarioName: input.scenarioName,
|
|
121
|
+
description: input.description,
|
|
122
|
+
category: input.category,
|
|
123
|
+
priority: input.priority,
|
|
124
|
+
steps: input.steps.map(toScenarioStep),
|
|
125
|
+
chainingKeys: [],
|
|
126
|
+
requiresAuth: false,
|
|
127
|
+
estimatedComplexity: "moderate",
|
|
128
|
+
testType: input.testType,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Build the agent-submitted candidates, running discriminator verification
|
|
132
|
+
* against the persisted diff text. Failed claims demote (never reject). */
|
|
133
|
+
function buildAgentCandidates(candidates, diffText) {
|
|
134
|
+
const demotions = [];
|
|
135
|
+
const built = candidates.map((input) => {
|
|
136
|
+
const scenario = toDraftedScenario(input);
|
|
137
|
+
const candidateId = computeCandidateId(scenario);
|
|
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 = {
|
|
153
|
+
scenario,
|
|
154
|
+
priority: derivePriorityTier(scenario),
|
|
155
|
+
novelty: Novelty.NEW,
|
|
156
|
+
source: CandidateSource.AGENT,
|
|
157
|
+
candidateId,
|
|
158
|
+
...(verifiedDiscriminator ? { verifiedDiscriminator } : {}),
|
|
159
|
+
};
|
|
160
|
+
return candidate;
|
|
161
|
+
});
|
|
162
|
+
return { candidates: built, demotions };
|
|
163
|
+
}
|
|
164
|
+
/** Recover full Candidates for pre-seeded ("server") plan items by matching
|
|
165
|
+
* candidateId back to the analyzed scenarios (repositoryAnalysis.scenarios) —
|
|
166
|
+
* ApprovedPlanItem is deliberately slim and doesn't carry steps[]. */
|
|
167
|
+
function recoverServerCandidates(priorPlan, allScenarios) {
|
|
168
|
+
if (!priorPlan)
|
|
169
|
+
return [];
|
|
170
|
+
const scenarioById = new Map();
|
|
171
|
+
for (const scenario of allScenarios) {
|
|
172
|
+
scenarioById.set(computeCandidateId(scenario), scenario);
|
|
173
|
+
}
|
|
174
|
+
const serverItems = [...priorPlan.generate, ...priorPlan.additional].filter((item) => item.source === CandidateSource.SERVER);
|
|
175
|
+
const recovered = [];
|
|
176
|
+
for (const item of serverItems) {
|
|
177
|
+
const scenario = scenarioById.get(item.candidateId);
|
|
178
|
+
if (!scenario) {
|
|
179
|
+
logger.warning(`${TOOL_NAME}: pre-seeded server candidate ${item.candidateId} not found in repositoryAnalysis.scenarios — dropping`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
recovered.push({
|
|
183
|
+
scenario,
|
|
184
|
+
priority: derivePriorityTier(scenario),
|
|
185
|
+
novelty: Novelty.EXISTING,
|
|
186
|
+
source: CandidateSource.SERVER,
|
|
187
|
+
candidateId: item.candidateId,
|
|
188
|
+
...(item.verifiedDiscriminator ? { verifiedDiscriminator: item.verifiedDiscriminator } : {}),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return recovered;
|
|
192
|
+
}
|
|
193
|
+
function resolveBudgetContext(stateData) {
|
|
194
|
+
const pbc = stateData.planBudgetContext;
|
|
195
|
+
return {
|
|
196
|
+
maxGenerate: pbc?.maxGenerate ?? MAX_TESTS_TO_GENERATE,
|
|
197
|
+
maxTotal: pbc?.maxTotal ?? MAX_RECOMMENDATIONS,
|
|
198
|
+
isUIOnlyPR: pbc?.isUIOnlyPR ?? false,
|
|
199
|
+
hasFrontendChanges: pbc?.hasFrontendChanges ?? false,
|
|
200
|
+
externalCoverage: new Set(pbc?.externalCoverageKeys ?? []),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function describeGenerationCall(item) {
|
|
204
|
+
switch (item.testType) {
|
|
205
|
+
case "contract":
|
|
206
|
+
return `call ${TOOL_CONTRACT_TEST_GENERATION}`;
|
|
207
|
+
case "integration":
|
|
208
|
+
return `call ${TOOL_BATCH_SCENARIO_TEST_GENERATION}, then ${TOOL_INTEGRATION_TEST_GENERATION} with the returned scenarioFile`;
|
|
209
|
+
case "e2e":
|
|
210
|
+
return `call ${TOOL_E2E_TEST_GENERATION}`;
|
|
211
|
+
case "ui":
|
|
212
|
+
return `call ${TOOL_UI_TEST_GENERATION}`;
|
|
213
|
+
default:
|
|
214
|
+
return "use the generation tool matching this testType";
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function renderPlanItem(item, rank) {
|
|
218
|
+
const discriminatorNote = item.verifiedDiscriminator ? `, verified discriminator: ${item.verifiedDiscriminator}` : "";
|
|
219
|
+
return `${rank}. [${item.testType}] "${item.scenarioName}" (${item.category}${discriminatorNote}) — ${describeGenerationCall(item)}`;
|
|
220
|
+
}
|
|
221
|
+
function renderPlanText(plan) {
|
|
222
|
+
const lines = [];
|
|
223
|
+
lines.push(`## Approved Test Plan (${plan.planId})`);
|
|
224
|
+
lines.push("");
|
|
225
|
+
lines.push("This plan is AUTHORITATIVE and FINAL. Generation tools reject scenarios that are not in the GENERATE list below. " +
|
|
226
|
+
"If you need to add, drop, or change a candidate, call skyramp_register_test_plan again with your full updated candidate list.");
|
|
227
|
+
lines.push("");
|
|
228
|
+
lines.push(`### GENERATE (${plan.generate.length}) — generate exactly these, in this order`);
|
|
229
|
+
if (plan.generate.length === 0) {
|
|
230
|
+
lines.push(" (none — nothing to generate this run)");
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
plan.generate.forEach((item, i) => lines.push(renderPlanItem(item, i + 1)));
|
|
234
|
+
}
|
|
235
|
+
lines.push("");
|
|
236
|
+
lines.push(`### ADDITIONAL (${plan.additional.length}) — report as recommendations; only generate if backfilling a failed GENERATE item`);
|
|
237
|
+
if (plan.additional.length === 0) {
|
|
238
|
+
lines.push(" (none)");
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
plan.additional.forEach((item, i) => lines.push(renderPlanItem(item, plan.generate.length + i + 1)));
|
|
242
|
+
}
|
|
243
|
+
if (plan.demotions.length > 0) {
|
|
244
|
+
lines.push("");
|
|
245
|
+
lines.push(`### Demotions (${plan.demotions.length}) — discriminator claims that did NOT verify (candidate still registered, claim dropped)`);
|
|
246
|
+
for (const demotion of plan.demotions) {
|
|
247
|
+
lines.push(`- ${demotion.candidateId}: ${demotion.reason}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return lines.join("\n");
|
|
251
|
+
}
|
|
252
|
+
// ── Tool registration ───────────────────────────────────────────────────────
|
|
253
|
+
export function registerRegisterTestPlanTool(server) {
|
|
254
|
+
server.registerTool(TOOL_NAME, {
|
|
255
|
+
annotations: {
|
|
256
|
+
destructiveHint: false,
|
|
257
|
+
readOnlyHint: false,
|
|
258
|
+
idempotentHint: false,
|
|
259
|
+
openWorldHint: false,
|
|
260
|
+
},
|
|
261
|
+
description: "REQUIRED before generation tools in testbot runs — the deterministic selection checkpoint that decides which candidate tests get generated. " +
|
|
262
|
+
"Register your COMPLETE candidate list (every test you would generate OR recommend) BEFORE calling any generation tool. " +
|
|
263
|
+
"Merges your candidates with any server pre-ranked candidates from skyramp_analyze_changes, verifies declared `discriminator` " +
|
|
264
|
+
"claims against the PR diff, re-ranks the union, and returns the mandated GENERATE list plus ADDITIONAL recommendations and " +
|
|
265
|
+
"demotion reasons for claims that failed verification. In an active testbot run, generation tools (skyramp_batch_scenario_test_generation, " +
|
|
266
|
+
"skyramp_contract_test_generation, skyramp_integration_test_generation) reject any scenario not in the returned GENERATE list — " +
|
|
267
|
+
"call this tool first, then generate exactly what it returns. Standalone/IDE MCP usage (no active testbot run) is unaffected.",
|
|
268
|
+
inputSchema: registerTestPlanSchema,
|
|
269
|
+
}, async (params) => {
|
|
270
|
+
let errorResult;
|
|
271
|
+
try {
|
|
272
|
+
logger.info(`Registering test plan${params.repository ? ` for repository ${params.repository}` : ""}`, {
|
|
273
|
+
candidateCount: params.candidates?.length ?? 0,
|
|
274
|
+
});
|
|
275
|
+
const stateManager = StateManager.fromStatePath(params.stateFile);
|
|
276
|
+
const stateData = await stateManager.readRepoData(params.repository);
|
|
277
|
+
const repositoryPath = (await stateManager.getRepoRepositoryPath(params.repository)) || "";
|
|
278
|
+
if (!stateData) {
|
|
279
|
+
errorResult = toolError(`State file is empty or invalid: ${params.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
|
|
280
|
+
return errorResult;
|
|
281
|
+
}
|
|
282
|
+
const diffText = stateData.diffText ?? "";
|
|
283
|
+
const allScenarios = stateData.repositoryAnalysis?.scenarios ?? [];
|
|
284
|
+
const { candidates: agentCandidates, demotions } = buildAgentCandidates(params.candidates ?? [], diffText);
|
|
285
|
+
const serverCandidates = recoverServerCandidates(stateData.approvedPlan, allScenarios);
|
|
286
|
+
// Merge by candidateId; a pre-seeded server candidate wins over an agent
|
|
287
|
+
// submission that happens to hash to the same id (identical scenario
|
|
288
|
+
// content) — source: "server" is preserved per the unified-plan design.
|
|
289
|
+
const merged = new Map();
|
|
290
|
+
for (const candidate of agentCandidates)
|
|
291
|
+
merged.set(candidate.candidateId, candidate);
|
|
292
|
+
for (const candidate of serverCandidates)
|
|
293
|
+
merged.set(candidate.candidateId, candidate);
|
|
294
|
+
const allCandidates = [...merged.values()];
|
|
295
|
+
// An empty union would persist an authoritative plan with an empty
|
|
296
|
+
// GENERATE list, which the generation gate then enforces — bricking
|
|
297
|
+
// the run until re-registration. Reject instead so the agent can
|
|
298
|
+
// self-correct in one step.
|
|
299
|
+
if (allCandidates.length === 0) {
|
|
300
|
+
errorResult = toolError("No candidates submitted and no server pre-ranked candidates exist for this run. " +
|
|
301
|
+
"Submit your complete candidate list (every test you would generate or recommend) in `candidates` — an empty plan would block all generation.");
|
|
302
|
+
return errorResult;
|
|
303
|
+
}
|
|
304
|
+
const budgetContext = resolveBudgetContext(stateData);
|
|
305
|
+
const result = selectPlan(allCandidates, { ...budgetContext, demotions });
|
|
306
|
+
const approvedPlan = {
|
|
307
|
+
planId: crypto.randomUUID(),
|
|
308
|
+
createdAt: new Date().toISOString(),
|
|
309
|
+
generate: result.generate.map(buildApprovedPlanItem),
|
|
310
|
+
additional: result.additional.map(buildApprovedPlanItem),
|
|
311
|
+
demotions: result.demotions,
|
|
312
|
+
};
|
|
313
|
+
try {
|
|
314
|
+
await stateManager.writeRepoData({ ...stateData, approvedPlan }, { repo: params.repository, repositoryPath, step: TOOL_NAME });
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
errorResult = toolError(`Failed to persist approved test plan to state file: ${error.message}. Retry this call.`);
|
|
318
|
+
return errorResult;
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
content: [{ type: "text", text: renderPlanText(approvedPlan) }],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
errorResult = toolError(`skyramp_register_test_plan failed: ${error?.message ?? String(error)}`);
|
|
326
|
+
return errorResult;
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// @ts-nocheck - Jest ESM type inference issues
|
|
2
|
+
import { jest } from "@jest/globals";
|
|
3
|
+
import * as fs from "fs/promises";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import * as os from "os";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
jest.unstable_mockModule("../../utils/logger.js", () => ({
|
|
8
|
+
logger: { info: jest.fn(), error: jest.fn(), warning: jest.fn(), debug: jest.fn() },
|
|
9
|
+
}));
|
|
10
|
+
const { registerRegisterTestPlanTool } = await import("./registerTestPlanTool.js");
|
|
11
|
+
const { computeCandidateId, DiscriminatorKind, CandidateSource } = await import("../../types/Recommendation.js");
|
|
12
|
+
function captureToolHandler() {
|
|
13
|
+
let handler;
|
|
14
|
+
let inputSchema;
|
|
15
|
+
const fakeServer = {
|
|
16
|
+
registerTool: (_name, opts, fn) => {
|
|
17
|
+
handler = fn;
|
|
18
|
+
inputSchema = opts.inputSchema;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
registerRegisterTestPlanTool(fakeServer);
|
|
22
|
+
// Mirrors the real MCP SDK's registerTool: validates args against inputSchema via Zod
|
|
23
|
+
// before invoking the handler, so tests exercise the real tool contract.
|
|
24
|
+
return async (params) => {
|
|
25
|
+
const parsed = z.object(inputSchema).safeParse(params);
|
|
26
|
+
if (!parsed.success) {
|
|
27
|
+
return { isError: true, content: [{ type: "text", text: parsed.error.message }] };
|
|
28
|
+
}
|
|
29
|
+
return handler(parsed.data);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function writeStateFile(dir, overrides = {}) {
|
|
33
|
+
const stateFile = path.join(dir, "analyze-changes-state.json");
|
|
34
|
+
await fs.writeFile(stateFile, JSON.stringify({
|
|
35
|
+
existingTests: [],
|
|
36
|
+
analysisScope: "current_branch_diff",
|
|
37
|
+
newEndpoints: [],
|
|
38
|
+
repositoryAnalysis: {},
|
|
39
|
+
...overrides,
|
|
40
|
+
metadata: {
|
|
41
|
+
sessionId: "test-session",
|
|
42
|
+
stateType: "analysis",
|
|
43
|
+
repositoryPath: dir,
|
|
44
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
45
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
46
|
+
},
|
|
47
|
+
}), "utf-8");
|
|
48
|
+
return stateFile;
|
|
49
|
+
}
|
|
50
|
+
async function readStateFile(stateFile) {
|
|
51
|
+
return JSON.parse(await fs.readFile(stateFile, "utf-8"));
|
|
52
|
+
}
|
|
53
|
+
const AGGREGATE_ANCHOR = "order_count = len(existing_orders)";
|
|
54
|
+
function multiRecordCandidate(overrides = {}) {
|
|
55
|
+
return {
|
|
56
|
+
scenarioName: "orders_multi_record_aggregate",
|
|
57
|
+
description: "Create two orders then assert the aggregate count.",
|
|
58
|
+
category: "business_rule",
|
|
59
|
+
priority: "high",
|
|
60
|
+
testType: "integration",
|
|
61
|
+
steps: [
|
|
62
|
+
{
|
|
63
|
+
order: 1,
|
|
64
|
+
method: "POST",
|
|
65
|
+
path: "/api/v1/orders",
|
|
66
|
+
description: "Create first order",
|
|
67
|
+
interactionType: "success",
|
|
68
|
+
expectedStatusCode: 201,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
order: 2,
|
|
72
|
+
method: "GET",
|
|
73
|
+
path: "/api/v1/orders/summary",
|
|
74
|
+
description: "Assert order_count is 2",
|
|
75
|
+
interactionType: "success",
|
|
76
|
+
expectedStatusCode: 200,
|
|
77
|
+
expectedResponseFields: ["order_count == 2"],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
...overrides,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
describe("skyramp_register_test_plan — SKYR-3879 Path B", () => {
|
|
84
|
+
let handler;
|
|
85
|
+
let tmpDirs = [];
|
|
86
|
+
beforeAll(() => {
|
|
87
|
+
handler = captureToolHandler();
|
|
88
|
+
});
|
|
89
|
+
afterEach(async () => {
|
|
90
|
+
for (const dir of tmpDirs) {
|
|
91
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
tmpDirs = [];
|
|
94
|
+
});
|
|
95
|
+
async function mkTmpDir() {
|
|
96
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "register-plan-test-"));
|
|
97
|
+
tmpDirs.push(dir);
|
|
98
|
+
return dir;
|
|
99
|
+
}
|
|
100
|
+
it("errors when stateFile does not exist", async () => {
|
|
101
|
+
const dir = await mkTmpDir();
|
|
102
|
+
const result = await handler({
|
|
103
|
+
stateFile: path.join(dir, "does-not-exist.json"),
|
|
104
|
+
candidates: [],
|
|
105
|
+
});
|
|
106
|
+
expect(result.isError).toBe(true);
|
|
107
|
+
expect(result.content[0].text).toContain("State file is empty or invalid");
|
|
108
|
+
});
|
|
109
|
+
it("rejects a relative stateFile path (schema refine)", async () => {
|
|
110
|
+
const result = await handler({ stateFile: "relative/path.json", candidates: [] });
|
|
111
|
+
expect(result.isError).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
it("rejects an empty candidate union instead of persisting an empty plan", async () => {
|
|
114
|
+
const dir = await mkTmpDir();
|
|
115
|
+
const stateFile = await writeStateFile(dir, { diffText: "" });
|
|
116
|
+
const result = await handler({ stateFile, candidates: [] });
|
|
117
|
+
expect(result.isError).toBe(true);
|
|
118
|
+
expect(result.content[0].text).toContain("No candidates submitted");
|
|
119
|
+
const persisted = JSON.parse(await fs.readFile(stateFile, "utf-8"));
|
|
120
|
+
expect(persisted.approvedPlan).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
it("promotes a verified discriminator candidate into GENERATE ahead of an unverified one", async () => {
|
|
123
|
+
const dir = await mkTmpDir();
|
|
124
|
+
const stateFile = await writeStateFile(dir, {
|
|
125
|
+
diffText: `--- a/orders.py\n+++ b/orders.py\n+${AGGREGATE_ANCHOR}\n`,
|
|
126
|
+
planBudgetContext: {
|
|
127
|
+
maxGenerate: 2,
|
|
128
|
+
maxTotal: 3,
|
|
129
|
+
isUIOnlyPR: false,
|
|
130
|
+
hasFrontendChanges: false,
|
|
131
|
+
externalCoverageKeys: [],
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const result = await handler({
|
|
135
|
+
stateFile,
|
|
136
|
+
candidates: [
|
|
137
|
+
multiRecordCandidate({
|
|
138
|
+
discriminator: { kind: DiscriminatorKind.MULTI_RECORD_AGGREGATE, changedCodeAnchor: AGGREGATE_ANCHOR },
|
|
139
|
+
}),
|
|
140
|
+
{
|
|
141
|
+
scenarioName: "products_crud",
|
|
142
|
+
description: "Basic CRUD happy path.",
|
|
143
|
+
category: "crud",
|
|
144
|
+
priority: "low",
|
|
145
|
+
testType: "contract",
|
|
146
|
+
steps: [
|
|
147
|
+
{
|
|
148
|
+
order: 1,
|
|
149
|
+
method: "GET",
|
|
150
|
+
path: "/api/v1/products",
|
|
151
|
+
description: "List products",
|
|
152
|
+
interactionType: "success",
|
|
153
|
+
expectedStatusCode: 200,
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
expect(result.isError).toBeFalsy();
|
|
160
|
+
expect(result.content[0].text).toContain("orders_multi_record_aggregate");
|
|
161
|
+
expect(result.content[0].text).toContain("verified discriminator: multi_record_aggregate");
|
|
162
|
+
const persisted = await readStateFile(stateFile);
|
|
163
|
+
expect(persisted.approvedPlan.generate[0].scenarioName).toBe("orders_multi_record_aggregate");
|
|
164
|
+
expect(persisted.approvedPlan.generate[0].verifiedDiscriminator).toBe(DiscriminatorKind.MULTI_RECORD_AGGREGATE);
|
|
165
|
+
expect(persisted.approvedPlan.demotions).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
it("demotes a gamed discriminator claim (no structural evidence) but keeps the candidate registered", async () => {
|
|
168
|
+
const dir = await mkTmpDir();
|
|
169
|
+
const stateFile = await writeStateFile(dir, {
|
|
170
|
+
diffText: `--- a/orders.py\n+++ b/orders.py\n+${AGGREGATE_ANCHOR}\n`,
|
|
171
|
+
planBudgetContext: {
|
|
172
|
+
maxGenerate: 2,
|
|
173
|
+
maxTotal: 3,
|
|
174
|
+
isUIOnlyPR: false,
|
|
175
|
+
hasFrontendChanges: false,
|
|
176
|
+
externalCoverageKeys: [],
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
const result = await handler({
|
|
180
|
+
stateFile,
|
|
181
|
+
candidates: [
|
|
182
|
+
multiRecordCandidate({
|
|
183
|
+
// No aggregate assertion — steps.length === 2 but the second step doesn't
|
|
184
|
+
// assert a count. A gamed claim: declares the discriminator anyway.
|
|
185
|
+
steps: [
|
|
186
|
+
{
|
|
187
|
+
order: 1,
|
|
188
|
+
method: "POST",
|
|
189
|
+
path: "/api/v1/orders",
|
|
190
|
+
description: "Create order",
|
|
191
|
+
interactionType: "success",
|
|
192
|
+
expectedStatusCode: 201,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
discriminator: { kind: DiscriminatorKind.MULTI_RECORD_AGGREGATE, changedCodeAnchor: AGGREGATE_ANCHOR },
|
|
196
|
+
}),
|
|
197
|
+
],
|
|
198
|
+
});
|
|
199
|
+
expect(result.isError).toBeFalsy();
|
|
200
|
+
expect(result.content[0].text).toContain("Demotions (1)");
|
|
201
|
+
expect(result.content[0].text).toContain("multi_record_aggregate unverified");
|
|
202
|
+
const persisted = await readStateFile(stateFile);
|
|
203
|
+
expect(persisted.approvedPlan.demotions).toHaveLength(1);
|
|
204
|
+
expect(persisted.approvedPlan.demotions[0].reason).toContain("multi_record_aggregate unverified");
|
|
205
|
+
// The candidate itself is NOT dropped — it must still appear somewhere in the plan.
|
|
206
|
+
const allScenarioNames = [...persisted.approvedPlan.generate, ...persisted.approvedPlan.additional].map((item) => item.scenarioName);
|
|
207
|
+
expect(allScenarioNames).toContain("orders_multi_record_aggregate");
|
|
208
|
+
// And it must NOT carry a verifiedDiscriminator.
|
|
209
|
+
const item = [...persisted.approvedPlan.generate, ...persisted.approvedPlan.additional].find((i) => i.scenarioName === "orders_multi_record_aggregate");
|
|
210
|
+
expect(item.verifiedDiscriminator).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
it("merges agent candidates with pre-seeded server candidates from analyze_changes", async () => {
|
|
213
|
+
const dir = await mkTmpDir();
|
|
214
|
+
const serverScenario = {
|
|
215
|
+
scenarioName: "server_seeded_scenario",
|
|
216
|
+
description: "Pre-ranked by the server's own classification.",
|
|
217
|
+
category: "business_rule",
|
|
218
|
+
priority: "high",
|
|
219
|
+
steps: [
|
|
220
|
+
{
|
|
221
|
+
order: 1,
|
|
222
|
+
method: "GET",
|
|
223
|
+
path: "/api/v1/reports",
|
|
224
|
+
description: "Fetch report",
|
|
225
|
+
interactionType: "success",
|
|
226
|
+
expectedStatusCode: 200,
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
chainingKeys: [],
|
|
230
|
+
requiresAuth: false,
|
|
231
|
+
estimatedComplexity: "simple",
|
|
232
|
+
testType: "contract",
|
|
233
|
+
};
|
|
234
|
+
const serverCandidateId = computeCandidateId(serverScenario);
|
|
235
|
+
const stateFile = await writeStateFile(dir, {
|
|
236
|
+
diffText: "",
|
|
237
|
+
repositoryAnalysis: { scenarios: [serverScenario] },
|
|
238
|
+
planBudgetContext: {
|
|
239
|
+
maxGenerate: 5,
|
|
240
|
+
maxTotal: 5,
|
|
241
|
+
isUIOnlyPR: false,
|
|
242
|
+
hasFrontendChanges: false,
|
|
243
|
+
externalCoverageKeys: [],
|
|
244
|
+
},
|
|
245
|
+
approvedPlan: {
|
|
246
|
+
planId: "prior-plan",
|
|
247
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
248
|
+
generate: [
|
|
249
|
+
{
|
|
250
|
+
candidateId: serverCandidateId,
|
|
251
|
+
scenarioName: "server_seeded_scenario",
|
|
252
|
+
testType: "contract",
|
|
253
|
+
category: "business_rule",
|
|
254
|
+
source: CandidateSource.SERVER,
|
|
255
|
+
matchKeys: [`name:server-seeded-scenario`, `ep:contract:GET:/api/v1/reports`],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
additional: [],
|
|
259
|
+
demotions: [],
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
const result = await handler({
|
|
263
|
+
stateFile,
|
|
264
|
+
candidates: [
|
|
265
|
+
{
|
|
266
|
+
scenarioName: "agent_drafted_scenario",
|
|
267
|
+
description: "Agent-drafted candidate this run.",
|
|
268
|
+
category: "workflow",
|
|
269
|
+
priority: "medium",
|
|
270
|
+
testType: "integration",
|
|
271
|
+
steps: [
|
|
272
|
+
{
|
|
273
|
+
order: 1,
|
|
274
|
+
method: "POST",
|
|
275
|
+
path: "/api/v1/carts",
|
|
276
|
+
description: "Create cart",
|
|
277
|
+
interactionType: "success",
|
|
278
|
+
expectedStatusCode: 201,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
});
|
|
284
|
+
expect(result.isError).toBeFalsy();
|
|
285
|
+
const persisted = await readStateFile(stateFile);
|
|
286
|
+
const names = persisted.approvedPlan.generate.map((item) => item.scenarioName);
|
|
287
|
+
expect(names).toContain("server_seeded_scenario");
|
|
288
|
+
expect(names).toContain("agent_drafted_scenario");
|
|
289
|
+
const serverItem = persisted.approvedPlan.generate.find((item) => item.scenarioName === "server_seeded_scenario");
|
|
290
|
+
expect(serverItem.source).toBe("server");
|
|
291
|
+
const agentItem = persisted.approvedPlan.generate.find((item) => item.scenarioName === "agent_drafted_scenario");
|
|
292
|
+
expect(agentItem.source).toBe("agent");
|
|
293
|
+
// New plan replaces the old planId (fresh registration, not an append).
|
|
294
|
+
expect(persisted.approvedPlan.planId).not.toBe("prior-plan");
|
|
295
|
+
});
|
|
296
|
+
});
|