@skyramp/mcp 0.0.65 → 0.1.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/build/playwright/traceRecordingPrompt.js +30 -36
  2. package/build/prompts/architectPersona.js +19 -0
  3. package/build/prompts/test-maintenance/drift-analysis-prompt.js +11 -6
  4. package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +49 -0
  5. package/build/prompts/test-maintenance/driftAnalysisSections.js +4 -2
  6. package/build/prompts/test-recommendation/analysisOutputPrompt.js +42 -50
  7. package/build/prompts/test-recommendation/mergeEnrichedScenarios.test.js +125 -0
  8. package/build/prompts/test-recommendation/recommendationSections.js +121 -4
  9. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +151 -9
  10. package/build/prompts/test-recommendation/test-recommendation-prompt.js +416 -61
  11. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +455 -63
  12. package/build/prompts/testbot/testbot-prompts.js +111 -100
  13. package/build/prompts/testbot/testbot-prompts.test.js +142 -0
  14. package/build/resources/analysisResources.js +13 -5
  15. package/build/services/ScenarioGenerationService.js +2 -2
  16. package/build/services/ScenarioGenerationService.test.js +35 -0
  17. package/build/services/TestExecutionService.js +1 -1
  18. package/build/tools/code-refactor/modularizationTool.js +2 -2
  19. package/build/tools/executeSkyrampTestTool.js +4 -3
  20. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +51 -21
  21. package/build/tools/generate-tests/generateContractRestTool.js +26 -4
  22. package/build/tools/generate-tests/generateIntegrationRestTool.js +44 -13
  23. package/build/tools/generate-tests/generateScenarioRestTool.js +17 -39
  24. package/build/tools/generate-tests/generateUIRestTool.js +69 -4
  25. package/build/tools/submitReportTool.js +27 -13
  26. package/build/tools/test-management/analyzeChangesTool.js +32 -10
  27. package/build/tools/test-management/analyzeChangesTool.test.js +85 -0
  28. package/build/types/RepositoryAnalysis.js +25 -3
  29. package/build/types/TestRecommendation.js +5 -4
  30. package/build/types/TestTypes.js +44 -9
  31. package/build/utils/AnalysisStateManager.js +43 -9
  32. package/build/utils/AnalysisStateManager.test.js +35 -0
  33. package/build/utils/routeParsers.js +35 -0
  34. package/build/utils/routeParsers.test.js +66 -1
  35. package/build/utils/scenarioDrafting.js +207 -360
  36. package/build/utils/scenarioDrafting.test.js +191 -256
  37. package/build/utils/trace-parser.js +24 -6
  38. package/build/utils/trace-parser.test.js +140 -0
  39. package/node_modules/playwright/lib/mcp/browser/browserServerBackend.js +3 -0
  40. package/node_modules/playwright/lib/mcp/browser/tab.js +8 -1
  41. package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -2
  42. package/node_modules/playwright/lib/mcp/browser/tools/navigate.js +1 -1
  43. package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +4 -4
  44. package/node_modules/playwright/lib/mcp/browser/tools/tabs.js +5 -4
  45. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  46. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -9
  47. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +304 -7
  48. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +128 -20
  49. package/package.json +2 -2
  50. package/node_modules/playwright/lib/mcp/terminal/help.json +0 -32
@@ -1,3 +1,6 @@
1
+ import { ScenarioSource } from "../types/RepositoryAnalysis.js";
2
+ import { TestType } from "../types/TestTypes.js";
3
+ import { CATEGORY_PRIORITY } from "../types/TestRecommendation.js";
1
4
  /**
2
5
  * Cap on drafted scenarios per category to prevent prompt bloat on large APIs.
3
6
  * Value of 4 balances coverage breadth (enough to surface patterns across
@@ -148,318 +151,44 @@ export function draftScenariosFromEndpoints(endpoints, newEndpoints = []) {
148
151
  });
149
152
  }
150
153
  }
151
- // ── Cross-resource integration scenarios ──
152
- const relationships = inferResourceRelationships(endpoints);
153
- const hasRelationships = relationships.size > 0;
154
- const creatableResources = Array.from(resourceGroups.keys()).filter(r => isRealResource(r) && resourceGroups.get(r).methods.has("POST"));
155
- const MAX_CROSS_RESOURCE = Math.min(Math.max(3, Math.floor(creatableResources.length / 2)), 8);
156
- let crossCount = 0;
157
- for (let i = 0; i < creatableResources.length && crossCount < MAX_CROSS_RESOURCE; i++) {
158
- for (let j = i + 1; j < creatableResources.length && crossCount < MAX_CROSS_RESOURCE; j++) {
159
- const r1 = creatableResources[i];
160
- const r2 = creatableResources[j];
161
- if (hasRelationships) {
162
- // Only pair resources with a detected relationship; skip unrelated ones.
163
- const r2DependsOnR1 = relationships.get(r2)?.has(r1) || relationships.get(r2)?.has(singularize(r1));
164
- const r1DependsOnR2 = relationships.get(r1)?.has(r2) || relationships.get(r1)?.has(singularize(r2));
165
- if (!r2DependsOnR1 && !r1DependsOnR2)
166
- continue;
167
- // Use relationship direction: parent is the one that doesn't depend on the other.
168
- const [parent, child] = r2DependsOnR1 ? [r1, r2] : [r2, r1];
169
- const gParent = resourceGroups.get(parent);
170
- const gChild = resourceGroups.get(child);
171
- const singularParent = singularize(parent);
172
- const singularChild = singularize(child);
173
- scenarios.push(buildCrossResourceScenario(parent, child, gParent, gChild, singularParent, singularChild));
174
- }
175
- else {
176
- // No relationship signals found — fall back to sequential pairing (existing behaviour).
177
- const g1 = resourceGroups.get(r1);
178
- const g2 = resourceGroups.get(r2);
179
- scenarios.push(buildCrossResourceScenario(r1, r2, g1, g2, singularize(r1), singularize(r2)));
180
- }
181
- crossCount++;
182
- }
183
- }
184
- // ── CRUD lifecycle scenarios ──
185
- for (const [resource, group] of resourceGroups) {
186
- const m = group.methods;
187
- if (!m.has("POST") || !m.has("GET") || !isRealResource(resource))
188
- continue;
189
- const steps = [];
190
- let order = 1;
191
- const singular = singularize(resource);
192
- steps.push({ order: order++, method: "POST", path: group.basePath, description: `Create a new ${singular}`, interactionType: "success", expectedStatusCode: 201 });
193
- if (group.paramPath)
194
- steps.push({ order: order++, method: "GET", path: group.paramPath, description: `Get the created ${singular} by ID`, interactionType: "success", expectedStatusCode: 200 });
195
- if ((m.has("PUT") || m.has("PATCH")) && group.paramPath)
196
- steps.push({ order: order++, method: m.has("PUT") ? "PUT" : "PATCH", path: group.paramPath, description: `Update the ${singular}`, interactionType: "success", expectedStatusCode: 200 });
197
- if (m.has("DELETE") && group.paramPath)
198
- steps.push({ order: order++, method: "DELETE", path: group.paramPath, description: `Delete the ${singular}`, interactionType: "success", expectedStatusCode: 204 });
199
- scenarios.push({
200
- scenarioName: `${resource}-crud-lifecycle`,
201
- description: `CRUD lifecycle for ${resource}: create, read, update, delete`,
202
- category: "crud",
203
- priority: "medium",
204
- steps,
205
- chainingKeys: ["id", `${singular}_id`],
206
- requiresAuth: true,
207
- estimatedComplexity: "moderate",
208
- source: "code-inferred",
209
- testType: "integration",
210
- });
211
- }
212
- scenarios.push(...draftUniqueConstraintScenarios(resourceGroups));
213
- scenarios.push(...draftCascadeDeleteScenarios(resourceGroups, relationships));
214
- scenarios.push(...draftPermissionBoundaryScenarios(resourceGroups));
215
154
  scenarios.push(...draftDiffDirectScenarios(newEndpoints, resourceGroups));
216
- return scenarios;
155
+ return capScenarios(scenarios);
217
156
  }
218
- function buildCrossResourceScenario(parent, child, gParent, gChild, singularParent, singularChild) {
219
- const steps = [];
220
- let order = 1;
221
- steps.push({
222
- order: order++, method: "POST", path: gParent.basePath,
223
- description: `Create a ${singularParent}`,
224
- interactionType: "success", expectedStatusCode: 201,
225
- });
226
- steps.push({
227
- order: order++, method: "POST", path: gChild.basePath,
228
- description: `Create a ${singularChild} referencing the ${singularParent}`,
229
- interactionType: "success", expectedStatusCode: 201,
230
- chainsFrom: { sourceStep: 1, sourceField: `${singularParent}_id`, sourceLocation: "body", targetParam: `${singularParent}_id`, targetLocation: "body" },
231
- });
232
- if (gChild.paramPath && gChild.methods.has("GET")) {
233
- steps.push({
234
- order: order++, method: "GET", path: gChild.paramPath,
235
- description: `Verify the ${singularChild} references the correct ${singularParent}`,
236
- interactionType: "success", expectedStatusCode: 200,
237
- });
238
- }
239
- if (gChild.paramPath && gChild.methods.has("DELETE")) {
240
- steps.push({
241
- order: order++, method: "DELETE", path: gChild.paramPath,
242
- description: `Clean up: delete the ${singularChild}`,
243
- interactionType: "success", expectedStatusCode: 204,
244
- });
245
- }
246
- return {
247
- scenarioName: `${parent}-${child}-integration`,
248
- description: `Multi-resource integration: create ${singularParent}, create ${singularChild} linked to it, verify, clean up`,
249
- category: "workflow",
250
- priority: "high",
251
- steps,
252
- chainingKeys: [`${singularParent}_id`, `${singularChild}_id`, "id"],
253
- requiresAuth: true,
254
- estimatedComplexity: "complex",
255
- source: "code-inferred",
256
- testType: "integration",
257
- };
258
- }
259
- export function draftUniqueConstraintScenarios(resourceGroups) {
260
- const scenarios = [];
261
- for (const [resource, group] of resourceGroups) {
262
- if (scenarios.length >= MAX_DRAFTED_SCENARIOS_PER_CATEGORY)
263
- break;
264
- if (!group.methods.has("POST") || !isRealResource(resource))
265
- continue;
266
- const singular = singularize(resource);
267
- scenarios.push({
268
- scenarioName: `${resource}-unique-constraint`,
269
- description: `Verify unique constraint: create ${singular}, attempt duplicate creation, expect 409 Conflict`,
270
- category: "business_rule",
271
- priority: "high",
272
- steps: [
273
- {
274
- order: 1, method: "POST", path: group.basePath,
275
- description: `Create a ${singular} with unique fields`,
276
- interactionType: "success", expectedStatusCode: 201,
277
- },
278
- {
279
- order: 2, method: "POST", path: group.basePath,
280
- description: `Attempt to create a duplicate ${singular} with the same unique fields — expect 409 Conflict`,
281
- interactionType: "error", expectedStatusCode: 409,
282
- },
283
- ...(group.paramPath && group.methods.has("GET") ? [{
284
- order: 3, method: "GET", path: group.paramPath,
285
- description: `Verify the original ${singular} is unchanged after duplicate attempt`,
286
- interactionType: "success", expectedStatusCode: 200,
287
- }] : []),
288
- ],
289
- chainingKeys: ["id", `${singular}_id`],
290
- requiresAuth: true,
291
- estimatedComplexity: "moderate",
292
- source: "code-inferred",
293
- testType: "integration",
294
- });
295
- }
296
- return scenarios;
297
- }
298
- export function draftCascadeDeleteScenarios(resourceGroups, relationships = new Map()) {
299
- const scenarios = [];
300
- const hasRelationships = relationships.size > 0;
301
- const deletableResources = Array.from(resourceGroups.keys()).filter(r => isRealResource(r) && resourceGroups.get(r).methods.has("POST") && resourceGroups.get(r).methods.has("DELETE"));
302
- for (let i = 0; i < deletableResources.length; i++) {
303
- for (let j = i + 1; j < deletableResources.length; j++) {
304
- if (scenarios.length >= MAX_DRAFTED_SCENARIOS_PER_CATEGORY)
305
- break;
306
- const r1 = deletableResources[i];
307
- const r2 = deletableResources[j];
308
- // Determine relationship direction: which is parent, which is child?
309
- let parent, child;
310
- if (hasRelationships) {
311
- const r2DependsOnR1 = relationships.get(r2)?.has(r1) || relationships.get(r2)?.has(singularize(r1));
312
- const r1DependsOnR2 = relationships.get(r1)?.has(r2) || relationships.get(r1)?.has(singularize(r2));
313
- if (!r2DependsOnR1 && !r1DependsOnR2)
314
- continue; // no detected relationship — skip
315
- [parent, child] = r2DependsOnR1 ? [r1, r2] : [r2, r1];
316
- }
317
- else {
318
- // Fallback: treat first as parent (existing behaviour)
319
- [parent, child] = [r1, r2];
320
- }
321
- const gParent = resourceGroups.get(parent);
322
- const gChild = resourceGroups.get(child);
323
- if (!gParent.paramPath || !gChild.paramPath)
324
- continue;
325
- const singularParent = singularize(parent);
326
- const singularChild = singularize(child);
327
- // Scenario A: cascade delete — parent delete removes child
328
- scenarios.push({
329
- scenarioName: `${parent}-${child}-cascade-delete`,
330
- description: `Verify cascade delete: create ${singularParent}, create ${singularChild} referencing it, delete ${singularParent}, verify ${singularChild} is also deleted`,
331
- category: "data_integrity",
332
- priority: "high",
333
- steps: [
334
- {
335
- order: 1, method: "POST", path: gParent.basePath,
336
- description: `Create a ${singularParent}`,
337
- interactionType: "success", expectedStatusCode: 201,
338
- },
339
- {
340
- order: 2, method: "POST", path: gChild.basePath,
341
- description: `Create a ${singularChild} referencing the ${singularParent}`,
342
- interactionType: "success", expectedStatusCode: 201,
343
- chainsFrom: { sourceStep: 1, sourceField: `${singularParent}_id`, sourceLocation: "body", targetParam: `${singularParent}_id`, targetLocation: "body" },
344
- },
345
- {
346
- order: 3, method: "DELETE", path: gParent.paramPath,
347
- description: `Delete the ${singularParent} — expect 204 if cascade is configured`,
348
- interactionType: "success", expectedStatusCode: 204,
349
- },
350
- {
351
- order: 4, method: "GET", path: gChild.paramPath,
352
- description: `Verify ${singularChild} was cascade-deleted — expect 404`,
353
- interactionType: "error", expectedStatusCode: 404,
354
- },
355
- ],
356
- chainingKeys: [`${singularParent}_id`, `${singularChild}_id`, "id"],
357
- requiresAuth: true,
358
- estimatedComplexity: "complex",
359
- source: "code-inferred",
360
- testType: "integration",
361
- });
362
- // Scenario B: delete blocked by referential integrity — parent cannot be removed while children exist
363
- if (scenarios.length < MAX_DRAFTED_SCENARIOS_PER_CATEGORY) {
364
- scenarios.push({
365
- scenarioName: `${parent}-${child}-delete-blocked`,
366
- description: `Verify referential integrity: create ${singularParent} with ${singularChild}, attempt to delete ${singularParent}, expect rejection (409/422), verify ${singularChild} is intact`,
367
- category: "data_integrity",
368
- priority: "high",
369
- steps: [
370
- {
371
- order: 1, method: "POST", path: gParent.basePath,
372
- description: `Create a ${singularParent}`,
373
- interactionType: "success", expectedStatusCode: 201,
374
- },
375
- {
376
- order: 2, method: "POST", path: gChild.basePath,
377
- description: `Create a ${singularChild} referencing the ${singularParent}`,
378
- interactionType: "success", expectedStatusCode: 201,
379
- chainsFrom: { sourceStep: 1, sourceField: `${singularParent}_id`, sourceLocation: "body", targetParam: `${singularParent}_id`, targetLocation: "body" },
380
- },
381
- {
382
- order: 3, method: "DELETE", path: gParent.paramPath,
383
- description: `Attempt to delete the ${singularParent} — expect 409 Conflict or 422 if referential integrity blocks it`,
384
- interactionType: "error", expectedStatusCode: 409,
385
- },
386
- {
387
- order: 4, method: "GET", path: gChild.paramPath,
388
- description: `Verify ${singularChild} is still intact after blocked delete — expect 200`,
389
- interactionType: "success", expectedStatusCode: 200,
390
- },
391
- ],
392
- chainingKeys: [`${singularParent}_id`, `${singularChild}_id`, "id"],
393
- requiresAuth: true,
394
- estimatedComplexity: "complex",
395
- source: "code-inferred",
396
- testType: "integration",
397
- });
398
- }
399
- }
400
- if (scenarios.length >= MAX_DRAFTED_SCENARIOS_PER_CATEGORY)
401
- break;
402
- }
403
- return scenarios;
404
- }
405
- export function draftPermissionBoundaryScenarios(resourceGroups) {
406
- const scenarios = [];
407
- for (const [resource, group] of resourceGroups) {
408
- if (scenarios.length >= MAX_DRAFTED_SCENARIOS_PER_CATEGORY)
409
- break;
410
- if (!isRealResource(resource))
411
- continue;
412
- const singular = singularize(resource);
413
- const protectedMethods = ["POST", "PUT", "PATCH", "DELETE"].filter(m => group.methods.has(m));
414
- if (protectedMethods.length === 0)
415
- continue;
416
- const targetMethod = protectedMethods[0];
417
- const targetPath = (targetMethod === "POST") ? group.basePath : (group.paramPath || group.basePath);
418
- scenarios.push({
419
- scenarioName: `${resource}-auth-boundary`,
420
- description: `Verify auth boundary: call ${targetMethod} ${resource} without authentication, expect 401 Unauthorized`,
421
- category: "security_boundary",
422
- priority: "high",
423
- steps: [
424
- {
425
- order: 1, method: targetMethod, path: targetPath,
426
- description: `Call ${targetMethod} ${targetPath} without authentication — expect 401 Unauthorized`,
427
- interactionType: "error", expectedStatusCode: 401,
428
- },
429
- ],
430
- chainingKeys: [],
431
- requiresAuth: false,
432
- estimatedComplexity: "simple",
433
- source: "code-inferred",
434
- testType: "contract",
435
- });
436
- if (group.paramPath && group.methods.has("GET") && group.methods.has("POST") && scenarios.length < MAX_DRAFTED_SCENARIOS_PER_CATEGORY) {
437
- scenarios.push({
438
- scenarioName: `${resource}-cross-user-isolation`,
439
- description: `Verify cross-user isolation: user A creates ${singular}, user B tries to access it, expect 403 Forbidden`,
440
- category: "security_boundary",
441
- priority: "high",
442
- steps: [
443
- {
444
- order: 1, method: "POST", path: group.basePath,
445
- description: `User A creates a ${singular}`,
446
- interactionType: "success", expectedStatusCode: 201,
447
- },
448
- {
449
- order: 2, method: "GET", path: group.paramPath,
450
- description: `User B attempts to access user A's ${singular} — expect 403 Forbidden`,
451
- interactionType: "error", expectedStatusCode: 403,
452
- },
453
- ],
454
- chainingKeys: ["id", `${singular}_id`],
455
- requiresAuth: true,
456
- estimatedComplexity: "moderate",
457
- source: "code-inferred",
458
- testType: "integration",
459
- });
157
+ const MAX_TOTAL_SCENARIOS = 30;
158
+ const TIER_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
159
+ /**
160
+ * Enforce a global cap on drafted scenarios while preserving category diversity.
161
+ *
162
+ * 1. CRITICAL (new_endpoint / diff-direct) scenarios prioritized first.
163
+ * 2. One scenario per non-empty category guaranteed (breadth).
164
+ * 3. Remaining budget filled by priority tier (HIGH > MEDIUM > LOW).
165
+ * 4. Hard cap at MAX_TOTAL_SCENARIOS — applied to the combined output.
166
+ */
167
+ function capScenarios(scenarios) {
168
+ if (scenarios.length <= MAX_TOTAL_SCENARIOS)
169
+ return scenarios;
170
+ const critical = scenarios.filter(s => CATEGORY_PRIORITY[s.category] === "CRITICAL");
171
+ const rest = scenarios.filter(s => CATEGORY_PRIORITY[s.category] !== "CRITICAL");
172
+ const breadthPicks = [];
173
+ const seenCategories = new Set();
174
+ for (const s of rest) {
175
+ if (!seenCategories.has(s.category)) {
176
+ seenCategories.add(s.category);
177
+ breadthPicks.push(s);
460
178
  }
461
179
  }
462
- return scenarios;
180
+ const breadthSet = new Set(breadthPicks);
181
+ const remaining = rest.filter(s => !breadthSet.has(s));
182
+ remaining.sort((a, b) => {
183
+ const ta = TIER_ORDER[CATEGORY_PRIORITY[a.category] ?? "LOW"] ?? 1;
184
+ const tb = TIER_ORDER[CATEGORY_PRIORITY[b.category] ?? "LOW"] ?? 1;
185
+ return tb - ta;
186
+ });
187
+ const budget = MAX_TOTAL_SCENARIOS - critical.length - breadthPicks.length;
188
+ const filler = remaining.slice(0, Math.max(budget, 0));
189
+ const combined = [...critical, ...breadthPicks, ...filler];
190
+ // Enforce the hard cap even if critical set alone exceeds it
191
+ return combined.slice(0, MAX_TOTAL_SCENARIOS);
463
192
  }
464
193
  // ── Diff-direct scenario drafting ──
465
194
  // Generates targeted scenarios for each new endpoint in the branch diff.
@@ -477,57 +206,53 @@ function diffDirectIntegration(method, resource, singular, group) {
477
206
  if (method === "POST") {
478
207
  steps.push({
479
208
  order: 1, method: "POST", path: group.basePath,
480
- description: `Create a ${singular} verify 201 and response fields`,
209
+ description: `Create ${singular} and verify response`,
481
210
  interactionType: "success", expectedStatusCode: 201,
482
211
  });
483
212
  if (group.paramPath && group.methods.has("GET")) {
484
213
  steps.push({
485
214
  order: 2, method: "GET", path: group.paramPath,
486
- description: `Get the created ${singular} verify fields match`,
215
+ description: `Retrieve created ${singular} and verify fields`,
487
216
  interactionType: "success", expectedStatusCode: 200,
488
217
  chainsFrom: { sourceStep: 1, sourceField: "id", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" },
489
218
  });
490
219
  }
491
220
  }
492
- else {
493
- // PUT/PATCH/DELETE: create the resource first so it exists
221
+ else if (method === "DELETE") {
494
222
  if (group.methods.has("POST")) {
495
223
  steps.push({
496
224
  order: 1, method: "POST", path: group.basePath,
497
- description: `Create a ${singular} to be ${method === "DELETE" ? "deleted" : "updated"}`,
225
+ description: `Create ${singular} to delete`,
498
226
  interactionType: "success", expectedStatusCode: 201,
499
227
  });
500
228
  }
501
229
  const targetPath = group.paramPath ?? group.basePath;
502
- const sourceStep = steps.length; // 1 if create step exists, 0 otherwise
503
230
  steps.push({
504
- order: steps.length + 1, method, path: targetPath,
505
- description: method === "DELETE"
506
- ? `DELETE the ${singular} — expect 204 No Content`
507
- : `${method} the ${singular} with valid data verify updated response fields`,
508
- interactionType: "success", expectedStatusCode: method === "DELETE" ? 204 : 200,
509
- ...(sourceStep > 0 ? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" } } : {}),
231
+ order: steps.length + 1, method: "DELETE", path: targetPath,
232
+ description: `Delete ${singular}`,
233
+ interactionType: "success", expectedStatusCode: 204,
234
+ ...(steps.length > 0 ? { chainsFrom: { sourceStep: 1, sourceField: "id", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" } } : {}),
510
235
  });
511
- if (method === "DELETE" && group.paramPath && group.methods.has("GET")) {
236
+ if (group.paramPath && group.methods.has("GET")) {
512
237
  steps.push({
513
238
  order: steps.length + 1, method: "GET", path: group.paramPath,
514
- description: `Verify the ${singular} is gone — expect 404`,
239
+ description: `Verify ${singular} is deleted (404)`,
515
240
  interactionType: "error", expectedStatusCode: 404,
516
241
  });
517
242
  }
518
243
  }
519
244
  const suffix = method === "DELETE" ? "delete" : method.toLowerCase();
520
245
  return {
521
- scenarioName: `${resource}-${suffix}-new-endpoint-happy-path`,
522
- description: `New endpoint happy path: discover prerequisites from source code, then ${steps.map(s => `${s.method} ${s.path}`).join(" ")}`,
246
+ scenarioName: `${resource}-${suffix}-lifecycle`,
247
+ description: `Lifecycle test: ${method} ${group.basePath} verify ${method === "DELETE" ? "delete and subsequent 404" : "create and read-back"} flow`,
523
248
  category: "new_endpoint",
524
249
  priority: "high",
525
250
  steps,
526
251
  chainingKeys: ["id", `${singular}_id`],
527
252
  requiresAuth: true,
528
- estimatedComplexity: "complex",
529
- source: "code-inferred",
530
- testType: "integration",
253
+ estimatedComplexity: "moderate",
254
+ source: ScenarioSource.CodeInferred,
255
+ testType: TestType.INTEGRATION,
531
256
  };
532
257
  }
533
258
  function diffDirectContract(method, path, resource) {
@@ -541,8 +266,8 @@ function diffDirectContract(method, path, resource) {
541
266
  chainingKeys: [],
542
267
  requiresAuth: true,
543
268
  estimatedComplexity: "simple",
544
- source: "code-inferred",
545
- testType: "contract",
269
+ source: ScenarioSource.CodeInferred,
270
+ testType: TestType.CONTRACT,
546
271
  };
547
272
  }
548
273
  function diffDirectNotFound(method, path, resource, singular) {
@@ -555,8 +280,8 @@ function diffDirectNotFound(method, path, resource, singular) {
555
280
  chainingKeys: [],
556
281
  requiresAuth: true,
557
282
  estimatedComplexity: "simple",
558
- source: "code-inferred",
559
- testType: "contract",
283
+ source: ScenarioSource.CodeInferred,
284
+ testType: TestType.CONTRACT,
560
285
  };
561
286
  }
562
287
  function diffDirectValidation(method, path, resource) {
@@ -569,8 +294,8 @@ function diffDirectValidation(method, path, resource) {
569
294
  chainingKeys: [],
570
295
  requiresAuth: true,
571
296
  estimatedComplexity: "simple",
572
- source: "code-inferred",
573
- testType: "contract",
297
+ source: ScenarioSource.CodeInferred,
298
+ testType: TestType.CONTRACT,
574
299
  };
575
300
  }
576
301
  /**
@@ -579,6 +304,47 @@ function diffDirectValidation(method, path, resource) {
579
304
  * derived totals (total_amount, item_count, subtotal) are recalculated.
580
305
  * This pattern catches the most common class of user-reported bugs.
581
306
  */
307
+ function diffDirectBoundaryValues(method, resource, singular, group) {
308
+ const steps = [];
309
+ const targetPath = group.paramPath ?? group.basePath;
310
+ const pathParamName = group.paramPath?.match(/\{([^}]+)\}/)?.[1] ?? `${singular}_id`;
311
+ if (group.methods.has("POST")) {
312
+ steps.push({
313
+ order: 1,
314
+ method: "POST",
315
+ path: group.basePath,
316
+ description: `Create a ${singular} for boundary testing`,
317
+ interactionType: "success",
318
+ expectedStatusCode: 201,
319
+ });
320
+ }
321
+ steps.push({
322
+ order: steps.length + 1,
323
+ method,
324
+ path: targetPath,
325
+ description: `${method} with valid boundary values (e.g. 0, maximum allowed, empty collection) — expect 200`,
326
+ interactionType: "success",
327
+ expectedStatusCode: 200,
328
+ // Signal the execution plan that a body is required. The agent reads source
329
+ // to discover the actual numeric/percentage field names to test at boundaries.
330
+ bodyMustInclude: ["numeric_or_percentage_field"],
331
+ ...(steps.length > 0 && group.paramPath
332
+ ? { chainsFrom: { sourceStep: 1, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
333
+ : {}),
334
+ });
335
+ return {
336
+ scenarioName: `${resource}-${method.toLowerCase()}-boundary-values`,
337
+ 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.`,
338
+ category: "new_endpoint",
339
+ priority: "high",
340
+ steps,
341
+ chainingKeys: ["id", pathParamName],
342
+ requiresAuth: true,
343
+ estimatedComplexity: "moderate",
344
+ source: ScenarioSource.CodeInferred,
345
+ testType: TestType.INTEGRATION,
346
+ };
347
+ }
582
348
  function diffDirectMutationRecalc(method, resource, singular, group) {
583
349
  const steps = [];
584
350
  if (group.methods.has("POST")) {
@@ -586,7 +352,7 @@ function diffDirectMutationRecalc(method, resource, singular, group) {
586
352
  order: 1,
587
353
  method: "POST",
588
354
  path: group.basePath,
589
- description: `Create a ${singular} with initial items`,
355
+ description: `Create a ${singular} for testing`,
590
356
  interactionType: "success",
591
357
  expectedStatusCode: 201,
592
358
  });
@@ -598,10 +364,9 @@ function diffDirectMutationRecalc(method, resource, singular, group) {
598
364
  order: steps.length + 1,
599
365
  method,
600
366
  path: targetPath,
601
- description: `${method} the ${singular} — add/replace items in the child collection (e.g. items array with product references chained from prior steps) and verify total_amount is recalculated`,
367
+ description: `${method} the ${singular} with valid changes read source to find mutable fields`,
602
368
  interactionType: "success",
603
369
  expectedStatusCode: 200,
604
- bodyMustInclude: ["child collection array (e.g. items)", "FK reference to parent resource (e.g. product_id)", "quantity or amount"],
605
370
  ...(sourceStep > 0 && group.paramPath
606
371
  ? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
607
372
  : {}),
@@ -611,39 +376,111 @@ function diffDirectMutationRecalc(method, resource, singular, group) {
611
376
  order: steps.length + 1,
612
377
  method: "GET",
613
378
  path: group.paramPath,
614
- description: `Verify the ${singular} reflects the updated child collection with correct FK references, quantities, and recalculated totals`,
379
+ description: `Verify the ${singular} reflects changes check any derived/calculated fields`,
615
380
  interactionType: "success",
616
381
  expectedStatusCode: 200,
617
- expectedResponseFields: ["child collection array", "each child's FK reference", "each child's quantity", "recalculated total"],
618
382
  ...(sourceStep > 0 && group.paramPath
619
383
  ? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
620
384
  : {}),
621
385
  });
622
386
  }
623
387
  return {
624
- scenarioName: `${resource}-${method.toLowerCase()}-add-items-recalculate`,
625
- description: `Mutation recalculation: ${method} ${targetPath} — modify child collection and verify derived totals are recomputed`,
388
+ scenarioName: `${resource}-${method.toLowerCase()}-mutation-verify`,
389
+ 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).`,
626
390
  category: "new_endpoint",
627
391
  priority: "high",
628
392
  steps,
629
393
  chainingKeys: ["id", pathParamName],
630
394
  requiresAuth: true,
631
395
  estimatedComplexity: "complex",
632
- source: "code-inferred",
633
- testType: "integration",
396
+ source: ScenarioSource.CodeInferred,
397
+ testType: TestType.INTEGRATION,
398
+ };
399
+ }
400
+ function diffDirectAuthBoundary(method, path, resource) {
401
+ return {
402
+ scenarioName: `${resource}-${method.toLowerCase()}-auth-boundary`,
403
+ description: `Auth boundary: ${method} ${path} without authentication returns 401 Unauthorized`,
404
+ category: "security_boundary",
405
+ priority: "high",
406
+ steps: [{ order: 1, method, path, description: `${method} ${path} without Authorization header — expect 401 Unauthorized`, interactionType: "error", expectedStatusCode: 401 }],
407
+ chainingKeys: [],
408
+ requiresAuth: false,
409
+ estimatedComplexity: "simple",
410
+ source: ScenarioSource.CodeInferred,
411
+ testType: TestType.CONTRACT,
412
+ };
413
+ }
414
+ /**
415
+ * Draft a unified scenario for GET endpoints without path params (collection or search).
416
+ * Rather than hardcoding keyword detection, the description instructs the LLM to read
417
+ * the diff source and determine whether query params are involved, then draft variations
418
+ * accordingly.
419
+ */
420
+ function diffDirectGetCollection(path, resource, singular, group) {
421
+ const steps = [];
422
+ if (group.methods.has("POST")) {
423
+ steps.push({
424
+ order: 1,
425
+ method: "POST",
426
+ path: group.basePath,
427
+ description: `Create a ${singular} for query verification`,
428
+ interactionType: "success",
429
+ expectedStatusCode: 201,
430
+ });
431
+ steps.push({
432
+ order: 2,
433
+ method: "GET",
434
+ path,
435
+ description: `Query ${resource} and verify results include the created item`,
436
+ interactionType: "success",
437
+ expectedStatusCode: 200,
438
+ });
439
+ }
440
+ else {
441
+ steps.push({
442
+ order: 1,
443
+ method: "GET",
444
+ path,
445
+ description: `Query ${resource} and verify response structure`,
446
+ interactionType: "success",
447
+ expectedStatusCode: 200,
448
+ });
449
+ }
450
+ // Include a path-specific suffix in scenarioName to avoid collision when multiple GET
451
+ // collection endpoints exist for the same resource (e.g. /products and /products/featured).
452
+ // Strip API prefix segments (api, v1, ...) and param segments, take the last 1-2 meaningful ones.
453
+ const meaningfulSegs = path.split("/").filter(s => s && !s.startsWith("{") && !SKIP_SEGMENTS.has(s));
454
+ const pathSlug = meaningfulSegs.slice(-2).join("-") || resource;
455
+ const uniqueName = pathSlug === resource ? `${resource}-list-query` : `${pathSlug}-list-query`;
456
+ return {
457
+ scenarioName: uniqueName,
458
+ description: `List/query test: GET ${path} — read source to find supported query params (filters, pagination, search), then test with and without them`,
459
+ category: "new_endpoint",
460
+ priority: "high",
461
+ steps,
462
+ chainingKeys: ["id", `${singular}_id`],
463
+ requiresAuth: true,
464
+ estimatedComplexity: group.methods.has("POST") ? "moderate" : "simple",
465
+ source: ScenarioSource.CodeInferred,
466
+ testType: group.methods.has("POST") ? TestType.INTEGRATION : TestType.CONTRACT,
634
467
  };
635
468
  }
636
469
  /**
637
470
  * Draft scenarios that directly test each new endpoint in the branch diff.
638
471
  *
639
- * For each new endpoint, up to 3 scenarios are produced:
640
- * PUT/PATCH → integration happy path (prereqs + create + PUT), contract, not-found
641
- * POST → integration happy path (prereqs + POST + verify GET), contract, validation error
642
- * DELETE → integration lifecycle (create DELETE verify 404), contract
472
+ * For each new endpoint, method-specific scenarios are produced:
473
+ * PUT/PATCH → mutation-recalc integration, boundary-values integration, contract, not-found
474
+ * POST → lifecycle integration (create + optional GET), contract, validation error
475
+ * DELETE → lifecycle integration (create + delete + 404), contract
643
476
  * GET /{id} → contract, not-found
477
+ * GET /coll → contract + collection-list integration (create-then-GET) or contract-only
644
478
  *
645
- * All carry category "new_endpoint" CRITICAL priority tier so they always
646
- * rank above structural candidates and occupy the GENERATE slots.
479
+ * Additionally, PUT/PATCH/DELETE methods get an auth-boundary scenario
480
+ * (category "security_boundary" HIGH priority, not CRITICAL) so it
481
+ * lands in ADDITIONAL deterministically instead of depending on LLM
482
+ * supplement. GET is excluded (often public); POST is excluded because
483
+ * the security-boundary supplement tier covers it.
647
484
  */
648
485
  export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
649
486
  if (newEndpoints.length === 0)
@@ -688,27 +525,29 @@ export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
688
525
  }
689
526
  }
690
527
  }
691
- // When extractResourceName returns null (e.g. "/{order_id}"), fall back to
692
- // matching the path suffix against known resourceGroup paths using segment
693
- // boundaries. Prefer the longest (most specific) match.
694
- if (!resource) {
528
+ // When extractResourceName returns null OR the resource isn't in resourceGroups,
529
+ // fall back to finding a matching group via prefix or suffix matching.
530
+ if (!resource || !resourceGroups.has(resource)) {
695
531
  let bestMatch = null;
696
532
  for (const [rName, rGroup] of resourceGroups) {
697
- if (rGroup.paramPath && pathSuffixMatches(rGroup.paramPath, epPath)) {
698
- if (!bestMatch || rGroup.paramPath.length > bestMatch.length) {
699
- bestMatch = { name: rName, path: rGroup.paramPath, length: rGroup.paramPath.length };
533
+ // Prefix match: epPath starts with group's basePath (e.g. /products/search → /products)
534
+ if (epPath.startsWith(rGroup.basePath + "/") || epPath === rGroup.basePath) {
535
+ if (!bestMatch || rGroup.basePath.length > bestMatch.length) {
536
+ bestMatch = { name: rName, path: rGroup.basePath, length: rGroup.basePath.length, isPrefixMatch: true };
700
537
  }
701
538
  }
702
- if (pathSuffixMatches(rGroup.basePath, epPath)) {
703
- if (!bestMatch || rGroup.basePath.length > bestMatch.length) {
704
- bestMatch = { name: rName, path: rGroup.basePath, length: rGroup.basePath.length };
539
+ // Suffix match for router-relative paths (e.g. /{order_id} → /api/v1/orders/{order_id})
540
+ if (rGroup.paramPath && pathSuffixMatches(rGroup.paramPath, epPath)) {
541
+ if (!bestMatch || rGroup.paramPath.length > bestMatch.length) {
542
+ bestMatch = { name: rName, path: rGroup.paramPath, length: rGroup.paramPath.length, isPrefixMatch: false };
705
543
  }
706
544
  }
707
545
  }
708
546
  if (!bestMatch)
709
547
  continue;
710
548
  resource = bestMatch.name;
711
- resolvedPath = bestMatch.path;
549
+ // For prefix matches (action sub-paths), keep original path; for suffix matches, use resolved path
550
+ resolvedPath = bestMatch.isPrefixMatch ? epPath : bestMatch.path;
712
551
  }
713
552
  const group = resourceGroups.get(resource);
714
553
  if (!group)
@@ -723,8 +562,10 @@ export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
723
562
  };
724
563
  for (const method of methods) {
725
564
  if (method === "PUT" || method === "PATCH") {
565
+ // mutation-recalc tests the primary calculation flow
726
566
  add(diffDirectMutationRecalc(method, resource, singular, group));
727
- add(diffDirectIntegration(method, resource, singular, group));
567
+ // boundary-values tests edge cases (0, max, empty)
568
+ add(diffDirectBoundaryValues(method, resource, singular, group));
728
569
  add(diffDirectContract(method, resolvedPath, resource));
729
570
  if (hasPathParam)
730
571
  add(diffDirectNotFound(method, resolvedPath, resource, singular));
@@ -742,6 +583,12 @@ export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
742
583
  add(diffDirectContract(method, resolvedPath, resource));
743
584
  add(diffDirectNotFound(method, resolvedPath, resource, singular));
744
585
  }
586
+ else if (method === "GET" && !hasPathParam) {
587
+ add(diffDirectContract(method, resolvedPath, resource));
588
+ add(diffDirectGetCollection(resolvedPath, resource, singular, group));
589
+ }
590
+ if (method === "PUT" || method === "PATCH" || method === "DELETE")
591
+ add(diffDirectAuthBoundary(method, resolvedPath, resource));
745
592
  }
746
593
  }
747
594
  return scenarios;