@skyramp/mcp 0.0.64-rc.8 → 0.0.64

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 (31) hide show
  1. package/build/index.js +2 -0
  2. package/build/playwright/registerPlaywrightTools.js +1 -1
  3. package/build/playwright/traceRecordingPrompt.js +9 -3
  4. package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -7
  5. package/build/prompts/test-maintenance/driftAnalysisSections.js +96 -34
  6. package/build/prompts/test-maintenance/enhanceAssertionSection.js +99 -0
  7. package/build/prompts/test-recommendation/recommendationSections.js +24 -9
  8. package/build/prompts/test-recommendation/test-recommendation-prompt.js +96 -27
  9. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +239 -2
  10. package/build/prompts/testbot/testbot-prompts.js +185 -120
  11. package/build/services/TestDiscoveryService.js +23 -0
  12. package/build/services/TestExecutionService.js +1 -1
  13. package/build/services/TestGenerationService.js +83 -12
  14. package/build/services/TestGenerationService.test.js +111 -2
  15. package/build/tool-phase-coverage.test.js +8 -2
  16. package/build/tool-phases.js +11 -13
  17. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +203 -0
  18. package/build/tools/generate-tests/generateContractRestTool.js +3 -73
  19. package/build/tools/generate-tests/generateIntegrationRestTool.js +11 -61
  20. package/build/tools/submitReportTool.js +11 -3
  21. package/build/tools/submitReportTool.test.js +1 -1
  22. package/build/tools/test-management/analyzeChangesTool.js +14 -4
  23. package/build/types/RepositoryAnalysis.js +1 -0
  24. package/build/utils/scenarioDrafting.js +121 -11
  25. package/build/utils/scenarioDrafting.test.js +266 -3
  26. package/node_modules/playwright/ThirdPartyNotices.txt +679 -3093
  27. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +52 -0
  28. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +290 -15
  29. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +60 -0
  30. package/package.json +2 -2
  31. package/build/tools/test-recommendation/recommendTestsTool.js +0 -274
@@ -8,7 +8,7 @@ import { parseWorkspaceAuthType } from "../../utils/workspaceAuth.js";
8
8
  import { AnalyticsService } from "../../services/AnalyticsService.js";
9
9
  import { StateManager, registerSession, storeSessionData, } from "../../utils/AnalysisStateManager.js";
10
10
  import { buildRecommendationPrompt } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
11
- import { MAX_RECOMMENDATIONS } from "../../prompts/test-recommendation/recommendationSections.js";
11
+ import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
12
12
  import { WorkspaceConfigManager } from "@skyramp/skyramp";
13
13
  import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
14
14
  import { computeBranchDiff } from "../../utils/branchDiff.js";
@@ -172,6 +172,12 @@ const analyzeChangesSchema = {
172
172
  .default(MAX_RECOMMENDATIONS)
173
173
  .optional()
174
174
  .describe(`Number of ranked test recommendations to generate. Defaults to ${MAX_RECOMMENDATIONS}.`),
175
+ maxGenerate: z
176
+ .number()
177
+ .int()
178
+ .min(0)
179
+ .optional()
180
+ .describe(`Number of tests to generate and execute. Defaults to ${MAX_TESTS_TO_GENERATE} (diff mode) or all recommendations (full repo).`),
175
181
  prNumber: z
176
182
  .number()
177
183
  .optional()
@@ -526,12 +532,16 @@ to produce a unified state file for the test health workflow.
526
532
  // ── Step 10: Build full RepositoryAnalysis for ranked recommendations ──
527
533
  const sessionId = crypto.randomUUID();
528
534
  // Build existing test locations map (type → file list) for deduplication in recommendations
535
+ // Include covered endpoints so the agent can cross-check resource paths before creating new files.
529
536
  const testLocationsByType = {};
530
537
  for (const t of existingTests) {
531
538
  const type = t.testType || "unknown";
532
- testLocationsByType[type] = testLocationsByType[type]
533
- ? `${testLocationsByType[type]}, ${t.testFile}`
539
+ const entry = t.apiEndpoint
540
+ ? `${t.testFile} (covers: ${t.apiEndpoint})`
534
541
  : t.testFile;
542
+ testLocationsByType[type] = testLocationsByType[type]
543
+ ? `${testLocationsByType[type]}, ${entry}`
544
+ : entry;
535
545
  }
536
546
  // Build the full RepositoryAnalysis object — same structure as analyzeRepositoryTool
537
547
  // so buildRecommendationPrompt can reason over enriched endpoint + scenario data
@@ -700,7 +710,7 @@ to produce a unified state file for the test health workflow.
700
710
  }
701
711
  }
702
712
  }
703
- const recommendationPrompt = buildRecommendationPrompt(fullAnalysis, analysisScope, topN, prContext, wsAuthHeader, wsAuthType);
713
+ const recommendationPrompt = buildRecommendationPrompt(fullAnalysis, analysisScope, topN, prContext, wsAuthHeader, wsAuthType, params.maxGenerate);
704
714
  const routerMountContext = grepRouterMountingContext(params.repositoryPath);
705
715
  await sendProgress(100, 100, "Analysis complete.");
706
716
  const stateSize = await stateManager.getSizeFormatted();
@@ -71,6 +71,7 @@ export const scenarioStepSchema = z.object({
71
71
  chainsFrom: z
72
72
  .union([chainingRefSchema, z.array(chainingRefSchema)])
73
73
  .optional(),
74
+ bodyMustInclude: z.array(z.string()).optional(),
74
75
  });
75
76
  export const draftedScenarioSchema = z.object({
76
77
  scenarioName: z.string(),
@@ -573,6 +573,66 @@ function diffDirectValidation(method, path, resource) {
573
573
  testType: "contract",
574
574
  };
575
575
  }
576
+ /**
577
+ * Draft a "mutation with collection modification" scenario for PUT/PATCH endpoints.
578
+ * This tests adding/removing child items (e.g., order line items) and verifying that
579
+ * derived totals (total_amount, item_count, subtotal) are recalculated.
580
+ * This pattern catches the most common class of user-reported bugs.
581
+ */
582
+ function diffDirectMutationRecalc(method, resource, singular, group) {
583
+ const steps = [];
584
+ if (group.methods.has("POST")) {
585
+ steps.push({
586
+ order: 1,
587
+ method: "POST",
588
+ path: group.basePath,
589
+ description: `Create a ${singular} with initial items`,
590
+ interactionType: "success",
591
+ expectedStatusCode: 201,
592
+ });
593
+ }
594
+ const targetPath = group.paramPath ?? group.basePath;
595
+ const pathParamName = group.paramPath?.match(/\{([^}]+)\}/)?.[1] ?? `${singular}_id`;
596
+ const sourceStep = steps.length;
597
+ steps.push({
598
+ order: steps.length + 1,
599
+ method,
600
+ 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`,
602
+ interactionType: "success",
603
+ expectedStatusCode: 200,
604
+ bodyMustInclude: ["child collection array (e.g. items)", "FK reference to parent resource (e.g. product_id)", "quantity or amount"],
605
+ ...(sourceStep > 0 && group.paramPath
606
+ ? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
607
+ : {}),
608
+ });
609
+ if (group.paramPath && group.methods.has("GET")) {
610
+ steps.push({
611
+ order: steps.length + 1,
612
+ method: "GET",
613
+ path: group.paramPath,
614
+ description: `Verify the ${singular} reflects the updated child collection with correct FK references, quantities, and recalculated totals`,
615
+ interactionType: "success",
616
+ expectedStatusCode: 200,
617
+ expectedResponseFields: ["child collection array", "each child's FK reference", "each child's quantity", "recalculated total"],
618
+ ...(sourceStep > 0 && group.paramPath
619
+ ? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
620
+ : {}),
621
+ });
622
+ }
623
+ return {
624
+ scenarioName: `${resource}-${method.toLowerCase()}-add-items-recalculate`,
625
+ description: `Mutation recalculation: ${method} ${targetPath} — modify child collection and verify derived totals are recomputed`,
626
+ category: "new_endpoint",
627
+ priority: "high",
628
+ steps,
629
+ chainingKeys: ["id", pathParamName],
630
+ requiresAuth: true,
631
+ estimatedComplexity: "complex",
632
+ source: "code-inferred",
633
+ testType: "integration",
634
+ };
635
+ }
576
636
  /**
577
637
  * Draft scenarios that directly test each new endpoint in the branch diff.
578
638
  *
@@ -597,15 +657,64 @@ export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
597
657
  methods.add(ep.method.toUpperCase());
598
658
  grouped.set(ep.path, methods);
599
659
  }
660
+ // Segment-boundary suffix match: avoids "/orders" matching "/preorders".
661
+ const pathSuffixMatches = (fullPath, suffix) => {
662
+ if (fullPath === suffix)
663
+ return true;
664
+ const fullSegs = fullPath.split("/").filter(Boolean);
665
+ const suffixSegs = suffix.split("/").filter(Boolean);
666
+ if (suffixSegs.length === 0 || suffixSegs.length > fullSegs.length)
667
+ return false;
668
+ const offset = fullSegs.length - suffixSegs.length;
669
+ for (let i = 0; i < suffixSegs.length; i++) {
670
+ if (fullSegs[offset + i] !== suffixSegs[i])
671
+ return false;
672
+ }
673
+ return true;
674
+ };
600
675
  for (const [epPath, methods] of grouped) {
601
- const resource = extractResourceName(epPath);
602
- if (!resource)
603
- continue;
676
+ let resource = extractResourceName(epPath);
677
+ let resolvedPath = epPath;
678
+ // When resource was found but epPath may be router-relative (e.g. "/orders"
679
+ // instead of "/api/v1/orders"), try to upgrade resolvedPath to the full path.
680
+ if (resource) {
681
+ const existingGroup = resourceGroups.get(resource);
682
+ if (existingGroup) {
683
+ if (existingGroup.paramPath && existingGroup.paramPath !== epPath && pathSuffixMatches(existingGroup.paramPath, epPath)) {
684
+ resolvedPath = existingGroup.paramPath;
685
+ }
686
+ else if (existingGroup.basePath !== epPath && pathSuffixMatches(existingGroup.basePath, epPath)) {
687
+ resolvedPath = existingGroup.basePath;
688
+ }
689
+ }
690
+ }
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) {
695
+ let bestMatch = null;
696
+ 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 };
700
+ }
701
+ }
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 };
705
+ }
706
+ }
707
+ }
708
+ if (!bestMatch)
709
+ continue;
710
+ resource = bestMatch.name;
711
+ resolvedPath = bestMatch.path;
712
+ }
604
713
  const group = resourceGroups.get(resource);
605
714
  if (!group)
606
715
  continue;
607
716
  const singular = singularize(resource);
608
- const hasPathParam = epPath.includes("{");
717
+ const hasPathParam = resolvedPath.includes("{");
609
718
  const add = (s) => {
610
719
  if (!seen.has(s.scenarioName)) {
611
720
  seen.add(s.scenarioName);
@@ -614,23 +723,24 @@ export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
614
723
  };
615
724
  for (const method of methods) {
616
725
  if (method === "PUT" || method === "PATCH") {
726
+ add(diffDirectMutationRecalc(method, resource, singular, group));
617
727
  add(diffDirectIntegration(method, resource, singular, group));
618
- add(diffDirectContract(method, epPath, resource));
728
+ add(diffDirectContract(method, resolvedPath, resource));
619
729
  if (hasPathParam)
620
- add(diffDirectNotFound(method, epPath, resource, singular));
730
+ add(diffDirectNotFound(method, resolvedPath, resource, singular));
621
731
  }
622
732
  else if (method === "POST") {
623
733
  add(diffDirectIntegration(method, resource, singular, group));
624
- add(diffDirectContract(method, epPath, resource));
625
- add(diffDirectValidation(method, epPath, resource));
734
+ add(diffDirectContract(method, resolvedPath, resource));
735
+ add(diffDirectValidation(method, resolvedPath, resource));
626
736
  }
627
737
  else if (method === "DELETE") {
628
738
  add(diffDirectIntegration(method, resource, singular, group));
629
- add(diffDirectContract(method, epPath, resource));
739
+ add(diffDirectContract(method, resolvedPath, resource));
630
740
  }
631
741
  else if (method === "GET" && hasPathParam) {
632
- add(diffDirectContract(method, epPath, resource));
633
- add(diffDirectNotFound(method, epPath, resource, singular));
742
+ add(diffDirectContract(method, resolvedPath, resource));
743
+ add(diffDirectNotFound(method, resolvedPath, resource, singular));
634
744
  }
635
745
  }
636
746
  }
@@ -317,12 +317,17 @@ describe("draftDiffDirectScenarios", () => {
317
317
  orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT"], paramPath: "/api/orders/{order_id}" },
318
318
  });
319
319
  const scenarios = draftDiffDirectScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }], groups);
320
- expect(scenarios.length).toBeGreaterThanOrEqual(2);
320
+ expect(scenarios.length).toBeGreaterThanOrEqual(3);
321
321
  for (const s of scenarios) {
322
322
  expect(s.category).toBe("new_endpoint");
323
323
  }
324
+ // Mutation-recalculation scenario is drafted first for PUT/PATCH
325
+ const mutationRecalc = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("add-items-recalculate"));
326
+ expect(mutationRecalc).toBeDefined();
327
+ expect(mutationRecalc.steps.some(st => st.method === "PUT")).toBe(true);
328
+ expect(mutationRecalc.description).toContain("recalculation");
324
329
  // Integration happy path includes the PUT step
325
- const integration = scenarios.find(s => s.testType === "integration");
330
+ const integration = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("happy-path"));
326
331
  expect(integration).toBeDefined();
327
332
  expect(integration.steps.some(st => st.method === "PUT")).toBe(true);
328
333
  // Description prompts LLM to discover prerequisites from source code
@@ -334,12 +339,40 @@ describe("draftDiffDirectScenarios", () => {
334
339
  const notFound = scenarios.find(s => s.steps.some(st => st.expectedStatusCode === 404));
335
340
  expect(notFound).toBeDefined();
336
341
  });
342
+ it("generates mutation-recalc scenario for a new PATCH endpoint", () => {
343
+ const groups = makeGroups({
344
+ orders: { basePath: "/api/orders", methods: ["GET", "POST", "PATCH"], paramPath: "/api/orders/{order_id}" },
345
+ });
346
+ const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/api/orders/{order_id}" }], groups);
347
+ // Mutation-recalculation scenario is drafted for PATCH
348
+ const mutationRecalc = scenarios.find(s => s.scenarioName === "orders-patch-add-items-recalculate");
349
+ expect(mutationRecalc).toBeDefined();
350
+ expect(mutationRecalc.testType).toBe("integration");
351
+ expect(mutationRecalc.category).toBe("new_endpoint");
352
+ expect(mutationRecalc.priority).toBe("high");
353
+ expect(mutationRecalc.description).toContain("recalculation");
354
+ expect(mutationRecalc.description).toContain("derived totals");
355
+ // Should have 3 steps: POST (create) → PATCH (add items) → GET (verify)
356
+ expect(mutationRecalc.steps).toHaveLength(3);
357
+ expect(mutationRecalc.steps[0].method).toBe("POST");
358
+ expect(mutationRecalc.steps[1].method).toBe("PATCH");
359
+ 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();
363
+ });
337
364
  it("integration scenario minimum steps: POST resource then PUT — LLM discovers prereqs from source code", () => {
338
365
  const groups = makeGroups({
339
366
  orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT"], paramPath: "/api/orders/{order_id}" },
340
367
  });
341
368
  const scenarios = draftDiffDirectScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }], groups);
342
- const integration = scenarios.find(s => s.testType === "integration");
369
+ // Mutation-recalculation scenario comes first
370
+ const mutationRecalc = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("add-items-recalculate"));
371
+ expect(mutationRecalc).toBeDefined();
372
+ expect(mutationRecalc.steps.some(st => st.method === "POST" && st.path === "/api/orders")).toBe(true);
373
+ 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"));
343
376
  expect(integration).toBeDefined();
344
377
  // Minimum steps: create the resource + call the new endpoint
345
378
  expect(integration.steps.some(st => st.method === "POST" && st.path === "/api/orders")).toBe(true);
@@ -378,4 +411,234 @@ describe("draftDiffDirectScenarios", () => {
378
411
  expect(diffDirect.length).toBeGreaterThan(0);
379
412
  expect(diffDirect.some(s => s.steps.some(st => st.method === "PUT"))).toBe(true);
380
413
  });
414
+ it("resolves router-relative param paths (e.g. /{order_id}) against known resourceGroups", () => {
415
+ const groups = makeGroups({
416
+ orders: { basePath: "/api/v1/orders", methods: ["GET", "POST", "PATCH", "DELETE"], paramPath: "/api/v1/orders/{order_id}" },
417
+ products: { basePath: "/api/v1/products", methods: ["GET", "POST"], paramPath: "/api/v1/products/{id}" },
418
+ });
419
+ // Diff scanner reports router-relative path, not the full API path
420
+ const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/{order_id}" }], groups);
421
+ expect(scenarios.length).toBeGreaterThanOrEqual(3);
422
+ // Mutation-recalculation scenario is drafted
423
+ const mutationRecalc = scenarios.find(s => s.scenarioName === "orders-patch-add-items-recalculate");
424
+ expect(mutationRecalc).toBeDefined();
425
+ expect(mutationRecalc.testType).toBe("integration");
426
+ expect(mutationRecalc.category).toBe("new_endpoint");
427
+ expect(mutationRecalc.steps[1].method).toBe("PATCH");
428
+ // Contract and not-found scenarios use the resolved full path
429
+ const contract = scenarios.find(s => s.scenarioName.includes("contract"));
430
+ expect(contract).toBeDefined();
431
+ expect(contract.steps[0].path).toBe("/api/v1/orders/{order_id}");
432
+ const notFound = scenarios.find(s => s.scenarioName.includes("not-found"));
433
+ expect(notFound).toBeDefined();
434
+ expect(notFound.steps[0].path).toBe("/api/v1/orders/{order_id}");
435
+ });
436
+ it("resolves router-relative base paths (e.g. /orders) against known resourceGroups", () => {
437
+ const groups = makeGroups({
438
+ orders: { basePath: "/api/v1/orders", methods: ["GET", "POST"], paramPath: "/api/v1/orders/{order_id}" },
439
+ });
440
+ const scenarios = draftDiffDirectScenarios([{ method: "POST", path: "/orders" }], groups);
441
+ expect(scenarios.length).toBeGreaterThanOrEqual(2);
442
+ const integration = scenarios.find(s => s.testType === "integration");
443
+ expect(integration).toBeDefined();
444
+ expect(integration.category).toBe("new_endpoint");
445
+ });
446
+ it("skips unresolvable router-relative paths", () => {
447
+ const groups = makeGroups({
448
+ products: { basePath: "/api/v1/products", methods: ["GET", "POST"], paramPath: "/api/v1/products/{id}" },
449
+ });
450
+ // /{nonexistent_id} doesn't match any known resource
451
+ const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/{nonexistent_id}" }], groups);
452
+ expect(scenarios).toEqual([]);
453
+ });
454
+ it("PR #195 regression: PATCH /{order_id} produces mutation-recalculate scenario in GENERATE slots", () => {
455
+ // Simulates the exact data from PR #195:
456
+ // - Full endpoint list with orders, products, reviews
457
+ // - newEndpoints has PATCH /{order_id} (router-relative)
458
+ const endpoints = [
459
+ { path: "/api/v1/orders", methods: ["GET", "POST"] },
460
+ { path: "/api/v1/orders/{order_id}", methods: ["GET", "PATCH", "DELETE"] },
461
+ { path: "/api/v1/products", methods: ["GET", "POST"] },
462
+ { path: "/api/v1/products/{product_id}", methods: ["GET", "PUT", "DELETE"] },
463
+ { path: "/api/v1/reviews", methods: ["GET", "POST"] },
464
+ { path: "/api/v1/reset", methods: ["POST"] },
465
+ ];
466
+ const newEndpoints = [{ method: "PATCH", path: "/{order_id}" }];
467
+ 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");
470
+ expect(mutationRecalc).toBeDefined();
471
+ expect(mutationRecalc.category).toBe("new_endpoint"); // → CRITICAL priority tier
472
+ expect(mutationRecalc.steps).toHaveLength(3);
473
+ expect(mutationRecalc.steps[0].method).toBe("POST");
474
+ expect(mutationRecalc.steps[1].method).toBe("PATCH");
475
+ expect(mutationRecalc.steps[2].method).toBe("GET");
476
+ expect(mutationRecalc.description).toContain("derived totals");
477
+ // Verify it outranks all non-new_endpoint scenarios (cascade-delete, unique-constraint, etc.)
478
+ const newEndpointScenarios = allScenarios.filter(s => s.category === "new_endpoint");
479
+ const otherScenarios = allScenarios.filter(s => s.category !== "new_endpoint");
480
+ expect(newEndpointScenarios.length).toBeGreaterThanOrEqual(3);
481
+ // All new_endpoint scenarios should include our mutation-recalculate
482
+ expect(newEndpointScenarios.some(s => s.scenarioName === "orders-patch-add-items-recalculate")).toBe(true);
483
+ // Non-new_endpoint scenarios should NOT include mutation-recalculate
484
+ expect(otherScenarios.every(s => s.scenarioName !== "orders-patch-add-items-recalculate")).toBe(true);
485
+ });
486
+ });
487
+ describe("diffDirectMutationRecalc — diverse app types (no hardcoded field names)", () => {
488
+ const makeGroups = (defs) => new Map(Object.entries(defs).map(([k, v]) => [k, { ...v, methods: new Set(v.methods) }]));
489
+ const testCases = [
490
+ {
491
+ name: "E-commerce: PATCH /orders/{order_id}",
492
+ resource: "orders",
493
+ group: { basePath: "/api/v1/orders", methods: ["GET", "POST", "PATCH"], paramPath: "/api/v1/orders/{order_id}" },
494
+ method: "PATCH",
495
+ expectedParamName: "order_id",
496
+ },
497
+ {
498
+ name: "Project management: PUT /projects/{id}",
499
+ resource: "projects",
500
+ group: { basePath: "/api/projects", methods: ["GET", "POST", "PUT"], paramPath: "/api/projects/{id}" },
501
+ method: "PUT",
502
+ expectedParamName: "id",
503
+ },
504
+ {
505
+ name: "Invoicing: PATCH /invoices/{invoice_id}",
506
+ resource: "invoices",
507
+ group: { basePath: "/v2/invoices", methods: ["GET", "POST", "PATCH"], paramPath: "/v2/invoices/{invoice_id}" },
508
+ method: "PATCH",
509
+ expectedParamName: "invoice_id",
510
+ },
511
+ {
512
+ name: "Education LMS: PUT /courses/{course_id}",
513
+ resource: "courses",
514
+ group: { basePath: "/api/courses", methods: ["GET", "POST", "PUT"], paramPath: "/api/courses/{course_id}" },
515
+ method: "PUT",
516
+ expectedParamName: "course_id",
517
+ },
518
+ {
519
+ name: "Healthcare: PATCH /prescriptions/{prescription_id}",
520
+ resource: "prescriptions",
521
+ group: { basePath: "/api/prescriptions", methods: ["GET", "POST", "PATCH"], paramPath: "/api/prescriptions/{prescription_id}" },
522
+ method: "PATCH",
523
+ expectedParamName: "prescription_id",
524
+ },
525
+ {
526
+ name: "Logistics: PUT /shipments/{shipment_id}",
527
+ resource: "shipments",
528
+ group: { basePath: "/api/shipments", methods: ["GET", "POST", "PUT"], paramPath: "/api/shipments/{shipment_id}" },
529
+ method: "PUT",
530
+ expectedParamName: "shipment_id",
531
+ },
532
+ {
533
+ name: "Restaurant: PATCH /menus/{menuId}",
534
+ resource: "menus",
535
+ group: { basePath: "/api/menus", methods: ["GET", "POST", "PATCH"], paramPath: "/api/menus/{menuId}" },
536
+ method: "PATCH",
537
+ expectedParamName: "menuId",
538
+ },
539
+ {
540
+ name: "Cloud billing: PUT /subscriptions/{subscription_id}",
541
+ resource: "subscriptions",
542
+ group: { basePath: "/billing/subscriptions", methods: ["GET", "POST", "PUT"], paramPath: "/billing/subscriptions/{subscription_id}" },
543
+ method: "PUT",
544
+ expectedParamName: "subscription_id",
545
+ },
546
+ {
547
+ name: "Warehouse: PATCH /inventories/{uuid}",
548
+ resource: "inventories",
549
+ group: { basePath: "/api/inventories", methods: ["GET", "POST", "PATCH"], paramPath: "/api/inventories/{uuid}" },
550
+ method: "PATCH",
551
+ expectedParamName: "uuid",
552
+ },
553
+ {
554
+ name: "Minimal: PATCH /items (no paramPath)",
555
+ resource: "items",
556
+ group: { basePath: "/items", methods: ["GET", "POST", "PATCH"] },
557
+ method: "PATCH",
558
+ expectedParamName: "item_id",
559
+ },
560
+ ];
561
+ it.each(testCases)("$name — produces valid mutation-recalc scenario", ({ resource, group, method, expectedParamName }) => {
562
+ const groups = makeGroups({ [resource]: group });
563
+ const scenarios = draftDiffDirectScenarios([{ method, path: group.paramPath ?? group.basePath }], groups);
564
+ const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-add-items-recalculate"));
565
+ expect(mutationRecalc).toBeDefined();
566
+ expect(mutationRecalc.category).toBe("new_endpoint");
567
+ expect(mutationRecalc.testType).toBe("integration");
568
+ expect(mutationRecalc.priority).toBe("high");
569
+ // Step 1: POST to create resource (all test cases have POST)
570
+ expect(mutationRecalc.steps[0].method).toBe("POST");
571
+ expect(mutationRecalc.steps[0].path).toBe(group.basePath);
572
+ // Step 2: PUT/PATCH with mutation
573
+ const mutationStep = mutationRecalc.steps[1];
574
+ expect(mutationStep.method).toBe(method);
575
+ expect(mutationStep.bodyMustInclude).toBeDefined();
576
+ expect(mutationStep.bodyMustInclude.length).toBe(3);
577
+ // 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
+ // Verify hints are descriptive (contain "e.g." or similar guidance)
585
+ expect(mutationStep.bodyMustInclude.some(h => h.includes("e.g."))).toBe(true);
586
+ // Chaining uses the actual path param name (only when paramPath exists)
587
+ if (group.paramPath) {
588
+ expect(mutationStep.chainsFrom).toBeDefined();
589
+ const chaining = mutationStep.chainsFrom;
590
+ expect(chaining.targetParam).toBe(expectedParamName);
591
+ expect(chaining.sourceStep).toBe(1);
592
+ expect(chaining.sourceField).toBe("id");
593
+ }
594
+ else {
595
+ expect(mutationStep.chainsFrom).toBeUndefined();
596
+ }
597
+ // Step 3: GET verification (if paramPath exists)
598
+ if (group.paramPath) {
599
+ expect(mutationRecalc.steps).toHaveLength(3);
600
+ const getStep = mutationRecalc.steps[2];
601
+ 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
+ }
609
+ }
610
+ // chainingKeys uses the actual path param name, not hardcoded singular_id
611
+ expect(mutationRecalc.chainingKeys).toContain("id");
612
+ expect(mutationRecalc.chainingKeys).toContain(expectedParamName);
613
+ // Description is domain-agnostic
614
+ expect(mutationRecalc.description).toContain("derived totals");
615
+ expect(mutationRecalc.description).toContain("Mutation recalculation");
616
+ });
617
+ it("no paramPath: PATCH step targets basePath, no GET verification, fallback param name", () => {
618
+ const groups = makeGroups({
619
+ items: { basePath: "/items", methods: ["GET", "POST", "PATCH"] },
620
+ });
621
+ const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/items" }], groups);
622
+ const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-add-items-recalculate"));
623
+ expect(mutationRecalc).toBeDefined();
624
+ // No paramPath → PATCH targets basePath, no GET step
625
+ expect(mutationRecalc.steps).toHaveLength(2);
626
+ expect(mutationRecalc.steps[1].path).toBe("/items");
627
+ // Fallback param name: singular + _id
628
+ expect(mutationRecalc.chainingKeys).toContain("item_id");
629
+ });
630
+ it("resource without POST: no create step, no chaining", () => {
631
+ const groups = makeGroups({
632
+ configs: { basePath: "/api/configs", methods: ["GET", "PATCH"], paramPath: "/api/configs/{config_id}" },
633
+ });
634
+ const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/api/configs/{config_id}" }], groups);
635
+ const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-add-items-recalculate"));
636
+ expect(mutationRecalc).toBeDefined();
637
+ // No POST → starts with PATCH directly, no chaining
638
+ expect(mutationRecalc.steps[0].method).toBe("PATCH");
639
+ expect(mutationRecalc.steps[0].chainsFrom).toBeUndefined();
640
+ // Still has GET verification
641
+ expect(mutationRecalc.steps[1].method).toBe("GET");
642
+ expect(mutationRecalc.steps[1].chainsFrom).toBeUndefined();
643
+ });
381
644
  });