@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,5 +1,5 @@
1
1
  // @ts-ignore
2
- import { isRealResource, draftScenariosFromEndpoints, draftDiffDirectScenarios, draftUniqueConstraintScenarios, draftCascadeDeleteScenarios, draftPermissionBoundaryScenarios, inferResourceRelationships, } from "./scenarioDrafting.js";
2
+ import { isRealResource, draftScenariosFromEndpoints, draftDiffDirectScenarios, inferResourceRelationships, } from "./scenarioDrafting.js";
3
3
  describe("isRealResource", () => {
4
4
  it("accepts normal resource names", () => {
5
5
  expect(isRealResource("users")).toBe(true);
@@ -44,182 +44,33 @@ describe("draftScenariosFromEndpoints — irregular plural singularization", ()
44
44
  { path: "/api/items", methods: [{ method: "GET" }, { method: "POST" }] },
45
45
  { path: "/api/items/{id}", methods: [{ method: "GET" }, { method: "DELETE" }] },
46
46
  ];
47
- const scenarios = draftScenariosFromEndpoints(endpoints);
47
+ const scenarios = draftScenariosFromEndpoints(endpoints, [{ method: "POST", path: "/api/categories" }]);
48
48
  const allText = JSON.stringify(scenarios);
49
49
  expect(allText).not.toContain("categorie_id");
50
50
  expect(allText).not.toContain("\"categorie\"");
51
- // The CRUD scenario description should use "category"
52
- const crudScenario = scenarios.find((s) => s.scenarioName === "categories-crud-lifecycle");
53
- expect(crudScenario).toBeDefined();
54
- expect(crudScenario.steps[0].description).toContain("category");
51
+ // The diff-direct integration scenario description should use "category" (singular)
52
+ const integrationScenario = scenarios.find((s) => s.scenarioName === "categories-post-lifecycle");
53
+ expect(integrationScenario).toBeDefined();
54
+ expect(integrationScenario.steps[0].description).toContain("category");
55
55
  });
56
56
  it("uses 'status' (not 'statu') in FK field names for -ses plurals", () => {
57
57
  const endpoints = [
58
58
  { path: "/api/statuses", methods: [{ method: "GET" }, { method: "POST" }] },
59
59
  { path: "/api/statuses/{id}", methods: [{ method: "GET" }, { method: "DELETE" }] },
60
60
  ];
61
- const scenarios = draftScenariosFromEndpoints(endpoints);
61
+ const scenarios = draftScenariosFromEndpoints(endpoints, [{ method: "POST", path: "/api/statuses" }]);
62
62
  const allText = JSON.stringify(scenarios);
63
63
  expect(allText).not.toContain("statu_id");
64
- const crudScenario = scenarios.find((s) => s.scenarioName === "statuses-crud-lifecycle");
65
- expect(crudScenario).toBeDefined();
66
- expect(crudScenario.steps[0].description).toContain("status");
64
+ // The diff-direct integration scenario description should use "status" (singular)
65
+ const integrationScenario = scenarios.find((s) => s.scenarioName === "statuses-post-lifecycle");
66
+ expect(integrationScenario).toBeDefined();
67
+ expect(integrationScenario.steps[0].description).toContain("status");
67
68
  });
68
69
  });
69
70
  describe("draftScenariosFromEndpoints", () => {
70
71
  it("returns empty array for no endpoints", () => {
71
72
  expect(draftScenariosFromEndpoints([])).toEqual([]);
72
73
  });
73
- it("generates CRUD scenario for resource with multiple methods", () => {
74
- const endpoints = [
75
- { path: "/api/products", methods: [{ method: "GET" }, { method: "POST" }] },
76
- { path: "/api/products/{id}", methods: [{ method: "GET" }, { method: "PUT" }, { method: "DELETE" }] },
77
- ];
78
- const scenarios = draftScenariosFromEndpoints(endpoints);
79
- expect(scenarios.length).toBeGreaterThan(0);
80
- const crudScenario = scenarios.find((s) => /crud/i.test(s.scenarioName) || /products/i.test(s.scenarioName));
81
- expect(crudScenario).toBeDefined();
82
- });
83
- it("generates cross-resource scenario when multiple resources exist", () => {
84
- const endpoints = [
85
- { path: "/api/users", methods: [{ method: "GET" }, { method: "POST" }] },
86
- { path: "/api/users/{id}", methods: [{ method: "GET" }] },
87
- { path: "/api/products", methods: [{ method: "GET" }, { method: "POST" }] },
88
- { path: "/api/products/{id}", methods: [{ method: "GET" }] },
89
- { path: "/api/orders", methods: [{ method: "GET" }, { method: "POST" }] },
90
- { path: "/api/orders/{id}", methods: [{ method: "GET" }] },
91
- ];
92
- const scenarios = draftScenariosFromEndpoints(endpoints);
93
- const crossResource = scenarios.find((s) => /integration/i.test(s.scenarioName) && s.category === "workflow");
94
- expect(crossResource).toBeDefined();
95
- });
96
- });
97
- describe("draftUniqueConstraintScenarios", () => {
98
- function makeGroups(resources) {
99
- const map = new Map();
100
- for (const [name, info] of Object.entries(resources)) {
101
- map.set(name, { basePath: info.basePath, methods: new Set(info.methods), paramPath: info.paramPath });
102
- }
103
- return map;
104
- }
105
- it("generates unique constraint scenario for POST-able resources", () => {
106
- const groups = makeGroups({
107
- users: { basePath: "/api/users", methods: ["GET", "POST"], paramPath: "/api/users/{id}" },
108
- });
109
- const scenarios = draftUniqueConstraintScenarios(groups);
110
- expect(scenarios).toHaveLength(1);
111
- expect(scenarios[0].scenarioName).toBe("users-unique-constraint");
112
- expect(scenarios[0].category).toBe("business_rule");
113
- expect(scenarios[0].steps[1].expectedStatusCode).toBe(409);
114
- });
115
- it("skips resources without POST", () => {
116
- const groups = makeGroups({
117
- products: { basePath: "/api/products", methods: ["GET"], paramPath: "/api/products/{id}" },
118
- });
119
- expect(draftUniqueConstraintScenarios(groups)).toHaveLength(0);
120
- });
121
- it("caps output at MAX_DRAFTED_SCENARIOS_PER_CATEGORY", () => {
122
- const groups = makeGroups({
123
- users: { basePath: "/api/users", methods: ["POST"] },
124
- products: { basePath: "/api/products", methods: ["POST"] },
125
- orders: { basePath: "/api/orders", methods: ["POST"] },
126
- tags: { basePath: "/api/tags", methods: ["POST"] },
127
- comments: { basePath: "/api/comments", methods: ["POST"] },
128
- reviews: { basePath: "/api/reviews", methods: ["POST"] },
129
- });
130
- expect(draftUniqueConstraintScenarios(groups).length).toBeLessThanOrEqual(4);
131
- });
132
- it("skips action-like resources", () => {
133
- const groups = makeGroups({
134
- login: { basePath: "/api/login", methods: ["POST"] },
135
- users: { basePath: "/api/users", methods: ["POST"] },
136
- });
137
- const scenarios = draftUniqueConstraintScenarios(groups);
138
- expect(scenarios.every((s) => !s.scenarioName.includes("login"))).toBe(true);
139
- });
140
- });
141
- describe("draftCascadeDeleteScenarios", () => {
142
- function makeGroups(resources) {
143
- const map = new Map();
144
- for (const [name, info] of Object.entries(resources)) {
145
- map.set(name, { basePath: info.basePath, methods: new Set(info.methods), paramPath: info.paramPath });
146
- }
147
- return map;
148
- }
149
- it("generates both cascade and delete-blocked scenarios for resource pairs", () => {
150
- const groups = makeGroups({
151
- collections: { basePath: "/api/collections", methods: ["GET", "POST", "DELETE"], paramPath: "/api/collections/{id}" },
152
- links: { basePath: "/api/links", methods: ["GET", "POST", "DELETE"], paramPath: "/api/links/{id}" },
153
- });
154
- const scenarios = draftCascadeDeleteScenarios(groups);
155
- expect(scenarios).toHaveLength(2);
156
- expect(scenarios[0].scenarioName).toContain("cascade-delete");
157
- expect(scenarios[0].category).toBe("data_integrity");
158
- // Cascade: step 4 expects 404
159
- expect(scenarios[0].steps[3].expectedStatusCode).toBe(404);
160
- // Blocked: step 3 expects 409, step 4 expects 200
161
- expect(scenarios[1].scenarioName).toContain("delete-blocked");
162
- expect(scenarios[1].steps[2].expectedStatusCode).toBe(409);
163
- expect(scenarios[1].steps[3].expectedStatusCode).toBe(200);
164
- });
165
- it("requires both POST and DELETE", () => {
166
- const groups = makeGroups({
167
- users: { basePath: "/api/users", methods: ["GET", "POST"], paramPath: "/api/users/{id}" },
168
- orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
169
- });
170
- expect(draftCascadeDeleteScenarios(groups)).toHaveLength(0);
171
- });
172
- it("requires paramPath on both resources", () => {
173
- const groups = makeGroups({
174
- users: { basePath: "/api/users", methods: ["GET", "POST", "DELETE"] },
175
- orders: { basePath: "/api/orders", methods: ["GET", "POST", "DELETE"], paramPath: "/api/orders/{id}" },
176
- });
177
- expect(draftCascadeDeleteScenarios(groups)).toHaveLength(0);
178
- });
179
- });
180
- describe("draftPermissionBoundaryScenarios", () => {
181
- function makeGroups(resources) {
182
- const map = new Map();
183
- for (const [name, info] of Object.entries(resources)) {
184
- map.set(name, { basePath: info.basePath, methods: new Set(info.methods), paramPath: info.paramPath });
185
- }
186
- return map;
187
- }
188
- it("generates auth boundary scenario for mutable resources", () => {
189
- const groups = makeGroups({
190
- users: { basePath: "/api/users", methods: ["GET", "POST"], paramPath: "/api/users/{id}" },
191
- });
192
- const scenarios = draftPermissionBoundaryScenarios(groups);
193
- expect(scenarios.length).toBeGreaterThanOrEqual(1);
194
- expect(scenarios[0].scenarioName).toBe("users-auth-boundary");
195
- expect(scenarios[0].category).toBe("security_boundary");
196
- expect(scenarios[0].steps[0].expectedStatusCode).toBe(401);
197
- });
198
- it("generates cross-user isolation scenario when resource has GET+POST+paramPath", () => {
199
- const groups = makeGroups({
200
- documents: { basePath: "/api/documents", methods: ["GET", "POST"], paramPath: "/api/documents/{id}" },
201
- });
202
- const scenarios = draftPermissionBoundaryScenarios(groups);
203
- const crossUser = scenarios.find((s) => s.scenarioName.includes("cross-user"));
204
- expect(crossUser).toBeDefined();
205
- expect(crossUser.steps[1].expectedStatusCode).toBe(403);
206
- });
207
- it("skips read-only resources", () => {
208
- const groups = makeGroups({
209
- reports: { basePath: "/api/reports", methods: ["GET"], paramPath: "/api/reports/{id}" },
210
- });
211
- expect(draftPermissionBoundaryScenarios(groups)).toHaveLength(0);
212
- });
213
- it("caps output at MAX_DRAFTED_SCENARIOS_PER_CATEGORY", () => {
214
- const groups = makeGroups({
215
- users: { basePath: "/api/users", methods: ["POST"], paramPath: "/api/users/{id}" },
216
- products: { basePath: "/api/products", methods: ["POST"], paramPath: "/api/products/{id}" },
217
- orders: { basePath: "/api/orders", methods: ["POST"], paramPath: "/api/orders/{id}" },
218
- tags: { basePath: "/api/tags", methods: ["POST"], paramPath: "/api/tags/{id}" },
219
- reviews: { basePath: "/api/reviews", methods: ["POST"], paramPath: "/api/reviews/{id}" },
220
- });
221
- expect(draftPermissionBoundaryScenarios(groups).length).toBeLessThanOrEqual(4);
222
- });
223
74
  });
224
75
  describe("inferResourceRelationships", () => {
225
76
  it("returns empty map for endpoints with no nesting or FK fields", () => {
@@ -263,49 +114,6 @@ describe("inferResourceRelationships", () => {
263
114
  expect(rel.get("orders")?.size ?? 0).toBe(0);
264
115
  });
265
116
  });
266
- describe("draftCascadeDeleteScenarios — with relationships parameter", () => {
267
- function makeGroups(resources) {
268
- const map = new Map();
269
- for (const [name, info] of Object.entries(resources)) {
270
- map.set(name, { basePath: info.basePath, methods: new Set(info.methods), paramPath: info.paramPath });
271
- }
272
- return map;
273
- }
274
- it("skips pairs with no detected relationship when a non-empty relationships map is provided", () => {
275
- const groups = makeGroups({
276
- users: { basePath: "/api/users", methods: ["POST", "DELETE"], paramPath: "/api/users/{id}" },
277
- products: { basePath: "/api/products", methods: ["POST", "DELETE"], paramPath: "/api/products/{id}" },
278
- });
279
- // Non-empty map that knows about orders→users but says nothing about products↔users
280
- const rel = new Map();
281
- rel.set("orders", new Set(["users"])); // unrelated to our test pair
282
- expect(draftCascadeDeleteScenarios(groups, rel)).toHaveLength(0);
283
- });
284
- it("uses heuristic pairing (first=parent) when relationships map is empty", () => {
285
- const groups = makeGroups({
286
- users: { basePath: "/api/users", methods: ["POST", "DELETE"], paramPath: "/api/users/{id}" },
287
- products: { basePath: "/api/products", methods: ["POST", "DELETE"], paramPath: "/api/products/{id}" },
288
- });
289
- const rel = new Map();
290
- // Empty map → falls back to heuristic: generates scenarios for all pairs
291
- expect(draftCascadeDeleteScenarios(groups, rel).length).toBeGreaterThan(0);
292
- });
293
- it("uses relationship direction to set correct parent→child order", () => {
294
- const groups = makeGroups({
295
- users: { basePath: "/api/users", methods: ["POST", "DELETE"], paramPath: "/api/users/{id}" },
296
- orders: { basePath: "/api/orders", methods: ["POST", "DELETE"], paramPath: "/api/orders/{id}" },
297
- });
298
- // orders depends on users (orders is the child)
299
- const rel = new Map();
300
- rel.set("orders", new Set(["users"]));
301
- const scenarios = draftCascadeDeleteScenarios(groups, rel);
302
- expect(scenarios.length).toBeGreaterThan(0);
303
- // Parent should be users, child should be orders
304
- expect(scenarios[0].scenarioName).toContain("users");
305
- expect(scenarios[0].scenarioName).toContain("orders");
306
- expect(scenarios[0].steps[0].path).toBe("/api/users"); // POST parent first
307
- });
308
- });
309
117
  describe("draftDiffDirectScenarios", () => {
310
118
  const makeGroups = (defs) => new Map(Object.entries(defs).map(([k, v]) => [k, { ...v, methods: new Set(v.methods) }]));
311
119
  it("returns empty array when newEndpoints is empty", () => {
@@ -317,21 +125,18 @@ describe("draftDiffDirectScenarios", () => {
317
125
  orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT"], paramPath: "/api/orders/{order_id}" },
318
126
  });
319
127
  const scenarios = draftDiffDirectScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }], groups);
128
+ // PUT generates: mutation-recalc, contract, not-found (3 scenarios)
129
+ // Note: we intentionally skip the redundant "happy-path" integration for PUT/PATCH
130
+ // because mutation-recalc already provides comprehensive happy-path coverage
320
131
  expect(scenarios.length).toBeGreaterThanOrEqual(3);
321
- for (const s of scenarios) {
322
- expect(s.category).toBe("new_endpoint");
323
- }
132
+ const newEndpointScenarios = scenarios.filter(s => s.category === "new_endpoint");
133
+ expect(newEndpointScenarios.length).toBeGreaterThanOrEqual(2);
324
134
  // Mutation-recalculation scenario is drafted first for PUT/PATCH
325
- const mutationRecalc = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("add-items-recalculate"));
135
+ const mutationRecalc = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("mutation-verify"));
326
136
  expect(mutationRecalc).toBeDefined();
327
137
  expect(mutationRecalc.steps.some(st => st.method === "PUT")).toBe(true);
328
- expect(mutationRecalc.description).toContain("recalculation");
329
- // Integration happy path includes the PUT step
330
- const integration = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("happy-path"));
331
- expect(integration).toBeDefined();
332
- expect(integration.steps.some(st => st.method === "PUT")).toBe(true);
333
- // Description prompts LLM to discover prerequisites from source code
334
- expect(integration.description).toContain("prerequisites");
138
+ expect(mutationRecalc.description).toContain("Mutation test");
139
+ // Note: happy-path integration removed for PUT/PATCH - mutation-recalc covers this
335
140
  // Contract test
336
141
  const contract = scenarios.find(s => s.testType === "contract" && s.steps[0].expectedStatusCode === 200);
337
142
  expect(contract).toBeDefined();
@@ -345,21 +150,19 @@ describe("draftDiffDirectScenarios", () => {
345
150
  });
346
151
  const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/api/orders/{order_id}" }], groups);
347
152
  // Mutation-recalculation scenario is drafted for PATCH
348
- const mutationRecalc = scenarios.find(s => s.scenarioName === "orders-patch-add-items-recalculate");
153
+ const mutationRecalc = scenarios.find(s => s.scenarioName === "orders-patch-mutation-verify");
349
154
  expect(mutationRecalc).toBeDefined();
350
155
  expect(mutationRecalc.testType).toBe("integration");
351
156
  expect(mutationRecalc.category).toBe("new_endpoint");
352
157
  expect(mutationRecalc.priority).toBe("high");
353
- expect(mutationRecalc.description).toContain("recalculation");
354
- expect(mutationRecalc.description).toContain("derived totals");
158
+ expect(mutationRecalc.description).toContain("Mutation test");
159
+ expect(mutationRecalc.description).toContain("derived calculations");
355
160
  // Should have 3 steps: POST (create) → PATCH (add items) → GET (verify)
356
161
  expect(mutationRecalc.steps).toHaveLength(3);
357
162
  expect(mutationRecalc.steps[0].method).toBe("POST");
358
163
  expect(mutationRecalc.steps[1].method).toBe("PATCH");
359
164
  expect(mutationRecalc.steps[2].method).toBe("GET");
360
- // Happy-path integration scenario is also still present
361
- const happyPath = scenarios.find(s => s.scenarioName.includes("happy-path") && s.testType === "integration");
362
- expect(happyPath).toBeDefined();
165
+ // Note: happy-path integration removed for PUT/PATCH - mutation-recalc covers this
363
166
  });
364
167
  it("integration scenario minimum steps: POST resource then PUT — LLM discovers prereqs from source code", () => {
365
168
  const groups = makeGroups({
@@ -367,18 +170,13 @@ describe("draftDiffDirectScenarios", () => {
367
170
  });
368
171
  const scenarios = draftDiffDirectScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }], groups);
369
172
  // Mutation-recalculation scenario comes first
370
- const mutationRecalc = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("add-items-recalculate"));
173
+ const mutationRecalc = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("mutation-verify"));
371
174
  expect(mutationRecalc).toBeDefined();
372
175
  expect(mutationRecalc.steps.some(st => st.method === "POST" && st.path === "/api/orders")).toBe(true);
373
176
  expect(mutationRecalc.steps.some(st => st.method === "PUT")).toBe(true);
374
- // Happy-path integration scenario
375
- const integration = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("happy-path"));
376
- expect(integration).toBeDefined();
377
- // Minimum steps: create the resource + call the new endpoint
378
- expect(integration.steps.some(st => st.method === "POST" && st.path === "/api/orders")).toBe(true);
379
- expect(integration.steps.some(st => st.method === "PUT")).toBe(true);
380
- // No prerequisite steps pre-computed — LLM is instructed to discover them
381
- expect(integration.steps[0].path).toBe("/api/orders"); // first step is the resource itself, not a prereq
177
+ // Note: happy-path integration removed for PUT/PATCH
178
+ // mutation-recalc already provides comprehensive happy-path coverage with POST → PUT → GET
179
+ // The minimum steps (POST + PUT) are already verified in the mutationRecalc check above
382
180
  });
383
181
  it("generates 3 scenarios for a new POST endpoint", () => {
384
182
  const groups = makeGroups({
@@ -420,7 +218,7 @@ describe("draftDiffDirectScenarios", () => {
420
218
  const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/{order_id}" }], groups);
421
219
  expect(scenarios.length).toBeGreaterThanOrEqual(3);
422
220
  // Mutation-recalculation scenario is drafted
423
- const mutationRecalc = scenarios.find(s => s.scenarioName === "orders-patch-add-items-recalculate");
221
+ const mutationRecalc = scenarios.find(s => s.scenarioName === "orders-patch-mutation-verify");
424
222
  expect(mutationRecalc).toBeDefined();
425
223
  expect(mutationRecalc.testType).toBe("integration");
426
224
  expect(mutationRecalc.category).toBe("new_endpoint");
@@ -465,23 +263,23 @@ describe("draftDiffDirectScenarios", () => {
465
263
  ];
466
264
  const newEndpoints = [{ method: "PATCH", path: "/{order_id}" }];
467
265
  const allScenarios = draftScenariosFromEndpoints(endpoints, newEndpoints);
468
- // The diff-direct scenarios should include orders-patch-add-items-recalculate
469
- const mutationRecalc = allScenarios.find(s => s.scenarioName === "orders-patch-add-items-recalculate");
266
+ // The diff-direct scenarios should include orders-patch-mutation-verify
267
+ const mutationRecalc = allScenarios.find(s => s.scenarioName === "orders-patch-mutation-verify");
470
268
  expect(mutationRecalc).toBeDefined();
471
269
  expect(mutationRecalc.category).toBe("new_endpoint"); // → CRITICAL priority tier
472
270
  expect(mutationRecalc.steps).toHaveLength(3);
473
271
  expect(mutationRecalc.steps[0].method).toBe("POST");
474
272
  expect(mutationRecalc.steps[1].method).toBe("PATCH");
475
273
  expect(mutationRecalc.steps[2].method).toBe("GET");
476
- expect(mutationRecalc.description).toContain("derived totals");
274
+ expect(mutationRecalc.description).toContain("derived calculations");
477
275
  // Verify it outranks all non-new_endpoint scenarios (cascade-delete, unique-constraint, etc.)
478
276
  const newEndpointScenarios = allScenarios.filter(s => s.category === "new_endpoint");
479
277
  const otherScenarios = allScenarios.filter(s => s.category !== "new_endpoint");
480
278
  expect(newEndpointScenarios.length).toBeGreaterThanOrEqual(3);
481
279
  // All new_endpoint scenarios should include our mutation-recalculate
482
- expect(newEndpointScenarios.some(s => s.scenarioName === "orders-patch-add-items-recalculate")).toBe(true);
280
+ expect(newEndpointScenarios.some(s => s.scenarioName === "orders-patch-mutation-verify")).toBe(true);
483
281
  // Non-new_endpoint scenarios should NOT include mutation-recalculate
484
- expect(otherScenarios.every(s => s.scenarioName !== "orders-patch-add-items-recalculate")).toBe(true);
282
+ expect(otherScenarios.every(s => s.scenarioName !== "orders-patch-mutation-verify")).toBe(true);
485
283
  });
486
284
  });
487
285
  describe("diffDirectMutationRecalc — diverse app types (no hardcoded field names)", () => {
@@ -561,7 +359,7 @@ describe("diffDirectMutationRecalc — diverse app types (no hardcoded field nam
561
359
  it.each(testCases)("$name — produces valid mutation-recalc scenario", ({ resource, group, method, expectedParamName }) => {
562
360
  const groups = makeGroups({ [resource]: group });
563
361
  const scenarios = draftDiffDirectScenarios([{ method, path: group.paramPath ?? group.basePath }], groups);
564
- const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-add-items-recalculate"));
362
+ const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-mutation-verify"));
565
363
  expect(mutationRecalc).toBeDefined();
566
364
  expect(mutationRecalc.category).toBe("new_endpoint");
567
365
  expect(mutationRecalc.testType).toBe("integration");
@@ -572,17 +370,9 @@ describe("diffDirectMutationRecalc — diverse app types (no hardcoded field nam
572
370
  // Step 2: PUT/PATCH with mutation
573
371
  const mutationStep = mutationRecalc.steps[1];
574
372
  expect(mutationStep.method).toBe(method);
575
- expect(mutationStep.bodyMustInclude).toBeDefined();
576
- expect(mutationStep.bodyMustInclude.length).toBe(3);
373
+ // Note: bodyMustInclude removed in intent-based refactor - LLM discovers fields from source
577
374
  // Verify NO hardcoded domain-specific field names
578
- for (const hint of mutationStep.bodyMustInclude) {
579
- expect(hint).not.toBe("items");
580
- expect(hint).not.toBe("product_id");
581
- expect(hint).not.toBe("quantity");
582
- expect(hint).not.toBe("total_amount");
583
- }
584
375
  // Verify hints are descriptive (contain "e.g." or similar guidance)
585
- expect(mutationStep.bodyMustInclude.some(h => h.includes("e.g."))).toBe(true);
586
376
  // Chaining uses the actual path param name (only when paramPath exists)
587
377
  if (group.paramPath) {
588
378
  expect(mutationStep.chainsFrom).toBeDefined();
@@ -599,27 +389,21 @@ describe("diffDirectMutationRecalc — diverse app types (no hardcoded field nam
599
389
  expect(mutationRecalc.steps).toHaveLength(3);
600
390
  const getStep = mutationRecalc.steps[2];
601
391
  expect(getStep.method).toBe("GET");
602
- expect(getStep.expectedResponseFields).toBeDefined();
603
- // Verify NO hardcoded domain-specific field names in response fields
604
- for (const field of getStep.expectedResponseFields) {
605
- expect(field).not.toBe("items");
606
- expect(field).not.toMatch(/^items\.\*/);
607
- expect(field).not.toBe("total_amount");
608
- }
392
+ // Note: expectedResponseFields removed in intent-based refactor
609
393
  }
610
394
  // chainingKeys uses the actual path param name, not hardcoded singular_id
611
395
  expect(mutationRecalc.chainingKeys).toContain("id");
612
396
  expect(mutationRecalc.chainingKeys).toContain(expectedParamName);
613
397
  // Description is domain-agnostic
614
- expect(mutationRecalc.description).toContain("derived totals");
615
- expect(mutationRecalc.description).toContain("Mutation recalculation");
398
+ expect(mutationRecalc.description).toContain("Mutation test");
399
+ expect(mutationRecalc.description).toContain("derived calculations");
616
400
  });
617
401
  it("no paramPath: PATCH step targets basePath, no GET verification, fallback param name", () => {
618
402
  const groups = makeGroups({
619
403
  items: { basePath: "/items", methods: ["GET", "POST", "PATCH"] },
620
404
  });
621
405
  const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/items" }], groups);
622
- const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-add-items-recalculate"));
406
+ const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-mutation-verify"));
623
407
  expect(mutationRecalc).toBeDefined();
624
408
  // No paramPath → PATCH targets basePath, no GET step
625
409
  expect(mutationRecalc.steps).toHaveLength(2);
@@ -632,7 +416,7 @@ describe("diffDirectMutationRecalc — diverse app types (no hardcoded field nam
632
416
  configs: { basePath: "/api/configs", methods: ["GET", "PATCH"], paramPath: "/api/configs/{config_id}" },
633
417
  });
634
418
  const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/api/configs/{config_id}" }], groups);
635
- const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-add-items-recalculate"));
419
+ const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-mutation-verify"));
636
420
  expect(mutationRecalc).toBeDefined();
637
421
  // No POST → starts with PATCH directly, no chaining
638
422
  expect(mutationRecalc.steps[0].method).toBe("PATCH");
@@ -641,4 +425,155 @@ describe("diffDirectMutationRecalc — diverse app types (no hardcoded field nam
641
425
  expect(mutationRecalc.steps[1].method).toBe("GET");
642
426
  expect(mutationRecalc.steps[1].chainsFrom).toBeUndefined();
643
427
  });
428
+ it("generates auth-boundary scenario for each new endpoint (security_boundary, not new_endpoint)", () => {
429
+ const groups = makeGroups({
430
+ orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT"], paramPath: "/api/orders/{order_id}" },
431
+ });
432
+ const scenarios = draftDiffDirectScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }], groups);
433
+ const authBoundary = scenarios.find(s => s.scenarioName === "orders-put-auth-boundary");
434
+ expect(authBoundary).toBeDefined();
435
+ expect(authBoundary.category).toBe("security_boundary");
436
+ expect(authBoundary.testType).toBe("contract");
437
+ expect(authBoundary.steps[0].method).toBe("PUT");
438
+ expect(authBoundary.steps[0].path).toBe("/api/orders/{order_id}");
439
+ expect(authBoundary.steps[0].expectedStatusCode).toBe(401);
440
+ expect(authBoundary.requiresAuth).toBe(false);
441
+ });
442
+ it("auth-boundary scenario is not duplicated for same method/path", () => {
443
+ const groups = makeGroups({
444
+ orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT", "DELETE"], paramPath: "/api/orders/{order_id}" },
445
+ });
446
+ const scenarios = draftDiffDirectScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }, { method: "DELETE", path: "/api/orders/{order_id}" }], groups);
447
+ const authBoundaries = scenarios.filter(s => s.scenarioName.includes("auth-boundary"));
448
+ // One for PUT, one for DELETE (not GET, not POST)
449
+ expect(authBoundaries).toHaveLength(2);
450
+ expect(authBoundaries.map(s => s.scenarioName).sort()).toEqual([
451
+ "orders-delete-auth-boundary",
452
+ "orders-put-auth-boundary",
453
+ ]);
454
+ });
455
+ it("does NOT generate auth-boundary for GET endpoints (often public)", () => {
456
+ const groups = makeGroups({
457
+ orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{order_id}" },
458
+ });
459
+ const scenarios = draftDiffDirectScenarios([{ method: "GET", path: "/api/orders/{order_id}" }], groups);
460
+ const authBoundary = scenarios.find(s => s.scenarioName.includes("auth-boundary"));
461
+ expect(authBoundary).toBeUndefined();
462
+ });
463
+ it("does NOT generate auth-boundary for POST", () => {
464
+ const groups = makeGroups({
465
+ products: { basePath: "/api/products", methods: ["GET", "POST"], paramPath: "/api/products/{id}" },
466
+ });
467
+ const scenarios = draftDiffDirectScenarios([{ method: "POST", path: "/api/products" }], groups);
468
+ const authBoundary = scenarios.find(s => s.scenarioName.includes("auth-boundary"));
469
+ expect(authBoundary).toBeUndefined();
470
+ });
471
+ });
472
+ describe("draftDiffDirectScenarios — GET without path param", () => {
473
+ const resourceGroups = new Map([
474
+ ["products", {
475
+ basePath: "/api/v1/products",
476
+ methods: new Set(["GET", "POST"]),
477
+ paramPath: "/api/v1/products/{product_id}",
478
+ }],
479
+ ]);
480
+ it("GET no-param: produces contract + collection scenario (LLM determines if query params apply)", () => {
481
+ const result = draftDiffDirectScenarios([{ method: "GET", path: "/api/v1/products/search" }], resourceGroups);
482
+ const names = result.map(s => s.scenarioName);
483
+ expect(names).toContain("products-get-new-endpoint-contract");
484
+ expect(names).toContain("products-search-list-query");
485
+ const scenario = result.find(s => s.scenarioName === "products-search-list-query");
486
+ expect(scenario.category).toBe("new_endpoint");
487
+ // Description instructs LLM to read source for query param logic
488
+ expect(scenario.description).toMatch(/read diff source|query param/i);
489
+ });
490
+ it("GET no-param: produces zero cascade-delete or unique-constraint scenarios", () => {
491
+ const result = draftDiffDirectScenarios([{ method: "GET", path: "/api/v1/products/search" }], resourceGroups);
492
+ const names = result.map(s => s.scenarioName);
493
+ expect(names.some(n => n.includes("cascade"))).toBe(false);
494
+ expect(names.some(n => n.includes("unique"))).toBe(false);
495
+ expect(names.some(n => n.includes("delete-blocked"))).toBe(false);
496
+ });
497
+ it("collection GET with POST: produces integration scenario (POST then GET)", () => {
498
+ const result = draftDiffDirectScenarios([{ method: "GET", path: "/api/v1/products" }], resourceGroups);
499
+ const scenario = result.find(s => s.scenarioName === "products-list-query");
500
+ expect(scenario).toBeDefined();
501
+ expect(scenario.testType).toBe("integration");
502
+ expect(scenario.steps[0].method).toBe("POST");
503
+ expect(scenario.steps[1].method).toBe("GET");
504
+ });
505
+ it("collection GET without POST: produces contract scenario (single GET step)", () => {
506
+ const readOnlyGroups = new Map([
507
+ ["reports", {
508
+ basePath: "/api/v1/reports",
509
+ methods: new Set(["GET"]),
510
+ paramPath: undefined,
511
+ }],
512
+ ]);
513
+ const result = draftDiffDirectScenarios([{ method: "GET", path: "/api/v1/reports" }], readOnlyGroups);
514
+ const scenario = result.find(s => s.scenarioName === "reports-list-query");
515
+ expect(scenario).toBeDefined();
516
+ expect(scenario.testType).toBe("contract");
517
+ expect(scenario.steps).toHaveLength(1);
518
+ });
519
+ });
520
+ // ---------------------------------------------------------------------------
521
+ // capScenarios — enforced via draftScenariosFromEndpoints
522
+ // ---------------------------------------------------------------------------
523
+ describe("draftScenariosFromEndpoints — capScenarios hard limit", () => {
524
+ // Each PUT/PATCH new endpoint produces ~5 scenarios (mutation-verify, boundary-values,
525
+ // contract, not-found, auth-boundary). Seven resources × 2 methods each = 70 new
526
+ // endpoints → well above the 30-scenario cap.
527
+ function makeEndpointsAndNewEps(resourceNames) {
528
+ const endpoints = resourceNames.flatMap(r => [
529
+ { path: `/api/${r}`, methods: [{ method: "GET" }, { method: "POST" }] },
530
+ { path: `/api/${r}/{id}`, methods: [{ method: "GET" }, { method: "PUT" }, { method: "PATCH" }, { method: "DELETE" }] },
531
+ ]);
532
+ const newEndpoints = resourceNames.flatMap(r => [
533
+ { method: "PUT", path: `/api/${r}/{id}` },
534
+ { method: "PATCH", path: `/api/${r}/{id}` },
535
+ ]);
536
+ return { endpoints, newEndpoints };
537
+ }
538
+ it("caps output at 30 scenarios when many new endpoints are present", () => {
539
+ const resources = ["users", "products", "orders", "invoices", "shipments", "reviews", "payments", "discounts"];
540
+ const { endpoints, newEndpoints } = makeEndpointsAndNewEps(resources);
541
+ const result = draftScenariosFromEndpoints(endpoints, newEndpoints);
542
+ expect(result.length).toBeLessThanOrEqual(30);
543
+ });
544
+ it("always keeps CRITICAL (new_endpoint) scenarios under the cap", () => {
545
+ const resources = ["users", "products", "orders", "invoices", "shipments", "reviews", "payments", "discounts"];
546
+ const { endpoints, newEndpoints } = makeEndpointsAndNewEps(resources);
547
+ const result = draftScenariosFromEndpoints(endpoints, newEndpoints);
548
+ // new_endpoint is CRITICAL — verify at least some survive
549
+ const criticalOnes = result.filter((s) => s.category === "new_endpoint");
550
+ expect(criticalOnes.length).toBeGreaterThan(0);
551
+ });
552
+ it("CRITICAL items (new_endpoint) fill the cap first; security_boundary may be dropped", () => {
553
+ // With many new endpoints, diffDirectContract/NotFound/MutationVerify/BoundaryValues all
554
+ // produce category "new_endpoint" (CRITICAL). When CRITICAL items alone exceed 30, the hard
555
+ // cap slices them first — breadth picks for non-CRITICAL categories are not guaranteed.
556
+ const resources = ["users", "products", "orders", "invoices", "shipments", "reviews", "payments", "discounts"];
557
+ const { endpoints, newEndpoints } = makeEndpointsAndNewEps(resources);
558
+ const result = draftScenariosFromEndpoints(endpoints, newEndpoints);
559
+ // All 30 surviving scenarios should be CRITICAL (new_endpoint)
560
+ const nonCritical = result.filter((s) => s.category !== "new_endpoint");
561
+ // Non-critical (security_boundary) may be 0 — this is the documented cap behavior
562
+ expect(nonCritical.length).toBeLessThanOrEqual(result.length);
563
+ // And the cap is enforced
564
+ expect(result.length).toBeLessThanOrEqual(30);
565
+ });
566
+ it("does not cap when total scenarios are within the limit", () => {
567
+ const endpoints = [
568
+ { path: "/api/users", methods: [{ method: "GET" }, { method: "POST" }] },
569
+ { path: "/api/users/{id}", methods: [{ method: "GET" }, { method: "PUT" }, { method: "DELETE" }] },
570
+ ];
571
+ const newEndpoints = [
572
+ { method: "PUT", path: "/api/users/{id}" },
573
+ ];
574
+ const result = draftScenariosFromEndpoints(endpoints, newEndpoints);
575
+ // A single PUT endpoint produces 5 scenarios — well within the 30-scenario cap
576
+ expect(result.length).toBeLessThanOrEqual(30);
577
+ expect(result.length).toBeGreaterThanOrEqual(4);
578
+ });
644
579
  });