@skyramp/mcp 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/prompts/test-maintenance/driftAnalysisSections.js +2 -2
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +2 -2
- package/build/prompts/test-recommendation/recommendationSections.js +10 -42
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +5 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +152 -67
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +18 -111
- package/build/prompts/testbot/testbot-prompts.js +9 -17
- package/build/services/ScenarioGenerationService.js +1 -2
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +4 -3
- package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +0 -9
- package/build/tools/test-management/analyzeChangesTool.js +5 -10
- package/build/types/TestRecommendation.js +0 -2
- package/build/utils/featureFlags.js +0 -25
- package/build/utils/scenarioDrafting.js +505 -116
- package/build/utils/scenarioDrafting.test.js +480 -260
- package/package.json +1 -1
- package/build/utils/httpDefaults.js +0 -12
|
@@ -1,44 +1,132 @@
|
|
|
1
1
|
import { ScenarioSource } from "../types/RepositoryAnalysis.js";
|
|
2
2
|
import { TestType } from "../types/TestTypes.js";
|
|
3
|
-
import { CATEGORY_PRIORITY
|
|
4
|
-
|
|
3
|
+
import { CATEGORY_PRIORITY } from "../types/TestRecommendation.js";
|
|
4
|
+
/**
|
|
5
|
+
* Cap on drafted scenarios per category to prevent prompt bloat on large APIs.
|
|
6
|
+
* Value of 4 balances coverage breadth (enough to surface patterns across
|
|
7
|
+
* resources) with token budget (each scenario adds ~15-20 lines to the prompt).
|
|
8
|
+
*/
|
|
9
|
+
const MAX_DRAFTED_SCENARIOS_PER_CATEGORY = 4;
|
|
10
|
+
const ACTION_PATTERN = /^(me|merge|archive|search|login|logout|verify|forgot|reset|config|dashboard|webhook|migration|favicon|payment|health|status|ping|metrics|callback|confirm|activate|deactivate|subscribe|unsubscribe|unknown)$/i;
|
|
11
|
+
const ACTION_VERB_HYPHEN = /^(forgot-|reset-|verify-|confirm-|send-|check-|get-|set-|update-|delete-|create-|trigger-|start-|stop-)/i;
|
|
12
|
+
export function isRealResource(r) {
|
|
13
|
+
return !ACTION_PATTERN.test(r) && !ACTION_VERB_HYPHEN.test(r);
|
|
14
|
+
}
|
|
5
15
|
const SKIP_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
|
|
16
|
+
/**
|
|
17
|
+
* Convert a plural resource name to its singular form for use in field names
|
|
18
|
+
* and step descriptions.
|
|
19
|
+
*
|
|
20
|
+
* Handles the most common irregular plurals found in REST API paths:
|
|
21
|
+
* -ies → -y (categories → category, companies → company)
|
|
22
|
+
* -ses → -s (statuses → status, classes → class)
|
|
23
|
+
* Falls back to removing the trailing "s" for regular plurals.
|
|
24
|
+
*/
|
|
25
|
+
function singularize(word) {
|
|
26
|
+
if (word.endsWith("ies") && word.length > 3) {
|
|
27
|
+
return word.slice(0, -3) + "y";
|
|
28
|
+
}
|
|
29
|
+
if (word.endsWith("ses") && word.length > 4) {
|
|
30
|
+
return word.slice(0, -2); // statuses → status
|
|
31
|
+
}
|
|
32
|
+
return word.endsWith("s") && word.length > 1 ? word.slice(0, -1) : word;
|
|
33
|
+
}
|
|
6
34
|
/**
|
|
7
35
|
* Extract the primary resource name from an endpoint path.
|
|
8
|
-
* Returns the last non-param, non-skip segment.
|
|
9
36
|
* E.g. "/api/v1/flow-costs/{cost_id}" → "flow-costs"
|
|
10
37
|
* "/collections/{id}/links" → "links"
|
|
11
|
-
* "/api/orders/search" → "search"
|
|
12
|
-
*
|
|
13
|
-
* Action sub-paths (search, archive, etc.) are NOT filtered here.
|
|
14
|
-
* The LLM resolves the full path from source code during enrichment.
|
|
15
38
|
*/
|
|
16
39
|
function extractResourceName(path) {
|
|
17
40
|
const segments = path.split("/").filter(Boolean);
|
|
18
41
|
const nonParam = segments.filter(s => !s.startsWith("{") && !SKIP_SEGMENTS.has(s));
|
|
19
|
-
|
|
42
|
+
const name = nonParam[nonParam.length - 1];
|
|
43
|
+
return name && isRealResource(name) ? name : null;
|
|
20
44
|
}
|
|
21
45
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* /
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* /
|
|
46
|
+
* Infer parent→child resource relationships from two signals already present
|
|
47
|
+
* in the endpoint data — no source code scanning required.
|
|
48
|
+
*
|
|
49
|
+
* Signal 1 — path nesting (always available):
|
|
50
|
+
* /collections/{id}/links → links depends on collections
|
|
51
|
+
*
|
|
52
|
+
* Signal 2 — request body FK fields (available when trace data is merged):
|
|
53
|
+
* POST /orders body: { product_id: "..." } → orders depends on products
|
|
54
|
+
*
|
|
55
|
+
* Returns a map of `dependent → Set<parent>`. Only confirmed, real-resource
|
|
56
|
+
* pairs are included. When no signal is found the map is empty, which tells
|
|
57
|
+
* the caller to fall back to heuristic pairing.
|
|
30
58
|
*/
|
|
31
|
-
function
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
export function inferResourceRelationships(endpoints) {
|
|
60
|
+
const relationships = new Map();
|
|
61
|
+
const addRelationship = (dependent, parent) => {
|
|
62
|
+
if (dependent === parent)
|
|
63
|
+
return;
|
|
64
|
+
if (!isRealResource(dependent) || !isRealResource(parent))
|
|
65
|
+
return;
|
|
66
|
+
const deps = relationships.get(dependent) ?? new Set();
|
|
67
|
+
deps.add(parent);
|
|
68
|
+
relationships.set(dependent, deps);
|
|
69
|
+
};
|
|
70
|
+
// ── Signal 1: path nesting ──
|
|
71
|
+
// Pattern: /…/parentResource/{param}/childResource
|
|
72
|
+
for (const ep of endpoints) {
|
|
73
|
+
const segments = ep.path.split("/").filter(Boolean);
|
|
74
|
+
for (let i = 0; i + 2 < segments.length; i++) {
|
|
75
|
+
const seg = segments[i];
|
|
76
|
+
const paramSeg = segments[i + 1];
|
|
77
|
+
const childSeg = segments[i + 2];
|
|
78
|
+
if (!seg.startsWith("{") &&
|
|
79
|
+
paramSeg.startsWith("{") &&
|
|
80
|
+
!childSeg.startsWith("{") &&
|
|
81
|
+
!SKIP_SEGMENTS.has(seg) &&
|
|
82
|
+
!SKIP_SEGMENTS.has(childSeg)) {
|
|
83
|
+
addRelationship(childSeg, seg);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// ── Signal 2: FK fields in request body interactions ──
|
|
88
|
+
// Collect all known resource names first so we can validate FK targets.
|
|
89
|
+
const knownResources = new Set(endpoints.map(ep => extractResourceName(ep.path)).filter(Boolean));
|
|
90
|
+
// Map singular form → actual resource name to handle irregular plurals.
|
|
91
|
+
// e.g. singularize("companies") = "company", so "company_id" resolves to "companies".
|
|
92
|
+
const singularToResource = new Map([...knownResources].map(r => [singularize(r), r]));
|
|
93
|
+
for (const ep of endpoints) {
|
|
94
|
+
const resource = extractResourceName(ep.path);
|
|
95
|
+
if (!resource)
|
|
96
|
+
continue;
|
|
97
|
+
for (const m of ep.methods) {
|
|
98
|
+
if (typeof m === "string")
|
|
99
|
+
continue;
|
|
100
|
+
if (!["POST", "PUT", "PATCH"].includes(m.method))
|
|
101
|
+
continue;
|
|
102
|
+
for (const interaction of m.interactions ?? []) {
|
|
103
|
+
const body = interaction.request?.body;
|
|
104
|
+
if (!body || typeof body !== "object")
|
|
105
|
+
continue;
|
|
106
|
+
for (const key of Object.keys(body)) {
|
|
107
|
+
if (!key.endsWith("_id"))
|
|
108
|
+
continue;
|
|
109
|
+
const depSingular = key.slice(0, -3); // "product_id" → "product"
|
|
110
|
+
const depPlural = depSingular + "s"; // naive plural, fine for regular nouns
|
|
111
|
+
// Only create relationship when the dependency actually exists as an endpoint.
|
|
112
|
+
// Check naive plural first (products), then exact singular (product),
|
|
113
|
+
// then reverse-singularize to catch irregular plurals (company → companies).
|
|
114
|
+
if (knownResources.has(depPlural)) {
|
|
115
|
+
addRelationship(resource, depPlural);
|
|
116
|
+
}
|
|
117
|
+
else if (knownResources.has(depSingular)) {
|
|
118
|
+
addRelationship(resource, depSingular);
|
|
119
|
+
}
|
|
120
|
+
else if (singularToResource.has(depSingular)) {
|
|
121
|
+
addRelationship(resource, singularToResource.get(depSingular));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return relationships;
|
|
40
128
|
}
|
|
41
|
-
export function draftScenariosFromEndpoints(endpoints, newEndpoints = []
|
|
129
|
+
export function draftScenariosFromEndpoints(endpoints, newEndpoints = []) {
|
|
42
130
|
const scenarios = [];
|
|
43
131
|
const resourceGroups = new Map();
|
|
44
132
|
for (const ep of endpoints) {
|
|
@@ -54,27 +142,13 @@ export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], change
|
|
|
54
142
|
}
|
|
55
143
|
const existing = resourceGroups.get(resource);
|
|
56
144
|
if (existing) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
existing.methods.add(typeof m === "string" ? m : m.method);
|
|
62
|
-
if (basePath.split("/").length < existing.basePath.split("/").length) {
|
|
63
|
-
existing.basePath = basePath;
|
|
64
|
-
}
|
|
65
|
-
if (paramPath && (!existing.paramPath || paramPath.split("/").length < existing.paramPath.split("/").length)) {
|
|
66
|
-
existing.paramPath = paramPath;
|
|
67
|
-
}
|
|
145
|
+
for (const m of ep.methods)
|
|
146
|
+
existing.methods.add(typeof m === "string" ? m : m.method);
|
|
147
|
+
if (basePath.split("/").length < existing.basePath.split("/").length) {
|
|
148
|
+
existing.basePath = basePath;
|
|
68
149
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// Store under a disambiguated key for resource group tracking.
|
|
72
|
-
const disambiguatedKey = `${resource}__${basePath.replace(/\//g, "_")}`;
|
|
73
|
-
resourceGroups.set(disambiguatedKey, {
|
|
74
|
-
basePath,
|
|
75
|
-
methods: new Set(ep.methods.map((m) => typeof m === "string" ? m : m.method)),
|
|
76
|
-
paramPath,
|
|
77
|
-
});
|
|
150
|
+
if (paramPath && (!existing.paramPath || paramPath.split("/").length < existing.paramPath.split("/").length)) {
|
|
151
|
+
existing.paramPath = paramPath;
|
|
78
152
|
}
|
|
79
153
|
}
|
|
80
154
|
else {
|
|
@@ -85,17 +159,11 @@ export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], change
|
|
|
85
159
|
});
|
|
86
160
|
}
|
|
87
161
|
}
|
|
88
|
-
scenarios.push(...
|
|
89
|
-
// Filter out changed endpoints that overlap with new endpoints (new takes priority)
|
|
90
|
-
const newEpKeys = new Set(newEndpoints.map(ep => `${ep.method.toUpperCase()} ${ep.path}`));
|
|
91
|
-
const uniqueChanged = changedEndpoints.filter(ep => !newEpKeys.has(`${ep.method.toUpperCase()} ${ep.path}`));
|
|
92
|
-
if (uniqueChanged.length > 0) {
|
|
93
|
-
scenarios.push(...draftMinimalScenarios(uniqueChanged, resourceGroups, "business_rule"));
|
|
94
|
-
}
|
|
162
|
+
scenarios.push(...draftDiffDirectScenarios(newEndpoints, resourceGroups));
|
|
95
163
|
return capScenarios(scenarios);
|
|
96
164
|
}
|
|
97
165
|
const MAX_TOTAL_SCENARIOS = 30;
|
|
98
|
-
const TIER_ORDER =
|
|
166
|
+
const TIER_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
|
|
99
167
|
/**
|
|
100
168
|
* Enforce a global cap on drafted scenarios while preserving category diversity.
|
|
101
169
|
*
|
|
@@ -130,84 +198,405 @@ function capScenarios(scenarios) {
|
|
|
130
198
|
// Enforce the hard cap even if critical set alone exceeds it
|
|
131
199
|
return combined.slice(0, MAX_TOTAL_SCENARIOS);
|
|
132
200
|
}
|
|
201
|
+
// ── Diff-direct scenario drafting ──
|
|
202
|
+
// Generates targeted scenarios for each new endpoint in the branch diff.
|
|
203
|
+
// These get category "new_endpoint" which maps to the CRITICAL priority tier
|
|
204
|
+
// in the scorer, so they always fill the GENERATE slots before any structural
|
|
205
|
+
// scenario — aligning the plan with what the LLM naturally wants to do anyway.
|
|
133
206
|
/**
|
|
134
|
-
*
|
|
207
|
+
* Build the minimum steps for a diff-direct integration scenario.
|
|
208
|
+
* Prerequisite resources (e.g. POST /products before POST /orders) are NOT
|
|
209
|
+
* computed here — the execution plan instructs the LLM to discover them from
|
|
210
|
+
* source code (FK fields in request bodies) and prepend the steps itself.
|
|
211
|
+
*/
|
|
212
|
+
function diffDirectIntegration(method, resource, singular, group) {
|
|
213
|
+
const steps = [];
|
|
214
|
+
if (method === "POST") {
|
|
215
|
+
steps.push({
|
|
216
|
+
order: 1, method: "POST", path: group.basePath,
|
|
217
|
+
description: `Create ${singular} and verify response`,
|
|
218
|
+
interactionType: "success", expectedStatusCode: 201,
|
|
219
|
+
});
|
|
220
|
+
if (group.paramPath && group.methods.has("GET")) {
|
|
221
|
+
steps.push({
|
|
222
|
+
order: 2, method: "GET", path: group.paramPath,
|
|
223
|
+
description: `Retrieve created ${singular} and verify fields`,
|
|
224
|
+
interactionType: "success", expectedStatusCode: 200,
|
|
225
|
+
chainsFrom: { sourceStep: 1, sourceField: "id", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" },
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else if (method === "DELETE") {
|
|
230
|
+
if (group.methods.has("POST")) {
|
|
231
|
+
steps.push({
|
|
232
|
+
order: 1, method: "POST", path: group.basePath,
|
|
233
|
+
description: `Create ${singular} to delete`,
|
|
234
|
+
interactionType: "success", expectedStatusCode: 201,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const targetPath = group.paramPath ?? group.basePath;
|
|
238
|
+
steps.push({
|
|
239
|
+
order: steps.length + 1, method: "DELETE", path: targetPath,
|
|
240
|
+
description: `Delete ${singular}`,
|
|
241
|
+
interactionType: "success", expectedStatusCode: 204,
|
|
242
|
+
...(steps.length > 0 ? { chainsFrom: { sourceStep: 1, sourceField: "id", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" } } : {}),
|
|
243
|
+
});
|
|
244
|
+
if (group.paramPath && group.methods.has("GET")) {
|
|
245
|
+
steps.push({
|
|
246
|
+
order: steps.length + 1, method: "GET", path: group.paramPath,
|
|
247
|
+
description: `Verify ${singular} is deleted (404)`,
|
|
248
|
+
interactionType: "error", expectedStatusCode: 404,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const suffix = method === "DELETE" ? "delete" : method.toLowerCase();
|
|
253
|
+
return {
|
|
254
|
+
scenarioName: `${resource}-${suffix}-lifecycle`,
|
|
255
|
+
description: `Lifecycle test: ${method} ${group.basePath} — verify ${method === "DELETE" ? "delete and subsequent 404" : "create and read-back"} flow`,
|
|
256
|
+
category: "new_endpoint",
|
|
257
|
+
priority: "high",
|
|
258
|
+
steps,
|
|
259
|
+
chainingKeys: ["id", `${singular}_id`],
|
|
260
|
+
requiresAuth: true,
|
|
261
|
+
estimatedComplexity: "moderate",
|
|
262
|
+
source: ScenarioSource.CodeInferred,
|
|
263
|
+
testType: TestType.INTEGRATION,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function diffDirectContract(method, path, resource) {
|
|
267
|
+
const expectedStatus = method === "POST" ? 201 : method === "DELETE" ? 204 : 200;
|
|
268
|
+
return {
|
|
269
|
+
scenarioName: `${resource}-${method.toLowerCase()}-new-endpoint-contract`,
|
|
270
|
+
description: `Contract: ${method} ${path} returns a schema-conformant response`,
|
|
271
|
+
category: "new_endpoint",
|
|
272
|
+
priority: "high",
|
|
273
|
+
steps: [{ order: 1, method, path, description: `${method} ${path} with valid input — verify response schema`, interactionType: "success", expectedStatusCode: expectedStatus }],
|
|
274
|
+
chainingKeys: [],
|
|
275
|
+
requiresAuth: true,
|
|
276
|
+
estimatedComplexity: "simple",
|
|
277
|
+
source: ScenarioSource.CodeInferred,
|
|
278
|
+
testType: TestType.CONTRACT,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function diffDirectNotFound(method, path, resource, singular) {
|
|
282
|
+
return {
|
|
283
|
+
scenarioName: `${resource}-${method.toLowerCase()}-new-endpoint-not-found`,
|
|
284
|
+
description: `Edge case: ${method} ${path} with a non-existent ${singular} ID returns 404`,
|
|
285
|
+
category: "new_endpoint",
|
|
286
|
+
priority: "high",
|
|
287
|
+
steps: [{ order: 1, method, path, description: `${method} ${path} with non-existent ${singular} ID — expect 404 Not Found`, interactionType: "error", expectedStatusCode: 404 }],
|
|
288
|
+
chainingKeys: [],
|
|
289
|
+
requiresAuth: true,
|
|
290
|
+
estimatedComplexity: "simple",
|
|
291
|
+
source: ScenarioSource.CodeInferred,
|
|
292
|
+
testType: TestType.CONTRACT,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function diffDirectValidation(method, path, resource) {
|
|
296
|
+
return {
|
|
297
|
+
scenarioName: `${resource}-${method.toLowerCase()}-new-endpoint-validation`,
|
|
298
|
+
description: `Validation: ${method} ${path} with missing required fields returns 422`,
|
|
299
|
+
category: "new_endpoint",
|
|
300
|
+
priority: "high",
|
|
301
|
+
steps: [{ order: 1, method, path, description: `${method} ${path} with empty/missing required fields — expect 422`, interactionType: "error", expectedStatusCode: 422 }],
|
|
302
|
+
chainingKeys: [],
|
|
303
|
+
requiresAuth: true,
|
|
304
|
+
estimatedComplexity: "simple",
|
|
305
|
+
source: ScenarioSource.CodeInferred,
|
|
306
|
+
testType: TestType.CONTRACT,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Draft a "mutation with collection modification" scenario for PUT/PATCH endpoints.
|
|
311
|
+
* This tests adding/removing child items (e.g., order line items) and verifying that
|
|
312
|
+
* derived totals (total_amount, item_count, subtotal) are recalculated.
|
|
313
|
+
* This pattern catches the most common class of user-reported bugs.
|
|
314
|
+
*/
|
|
315
|
+
function diffDirectBoundaryValues(method, resource, singular, group) {
|
|
316
|
+
const steps = [];
|
|
317
|
+
const targetPath = group.paramPath ?? group.basePath;
|
|
318
|
+
const pathParamName = group.paramPath?.match(/\{([^}]+)\}/)?.[1] ?? `${singular}_id`;
|
|
319
|
+
if (group.methods.has("POST")) {
|
|
320
|
+
steps.push({
|
|
321
|
+
order: 1,
|
|
322
|
+
method: "POST",
|
|
323
|
+
path: group.basePath,
|
|
324
|
+
description: `Create a ${singular} for boundary testing`,
|
|
325
|
+
interactionType: "success",
|
|
326
|
+
expectedStatusCode: 201,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
steps.push({
|
|
330
|
+
order: steps.length + 1,
|
|
331
|
+
method,
|
|
332
|
+
path: targetPath,
|
|
333
|
+
description: `${method} with valid boundary values (e.g. 0, maximum allowed, empty collection) — expect 200`,
|
|
334
|
+
interactionType: "success",
|
|
335
|
+
expectedStatusCode: 200,
|
|
336
|
+
// Signal the execution plan that a body is required. The agent reads source
|
|
337
|
+
// to discover the actual numeric/percentage field names to test at boundaries.
|
|
338
|
+
bodyMustInclude: ["numeric_or_percentage_field"],
|
|
339
|
+
...(steps.length > 0 && group.paramPath
|
|
340
|
+
? { chainsFrom: { sourceStep: 1, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
|
|
341
|
+
: {}),
|
|
342
|
+
});
|
|
343
|
+
return {
|
|
344
|
+
scenarioName: `${resource}-${method.toLowerCase()}-boundary-values`,
|
|
345
|
+
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.`,
|
|
346
|
+
category: "new_endpoint",
|
|
347
|
+
priority: "high",
|
|
348
|
+
steps,
|
|
349
|
+
chainingKeys: ["id", pathParamName],
|
|
350
|
+
requiresAuth: true,
|
|
351
|
+
estimatedComplexity: "moderate",
|
|
352
|
+
source: ScenarioSource.CodeInferred,
|
|
353
|
+
testType: TestType.INTEGRATION,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function diffDirectMutationRecalc(method, resource, singular, group) {
|
|
357
|
+
const steps = [];
|
|
358
|
+
if (group.methods.has("POST")) {
|
|
359
|
+
steps.push({
|
|
360
|
+
order: 1,
|
|
361
|
+
method: "POST",
|
|
362
|
+
path: group.basePath,
|
|
363
|
+
description: `Create a ${singular} for testing`,
|
|
364
|
+
interactionType: "success",
|
|
365
|
+
expectedStatusCode: 201,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
const targetPath = group.paramPath ?? group.basePath;
|
|
369
|
+
const pathParamName = group.paramPath?.match(/\{([^}]+)\}/)?.[1] ?? `${singular}_id`;
|
|
370
|
+
const sourceStep = steps.length;
|
|
371
|
+
steps.push({
|
|
372
|
+
order: steps.length + 1,
|
|
373
|
+
method,
|
|
374
|
+
path: targetPath,
|
|
375
|
+
description: `${method} the ${singular} with valid changes — read source to find mutable fields`,
|
|
376
|
+
interactionType: "success",
|
|
377
|
+
expectedStatusCode: 200,
|
|
378
|
+
...(sourceStep > 0 && group.paramPath
|
|
379
|
+
? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
|
|
380
|
+
: {}),
|
|
381
|
+
});
|
|
382
|
+
if (group.paramPath && group.methods.has("GET")) {
|
|
383
|
+
steps.push({
|
|
384
|
+
order: steps.length + 1,
|
|
385
|
+
method: "GET",
|
|
386
|
+
path: group.paramPath,
|
|
387
|
+
description: `Verify the ${singular} reflects changes — check any derived/calculated fields`,
|
|
388
|
+
interactionType: "success",
|
|
389
|
+
expectedStatusCode: 200,
|
|
390
|
+
...(sourceStep > 0 && group.paramPath
|
|
391
|
+
? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
|
|
392
|
+
: {}),
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
scenarioName: `${resource}-${method.toLowerCase()}-mutation-verify`,
|
|
397
|
+
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).`,
|
|
398
|
+
category: "new_endpoint",
|
|
399
|
+
priority: "high",
|
|
400
|
+
steps,
|
|
401
|
+
chainingKeys: ["id", pathParamName],
|
|
402
|
+
requiresAuth: true,
|
|
403
|
+
estimatedComplexity: "complex",
|
|
404
|
+
source: ScenarioSource.CodeInferred,
|
|
405
|
+
testType: TestType.INTEGRATION,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function diffDirectAuthBoundary(method, path, resource) {
|
|
409
|
+
return {
|
|
410
|
+
scenarioName: `${resource}-${method.toLowerCase()}-auth-boundary`,
|
|
411
|
+
description: `Auth boundary: ${method} ${path} without authentication returns 401 Unauthorized`,
|
|
412
|
+
category: "security_boundary",
|
|
413
|
+
priority: "high",
|
|
414
|
+
steps: [{ order: 1, method, path, description: `${method} ${path} without Authorization header — expect 401 Unauthorized`, interactionType: "error", expectedStatusCode: 401 }],
|
|
415
|
+
chainingKeys: [],
|
|
416
|
+
requiresAuth: false,
|
|
417
|
+
estimatedComplexity: "simple",
|
|
418
|
+
source: ScenarioSource.CodeInferred,
|
|
419
|
+
testType: TestType.CONTRACT,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Draft a unified scenario for GET endpoints without path params (collection or search).
|
|
424
|
+
* Rather than hardcoding keyword detection, the description instructs the LLM to read
|
|
425
|
+
* the diff source and determine whether query params are involved, then draft variations
|
|
426
|
+
* accordingly.
|
|
427
|
+
*/
|
|
428
|
+
function diffDirectGetCollection(path, resource, singular, group) {
|
|
429
|
+
const steps = [];
|
|
430
|
+
if (group.methods.has("POST")) {
|
|
431
|
+
steps.push({
|
|
432
|
+
order: 1,
|
|
433
|
+
method: "POST",
|
|
434
|
+
path: group.basePath,
|
|
435
|
+
description: `Create a ${singular} for query verification`,
|
|
436
|
+
interactionType: "success",
|
|
437
|
+
expectedStatusCode: 201,
|
|
438
|
+
});
|
|
439
|
+
steps.push({
|
|
440
|
+
order: 2,
|
|
441
|
+
method: "GET",
|
|
442
|
+
path,
|
|
443
|
+
description: `Query ${resource} and verify results include the created item`,
|
|
444
|
+
interactionType: "success",
|
|
445
|
+
expectedStatusCode: 200,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
steps.push({
|
|
450
|
+
order: 1,
|
|
451
|
+
method: "GET",
|
|
452
|
+
path,
|
|
453
|
+
description: `Query ${resource} and verify response structure`,
|
|
454
|
+
interactionType: "success",
|
|
455
|
+
expectedStatusCode: 200,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
// Include a path-specific suffix in scenarioName to avoid collision when multiple GET
|
|
459
|
+
// collection endpoints exist for the same resource (e.g. /products and /products/featured).
|
|
460
|
+
// Strip API prefix segments (api, v1, ...) and param segments, take the last 1-2 meaningful ones.
|
|
461
|
+
const meaningfulSegs = path.split("/").filter(s => s && !s.startsWith("{") && !SKIP_SEGMENTS.has(s));
|
|
462
|
+
const pathSlug = meaningfulSegs.slice(-2).join("-") || resource;
|
|
463
|
+
const uniqueName = pathSlug === resource ? `${resource}-list-query` : `${pathSlug}-list-query`;
|
|
464
|
+
return {
|
|
465
|
+
scenarioName: uniqueName,
|
|
466
|
+
description: `List/query test: GET ${path} — read source to find supported query params (filters, pagination, search), then test with and without them`,
|
|
467
|
+
category: "new_endpoint",
|
|
468
|
+
priority: "high",
|
|
469
|
+
steps,
|
|
470
|
+
chainingKeys: ["id", `${singular}_id`],
|
|
471
|
+
requiresAuth: true,
|
|
472
|
+
estimatedComplexity: group.methods.has("POST") ? "moderate" : "simple",
|
|
473
|
+
source: ScenarioSource.CodeInferred,
|
|
474
|
+
testType: group.methods.has("POST") ? TestType.INTEGRATION : TestType.CONTRACT,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Draft scenarios that directly test each new endpoint in the branch diff.
|
|
135
479
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* POST
|
|
139
|
-
*
|
|
140
|
-
*
|
|
480
|
+
* For each new endpoint, method-specific scenarios are produced:
|
|
481
|
+
* PUT/PATCH → mutation-recalc integration, boundary-values integration, contract, not-found
|
|
482
|
+
* POST → lifecycle integration (create + optional GET), contract, validation error
|
|
483
|
+
* DELETE → lifecycle integration (create + delete + 404), contract
|
|
484
|
+
* GET /{id} → contract, not-found
|
|
485
|
+
* GET /coll → contract + collection-list integration (create-then-GET) or contract-only
|
|
141
486
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
487
|
+
* Additionally, PUT/PATCH/DELETE methods get an auth-boundary scenario
|
|
488
|
+
* (category "security_boundary" → HIGH priority, not CRITICAL) so it
|
|
489
|
+
* lands in ADDITIONAL deterministically instead of depending on LLM
|
|
490
|
+
* supplement. GET is excluded (often public); POST is excluded because
|
|
491
|
+
* the security-boundary supplement tier covers it.
|
|
145
492
|
*/
|
|
146
|
-
export function
|
|
147
|
-
if (
|
|
493
|
+
export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
|
|
494
|
+
if (newEndpoints.length === 0)
|
|
148
495
|
return [];
|
|
149
496
|
const scenarios = [];
|
|
150
|
-
// Dedup by the true coverage identity (method + path + testType), not just scenarioName.
|
|
151
|
-
// This prevents silent loss when different paths produce the same resource-based name.
|
|
152
497
|
const seen = new Set();
|
|
153
|
-
const add = (key, s) => {
|
|
154
|
-
if (!seen.has(key)) {
|
|
155
|
-
seen.add(key);
|
|
156
|
-
scenarios.push(s);
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
498
|
// Group flat list by path
|
|
160
499
|
const grouped = new Map();
|
|
161
|
-
for (const ep of
|
|
500
|
+
for (const ep of newEndpoints) {
|
|
162
501
|
const methods = grouped.get(ep.path) ?? new Set();
|
|
163
502
|
methods.add(ep.method.toUpperCase());
|
|
164
503
|
grouped.set(ep.path, methods);
|
|
165
504
|
}
|
|
166
|
-
|
|
505
|
+
// Segment-boundary suffix match: avoids "/orders" matching "/preorders".
|
|
506
|
+
const pathSuffixMatches = (fullPath, suffix) => {
|
|
507
|
+
if (fullPath === suffix)
|
|
508
|
+
return true;
|
|
509
|
+
const fullSegs = fullPath.split("/").filter(Boolean);
|
|
510
|
+
const suffixSegs = suffix.split("/").filter(Boolean);
|
|
511
|
+
if (suffixSegs.length === 0 || suffixSegs.length > fullSegs.length)
|
|
512
|
+
return false;
|
|
513
|
+
const offset = fullSegs.length - suffixSegs.length;
|
|
514
|
+
for (let i = 0; i < suffixSegs.length; i++) {
|
|
515
|
+
if (fullSegs[offset + i] !== suffixSegs[i])
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
return true;
|
|
519
|
+
};
|
|
167
520
|
for (const [epPath, methods] of grouped) {
|
|
168
|
-
|
|
521
|
+
let resource = extractResourceName(epPath);
|
|
522
|
+
let resolvedPath = epPath;
|
|
523
|
+
// When resource was found but epPath may be router-relative (e.g. "/orders"
|
|
524
|
+
// instead of "/api/v1/orders"), try to upgrade resolvedPath to the full path.
|
|
525
|
+
if (resource) {
|
|
526
|
+
const existingGroup = resourceGroups.get(resource);
|
|
527
|
+
if (existingGroup) {
|
|
528
|
+
if (existingGroup.paramPath && existingGroup.paramPath !== epPath && pathSuffixMatches(existingGroup.paramPath, epPath)) {
|
|
529
|
+
resolvedPath = existingGroup.paramPath;
|
|
530
|
+
}
|
|
531
|
+
else if (existingGroup.basePath !== epPath && pathSuffixMatches(existingGroup.basePath, epPath)) {
|
|
532
|
+
resolvedPath = existingGroup.basePath;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// When extractResourceName returns null OR the resource isn't in resourceGroups,
|
|
537
|
+
// fall back to finding a matching group via prefix or suffix matching.
|
|
538
|
+
if (!resource || !resourceGroups.has(resource)) {
|
|
539
|
+
let bestMatch = null;
|
|
540
|
+
for (const [rName, rGroup] of resourceGroups) {
|
|
541
|
+
// Prefix match: epPath starts with group's basePath (e.g. /products/search → /products)
|
|
542
|
+
if (epPath.startsWith(rGroup.basePath + "/") || epPath === rGroup.basePath) {
|
|
543
|
+
if (!bestMatch || rGroup.basePath.length > bestMatch.length) {
|
|
544
|
+
bestMatch = { name: rName, path: rGroup.basePath, length: rGroup.basePath.length, isPrefixMatch: true };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Suffix match for router-relative paths (e.g. /{order_id} → /api/v1/orders/{order_id})
|
|
548
|
+
if (rGroup.paramPath && pathSuffixMatches(rGroup.paramPath, epPath)) {
|
|
549
|
+
if (!bestMatch || rGroup.paramPath.length > bestMatch.length) {
|
|
550
|
+
bestMatch = { name: rName, path: rGroup.paramPath, length: rGroup.paramPath.length, isPrefixMatch: false };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (!bestMatch)
|
|
555
|
+
continue;
|
|
556
|
+
resource = bestMatch.name;
|
|
557
|
+
// For prefix matches (action sub-paths), keep original path; for suffix matches, use resolved path
|
|
558
|
+
resolvedPath = bestMatch.isPrefixMatch ? epPath : bestMatch.path;
|
|
559
|
+
}
|
|
560
|
+
const group = resourceGroups.get(resource);
|
|
561
|
+
if (!group)
|
|
562
|
+
continue;
|
|
563
|
+
const singular = singularize(resource);
|
|
564
|
+
const hasPathParam = resolvedPath.includes("{");
|
|
565
|
+
const add = (s) => {
|
|
566
|
+
if (!seen.has(s.scenarioName)) {
|
|
567
|
+
seen.add(s.scenarioName);
|
|
568
|
+
scenarios.push(s);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
169
571
|
for (const method of methods) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
add(
|
|
194
|
-
|
|
195
|
-
description: `Integration: ${method} ${epPath} — read source to discover prerequisites, then verify the full lifecycle`,
|
|
196
|
-
category,
|
|
197
|
-
priority: "high",
|
|
198
|
-
steps: [{
|
|
199
|
-
order: 1, method, path: epPath,
|
|
200
|
-
description: `${method} ${epPath} — verify end-to-end flow (LLM adds prerequisite and verification steps from source)`,
|
|
201
|
-
interactionType: "success",
|
|
202
|
-
expectedStatusCode: inferExpectedStatus(method),
|
|
203
|
-
}],
|
|
204
|
-
chainingKeys: [],
|
|
205
|
-
requiresAuth: true,
|
|
206
|
-
estimatedComplexity: "moderate",
|
|
207
|
-
source: ScenarioSource.CodeInferred,
|
|
208
|
-
testType: TestType.INTEGRATION,
|
|
209
|
-
});
|
|
572
|
+
if (method === "PUT" || method === "PATCH") {
|
|
573
|
+
// mutation-recalc tests the primary calculation flow
|
|
574
|
+
add(diffDirectMutationRecalc(method, resource, singular, group));
|
|
575
|
+
// boundary-values tests edge cases (0, max, empty)
|
|
576
|
+
add(diffDirectBoundaryValues(method, resource, singular, group));
|
|
577
|
+
add(diffDirectContract(method, resolvedPath, resource));
|
|
578
|
+
if (hasPathParam)
|
|
579
|
+
add(diffDirectNotFound(method, resolvedPath, resource, singular));
|
|
580
|
+
}
|
|
581
|
+
else if (method === "POST") {
|
|
582
|
+
add(diffDirectIntegration(method, resource, singular, group));
|
|
583
|
+
add(diffDirectContract(method, resolvedPath, resource));
|
|
584
|
+
add(diffDirectValidation(method, resolvedPath, resource));
|
|
585
|
+
}
|
|
586
|
+
else if (method === "DELETE") {
|
|
587
|
+
add(diffDirectIntegration(method, resource, singular, group));
|
|
588
|
+
add(diffDirectContract(method, resolvedPath, resource));
|
|
589
|
+
}
|
|
590
|
+
else if (method === "GET" && hasPathParam) {
|
|
591
|
+
add(diffDirectContract(method, resolvedPath, resource));
|
|
592
|
+
add(diffDirectNotFound(method, resolvedPath, resource, singular));
|
|
593
|
+
}
|
|
594
|
+
else if (method === "GET" && !hasPathParam) {
|
|
595
|
+
add(diffDirectContract(method, resolvedPath, resource));
|
|
596
|
+
add(diffDirectGetCollection(resolvedPath, resource, singular, group));
|
|
210
597
|
}
|
|
598
|
+
if (method === "PUT" || method === "PATCH" || method === "DELETE")
|
|
599
|
+
add(diffDirectAuthBoundary(method, resolvedPath, resource));
|
|
211
600
|
}
|
|
212
601
|
}
|
|
213
602
|
return scenarios;
|