@skyramp/mcp 0.3.8 → 0.4.0-rc.1
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/commands/commandLibrary.d.ts +1 -1
- package/build/commands/commandLibrary.js +3 -3
- package/build/commands/recommendTestsAndExecuteCommand.d.ts +1 -1
- package/build/commands/recommendTestsAndExecuteCommand.js +35 -20
- package/build/commands/testThisEndpointCommand.js +35 -19
- package/build/index.js +9 -3
- package/build/playwright/blueprintDigest.d.ts +15 -0
- package/build/playwright/blueprintDigest.js +152 -0
- package/build/playwright/blueprintDigestStore.d.ts +31 -0
- package/build/playwright/blueprintDigestStore.js +117 -0
- package/build/playwright/registerPlaywrightTools.js +60 -12
- package/build/playwright/traceRecordingPrompt.js +8 -7
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +9 -8
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -2
- package/build/prompts/promptAssets.d.ts +20 -0
- package/build/prompts/promptAssets.js +55 -0
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +19 -5
- package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
- package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +0 -10
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -11
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +8 -4
- package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +5 -22
- package/build/prompts/test-recommendation/diffExecutionPlan.js +37 -465
- package/build/prompts/test-recommendation/recommendationSections.d.ts +7 -17
- package/build/prompts/test-recommendation/recommendationSections.js +67 -309
- package/build/prompts/test-recommendation/recommendationShared.d.ts +19 -47
- package/build/prompts/test-recommendation/recommendationShared.js +49 -155
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +0 -5
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +10 -153
- package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -29
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +32 -457
- package/build/prompts/testbot/planDeclarations.d.ts +6 -0
- package/build/prompts/testbot/planDeclarations.js +9 -0
- package/build/prompts/testbot/testbot-prompts.d.ts +8 -0
- package/build/prompts/testbot/testbot-prompts.js +256 -381
- package/build/recommendation/answers.d.ts +35 -0
- package/build/recommendation/answers.js +96 -0
- package/build/recommendation/registerPlan.d.ts +49 -0
- package/build/recommendation/registerPlan.js +117 -0
- package/build/recommendation/runVerifiers.d.ts +10 -0
- package/build/recommendation/runVerifiers.js +49 -0
- package/build/recommendation/subjectStep.d.ts +42 -0
- package/build/recommendation/subjectStep.js +86 -0
- package/build/recommendation/types.d.ts +163 -0
- package/build/recommendation/types.js +20 -0
- package/build/recommendation/verifierContracts.d.ts +382 -0
- package/build/recommendation/verifierContracts.js +263 -0
- package/build/recommendation/verifiers/changedFile.d.ts +2 -0
- package/build/recommendation/verifiers/changedFile.js +82 -0
- package/build/recommendation/verifiers/citedPath.d.ts +12 -0
- package/build/recommendation/verifiers/citedPath.js +35 -0
- package/build/recommendation/verifiers/coverage.d.ts +7 -0
- package/build/recommendation/verifiers/coverage.js +617 -0
- package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +11 -0
- package/build/recommendation/verifiers/deliveredMatchesPlan.js +33 -0
- package/build/recommendation/verifiers/endpointGrounded.d.ts +17 -0
- package/build/recommendation/verifiers/endpointGrounded.js +128 -0
- package/build/recommendation/verifiers/existingCoverage.d.ts +6 -0
- package/build/recommendation/verifiers/existingCoverage.js +51 -0
- package/build/recommendation/verifiers/expectedOutcome.d.ts +31 -0
- package/build/recommendation/verifiers/expectedOutcome.js +105 -0
- package/build/recommendation/verifiers/removedElementGuarded.d.ts +2 -0
- package/build/recommendation/verifiers/removedElementGuarded.js +57 -0
- package/build/recommendation/verifiers/reportedCategory.d.ts +26 -0
- package/build/recommendation/verifiers/reportedCategory.js +84 -0
- package/build/recommendation/verifiers/screenRoute.d.ts +10 -0
- package/build/recommendation/verifiers/screenRoute.js +118 -0
- package/build/recommendation/verifiers/statedDifference.d.ts +6 -0
- package/build/recommendation/verifiers/statedDifference.js +140 -0
- package/build/recommendation/verifiers/uiElementGrounded.d.ts +7 -0
- package/build/recommendation/verifiers/uiElementGrounded.js +318 -0
- package/build/resources/analysisResources.js +1 -114
- package/build/resources/testbotResource.js +23 -13
- package/build/services/ModularizationService.js +2 -1
- package/build/services/TestDiscoveryService.d.ts +3 -72
- package/build/services/TestDiscoveryService.js +10 -303
- package/build/services/containerEnv.d.ts +1 -1
- package/build/services/containerEnv.js +12 -0
- package/build/skills/fixTestImportErrorsSkill.d.ts +13 -0
- package/build/skills/fixTestImportErrorsSkill.js +20 -0
- package/build/toolNames.d.ts +1 -0
- package/build/toolNames.js +1 -0
- package/build/tools/code-refactor/enhanceAssertionsTool.js +3 -3
- package/build/tools/code-refactor/modularizationTool.js +2 -1
- package/build/tools/executeSkyrampTestTool.d.ts +80 -0
- package/build/tools/executeSkyrampTestTool.js +246 -19
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -0
- package/build/tools/generate-tests/generateContractRestTool.js +3 -3
- package/build/tools/generate-tests/planGuard.d.ts +2 -2
- package/build/tools/generate-tests/planGuard.js +78 -18
- package/build/tools/one-click/oneClickTool.d.ts +0 -1
- package/build/tools/one-click/oneClickTool.js +0 -5
- package/build/tools/submitReportTool.d.ts +48 -42
- package/build/tools/submitReportTool.js +576 -193
- package/build/tools/test-management/actionsTool.js +72 -4
- package/build/tools/test-management/analyzeChangesTool.d.ts +144 -48
- package/build/tools/test-management/analyzeChangesTool.js +212 -1219
- package/build/tools/test-management/analyzeTestHealthTool.js +13 -24
- 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 +795 -172
- package/build/tools/test-management/registerTestPlanTool.js +609 -542
- package/build/tools/test-management/resolveScreenTool.d.ts +75 -0
- package/build/tools/test-management/resolveScreenTool.js +289 -0
- package/build/types/BlueprintDigest.d.ts +34 -0
- package/build/types/BlueprintDigest.js +1 -0
- package/build/types/RepositoryAnalysis.d.ts +20 -1559
- package/build/types/RepositoryAnalysis.js +2 -58
- package/build/types/StepMethod.d.ts +40 -0
- package/build/types/StepMethod.js +77 -0
- package/build/types/TestAnalysis.d.ts +12 -0
- package/build/types/TestExecution.d.ts +4 -0
- package/build/types/TestRecommendation.d.ts +24 -24
- package/build/types/TestRecommendation.js +91 -89
- package/build/types/TestbotPromptOptions.d.ts +0 -4
- package/build/types/TestbotReport.d.ts +64 -2
- package/build/utils/AnalysisStateManager.d.ts +79 -113
- package/build/utils/AnalysisStateManager.js +147 -57
- package/build/utils/assertion-verify/api-shared-lints.js +1 -1
- package/build/utils/assertion-verify/metrics.js +85 -36
- package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
- package/build/utils/assertion-verify/ui-lints.js +32 -0
- package/build/utils/branchDiff.d.ts +63 -31
- package/build/utils/branchDiff.js +242 -94
- package/build/utils/containedPath.d.ts +18 -0
- package/build/utils/containedPath.js +73 -0
- package/build/utils/dartRouteExtractor.d.ts +18 -34
- package/build/utils/dartRouteExtractor.js +101 -173
- package/build/utils/featureFlags.d.ts +12 -0
- package/build/utils/featureFlags.js +14 -0
- package/build/utils/frontendSelectors.d.ts +48 -27
- package/build/utils/frontendSelectors.js +241 -80
- package/build/utils/pathMatching.d.ts +2 -4
- package/build/utils/pathMatching.js +2 -4
- package/build/utils/planMatchKeys.d.ts +38 -47
- package/build/utils/planMatchKeys.js +143 -81
- package/build/utils/rebaselineSnapshots.d.ts +24 -0
- package/build/utils/rebaselineSnapshots.js +65 -0
- package/build/utils/removedUiElements.d.ts +22 -0
- package/build/utils/removedUiElements.js +106 -0
- package/build/utils/reportVerification.d.ts +2 -6
- package/build/utils/reportVerification.js +61 -2
- package/build/utils/screenRoutes.d.ts +66 -0
- package/build/utils/screenRoutes.js +727 -0
- package/build/utils/sourceRouteExtractor.js +320 -112
- package/build/utils/testFileClassification.d.ts +11 -2
- package/build/utils/testFileClassification.js +44 -2
- package/build/utils/testFixtures.d.ts +5 -0
- package/build/utils/testFixtures.js +13 -0
- package/build/utils/utils.d.ts +0 -1
- package/build/utils/utils.js +0 -11
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/workspace/workspace.d.ts +12 -12
- package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
- package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
- package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/plugin.json +6 -0
- package/plugin/prompts/declaring-a-plan.md +20 -0
- package/plugin/prompts/generate-tests/context-fetching.md +4 -0
- package/plugin/prompts/generate-tests/execution-plan.md +63 -0
- package/plugin/prompts/generate-tests/generation.md +108 -0
- package/plugin/prompts/generate-tests/path-parameters.md +1 -0
- package/plugin/prompts/generate-tests/reasoning-protocol.md +17 -0
- package/plugin/prompts/generate-tests/tool-workflow-variants.md +61 -0
- package/plugin/prompts/generate-tests/tool-workflows.md +65 -0
- package/plugin/prompts/plan-tests.md +42 -0
- package/plugin/prompts/testbot-task1.md +82 -0
- package/plugin/skills/fix-test-import-errors/SKILL.md +98 -0
- package/build/prompts/test-recommendation/analysisOutputPrompt.d.ts +0 -84
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +0 -369
- package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +0 -7
- package/build/prompts/test-recommendation/fullRepoCatalog.js +0 -283
- package/build/prompts/test-recommendation/scopeAssessment.d.ts +0 -81
- package/build/prompts/test-recommendation/scopeAssessment.js +0 -359
- package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +0 -7
- package/build/recommendation/budgeters/diversityBalancedBudgeter.js +0 -105
- package/build/recommendation/budgeters/fixedNBudgeter.d.ts +0 -7
- package/build/recommendation/budgeters/fixedNBudgeter.js +0 -11
- package/build/recommendation/budgeters/shared.d.ts +0 -32
- package/build/recommendation/budgeters/shared.js +0 -246
- package/build/recommendation/discriminators.d.ts +0 -37
- package/build/recommendation/discriminators.js +0 -379
- package/build/recommendation/diversity.d.ts +0 -47
- package/build/recommendation/diversity.js +0 -101
- package/build/recommendation/planRanker.d.ts +0 -65
- package/build/recommendation/planRanker.js +0 -83
- package/build/recommendation/testFixtures.d.ts +0 -25
- package/build/recommendation/testFixtures.js +0 -45
- package/build/types/FrontendIntegration.d.ts +0 -28
- package/build/types/FrontendIntegration.js +0 -22
- package/build/types/Recommendation.d.ts +0 -146
- package/build/types/Recommendation.js +0 -74
- package/build/utils/changedRoutes.d.ts +0 -29
- package/build/utils/changedRoutes.js +0 -87
- package/build/utils/frontendIntegration.d.ts +0 -9
- package/build/utils/frontendIntegration.js +0 -243
- package/build/utils/importerHop.d.ts +0 -135
- package/build/utils/importerHop.js +0 -489
- package/build/utils/pathAffinityClassification.d.ts +0 -49
- package/build/utils/pathAffinityClassification.js +0 -180
- package/build/utils/pythonMountPrefixes.d.ts +0 -25
- package/build/utils/pythonMountPrefixes.js +0 -347
- package/build/utils/repoScanner.d.ts +0 -34
- package/build/utils/repoScanner.js +0 -300
- package/build/utils/routeParsers.d.ts +0 -95
- package/build/utils/routeParsers.js +0 -951
- package/build/utils/scenarioDrafting.d.ts +0 -92
- package/build/utils/scenarioDrafting.js +0 -951
- package/build/utils/subjectEndpoints.d.ts +0 -19
- package/build/utils/subjectEndpoints.js +0 -98
- package/build/utils/uiPageEnumerator.d.ts +0 -172
- package/build/utils/uiPageEnumerator.js +0 -474
|
@@ -1,951 +0,0 @@
|
|
|
1
|
-
import { ScenarioSource } from "../types/RepositoryAnalysis.js";
|
|
2
|
-
import { TestType } from "../types/TestTypes.js";
|
|
3
|
-
import { CATEGORY_PRIORITY, PriorityTier } from "../types/TestRecommendation.js";
|
|
4
|
-
import { inferExpectedStatus } from "./httpDefaults.js";
|
|
5
|
-
import { WorkspaceAuthType } from "./workspaceAuth.js";
|
|
6
|
-
import { deriveResourceToken } from "./importerHop.js";
|
|
7
|
-
import { isSegmentPrefix, pathSuffixMatches } from "./pathMatching.js";
|
|
8
|
-
import { singularize } from "./pluralization.js";
|
|
9
|
-
// Only tokens that are structurally non-testable (meta, infra, static assets).
|
|
10
|
-
// Semantic tokens like login, search, webhook, payment are removed — the LLM
|
|
11
|
-
// already knows how to handle them and the filter was suppressing valid surfaces.
|
|
12
|
-
const ACTION_PATTERN = /^(me|merge|archive|dashboard|migration|favicon|health|status|ping|metrics)$/i;
|
|
13
|
-
const ACTION_VERB_HYPHEN = /^(forgot-|reset-|verify-|confirm-|send-|check-|get-|set-|update-|delete-|create-|trigger-|start-|stop-)/i;
|
|
14
|
-
/** Matches paths whose last segment is an execution verb (e.g. /jobs/execute, /actions/run). */
|
|
15
|
-
const EXECUTION_SUFFIX = /\/(execute|run|trigger|process|invoke|dispatch|send|apply)$/i;
|
|
16
|
-
function isExecutionEndpoint(path) {
|
|
17
|
-
return EXECUTION_SUFFIX.test(path);
|
|
18
|
-
}
|
|
19
|
-
/** Extract the execution verb from the path's last segment (e.g. "/jobs/run" → "run"). */
|
|
20
|
-
function extractExecutionVerb(endpointPath) {
|
|
21
|
-
const match = EXECUTION_SUFFIX.exec(endpointPath);
|
|
22
|
-
return match ? match[1].toLowerCase() : "execute";
|
|
23
|
-
}
|
|
24
|
-
export function isRealResource(r) {
|
|
25
|
-
return !ACTION_PATTERN.test(r) && !ACTION_VERB_HYPHEN.test(r);
|
|
26
|
-
}
|
|
27
|
-
const SKIP_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
|
|
28
|
-
/**
|
|
29
|
-
* Extract the primary resource name from an endpoint path.
|
|
30
|
-
* E.g. "/api/v1/flow-costs/{cost_id}" → "flow-costs"
|
|
31
|
-
* "/collections/{id}/links" → "links"
|
|
32
|
-
*/
|
|
33
|
-
function extractResourceName(path) {
|
|
34
|
-
const segments = path.split("/").filter(Boolean);
|
|
35
|
-
const nonParam = segments.filter(s => !s.startsWith("{") && !SKIP_SEGMENTS.has(s));
|
|
36
|
-
const name = nonParam[nonParam.length - 1];
|
|
37
|
-
return name && isRealResource(name) ? name : null;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Infer parent→child resource relationships from two signals already present
|
|
41
|
-
* in the endpoint data — no source code scanning required.
|
|
42
|
-
*
|
|
43
|
-
* Signal 1 — path nesting (always available):
|
|
44
|
-
* /collections/{id}/links → links depends on collections
|
|
45
|
-
*
|
|
46
|
-
* Signal 2 — request body FK fields (available when trace data is merged):
|
|
47
|
-
* POST /orders body: { product_id: "..." } → orders depends on products
|
|
48
|
-
*
|
|
49
|
-
* Returns a map of `dependent → Set<parent>`. Only confirmed, real-resource
|
|
50
|
-
* pairs are included. When no signal is found the map is empty, which tells
|
|
51
|
-
* the caller to fall back to heuristic pairing.
|
|
52
|
-
*/
|
|
53
|
-
export function inferResourceRelationships(endpoints) {
|
|
54
|
-
const relationships = new Map();
|
|
55
|
-
const addRelationship = (dependent, parent) => {
|
|
56
|
-
if (dependent === parent)
|
|
57
|
-
return;
|
|
58
|
-
if (!isRealResource(dependent) || !isRealResource(parent))
|
|
59
|
-
return;
|
|
60
|
-
const deps = relationships.get(dependent) ?? new Set();
|
|
61
|
-
deps.add(parent);
|
|
62
|
-
relationships.set(dependent, deps);
|
|
63
|
-
};
|
|
64
|
-
// ── Signal 1: path nesting ──
|
|
65
|
-
// Pattern: /…/parentResource/{param}/childResource
|
|
66
|
-
for (const ep of endpoints) {
|
|
67
|
-
const segments = ep.path.split("/").filter(Boolean);
|
|
68
|
-
for (let i = 0; i + 2 < segments.length; i++) {
|
|
69
|
-
const seg = segments[i];
|
|
70
|
-
const paramSeg = segments[i + 1];
|
|
71
|
-
const childSeg = segments[i + 2];
|
|
72
|
-
if (!seg.startsWith("{") &&
|
|
73
|
-
paramSeg.startsWith("{") &&
|
|
74
|
-
!childSeg.startsWith("{") &&
|
|
75
|
-
!SKIP_SEGMENTS.has(seg) &&
|
|
76
|
-
!SKIP_SEGMENTS.has(childSeg)) {
|
|
77
|
-
addRelationship(childSeg, seg);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
// ── Signal 2: FK fields in request body interactions ──
|
|
82
|
-
// Collect all known resource names first so we can validate FK targets.
|
|
83
|
-
const knownResources = new Set(endpoints.map(ep => extractResourceName(ep.path)).filter(Boolean));
|
|
84
|
-
// Map singular form → actual resource name to handle irregular plurals.
|
|
85
|
-
// e.g. singularize("companies") = "company", so "company_id" resolves to "companies".
|
|
86
|
-
const singularToResource = new Map([...knownResources].map(r => [singularize(r), r]));
|
|
87
|
-
for (const ep of endpoints) {
|
|
88
|
-
const resource = extractResourceName(ep.path);
|
|
89
|
-
if (!resource)
|
|
90
|
-
continue;
|
|
91
|
-
for (const m of ep.methods) {
|
|
92
|
-
if (typeof m === "string")
|
|
93
|
-
continue;
|
|
94
|
-
if (!["POST", "PUT", "PATCH"].includes(m.method))
|
|
95
|
-
continue;
|
|
96
|
-
for (const interaction of m.interactions ?? []) {
|
|
97
|
-
const body = interaction.request?.body;
|
|
98
|
-
if (!body || typeof body !== "object")
|
|
99
|
-
continue;
|
|
100
|
-
for (const key of Object.keys(body)) {
|
|
101
|
-
if (!key.endsWith("_id"))
|
|
102
|
-
continue;
|
|
103
|
-
const depSingular = key.slice(0, -3); // "product_id" → "product"
|
|
104
|
-
const depPlural = depSingular + "s"; // naive plural, fine for regular nouns
|
|
105
|
-
// Only create relationship when the dependency actually exists as an endpoint.
|
|
106
|
-
// Check naive plural first (products), then exact singular (product),
|
|
107
|
-
// then reverse-singularize to catch irregular plurals (company → companies).
|
|
108
|
-
if (knownResources.has(depPlural)) {
|
|
109
|
-
addRelationship(resource, depPlural);
|
|
110
|
-
}
|
|
111
|
-
else if (knownResources.has(depSingular)) {
|
|
112
|
-
addRelationship(resource, depSingular);
|
|
113
|
-
}
|
|
114
|
-
else if (singularToResource.has(depSingular)) {
|
|
115
|
-
addRelationship(resource, singularToResource.get(depSingular));
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return relationships;
|
|
122
|
-
}
|
|
123
|
-
export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], wsAuthType, options = {}, removedEndpoints = []) {
|
|
124
|
-
const scenarios = [];
|
|
125
|
-
// A drafted step path reaches the agent as the plan's `endpoint` and as the
|
|
126
|
-
// URL the generated test calls, so it has to be the spelling an HTTP client
|
|
127
|
-
// accepts. A route scanned from Express or NestJS source arrives as
|
|
128
|
-
// `/members/:uid`; the agent then spends one of its two repair attempts
|
|
129
|
-
// fixing the URL. Measured on eval run 32606742433, fixture
|
|
130
|
-
// cc15-org-reviewer-role: "2-attempt limit reached after URL fix", and the
|
|
131
|
-
// test failed on an unrelated assertion it had no attempt left to correct.
|
|
132
|
-
//
|
|
133
|
-
// Normalize here, at the one place the drafter reads an endpoint, rather than
|
|
134
|
-
// rewriting every stored route: `extractResourceFromPath` already treats both
|
|
135
|
-
// spellings as a parameter, so nothing else in the pipeline needs the change.
|
|
136
|
-
// Every path this function compares must be normalized together. Converting
|
|
137
|
-
// `endpoints` alone made the attack-surface sibling search compare a brace
|
|
138
|
-
// path against a colon `changedEndpoints` entry and find nothing.
|
|
139
|
-
endpoints = endpoints.map((ep) => ({ ...ep, path: toBraceParams(ep.path) }));
|
|
140
|
-
newEndpoints = newEndpoints.map((ep) => ({ ...ep, path: toBraceParams(ep.path) }));
|
|
141
|
-
removedEndpoints = removedEndpoints.map((ep) => ({ ...ep, path: toBraceParams(ep.path) }));
|
|
142
|
-
if (options.changedEndpoints) {
|
|
143
|
-
options = {
|
|
144
|
-
...options,
|
|
145
|
-
changedEndpoints: options.changedEndpoints.map((ep) => ({ ...ep, path: toBraceParams(ep.path) })),
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
const resourceGroups = new Map();
|
|
149
|
-
for (const ep of endpoints) {
|
|
150
|
-
const segments = ep.path.split("/").filter(Boolean);
|
|
151
|
-
// Was `!s.startsWith("{")` — brace-only, so a colon-style segment became the
|
|
152
|
-
// resource and the drafted scenario was named ":uid-delete-auth-boundary".
|
|
153
|
-
// `isPathParam` is this file's own predicate and already accepts both forms;
|
|
154
|
-
// it deliberately does NOT reject a literal id (see its comment, SKYR-4229),
|
|
155
|
-
// so this changes the param spelling only.
|
|
156
|
-
const nonParamSegs = segments.filter(s => !isPathParam(s) && !SKIP_SEGMENTS.has(s));
|
|
157
|
-
const resource = nonParamSegs[nonParamSegs.length - 1] || "unknown";
|
|
158
|
-
const hasParam = /\{/.test(ep.path);
|
|
159
|
-
const resourceSegIdx = segments.lastIndexOf(resource);
|
|
160
|
-
const basePath = "/" + segments.slice(0, resourceSegIdx + 1).join("/");
|
|
161
|
-
let paramPath;
|
|
162
|
-
if (hasParam && resourceSegIdx + 1 < segments.length && segments[resourceSegIdx + 1].startsWith("{")) {
|
|
163
|
-
paramPath = basePath + "/" + segments[resourceSegIdx + 1];
|
|
164
|
-
}
|
|
165
|
-
const existing = resourceGroups.get(resource);
|
|
166
|
-
if (existing) {
|
|
167
|
-
// Only merge if the paths are related at a segment boundary.
|
|
168
|
-
const related = isSegmentPrefix(basePath, existing.basePath) || isSegmentPrefix(existing.basePath, basePath);
|
|
169
|
-
if (related) {
|
|
170
|
-
for (const m of ep.methods)
|
|
171
|
-
existing.methods.add(typeof m === "string" ? m : m.method);
|
|
172
|
-
if (basePath.split("/").length < existing.basePath.split("/").length) {
|
|
173
|
-
existing.basePath = basePath;
|
|
174
|
-
}
|
|
175
|
-
if (paramPath && (!existing.paramPath || paramPath.split("/").length < existing.paramPath.split("/").length)) {
|
|
176
|
-
existing.paramPath = paramPath;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
else {
|
|
180
|
-
// Use the raw basePath as the disambiguating suffix — replacing "/" with "_" could
|
|
181
|
-
// produce collisions between e.g. /orders/search and /orders_search.
|
|
182
|
-
const disambiguatedKey = `${resource}::${basePath}`;
|
|
183
|
-
resourceGroups.set(disambiguatedKey, {
|
|
184
|
-
basePath,
|
|
185
|
-
methods: new Set(ep.methods.map((m) => typeof m === "string" ? m : m.method)),
|
|
186
|
-
paramPath,
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
else {
|
|
191
|
-
resourceGroups.set(resource, {
|
|
192
|
-
basePath,
|
|
193
|
-
methods: new Set(ep.methods.map((m) => typeof m === "string" ? m : m.method)),
|
|
194
|
-
paramPath,
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
scenarios.push(...draftDiffDirectScenarios(newEndpoints, resourceGroups, wsAuthType));
|
|
199
|
-
scenarios.push(...draftAttackSurfaceExpansionScenarios(endpoints, options.changedEndpoints ?? [], wsAuthType, options.securityRelevantDiff ?? false));
|
|
200
|
-
// 404-guard scenarios for removed endpoints — absence assertions confirming
|
|
201
|
-
// the endpoint is gone. Single-step contract tests, no chaining needed.
|
|
202
|
-
for (const ep of removedEndpoints) {
|
|
203
|
-
scenarios.push({
|
|
204
|
-
scenarioName: `verify-removed-${ep.method.toLowerCase()}-${ep.path.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}`,
|
|
205
|
-
description: `Verify ${ep.method} ${ep.path} returns 404 — endpoint was removed in this PR`,
|
|
206
|
-
category: "breaking_change",
|
|
207
|
-
priority: "high",
|
|
208
|
-
steps: [{
|
|
209
|
-
order: 1,
|
|
210
|
-
method: ep.method,
|
|
211
|
-
path: ep.path,
|
|
212
|
-
description: `${ep.method} ${ep.path} → 404 (endpoint removed)`,
|
|
213
|
-
interactionType: "error",
|
|
214
|
-
expectedStatusCode: 404,
|
|
215
|
-
}],
|
|
216
|
-
chainingKeys: [],
|
|
217
|
-
requiresAuth: false,
|
|
218
|
-
estimatedComplexity: "simple",
|
|
219
|
-
source: ScenarioSource.CodeInferred,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
return capScenarios(scenarios);
|
|
223
|
-
}
|
|
224
|
-
const MAX_TOTAL_SCENARIOS = 30;
|
|
225
|
-
const TIER_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
|
|
226
|
-
/**
|
|
227
|
-
* Enforce a global cap on drafted scenarios while preserving category diversity.
|
|
228
|
-
*
|
|
229
|
-
* 1. CRITICAL (bug_caught, requirement_conflict) scenarios prioritized first.
|
|
230
|
-
* 2. One scenario per non-empty category guaranteed (breadth).
|
|
231
|
-
* 3. Remaining budget filled by priority tier (HIGH > MEDIUM > LOW).
|
|
232
|
-
* 4. Hard cap at MAX_TOTAL_SCENARIOS — applied to the combined output.
|
|
233
|
-
*/
|
|
234
|
-
export function capScenarios(scenarios) {
|
|
235
|
-
if (scenarios.length <= MAX_TOTAL_SCENARIOS)
|
|
236
|
-
return scenarios;
|
|
237
|
-
const critical = scenarios.filter(s => CATEGORY_PRIORITY[s.category] === PriorityTier.CRITICAL);
|
|
238
|
-
const rest = scenarios.filter(s => CATEGORY_PRIORITY[s.category] !== PriorityTier.CRITICAL);
|
|
239
|
-
const breadthPicks = [];
|
|
240
|
-
const seenCategories = new Set();
|
|
241
|
-
for (const s of rest) {
|
|
242
|
-
if (!seenCategories.has(s.category)) {
|
|
243
|
-
seenCategories.add(s.category);
|
|
244
|
-
breadthPicks.push(s);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
const breadthSet = new Set(breadthPicks);
|
|
248
|
-
const remaining = rest.filter(s => !breadthSet.has(s));
|
|
249
|
-
remaining.sort((a, b) => {
|
|
250
|
-
const ta = TIER_ORDER[CATEGORY_PRIORITY[a.category] ?? PriorityTier.LOW] ?? 1;
|
|
251
|
-
const tb = TIER_ORDER[CATEGORY_PRIORITY[b.category] ?? PriorityTier.LOW] ?? 1;
|
|
252
|
-
return tb - ta;
|
|
253
|
-
});
|
|
254
|
-
const budget = MAX_TOTAL_SCENARIOS - critical.length - breadthPicks.length;
|
|
255
|
-
const filler = remaining.slice(0, Math.max(budget, 0));
|
|
256
|
-
const combined = [...critical, ...breadthPicks, ...filler];
|
|
257
|
-
// Enforce the hard cap even if critical set alone exceeds it
|
|
258
|
-
return combined.slice(0, MAX_TOTAL_SCENARIOS);
|
|
259
|
-
}
|
|
260
|
-
// ── Diff-direct scenario drafting ──
|
|
261
|
-
// Generates targeted scenarios for each new endpoint in the branch diff.
|
|
262
|
-
// These get category "new_endpoint", which maps to the MEDIUM priority tier in
|
|
263
|
-
// the scorer — BELOW business_rule, security_boundary and the other categories
|
|
264
|
-
// that state what a test proves. They compete for GENERATE slots on merit
|
|
265
|
-
// rather than auto-filling ahead of everything else; CRITICAL is reserved for
|
|
266
|
-
// bug_caught, which targets an actually identified flaw. Level with the
|
|
267
|
-
// structural categories was still too high: the last rank key is the
|
|
268
|
-
// candidateId, so among same-tier candidates the alphabet decided.
|
|
269
|
-
/**
|
|
270
|
-
* Build the minimum steps for a diff-direct integration scenario.
|
|
271
|
-
* Prerequisite resources (e.g. POST /products before POST /orders) are NOT
|
|
272
|
-
* computed here — the execution plan instructs the LLM to discover them from
|
|
273
|
-
* source code (FK fields in request bodies) and prepend the steps itself.
|
|
274
|
-
*/
|
|
275
|
-
function diffDirectIntegration(method, resource, singular, group) {
|
|
276
|
-
const steps = [];
|
|
277
|
-
if (method === "POST") {
|
|
278
|
-
steps.push({
|
|
279
|
-
order: 1, method: "POST", path: group.basePath,
|
|
280
|
-
description: `Create ${singular} and verify response`,
|
|
281
|
-
interactionType: "success", expectedStatusCode: 201,
|
|
282
|
-
});
|
|
283
|
-
if (group.paramPath && group.methods.has("GET")) {
|
|
284
|
-
steps.push({
|
|
285
|
-
order: 2, method: "GET", path: group.paramPath,
|
|
286
|
-
description: `Retrieve created ${singular} and verify fields`,
|
|
287
|
-
interactionType: "success", expectedStatusCode: 200,
|
|
288
|
-
chainsFrom: { sourceStep: 1, sourceField: "<id-field>", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" },
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
else if (method === "DELETE") {
|
|
293
|
-
if (group.methods.has("POST")) {
|
|
294
|
-
steps.push({
|
|
295
|
-
order: 1, method: "POST", path: group.basePath,
|
|
296
|
-
description: `Create ${singular} to delete`,
|
|
297
|
-
interactionType: "success", expectedStatusCode: 201,
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
const targetPath = group.paramPath ?? group.basePath;
|
|
301
|
-
steps.push({
|
|
302
|
-
order: steps.length + 1, method: "DELETE", path: targetPath,
|
|
303
|
-
description: `Delete ${singular}`,
|
|
304
|
-
interactionType: "success", expectedStatusCode: 204,
|
|
305
|
-
...(steps.length > 0 ? { chainsFrom: { sourceStep: 1, sourceField: "<id-field>", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" } } : {}),
|
|
306
|
-
});
|
|
307
|
-
if (group.paramPath && group.methods.has("GET")) {
|
|
308
|
-
steps.push({
|
|
309
|
-
order: steps.length + 1, method: "GET", path: group.paramPath,
|
|
310
|
-
description: `Verify ${singular} is deleted (404)`,
|
|
311
|
-
interactionType: "error", expectedStatusCode: 404,
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
const suffix = method === "DELETE" ? "delete" : method.toLowerCase();
|
|
316
|
-
return {
|
|
317
|
-
scenarioName: `${resource}-${suffix}-lifecycle`,
|
|
318
|
-
description: `Lifecycle test: ${method} ${group.basePath} — verify ${method === "DELETE" ? "delete and subsequent 404" : "create and read-back"} flow`,
|
|
319
|
-
category: "new_endpoint",
|
|
320
|
-
priority: "high",
|
|
321
|
-
steps,
|
|
322
|
-
chainingKeys: ["id", `${singular}_id`],
|
|
323
|
-
requiresAuth: true,
|
|
324
|
-
estimatedComplexity: "moderate",
|
|
325
|
-
source: ScenarioSource.CodeInferred,
|
|
326
|
-
testType: TestType.INTEGRATION,
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
function diffDirectContract(method, path, resource) {
|
|
330
|
-
const expectedStatus = inferExpectedStatus(method);
|
|
331
|
-
return {
|
|
332
|
-
scenarioName: `${resource}-${method.toLowerCase()}-new-endpoint-contract`,
|
|
333
|
-
description: `Contract: ${method} ${path} returns a schema-conformant response`,
|
|
334
|
-
category: "new_endpoint",
|
|
335
|
-
priority: "high",
|
|
336
|
-
steps: [{ order: 1, method, path, description: `${method} ${path} with valid input — verify response schema`, interactionType: "success", expectedStatusCode: expectedStatus }],
|
|
337
|
-
chainingKeys: [],
|
|
338
|
-
requiresAuth: true,
|
|
339
|
-
estimatedComplexity: "simple",
|
|
340
|
-
source: ScenarioSource.CodeInferred,
|
|
341
|
-
testType: TestType.CONTRACT,
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
function diffDirectNotFound(method, path, resource, singular) {
|
|
345
|
-
return {
|
|
346
|
-
scenarioName: `${resource}-${method.toLowerCase()}-new-endpoint-not-found`,
|
|
347
|
-
description: `Edge case: ${method} ${path} with a non-existent ${singular} ID returns 404`,
|
|
348
|
-
category: "new_endpoint",
|
|
349
|
-
priority: "high",
|
|
350
|
-
steps: [{ order: 1, method, path, description: `${method} ${path} with non-existent ${singular} ID — expect 404 Not Found`, interactionType: "error", expectedStatusCode: 404 }],
|
|
351
|
-
chainingKeys: [],
|
|
352
|
-
requiresAuth: true,
|
|
353
|
-
estimatedComplexity: "simple",
|
|
354
|
-
source: ScenarioSource.CodeInferred,
|
|
355
|
-
testType: TestType.CONTRACT,
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
function diffDirectValidation(method, path, resource) {
|
|
359
|
-
return {
|
|
360
|
-
scenarioName: `${resource}-${method.toLowerCase()}-new-endpoint-validation`,
|
|
361
|
-
description: `Validation: ${method} ${path} with missing required fields returns 422`,
|
|
362
|
-
category: "new_endpoint",
|
|
363
|
-
priority: "high",
|
|
364
|
-
steps: [{ order: 1, method, path, description: `${method} ${path} with empty/missing required fields — expect 422`, interactionType: "error", expectedStatusCode: 422 }],
|
|
365
|
-
chainingKeys: [],
|
|
366
|
-
requiresAuth: true,
|
|
367
|
-
estimatedComplexity: "simple",
|
|
368
|
-
source: ScenarioSource.CodeInferred,
|
|
369
|
-
testType: TestType.CONTRACT,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
/**
|
|
373
|
-
* Draft a "mutation with collection modification" scenario for PUT/PATCH endpoints.
|
|
374
|
-
* This tests adding/removing child items (e.g., order line items) and verifying that
|
|
375
|
-
* derived totals (total_amount, item_count, subtotal) are recalculated.
|
|
376
|
-
* This pattern catches the most common class of user-reported bugs.
|
|
377
|
-
*/
|
|
378
|
-
function diffDirectBoundaryValues(method, resource, singular, group) {
|
|
379
|
-
const steps = [];
|
|
380
|
-
const targetPath = group.paramPath ?? group.basePath;
|
|
381
|
-
const pathParamName = group.paramPath?.match(/\{([^}]+)\}/)?.[1] ?? `${singular}_id`;
|
|
382
|
-
if (group.methods.has("POST")) {
|
|
383
|
-
steps.push({
|
|
384
|
-
order: 1,
|
|
385
|
-
method: "POST",
|
|
386
|
-
path: group.basePath,
|
|
387
|
-
description: `Create a ${singular} for boundary testing`,
|
|
388
|
-
interactionType: "success",
|
|
389
|
-
expectedStatusCode: 201,
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
steps.push({
|
|
393
|
-
order: steps.length + 1,
|
|
394
|
-
method,
|
|
395
|
-
path: targetPath,
|
|
396
|
-
description: `${method} with valid boundary values (e.g. 0, maximum allowed, empty collection) — expect 200`,
|
|
397
|
-
interactionType: "success",
|
|
398
|
-
expectedStatusCode: 200,
|
|
399
|
-
...(steps.length > 0 && group.paramPath
|
|
400
|
-
? { chainsFrom: { sourceStep: 1, sourceField: "<id-field>", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
|
|
401
|
-
: {}),
|
|
402
|
-
});
|
|
403
|
-
return {
|
|
404
|
-
scenarioName: `${resource}-${method.toLowerCase()}-boundary-values`,
|
|
405
|
-
description: `Boundary test: ${method} ${targetPath} — test valid boundary values (0%, 100%, minimum, maximum allowed by schema). Read source to identify numeric/percentage fields and their constraints; assert the response reflects the boundary value correctly.`,
|
|
406
|
-
category: "new_endpoint",
|
|
407
|
-
priority: "high",
|
|
408
|
-
steps,
|
|
409
|
-
chainingKeys: ["id", pathParamName],
|
|
410
|
-
requiresAuth: true,
|
|
411
|
-
estimatedComplexity: "moderate",
|
|
412
|
-
source: ScenarioSource.CodeInferred,
|
|
413
|
-
testType: TestType.INTEGRATION,
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
function diffDirectMutationRecalc(method, resource, singular, group) {
|
|
417
|
-
const steps = [];
|
|
418
|
-
if (group.methods.has("POST")) {
|
|
419
|
-
steps.push({
|
|
420
|
-
order: 1,
|
|
421
|
-
method: "POST",
|
|
422
|
-
path: group.basePath,
|
|
423
|
-
description: `Create a ${singular} for testing`,
|
|
424
|
-
interactionType: "success",
|
|
425
|
-
expectedStatusCode: 201,
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
const targetPath = group.paramPath ?? group.basePath;
|
|
429
|
-
const pathParamName = group.paramPath?.match(/\{([^}]+)\}/)?.[1] ?? `${singular}_id`;
|
|
430
|
-
const sourceStep = steps.length;
|
|
431
|
-
steps.push({
|
|
432
|
-
order: steps.length + 1,
|
|
433
|
-
method,
|
|
434
|
-
path: targetPath,
|
|
435
|
-
description: `${method} the ${singular} with valid changes — read source to find mutable fields`,
|
|
436
|
-
interactionType: "success",
|
|
437
|
-
expectedStatusCode: 200,
|
|
438
|
-
...(sourceStep > 0 && group.paramPath
|
|
439
|
-
? { chainsFrom: { sourceStep, sourceField: "<id-field>", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
|
|
440
|
-
: {}),
|
|
441
|
-
});
|
|
442
|
-
if (group.paramPath && group.methods.has("GET")) {
|
|
443
|
-
steps.push({
|
|
444
|
-
order: steps.length + 1,
|
|
445
|
-
method: "GET",
|
|
446
|
-
path: group.paramPath,
|
|
447
|
-
description: `Verify the ${singular} reflects changes — check any derived/calculated fields`,
|
|
448
|
-
interactionType: "success",
|
|
449
|
-
expectedStatusCode: 200,
|
|
450
|
-
...(sourceStep > 0 && group.paramPath
|
|
451
|
-
? { chainsFrom: { sourceStep, sourceField: "<id-field>", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
|
|
452
|
-
: {}),
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
return {
|
|
456
|
-
scenarioName: `${resource}-${method.toLowerCase()}-mutation-verify`,
|
|
457
|
-
description: `Mutation test: ${method} ${targetPath} — update fields and verify derived calculations (totals, discounts, sums, quantities) are recomputed correctly. Test at least one calculation-affecting change (e.g. discount percentage, quantity update, item addition/removal).`,
|
|
458
|
-
category: "new_endpoint",
|
|
459
|
-
priority: "high",
|
|
460
|
-
steps,
|
|
461
|
-
chainingKeys: ["id", pathParamName],
|
|
462
|
-
requiresAuth: true,
|
|
463
|
-
estimatedComplexity: "complex",
|
|
464
|
-
source: ScenarioSource.CodeInferred,
|
|
465
|
-
testType: TestType.INTEGRATION,
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
function diffDirectExecutionHappy(method, path, resource) {
|
|
469
|
-
const verb = extractExecutionVerb(path);
|
|
470
|
-
return {
|
|
471
|
-
scenarioName: `${resource}-${method.toLowerCase()}-${verb}-happy`,
|
|
472
|
-
description: `Execution test: ${method} ${path} with valid payload — read source to identify required input fields; assert response reflects expected output (result, status, processed data)`,
|
|
473
|
-
category: "new_endpoint",
|
|
474
|
-
priority: "high",
|
|
475
|
-
steps: [{
|
|
476
|
-
order: 1,
|
|
477
|
-
method,
|
|
478
|
-
path,
|
|
479
|
-
description: `${method} ${path} with valid input payload — expect 200 OK with execution result`,
|
|
480
|
-
interactionType: "success",
|
|
481
|
-
expectedStatusCode: 200,
|
|
482
|
-
}],
|
|
483
|
-
chainingKeys: [],
|
|
484
|
-
requiresAuth: true,
|
|
485
|
-
estimatedComplexity: "moderate",
|
|
486
|
-
source: ScenarioSource.CodeInferred,
|
|
487
|
-
testType: TestType.INTEGRATION,
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
function diffDirectExecutionInvalid(method, path, resource) {
|
|
491
|
-
const verb = extractExecutionVerb(path);
|
|
492
|
-
return {
|
|
493
|
-
scenarioName: `${resource}-${method.toLowerCase()}-${verb}-invalid`,
|
|
494
|
-
description: `Validation test: ${method} ${path} with missing or invalid request body — read source to find required fields; assert 422 Unprocessable Entity with meaningful error`,
|
|
495
|
-
category: "new_endpoint",
|
|
496
|
-
priority: "high",
|
|
497
|
-
steps: [{
|
|
498
|
-
order: 1,
|
|
499
|
-
method,
|
|
500
|
-
path,
|
|
501
|
-
description: `${method} ${path} with missing required fields — expect 422 Unprocessable Entity`,
|
|
502
|
-
interactionType: "error",
|
|
503
|
-
expectedStatusCode: 422,
|
|
504
|
-
}],
|
|
505
|
-
chainingKeys: [],
|
|
506
|
-
requiresAuth: true,
|
|
507
|
-
estimatedComplexity: "simple",
|
|
508
|
-
source: ScenarioSource.CodeInferred,
|
|
509
|
-
testType: TestType.CONTRACT,
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
function diffDirectAuthBoundary(method, path, resource, wsAuthType) {
|
|
513
|
-
// Bearer and OAuth both use the "Authorization: Bearer <token>" scheme. These setups
|
|
514
|
-
// commonly return 403 (Forbidden) rather than 401 (Unauthorized) when a token is
|
|
515
|
-
// absent, because the framework treats a missing credential as insufficient permission
|
|
516
|
-
// rather than unauthenticated. All other auth types use 401.
|
|
517
|
-
const expectedStatusCode = wsAuthType === WorkspaceAuthType.Bearer || wsAuthType === WorkspaceAuthType.OAuth ? 403 : 401;
|
|
518
|
-
const statusLabel = expectedStatusCode === 403 ? "403 Forbidden" : "401 Unauthorized";
|
|
519
|
-
return {
|
|
520
|
-
scenarioName: `${resource}-${method.toLowerCase()}-auth-boundary`,
|
|
521
|
-
description: `Auth boundary: ${method} ${path} without authentication returns ${statusLabel}`,
|
|
522
|
-
category: "security_boundary",
|
|
523
|
-
priority: "high",
|
|
524
|
-
steps: [{ order: 1, method, path, description: `${method} ${path} without Authorization header — expect ${statusLabel}`, interactionType: "error", expectedStatusCode }],
|
|
525
|
-
chainingKeys: [],
|
|
526
|
-
requiresAuth: false,
|
|
527
|
-
estimatedComplexity: "simple",
|
|
528
|
-
source: ScenarioSource.CodeInferred,
|
|
529
|
-
testType: TestType.CONTRACT,
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
const DESTRUCTIVE_ACTION_TOKENS = new Set(["delete", "destroy", "remove", "cancel", "archive"]);
|
|
533
|
-
const SOURCE_RESOURCE_SKIP_SEGMENTS = new Set([
|
|
534
|
-
...SKIP_SEGMENTS,
|
|
535
|
-
"app", "apps", "handler", "handlers", "index", "page", "pages", "route", "routes", "router", "server", "src",
|
|
536
|
-
]);
|
|
537
|
-
/**
|
|
538
|
-
* `:name` to `{name}`, including an Express/Fastify constraint or modifier
|
|
539
|
-
* (`:id(\d+)`, `:id?`). The match starts at a segment boundary and needs a
|
|
540
|
-
* letter or underscore first, so a port or a mid-segment colon is left alone.
|
|
541
|
-
* Next.js `[id]` stays as written — those mirror file paths.
|
|
542
|
-
*/
|
|
543
|
-
function toBraceParams(path) {
|
|
544
|
-
return (path ?? "").replace(/(^|\/):([A-Za-z_]\w*)(\([^)]*\))?[?*+]?/g, "$1{$2}");
|
|
545
|
-
}
|
|
546
|
-
function pathSegments(path) {
|
|
547
|
-
return path.split("/").filter(Boolean);
|
|
548
|
-
}
|
|
549
|
-
/**
|
|
550
|
-
* Recognises a declared parameter, NOT a literal id value. A UUID, digits or a
|
|
551
|
-
* long hex segment reads as a resource name here, unlike routeParsers.ts's
|
|
552
|
-
* isParamSegment + isOpaqueIdSegment. So `collectionPathForChangedEndpoint`
|
|
553
|
-
* does not reduce `/orders/<uuid>` to `/orders`, and no attack-surface
|
|
554
|
-
* auth-boundary scenario is drafted for that endpoint. Left as-is on purpose:
|
|
555
|
-
* fixing it drafts MORE scenarios, which needs measurement. SKYR-4229 tracks
|
|
556
|
-
* it; pathSegmentClassification.characterization.test.ts pins today's answer.
|
|
557
|
-
*/
|
|
558
|
-
function isPathParam(segment) {
|
|
559
|
-
return /^\{[^}]+\}$/.test(segment) ||
|
|
560
|
-
/^:[A-Za-z_][A-Za-z0-9_]*$/.test(segment) ||
|
|
561
|
-
/^\[\.\.\.[^\]]+\]$/.test(segment) ||
|
|
562
|
-
/^\[\[\.\.\.[^\]]+\]\]$/.test(segment) ||
|
|
563
|
-
/^\[[^\].][^\]]*\]$/.test(segment);
|
|
564
|
-
}
|
|
565
|
-
function actionSegmentTokens(segment) {
|
|
566
|
-
return segment
|
|
567
|
-
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
568
|
-
.split(/[^a-z0-9]+/i)
|
|
569
|
-
.filter(Boolean)
|
|
570
|
-
.map(token => token.toLowerCase());
|
|
571
|
-
}
|
|
572
|
-
function isDestructiveActionSegment(segment) {
|
|
573
|
-
return actionSegmentTokens(segment).some(token => DESTRUCTIVE_ACTION_TOKENS.has(token));
|
|
574
|
-
}
|
|
575
|
-
function sourceFileResource(sourceFile) {
|
|
576
|
-
if (!sourceFile)
|
|
577
|
-
return undefined;
|
|
578
|
-
const sourceSegments = sourceFile.split(/[\\/]/).filter(Boolean);
|
|
579
|
-
const filename = sourceSegments.at(-1)?.replace(/\.[^.]+$/, "").toLowerCase();
|
|
580
|
-
if (filename && isRealResource(filename) && !SOURCE_RESOURCE_SKIP_SEGMENTS.has(filename))
|
|
581
|
-
return filename;
|
|
582
|
-
return [...sourceSegments]
|
|
583
|
-
.slice(0, -1)
|
|
584
|
-
.reverse()
|
|
585
|
-
.map(segment => segment.replace(/\.[^.]+$/, "").toLowerCase())
|
|
586
|
-
.find(segment => segment && isRealResource(segment) && !SOURCE_RESOURCE_SKIP_SEGMENTS.has(segment));
|
|
587
|
-
}
|
|
588
|
-
function collectionPathForChangedEndpoint(path, sourceFile) {
|
|
589
|
-
const segments = pathSegments(path);
|
|
590
|
-
const sourceResource = sourceFileResource(sourceFile);
|
|
591
|
-
if (segments.length > 0 &&
|
|
592
|
-
isPathParam(segments[0]) &&
|
|
593
|
-
sourceResource &&
|
|
594
|
-
(segments.length === 1 || segments[1] !== sourceResource)) {
|
|
595
|
-
return `/${sourceResource}`;
|
|
596
|
-
}
|
|
597
|
-
if (segments.length >= 2) {
|
|
598
|
-
const last = segments[segments.length - 1];
|
|
599
|
-
if (isPathParam(last)) {
|
|
600
|
-
return `/${segments.slice(0, -1).join("/")}`;
|
|
601
|
-
}
|
|
602
|
-
const paramIdx = segments.findIndex(isPathParam);
|
|
603
|
-
if (paramIdx > 0) {
|
|
604
|
-
return `/${segments.slice(0, paramIdx).join("/")}`;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
return sourceResource ? `/${sourceResource}` : undefined;
|
|
608
|
-
}
|
|
609
|
-
function resourceFromCollectionPath(collectionPath) {
|
|
610
|
-
const segments = pathSegments(collectionPath);
|
|
611
|
-
return segments.filter(s => !SKIP_SEGMENTS.has(s)).at(-1) ?? "resource";
|
|
612
|
-
}
|
|
613
|
-
function actionNameFromPath(path) {
|
|
614
|
-
const action = pathSegments(path).at(-1) ?? "destructive";
|
|
615
|
-
return action.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase() || "destructive";
|
|
616
|
-
}
|
|
617
|
-
function endpointMethods(endpoint) {
|
|
618
|
-
return endpoint.methods.map(m => (typeof m === "string" ? m : m.method).toUpperCase());
|
|
619
|
-
}
|
|
620
|
-
function endpointSourceFiles(endpoint) {
|
|
621
|
-
return new Set(endpoint.methods
|
|
622
|
-
.filter((m) => typeof m !== "string")
|
|
623
|
-
.map(m => m.sourceFile)
|
|
624
|
-
.filter((sourceFile) => Boolean(sourceFile)));
|
|
625
|
-
}
|
|
626
|
-
function resolveSiblingPath(endpointPath, collectionPath) {
|
|
627
|
-
if (isSegmentPrefix(endpointPath, collectionPath))
|
|
628
|
-
return endpointPath;
|
|
629
|
-
const segments = pathSegments(endpointPath);
|
|
630
|
-
if (segments.length > 0 && isDestructiveActionSegment(segments[0])) {
|
|
631
|
-
return `${collectionPath}/${segments.join("/")}`;
|
|
632
|
-
}
|
|
633
|
-
return endpointPath;
|
|
634
|
-
}
|
|
635
|
-
function isDestructiveSiblingEndpoint(endpoint, changed, collectionPath) {
|
|
636
|
-
const methods = endpointMethods(endpoint);
|
|
637
|
-
if (!methods.includes("POST"))
|
|
638
|
-
return false;
|
|
639
|
-
const endpointSegments = pathSegments(endpoint.path);
|
|
640
|
-
const samePathFamily = isSegmentPrefix(endpoint.path, collectionPath) && endpoint.path !== collectionPath;
|
|
641
|
-
const routerRelativeDestructive = endpointSegments.length > 0 &&
|
|
642
|
-
isDestructiveActionSegment(endpointSegments[0]);
|
|
643
|
-
const sameSourceFile = Boolean(routerRelativeDestructive && changed.sourceFile && endpointSourceFiles(endpoint).has(changed.sourceFile));
|
|
644
|
-
if (!samePathFamily && !sameSourceFile)
|
|
645
|
-
return false;
|
|
646
|
-
const collectionSegments = samePathFamily ? pathSegments(collectionPath) : [];
|
|
647
|
-
const suffix = endpointSegments.slice(collectionSegments.length);
|
|
648
|
-
return suffix.some(segment => isDestructiveActionSegment(segment));
|
|
649
|
-
}
|
|
650
|
-
function draftAttackSurfaceExpansionScenarios(endpoints, changedEndpoints, wsAuthType, securityRelevantDiff) {
|
|
651
|
-
if (!securityRelevantDiff || changedEndpoints.length === 0)
|
|
652
|
-
return [];
|
|
653
|
-
const scenarios = [];
|
|
654
|
-
const seen = new Set();
|
|
655
|
-
for (const changed of changedEndpoints) {
|
|
656
|
-
if (changed.method.toUpperCase() !== "DELETE")
|
|
657
|
-
continue;
|
|
658
|
-
const collectionPath = collectionPathForChangedEndpoint(changed.path, changed.sourceFile);
|
|
659
|
-
if (!collectionPath)
|
|
660
|
-
continue;
|
|
661
|
-
const resource = resourceFromCollectionPath(collectionPath);
|
|
662
|
-
for (const endpoint of endpoints) {
|
|
663
|
-
if (!isDestructiveSiblingEndpoint(endpoint, changed, collectionPath))
|
|
664
|
-
continue;
|
|
665
|
-
const siblingPath = resolveSiblingPath(endpoint.path, collectionPath);
|
|
666
|
-
const scenario = diffDirectAuthBoundary("POST", siblingPath, `${resource}-${actionNameFromPath(siblingPath)}`, wsAuthType);
|
|
667
|
-
scenario.description = `Attack-surface auth boundary: ${siblingPath} is a destructive sibling of changed ${changed.method.toUpperCase()} ${changed.path}; verify it rejects missing authentication`;
|
|
668
|
-
scenario.isAttackSurfaceSecurityBoundary = true;
|
|
669
|
-
scenario.steps[0].description = `POST ${siblingPath} without the new auth/admin guard — expect ${scenario.steps[0].expectedStatusCode}`;
|
|
670
|
-
if (!seen.has(scenario.scenarioName)) {
|
|
671
|
-
seen.add(scenario.scenarioName);
|
|
672
|
-
scenarios.push(scenario);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
return scenarios;
|
|
677
|
-
}
|
|
678
|
-
/**
|
|
679
|
-
* Draft a unified scenario for GET endpoints without path params (collection or search).
|
|
680
|
-
* Rather than hardcoding keyword detection, the description instructs the LLM to read
|
|
681
|
-
* the diff source and determine whether query params are involved, then draft variations
|
|
682
|
-
* accordingly.
|
|
683
|
-
*/
|
|
684
|
-
function diffDirectGetCollection(path, resource, singular, group) {
|
|
685
|
-
const steps = [];
|
|
686
|
-
if (group.methods.has("POST")) {
|
|
687
|
-
steps.push({
|
|
688
|
-
order: 1,
|
|
689
|
-
method: "POST",
|
|
690
|
-
path: group.basePath,
|
|
691
|
-
description: `Create a ${singular} for query verification`,
|
|
692
|
-
interactionType: "success",
|
|
693
|
-
expectedStatusCode: 201,
|
|
694
|
-
});
|
|
695
|
-
steps.push({
|
|
696
|
-
order: 2,
|
|
697
|
-
method: "GET",
|
|
698
|
-
path,
|
|
699
|
-
description: `Query ${resource} and verify results include the created item`,
|
|
700
|
-
interactionType: "success",
|
|
701
|
-
expectedStatusCode: 200,
|
|
702
|
-
});
|
|
703
|
-
}
|
|
704
|
-
else {
|
|
705
|
-
steps.push({
|
|
706
|
-
order: 1,
|
|
707
|
-
method: "GET",
|
|
708
|
-
path,
|
|
709
|
-
description: `Query ${resource} and verify response structure`,
|
|
710
|
-
interactionType: "success",
|
|
711
|
-
expectedStatusCode: 200,
|
|
712
|
-
});
|
|
713
|
-
}
|
|
714
|
-
// Include a path-specific suffix in scenarioName to avoid collision when multiple GET
|
|
715
|
-
// collection endpoints exist for the same resource (e.g. /products and /products/featured).
|
|
716
|
-
// Strip API prefix segments (api, v1, ...) and param segments, take the last 1-2 meaningful ones.
|
|
717
|
-
const meaningfulSegs = path.split("/").filter(s => s && !s.startsWith("{") && !SKIP_SEGMENTS.has(s));
|
|
718
|
-
const pathSlug = meaningfulSegs.slice(-2).join("-") || resource;
|
|
719
|
-
const uniqueName = pathSlug === resource ? `${resource}-list-query` : `${pathSlug}-list-query`;
|
|
720
|
-
return {
|
|
721
|
-
scenarioName: uniqueName,
|
|
722
|
-
description: `List/query test: GET ${path} — read source to find supported query params (filters, pagination, search), then test with and without them`,
|
|
723
|
-
category: "new_endpoint",
|
|
724
|
-
priority: "high",
|
|
725
|
-
steps,
|
|
726
|
-
chainingKeys: ["id", `${singular}_id`],
|
|
727
|
-
requiresAuth: true,
|
|
728
|
-
estimatedComplexity: group.methods.has("POST") ? "moderate" : "simple",
|
|
729
|
-
source: ScenarioSource.CodeInferred,
|
|
730
|
-
testType: group.methods.has("POST") ? TestType.INTEGRATION : TestType.CONTRACT,
|
|
731
|
-
};
|
|
732
|
-
}
|
|
733
|
-
/**
|
|
734
|
-
* Draft scenarios that directly test each new endpoint in the branch diff.
|
|
735
|
-
*
|
|
736
|
-
* For each new endpoint, method-specific scenarios are produced:
|
|
737
|
-
* PUT/PATCH → mutation-recalc integration, boundary-values integration, contract, not-found
|
|
738
|
-
* POST → lifecycle integration (create + optional GET), contract, validation error
|
|
739
|
-
* DELETE → lifecycle integration (create + delete + 404), contract
|
|
740
|
-
* GET /{id} → contract, not-found
|
|
741
|
-
* GET /coll → contract + collection-list integration (create-then-GET) or contract-only
|
|
742
|
-
*
|
|
743
|
-
* Additionally, PUT/PATCH/DELETE methods get an auth-boundary scenario
|
|
744
|
-
* (category "security_boundary" → HIGH priority, not CRITICAL) so it
|
|
745
|
-
* lands in ADDITIONAL deterministically instead of depending on LLM
|
|
746
|
-
* supplement. GET is excluded (often public); POST is excluded because
|
|
747
|
-
* the security-boundary supplement tier covers it. Exception: execution
|
|
748
|
-
* endpoints (/execute, /run, /trigger, etc.) include auth-boundary for
|
|
749
|
-
* all non-GET methods (including POST) since they are high-value targets.
|
|
750
|
-
*/
|
|
751
|
-
export function draftDiffDirectScenarios(newEndpoints, resourceGroups, wsAuthType) {
|
|
752
|
-
if (newEndpoints.length === 0)
|
|
753
|
-
return [];
|
|
754
|
-
const scenarios = [];
|
|
755
|
-
const seen = new Set();
|
|
756
|
-
// Group flat list by path
|
|
757
|
-
const grouped = new Map();
|
|
758
|
-
for (const ep of newEndpoints) {
|
|
759
|
-
const methods = grouped.get(ep.path) ?? new Set();
|
|
760
|
-
methods.add(ep.method.toUpperCase());
|
|
761
|
-
grouped.set(ep.path, methods);
|
|
762
|
-
}
|
|
763
|
-
for (const [epPath, methods] of grouped) {
|
|
764
|
-
let resource = extractResourceName(epPath);
|
|
765
|
-
let resolvedPath = epPath;
|
|
766
|
-
// The map key to use for the final resourceGroups lookup — may differ from `resource`
|
|
767
|
-
// when the entry was stored under a disambiguated "resource::basePath" key.
|
|
768
|
-
let resourceGroupKey = resource;
|
|
769
|
-
// When resource was found but epPath may be router-relative (e.g. "/orders"
|
|
770
|
-
// instead of "/api/v1/orders"), try to upgrade resolvedPath to the full path.
|
|
771
|
-
if (resource) {
|
|
772
|
-
const existingGroup = resourceGroups.get(resource);
|
|
773
|
-
if (existingGroup) {
|
|
774
|
-
if (existingGroup.paramPath && existingGroup.paramPath !== epPath && pathSuffixMatches(existingGroup.paramPath, epPath)) {
|
|
775
|
-
resolvedPath = existingGroup.paramPath;
|
|
776
|
-
}
|
|
777
|
-
else if (existingGroup.basePath !== epPath && pathSuffixMatches(existingGroup.basePath, epPath)) {
|
|
778
|
-
resolvedPath = existingGroup.basePath;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
// When extractResourceName returns null OR the resource isn't in resourceGroups,
|
|
783
|
-
// fall back to finding a matching group via prefix or suffix matching.
|
|
784
|
-
if (!resource || !resourceGroups.has(resource)) {
|
|
785
|
-
let bestMatch = null;
|
|
786
|
-
for (const [rName, rGroup] of resourceGroups) {
|
|
787
|
-
// Prefix match: epPath starts with group's basePath (e.g. /products/search → /products)
|
|
788
|
-
if (epPath.startsWith(rGroup.basePath + "/") || epPath === rGroup.basePath) {
|
|
789
|
-
if (!bestMatch || rGroup.basePath.length > bestMatch.length) {
|
|
790
|
-
bestMatch = { name: rName, path: rGroup.basePath, length: rGroup.basePath.length, isPrefixMatch: true };
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
// Suffix match for router-relative paths (e.g. /{order_id} → /api/v1/orders/{order_id})
|
|
794
|
-
if (rGroup.paramPath && pathSuffixMatches(rGroup.paramPath, epPath)) {
|
|
795
|
-
if (!bestMatch || rGroup.paramPath.length > bestMatch.length) {
|
|
796
|
-
bestMatch = { name: rName, path: rGroup.paramPath, length: rGroup.paramPath.length, isPrefixMatch: false };
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
if (!bestMatch)
|
|
801
|
-
continue;
|
|
802
|
-
// Keep the full map key for lookup — disambiguated keys use "resource::basePath" format.
|
|
803
|
-
// Use only the prefix before "::" as the resource name for scenario naming/singularization.
|
|
804
|
-
resourceGroupKey = bestMatch.name;
|
|
805
|
-
resource = resourceGroupKey.includes("::") ? resourceGroupKey.split("::")[0] : resourceGroupKey;
|
|
806
|
-
// For prefix matches (action sub-paths), keep original path; for suffix matches, use resolved path
|
|
807
|
-
resolvedPath = bestMatch.isPrefixMatch ? epPath : bestMatch.path;
|
|
808
|
-
}
|
|
809
|
-
const group = resourceGroups.get(resourceGroupKey);
|
|
810
|
-
if (!group)
|
|
811
|
-
continue;
|
|
812
|
-
const singular = singularize(resource);
|
|
813
|
-
const hasPathParam = resolvedPath.includes("{");
|
|
814
|
-
const add = (s) => {
|
|
815
|
-
if (!seen.has(s.scenarioName)) {
|
|
816
|
-
seen.add(s.scenarioName);
|
|
817
|
-
scenarios.push(s);
|
|
818
|
-
}
|
|
819
|
-
};
|
|
820
|
-
for (const method of methods) {
|
|
821
|
-
// Execution endpoints get their own focused templates instead of CRUD lifecycle ones.
|
|
822
|
-
// Caller-traced execution endpoints (Bug 5 scenario) are handled by the LLM prompt;
|
|
823
|
-
// this fires only when a new /execute-style endpoint appears directly in the diff.
|
|
824
|
-
// GET is excluded — execution endpoints use request bodies, which don't apply to GET.
|
|
825
|
-
// MULTI is excluded — it's a sentinel for catch-all handlers, not a real HTTP verb.
|
|
826
|
-
if (isExecutionEndpoint(resolvedPath) && method !== "GET" && method !== "MULTI") {
|
|
827
|
-
add(diffDirectExecutionHappy(method, resolvedPath, resource));
|
|
828
|
-
add(diffDirectExecutionInvalid(method, resolvedPath, resource));
|
|
829
|
-
add(diffDirectAuthBoundary(method, resolvedPath, resource, wsAuthType));
|
|
830
|
-
continue;
|
|
831
|
-
}
|
|
832
|
-
if (method === "PUT" || method === "PATCH") {
|
|
833
|
-
// mutation-recalc tests the primary calculation flow
|
|
834
|
-
add(diffDirectMutationRecalc(method, resource, singular, group));
|
|
835
|
-
// boundary-values tests edge cases (0, max, empty)
|
|
836
|
-
add(diffDirectBoundaryValues(method, resource, singular, group));
|
|
837
|
-
add(diffDirectContract(method, resolvedPath, resource));
|
|
838
|
-
if (hasPathParam)
|
|
839
|
-
add(diffDirectNotFound(method, resolvedPath, resource, singular));
|
|
840
|
-
}
|
|
841
|
-
else if (method === "POST") {
|
|
842
|
-
add(diffDirectIntegration(method, resource, singular, group));
|
|
843
|
-
add(diffDirectContract(method, resolvedPath, resource));
|
|
844
|
-
add(diffDirectValidation(method, resolvedPath, resource));
|
|
845
|
-
}
|
|
846
|
-
else if (method === "DELETE") {
|
|
847
|
-
add(diffDirectIntegration(method, resource, singular, group));
|
|
848
|
-
add(diffDirectContract(method, resolvedPath, resource));
|
|
849
|
-
}
|
|
850
|
-
else if (method === "GET" && hasPathParam) {
|
|
851
|
-
add(diffDirectContract(method, resolvedPath, resource));
|
|
852
|
-
add(diffDirectNotFound(method, resolvedPath, resource, singular));
|
|
853
|
-
}
|
|
854
|
-
else if (method === "GET" && !hasPathParam) {
|
|
855
|
-
add(diffDirectContract(method, resolvedPath, resource));
|
|
856
|
-
add(diffDirectGetCollection(resolvedPath, resource, singular, group));
|
|
857
|
-
}
|
|
858
|
-
if (method === "PUT" || method === "PATCH" || method === "DELETE")
|
|
859
|
-
add(diffDirectAuthBoundary(method, resolvedPath, resource, wsAuthType));
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
return scenarios;
|
|
863
|
-
}
|
|
864
|
-
// ── Response-shape scenarios for importer-hop-mapped endpoints (SKYR-3855) ──
|
|
865
|
-
// A changed DTO/service file has no route of its own, but the endpoints that
|
|
866
|
-
// return/consume it (found via `mapUnmatchedFilesToImporterEndpoints`) can
|
|
867
|
-
// have their response shape verified directly — this is the deterministic
|
|
868
|
-
// signal that replaces the generic structural scenarios the ranker would
|
|
869
|
-
// otherwise fall back to when a backend change matches zero endpoints.
|
|
870
|
-
const RESPONSE_SHAPE_SCENARIOS_PER_FILE = 2;
|
|
871
|
-
/** A GET on a single record (path param or a literal single-record action
|
|
872
|
-
* segment like "/me") is a more direct place to observe a response-shape
|
|
873
|
-
* change than a list/collection GET, so it's preferred when capping. */
|
|
874
|
-
function isDetailGetPath(endpointPath) {
|
|
875
|
-
const segments = endpointPath.split("/").filter(Boolean);
|
|
876
|
-
const last = segments.at(-1) ?? "";
|
|
877
|
-
return last.startsWith("{") || last.startsWith(":") || /^me$/i.test(last);
|
|
878
|
-
}
|
|
879
|
-
function slugify(value) {
|
|
880
|
-
return value
|
|
881
|
-
.toLowerCase()
|
|
882
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
883
|
-
.replace(/^-|-$/g, "") || "change";
|
|
884
|
-
}
|
|
885
|
-
/**
|
|
886
|
-
* Drafts response-shape verification scenarios for endpoints reached via the
|
|
887
|
-
* importer-hop classification: files that don't declare routes themselves
|
|
888
|
-
* (DTOs, services, models) but are imported by files that do.
|
|
889
|
-
*
|
|
890
|
-
* `byFile` maps each hop-mapped source file to the endpoint paths it reaches;
|
|
891
|
-
* `addedFieldsByFile` maps the same files to property names added in their
|
|
892
|
-
* diff hunks (best-effort — an empty list is fine, the scenario is still
|
|
893
|
-
* drafted, just without field hints).
|
|
894
|
-
*/
|
|
895
|
-
export function draftResponseShapeScenarios(hopEndpoints, byFile, addedFieldsByFile) {
|
|
896
|
-
const scenarios = [];
|
|
897
|
-
const byPath = new Map();
|
|
898
|
-
for (const ep of hopEndpoints) {
|
|
899
|
-
if (!byPath.has(ep.path))
|
|
900
|
-
byPath.set(ep.path, ep);
|
|
901
|
-
}
|
|
902
|
-
for (const [file, paths] of Object.entries(byFile)) {
|
|
903
|
-
const fileName = file.split(/[\\/]/).pop() ?? file;
|
|
904
|
-
const resource = deriveResourceToken(file);
|
|
905
|
-
const addedFields = addedFieldsByFile[file] ?? [];
|
|
906
|
-
const getEndpoints = [...new Set(paths)]
|
|
907
|
-
.map((p) => byPath.get(p))
|
|
908
|
-
.filter((ep) => !!ep && ep.methods.includes("GET"));
|
|
909
|
-
if (getEndpoints.length === 0)
|
|
910
|
-
continue;
|
|
911
|
-
// Prefer detail GETs over collection GETs, then keep source order.
|
|
912
|
-
const sorted = [...getEndpoints].sort((a, b) => Number(isDetailGetPath(a.path) ? 0 : 1) - Number(isDetailGetPath(b.path) ? 0 : 1));
|
|
913
|
-
for (const ep of sorted.slice(0, RESPONSE_SHAPE_SCENARIOS_PER_FILE)) {
|
|
914
|
-
const fieldsList = addedFields.join("`, `");
|
|
915
|
-
const fieldsPhrase = addedFields.length > 0
|
|
916
|
-
? `includes the \`${fieldsList}\`${addedFields.length > 1 ? " fields" : " field"} added in ${fileName}`
|
|
917
|
-
: `reflects the change made in ${fileName}`;
|
|
918
|
-
const nameSuffix = addedFields.length > 0 ? addedFields[0] : "response-change";
|
|
919
|
-
scenarios.push({
|
|
920
|
-
// The endpoint path is part of the name because one file can reach
|
|
921
|
-
// several GETs and neither the resource token (from the file) nor the
|
|
922
|
-
// suffix (a field name, or the constant "response-change" when the hunk
|
|
923
|
-
// yields none) varies between them. Two same-named candidates are
|
|
924
|
-
// merged by name in registerTestPlanTool, so a collision here loses a
|
|
925
|
-
// scenario outright rather than showing up as a duplicate.
|
|
926
|
-
scenarioName: `${slugify(resource)}-response-shape-${slugify(nameSuffix)}-${slugify(ep.path)}`,
|
|
927
|
-
description: `Verify GET ${ep.path} response ${fieldsPhrase}.`,
|
|
928
|
-
category: "breaking_change",
|
|
929
|
-
priority: "high",
|
|
930
|
-
steps: [{
|
|
931
|
-
order: 1,
|
|
932
|
-
method: "GET",
|
|
933
|
-
path: ep.path,
|
|
934
|
-
description: `GET ${ep.path} — verify response shape ${fieldsPhrase}`,
|
|
935
|
-
interactionType: "success",
|
|
936
|
-
expectedStatusCode: 200,
|
|
937
|
-
...(addedFields.length > 0 ? { expectedResponseFields: addedFields } : {}),
|
|
938
|
-
}],
|
|
939
|
-
chainingKeys: [],
|
|
940
|
-
requiresAuth: true,
|
|
941
|
-
estimatedComplexity: "simple",
|
|
942
|
-
source: ScenarioSource.CodeInferred,
|
|
943
|
-
testType: TestType.CONTRACT,
|
|
944
|
-
bugCatchingTarget: addedFields.length > 0
|
|
945
|
-
? `response shape regression: \`${fieldsList}\` added in ${fileName} missing or wrong in GET ${ep.path} response`
|
|
946
|
-
: `response shape regression: change in ${fileName} not reflected in GET ${ep.path} response`,
|
|
947
|
-
});
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
return scenarios;
|
|
951
|
-
}
|