@skyramp/mcp 0.2.8 → 0.2.10-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/build/index.js +1 -8
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
  3. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
  4. package/build/prompts/personas.d.ts +2 -2
  5. package/build/prompts/personas.js +2 -2
  6. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
  7. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +6 -1
  8. package/build/prompts/sut-setup/shared.d.ts +20 -0
  9. package/build/prompts/sut-setup/shared.js +67 -7
  10. package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
  11. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
  12. package/build/prompts/test-maintenance/drift-analysis-prompt.js +63 -3
  13. package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -25
  14. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +20 -0
  15. package/build/prompts/test-maintenance/driftAnalysisShared.js +80 -0
  16. package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +32 -0
  17. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +212 -0
  18. package/build/prompts/test-recommendation/recommendationShared.js +1 -1
  19. package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
  20. package/build/prompts/testbot/testbot-prompts.js +10 -9
  21. package/build/resources/testbotResource.js +1 -1
  22. package/build/services/TestDiscoveryService.d.ts +12 -10
  23. package/build/services/TestDiscoveryService.js +137 -83
  24. package/build/services/TestDiscoveryService.test.js +370 -15
  25. package/build/services/TestExecutionService.d.ts +1 -1
  26. package/build/services/TestExecutionService.js +7 -7
  27. package/build/services/TestExecutionService.test.js +4 -1
  28. package/build/tools/executeSkyrampTestTool.js +40 -71
  29. package/build/tools/submitReportTool.d.ts +25 -25
  30. package/build/tools/submitReportTool.js +123 -34
  31. package/build/tools/submitReportTool.test.js +413 -94
  32. package/build/tools/test-management/actionsTool.js +177 -77
  33. package/build/tools/test-management/actionsTool.test.d.ts +1 -0
  34. package/build/tools/test-management/actionsTool.test.js +297 -0
  35. package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
  36. package/build/tools/test-management/analyzeChangesTool.js +74 -169
  37. package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
  38. package/build/tools/test-management/analyzeTestHealthTool.js +63 -22
  39. package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
  40. package/build/tools/test-management/analyzeTestHealthTool.test.js +268 -0
  41. package/build/tools/workspace/initializeWorkspaceTool.js +37 -24
  42. package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
  43. package/build/types/FrontendIntegration.d.ts +25 -0
  44. package/build/types/FrontendIntegration.js +19 -0
  45. package/build/types/RepositoryAnalysis.d.ts +8 -8
  46. package/build/types/TestAnalysis.d.ts +18 -49
  47. package/build/types/TestAnalysis.js +0 -28
  48. package/build/types/TestTypes.d.ts +3 -1
  49. package/build/types/TestTypes.js +3 -1
  50. package/build/types/TestbotReport.d.ts +1 -1
  51. package/build/utils/AnalysisStateManager.d.ts +16 -22
  52. package/build/utils/docker.test.js +1 -1
  53. package/build/utils/featureFlags.d.ts +1 -1
  54. package/build/utils/featureFlags.js +1 -1
  55. package/build/utils/frontendIntegration.d.ts +9 -0
  56. package/build/utils/frontendIntegration.js +237 -0
  57. package/build/utils/frontendIntegration.test.d.ts +1 -0
  58. package/build/utils/frontendIntegration.test.js +229 -0
  59. package/build/utils/pr-comment-parser.d.ts +3 -3
  60. package/build/utils/pr-comment-parser.js +6 -6
  61. package/build/utils/pr-comment-parser.test.js +3 -3
  62. package/build/utils/repoScanner.d.ts +7 -0
  63. package/build/utils/repoScanner.js +14 -6
  64. package/build/utils/repoScanner.test.js +9 -9
  65. package/build/utils/routeParsers.d.ts +32 -0
  66. package/build/utils/routeParsers.js +205 -2
  67. package/build/utils/routeParsers.test.js +279 -29
  68. package/build/utils/sourceRouteExtractor.js +174 -5
  69. package/build/utils/sourceRouteExtractor.test.js +173 -0
  70. package/build/utils/utils.d.ts +11 -0
  71. package/build/utils/utils.js +19 -0
  72. package/build/utils/utils.test.js +23 -1
  73. package/build/utils/versions.d.ts +3 -3
  74. package/build/utils/versions.js +1 -1
  75. package/build/workspace/workspace.d.ts +21 -21
  76. package/build/workspace/workspace.js +7 -4
  77. package/build/workspace/workspace.test.js +65 -6
  78. package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
  79. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  80. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
  81. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
  82. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
  83. package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
  84. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
  85. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  86. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  87. package/node_modules/playwright/package.json +1 -1
  88. package/package.json +3 -3
@@ -6,6 +6,7 @@ import { TestExecutionStatus } from "../types/TestExecution.js";
6
6
  import * as fs from "fs/promises";
7
7
  import * as path from "path";
8
8
  import * as os from "os";
9
+ import { z } from "zod";
9
10
  jest.unstable_mockModule("../utils/logger.js", () => ({
10
11
  logger: { info: jest.fn(), error: jest.fn(), warning: jest.fn(), debug: jest.fn() },
11
12
  }));
@@ -18,17 +19,45 @@ const { AnalyticsService } = await import("../services/AnalyticsService.js");
18
19
  const pushMCPToolEventMock = AnalyticsService.pushMCPToolEvent;
19
20
  function captureToolHandler() {
20
21
  let handler;
22
+ let inputSchema;
21
23
  const fakeServer = {
22
- registerTool: (_name, _opts, fn) => {
24
+ registerTool: (_name, opts, fn) => {
23
25
  handler = fn;
26
+ inputSchema = opts.inputSchema;
24
27
  },
25
28
  };
26
29
  registerSubmitReportTool(fakeServer);
27
- return handler;
30
+ // Mirrors the real MCP SDK's registerTool: validates args against inputSchema via Zod
31
+ // before invoking the handler, so tests exercise the real tool contract (refinements
32
+ // included) instead of bypassing validation entirely.
33
+ return async (params) => {
34
+ const parsed = z.object(inputSchema).safeParse(params);
35
+ if (!parsed.success) {
36
+ return { isError: true, content: [{ type: "text", text: parsed.error.message }] };
37
+ }
38
+ return handler(parsed.data);
39
+ };
28
40
  }
29
41
  async function writeStateFile(dir, existingTests) {
30
42
  const stateFile = path.join(dir, "analyze-changes-state.json");
31
- await fs.writeFile(stateFile, JSON.stringify({ existingTests }), "utf-8");
43
+ // maintenanceVerdicts: [] simulates skyramp_actions having already run with nothing to
44
+ // do — the realistic state for tests not specifically exercising the maintenance flow.
45
+ await fs.writeFile(stateFile, JSON.stringify({ existingTests, maintenanceVerdicts: [], metadata: sampleMetadata(dir) }), "utf-8");
46
+ return stateFile;
47
+ }
48
+ function sampleMetadata(dir) {
49
+ return {
50
+ sessionId: "test-session",
51
+ stateType: "unified",
52
+ repositoryPath: dir,
53
+ createdAt: "2026-01-01T00:00:00.000Z",
54
+ updatedAt: "2026-01-01T00:00:00.000Z",
55
+ };
56
+ }
57
+ // SKYR-3906: state written by skyramp_actions after drift-analysis reconciliation.
58
+ async function writeReconciledStateFile(dir, existingTests, maintenanceVerdicts, ignoredTestFiles = []) {
59
+ const stateFile = path.join(dir, "analyze-changes-state.json");
60
+ await fs.writeFile(stateFile, JSON.stringify({ existingTests, maintenanceVerdicts, ignoredTestFiles, metadata: sampleMetadata(dir) }), "utf-8");
32
61
  return stateFile;
33
62
  }
34
63
  function sampleReportParams(outputFile, stateFile) {
@@ -37,9 +66,8 @@ function sampleReportParams(outputFile, stateFile) {
37
66
  stateFile,
38
67
  businessCaseAnalysis: "This PR adds product search. Tests needed for filtering.",
39
68
  newTestsCreated: [
40
- { testId: "smoke-get-products-search", testType: TestType.SMOKE, category: "workflow", endpoint: "GET /api/v1/products/search", fileName: "search_smoke_test.py", reasoning: "Validates product search returns correct results for category filter" },
69
+ { testId: "smoke-get-products-search", testType: TestType.SMOKE, category: "workflow", endpoint: "GET /api/v1/products/search", fileName: "search_smoke_test.py", description: "Searches products by category filter and asserts matching results are returned", reasoning: "Validates product search returns correct results for category filter" },
41
70
  ],
42
- testMaintenance: [{ action: "UPDATE", testType: TestType.CONTRACT, endpoint: "GET /api/v1/products", fileName: "products_contract_test.py", description: "Updated auth flow in existing tests", beforeDetails: "", afterDetails: "" }],
43
71
  testResults: [
44
72
  { testType: TestType.SMOKE, endpoint: "GET /api/v1/products", status: "Pass", details: "2.1s, products_smoke_test.py" },
45
73
  { testType: TestType.SMOKE, endpoint: "GET /api/v1/products/search", status: "Fail", details: "3.4s, search_smoke_test.py" },
@@ -83,7 +111,7 @@ describe("registerSubmitReportTool", () => {
83
111
  expect(written.newTestsCreated).toHaveLength(1);
84
112
  expect(written.testResults).toHaveLength(2);
85
113
  expect(written.issuesFound).toHaveLength(1);
86
- expect(written.testMaintenance).toHaveLength(1);
114
+ expect(written.testMaintenance).toBeUndefined();
87
115
  });
88
116
  it("accepts and persists the per-item `repository` field", async () => {
89
117
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
@@ -373,12 +401,12 @@ describe("registerSubmitReportTool", () => {
373
401
  expect(result.success).toBe(true);
374
402
  });
375
403
  it("rejects targetElement missing accessibleName", () => {
376
- const { accessibleName, ...withoutName } = validTarget;
404
+ const { accessibleName: _accessibleName, ...withoutName } = validTarget;
377
405
  const result = targetElementSchema.safeParse(withoutName);
378
406
  expect(result.success).toBe(false);
379
407
  });
380
408
  it("rejects targetElement missing role", () => {
381
- const { role, ...withoutRole } = validTarget;
409
+ const { role: _role, ...withoutRole } = validTarget;
382
410
  const result = targetElementSchema.safeParse(withoutRole);
383
411
  expect(result.success).toBe(false);
384
412
  });
@@ -483,6 +511,7 @@ describe("registerSubmitReportTool", () => {
483
511
  fileName: "orders_patch_contract.py",
484
512
  reasoning: "Validates request shape and 200/422 status codes",
485
513
  targetElements: [validTarget],
514
+ description: "calls the PATCH /api/v1/orders/{id} API and validates it implements its contract.",
486
515
  });
487
516
  expect(result.success).toBe(false);
488
517
  if (!result.success) {
@@ -498,6 +527,7 @@ describe("registerSubmitReportTool", () => {
498
527
  fileName: "orders_integration.py",
499
528
  reasoning: "Multi-step order lifecycle",
500
529
  pageContext: validPage,
530
+ description: "calls the POST /api/v1/orders API to create an order and then do stuff.",
501
531
  });
502
532
  expect(result.success).toBe(false);
503
533
  if (!result.success) {
@@ -558,9 +588,11 @@ describe("registerSubmitReportTool", () => {
558
588
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
559
589
  tmpDirs.push(tmpDir);
560
590
  const outputFile = path.join(tmpDir, "report.json");
561
- // sampleReportParams: 1 new test, 1 maintenance (no matching stateFile entry), 2 results
562
- // (1 Pass + 1 Fail), 1 issue (no severity).
563
- const stateFile = await writeStateFile(tmpDir, []);
591
+ // sampleReportParams: 1 new test, 2 results (1 Pass + 1 Fail), 1 issue (no severity).
592
+ // Plus 1 maintenance verdict (no matching existingTests entry) from state.
593
+ const stateFile = await writeReconciledStateFile(tmpDir, [], [
594
+ { testFilePath: "/repo/products_contract_test.py", action: DriftAction.Update, rationale: "Updated auth flow in existing tests", testType: TestType.CONTRACT, endpoint: "GET /api/v1/products" },
595
+ ]);
564
596
  await handler(sampleReportParams(outputFile, stateFile));
565
597
  const params = lastAnalyticsParams();
566
598
  expect(params.testsGenerated).toBe("1");
@@ -604,7 +636,6 @@ describe("registerSubmitReportTool", () => {
604
636
  stateFile,
605
637
  businessCaseAnalysis: "Config-only change.",
606
638
  newTestsCreated: [],
607
- testMaintenance: [],
608
639
  testResults: [],
609
640
  issuesFound: [],
610
641
  });
@@ -619,18 +650,15 @@ describe("registerSubmitReportTool", () => {
619
650
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
620
651
  tmpDirs.push(tmpDir);
621
652
  const outputFile = path.join(tmpDir, "report.json");
622
- const stateFile = await writeStateFile(tmpDir, []);
623
- await handler({
624
- ...sampleReportParams(outputFile, stateFile),
625
- testMaintenance: [
626
- // Real change
627
- { action: "UPDATE", testType: TestType.CONTRACT, endpoint: "GET /api/v1/products", fileName: "products_contract_test.py", description: "Patched auth", beforeDetails: "", afterDetails: "" },
628
- { action: "REGENERATE", testType: TestType.SMOKE, endpoint: "GET /api/v1/orders", fileName: "orders_smoke_test.py", description: "Rewrote for new shape", beforeDetails: "", afterDetails: "" },
629
- // No-ops — assessed but not modified
630
- { action: "IGNORE", testType: TestType.SMOKE, endpoint: "GET /api/v1/reviews", fileName: "reviews_smoke_test.py", description: "No action required", beforeDetails: "", afterDetails: "" },
631
- { action: "VERIFY", testType: TestType.SMOKE, endpoint: "GET /api/v1/coupons", fileName: "coupons_smoke_test.py", description: "Verified still valid", beforeDetails: "", afterDetails: "" },
632
- ],
633
- });
653
+ const stateFile = await writeReconciledStateFile(tmpDir, [], [
654
+ // Real change
655
+ { testFilePath: "/repo/products_contract_test.py", action: DriftAction.Update, rationale: "Patched auth", testType: TestType.CONTRACT, endpoint: "GET /api/v1/products" },
656
+ { testFilePath: "/repo/orders_smoke_test.py", action: DriftAction.Regenerate, rationale: "Rewrote for new shape", testType: TestType.SMOKE, endpoint: "GET /api/v1/orders" },
657
+ // No-ops — assessed but not modified
658
+ { testFilePath: "/repo/reviews_smoke_test.py", action: DriftAction.Ignore, rationale: "No action required", testType: TestType.SMOKE, endpoint: "GET /api/v1/reviews" },
659
+ { testFilePath: "/repo/coupons_smoke_test.py", action: DriftAction.Verify, rationale: "Verified still valid", testType: TestType.SMOKE, endpoint: "GET /api/v1/coupons" },
660
+ ]);
661
+ await handler(sampleReportParams(outputFile, stateFile));
634
662
  const params = lastAnalyticsParams();
635
663
  // 2 change actions (UPDATE + REGENERATE), not 4
636
664
  expect(params.testsMaintained).toBe("2");
@@ -645,89 +673,106 @@ describe("registerSubmitReportTool", () => {
645
673
  // when toOutput or the stateFile enrichment path changes.
646
674
  // ─────────────────────────────────────────────────────────────────────────────
647
675
  describe("testMaintenance output shapes", () => {
648
- const baseMaintenance = {
676
+ const baseVerdict = {
677
+ testFilePath: "/repo/backend/tests/orders_contract_test.py",
678
+ rationale: "Updated discount fields in assertions",
649
679
  testType: TestType.CONTRACT,
650
680
  endpoint: "GET /api/v1/orders",
651
- fileName: "orders_contract_test.py",
652
- description: "Updated discount fields in assertions",
653
- beforeDetails: "",
654
- afterDetails: "",
655
681
  };
656
- async function writeAndRead(tmpDir, maintenance, extra) {
682
+ async function submitAndReadMaintenance(tmpDir, stateFile, testMaintenanceDetails = []) {
657
683
  const outputFile = path.join(tmpDir, "report.json");
658
- await handler({
684
+ const result = await handler({
659
685
  summaryOutputFile: outputFile,
686
+ stateFile,
660
687
  businessCaseAnalysis: "Test",
661
688
  newTestsCreated: [],
662
- testMaintenance: maintenance,
689
+ testMaintenanceDetails,
663
690
  testResults: [],
664
691
  issuesFound: [],
665
- ...extra,
666
692
  });
693
+ if (result.isError)
694
+ throw new Error(`handler rejected: ${result.content?.[0]?.text}`);
667
695
  const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
668
696
  return written.testMaintenance;
669
697
  }
670
- // ── No matching stateFile entry ───────────────────────────────────────────
671
- it("UPDATE with no matching stateFile entry: beforeStatus=Unknown, afterStatus=Unknown", async () => {
698
+ // ── No matching existingTests entry ───────────────────────────────────────
699
+ it("UPDATE with no matching existingTests entry: beforeStatus=Unknown, afterStatus=Unknown", async () => {
672
700
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
673
701
  tmpDirs.push(tmpDir);
674
- const stateFile = await writeStateFile(tmpDir, []);
675
- const result = await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Update }], { stateFile });
702
+ const stateFile = await writeReconciledStateFile(tmpDir, [], [{ ...baseVerdict, action: DriftAction.Update }]);
703
+ const result = await submitAndReadMaintenance(tmpDir, stateFile);
676
704
  expect(result[0].beforeStatus).toBe("Unknown");
677
705
  expect(result[0].afterStatus).toBe("Unknown");
678
706
  expect(result[0].beforeDetails).toBe("");
679
707
  expect(result[0].afterDetails).toBe("");
680
708
  });
681
- it("IGNORE with no matching stateFile entry: both Skipped", async () => {
709
+ it("IGNORE with no matching existingTests entry: both Skipped", async () => {
682
710
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
683
711
  tmpDirs.push(tmpDir);
684
- const stateFile = await writeStateFile(tmpDir, []);
685
- const result = await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Ignore }], { stateFile });
712
+ const stateFile = await writeReconciledStateFile(tmpDir, [], [{ ...baseVerdict, action: DriftAction.Ignore }]);
713
+ const result = await submitAndReadMaintenance(tmpDir, stateFile);
686
714
  expect(result[0].beforeStatus).toBe("Skipped");
687
715
  expect(result[0].afterStatus).toBe("Skipped");
688
716
  expect(result[0].beforeDetails).toBe("");
689
717
  expect(result[0].afterDetails).toBe("");
690
718
  });
691
- it("DELETE with no matching stateFile entry: beforeStatus=Unknown, afterStatus=Skipped", async () => {
719
+ it("DELETE with no matching existingTests entry: beforeStatus=Unknown, afterStatus=Skipped", async () => {
692
720
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
693
721
  tmpDirs.push(tmpDir);
694
- const stateFile = await writeStateFile(tmpDir, []);
695
- const result = await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Delete }], { stateFile });
722
+ const stateFile = await writeReconciledStateFile(tmpDir, [], [{ ...baseVerdict, action: DriftAction.Delete }]);
723
+ const result = await submitAndReadMaintenance(tmpDir, stateFile);
696
724
  expect(result[0].beforeStatus).toBe("Unknown");
697
725
  expect(result[0].afterStatus).toBe("Skipped");
698
726
  });
699
- // ── With matching stateFile entry ─────────────────────────────────────────
700
- it("UPDATE with before=Pass after=Pass: status from stateFile, details from LLM", async () => {
727
+ // ── With matching existingTests entry ─────────────────────────────────────
728
+ it("UPDATE with before=Pass after=Pass: status from stateFile, details from the LLM-drafted testMaintenanceDetails", async () => {
701
729
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
702
730
  tmpDirs.push(tmpDir);
703
- const stateFile = await writeStateFile(tmpDir, [{
704
- testFile: "/repo/backend/tests/orders_contract_test.py",
731
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
732
+ testFile: baseVerdict.testFilePath,
705
733
  executionBefore: { status: TestExecutionStatus.Pass, errors: [], duration: 15090 },
706
734
  executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 12880 },
707
- }]);
708
- const result = await writeAndRead(tmpDir, [{
709
- ...baseMaintenance, action: DriftAction.Update,
710
- beforeDetails: "4 passed in 15.09s",
711
- afterDetails: "5 passed in 12.88s",
712
- }], { stateFile });
735
+ }], [{ ...baseVerdict, action: DriftAction.Update }]);
736
+ const result = await submitAndReadMaintenance(tmpDir, stateFile, [
737
+ { testFilePath: baseVerdict.testFilePath, beforeDetails: "4 passed in 15.09s", afterDetails: "5 passed in 12.88s" },
738
+ ]);
713
739
  expect(result[0].beforeStatus).toBe("Pass");
714
740
  expect(result[0].beforeDetails).toBe("4 passed in 15.09s");
715
741
  expect(result[0].afterStatus).toBe("Pass");
716
742
  expect(result[0].afterDetails).toBe("5 passed in 12.88s");
717
743
  });
718
- it("UPDATE with before=Pass after=Fail: status from stateFile, details from LLM", async () => {
744
+ it("UPDATE with before=Pass after=Fail: rejects when afterDetails is missing for a recorded execution", async () => {
745
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
746
+ tmpDirs.push(tmpDir);
747
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
748
+ testFile: baseVerdict.testFilePath,
749
+ executionBefore: { status: TestExecutionStatus.Pass, errors: [], duration: 15090 },
750
+ executionAfter: { status: TestExecutionStatus.Fail, errors: ["check_schema fails, test isolation bug"], duration: 10940 },
751
+ }], [{ ...baseVerdict, action: DriftAction.Update }]);
752
+ const outputFile = path.join(tmpDir, "report.json");
753
+ const result = await handler({
754
+ summaryOutputFile: outputFile,
755
+ stateFile,
756
+ businessCaseAnalysis: "Test",
757
+ newTestsCreated: [],
758
+ testMaintenanceDetails: [{ testFilePath: baseVerdict.testFilePath, beforeDetails: "4 passed in 15.09s", afterDetails: "" }], // afterDetails left blank
759
+ testResults: [],
760
+ issuesFound: [],
761
+ });
762
+ expect(result.isError).toBe(true);
763
+ expect(result.content[0].text).toContain("orders_contract_test.py (afterDetails)");
764
+ });
765
+ it("UPDATE with before=Pass after=Fail: afterDetails carries the LLM's drafted root cause when supplied", async () => {
719
766
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
720
767
  tmpDirs.push(tmpDir);
721
- const stateFile = await writeStateFile(tmpDir, [{
722
- testFile: "/repo/backend/tests/orders_contract_test.py",
768
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
769
+ testFile: baseVerdict.testFilePath,
723
770
  executionBefore: { status: TestExecutionStatus.Pass, errors: [], duration: 15090 },
724
- executionAfter: { status: TestExecutionStatus.Fail, errors: [], duration: 10940 },
725
- }]);
726
- const result = await writeAndRead(tmpDir, [{
727
- ...baseMaintenance, action: DriftAction.Update,
728
- beforeDetails: "4 passed in 15.09s",
729
- afterDetails: "FAILED test_orders_get — check_schema fails, test isolation bug",
730
- }], { stateFile });
771
+ executionAfter: { status: TestExecutionStatus.Fail, errors: ["check_schema fails, test isolation bug"], duration: 10940 },
772
+ }], [{ ...baseVerdict, action: DriftAction.Update }]);
773
+ const result = await submitAndReadMaintenance(tmpDir, stateFile, [
774
+ { testFilePath: baseVerdict.testFilePath, beforeDetails: "4 passed in 15.09s", afterDetails: "FAILED test_orders_get — check_schema fails, test isolation bug" },
775
+ ]);
731
776
  expect(result[0].beforeStatus).toBe("Pass");
732
777
  expect(result[0].afterStatus).toBe("Fail");
733
778
  expect(result[0].afterDetails).toBe("FAILED test_orders_get — check_schema fails, test isolation bug");
@@ -735,67 +780,135 @@ describe("registerSubmitReportTool", () => {
735
780
  it("UPDATE with crashed execution: status is Error", async () => {
736
781
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
737
782
  tmpDirs.push(tmpDir);
738
- const stateFile = await writeStateFile(tmpDir, [{
739
- testFile: "/repo/backend/tests/orders_contract_test.py",
783
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
784
+ testFile: baseVerdict.testFilePath,
740
785
  executionBefore: { status: TestExecutionStatus.Error, errors: ["Connection refused"], duration: 500 },
741
786
  executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 2000 },
742
- }]);
743
- const result = await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Update }], { stateFile });
787
+ }], [{ ...baseVerdict, action: DriftAction.Update }]);
788
+ const result = await submitAndReadMaintenance(tmpDir, stateFile, [
789
+ { testFilePath: baseVerdict.testFilePath, beforeDetails: "Error — Connection refused", afterDetails: "5 passed in 2.00s" },
790
+ ]);
744
791
  expect(result[0].beforeStatus).toBe("Error");
745
792
  expect(result[0].afterStatus).toBe("Pass");
746
793
  });
747
794
  it("UPDATE Fail→Pass: counted as maintenanceRecovered in analytics", async () => {
748
795
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
749
796
  tmpDirs.push(tmpDir);
750
- const stateFile = await writeStateFile(tmpDir, [{
751
- testFile: "/repo/backend/tests/orders_contract_test.py",
797
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
798
+ testFile: baseVerdict.testFilePath,
752
799
  executionBefore: { status: TestExecutionStatus.Fail, errors: ["FAILED test_foo - assert False"], duration: 5000 },
753
800
  executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 5000 },
754
- }]);
755
- await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Update }], { stateFile });
801
+ }], [{ ...baseVerdict, action: DriftAction.Update }]);
802
+ await submitAndReadMaintenance(tmpDir, stateFile, [
803
+ { testFilePath: baseVerdict.testFilePath, beforeDetails: "FAILED test_foo - assert False", afterDetails: "5 passed in 5.00s" },
804
+ ]);
756
805
  expect(pushMCPToolEventMock.mock.calls.at(-1)?.[2].maintenanceRecovered).toBe("1");
757
806
  });
758
- it("UPDATE with no before-execution recorded: beforeStatus=Unknown, afterStatus=Pass", async () => {
807
+ it("UPDATE with no before-execution recorded: beforeStatus=Unknown, afterStatus=Pass, no beforeDetails required", async () => {
759
808
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
760
809
  tmpDirs.push(tmpDir);
761
- const stateFile = await writeStateFile(tmpDir, [{
762
- testFile: "/repo/backend/tests/orders_contract_test.py",
810
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
811
+ testFile: baseVerdict.testFilePath,
763
812
  // executionBefore absent — agent skipped before-phase
764
813
  executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 2000 },
765
- }]);
766
- const result = await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Update }], { stateFile });
814
+ }], [{ ...baseVerdict, action: DriftAction.Update }]);
815
+ // Only afterDetails is meaningful there's no recorded before-execution to summarize,
816
+ // so beforeDetails is the empty-string sentinel (the field is required, not omittable).
817
+ const result = await submitAndReadMaintenance(tmpDir, stateFile, [
818
+ { testFilePath: baseVerdict.testFilePath, beforeDetails: "", afterDetails: "5 passed in 2.00s" },
819
+ ]);
767
820
  expect(result[0].beforeStatus).toBe("Unknown");
768
821
  expect(result[0].afterStatus).toBe("Pass");
769
822
  });
770
- it("DELETE with before-execution, no after (file deleted): beforeStatus=Pass, afterStatus=Skipped", async () => {
823
+ it("DELETE with before-execution, no after (file deleted): beforeStatus=Pass, afterStatus=Skipped, no afterDetails required", async () => {
771
824
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
772
825
  tmpDirs.push(tmpDir);
773
- const stateFile = await writeStateFile(tmpDir, [{
774
- testFile: "/repo/backend/tests/orders_contract_test.py",
826
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
827
+ testFile: baseVerdict.testFilePath,
775
828
  executionBefore: { status: TestExecutionStatus.Pass, errors: [], duration: 2000 },
776
829
  // executionAfter absent — file was deleted, nothing to run
777
- }]);
778
- const result = await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Delete }], { stateFile });
830
+ }], [{ ...baseVerdict, action: DriftAction.Delete }]);
831
+ // Only beforeDetails is meaningful there's no recorded after-execution to summarize,
832
+ // so afterDetails is the empty-string sentinel (the field is required, not omittable).
833
+ const result = await submitAndReadMaintenance(tmpDir, stateFile, [
834
+ { testFilePath: baseVerdict.testFilePath, beforeDetails: "4 passed in 2.00s", afterDetails: "" },
835
+ ]);
779
836
  expect(result[0].beforeStatus).toBe("Pass");
780
837
  expect(result[0].afterStatus).toBe("Skipped");
781
838
  });
782
- it("fileName matched by suffix: absolute stateFile path vs relative fileName", async () => {
839
+ it("REGENERATE with before=Fail after=Pass: requires both details, rejects when either is missing", async () => {
783
840
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
784
841
  tmpDirs.push(tmpDir);
785
- const stateFile = await writeStateFile(tmpDir, [{
786
- testFile: "/repo/backend/tests/python/orders_contract_test.py",
787
- executionBefore: { status: TestExecutionStatus.Pass, errors: [], duration: 15000 },
788
- executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 12000 },
789
- }]);
790
- // fileName is relative — should still match via endsWith
791
- const result = await writeAndRead(tmpDir, [{
792
- ...baseMaintenance,
793
- action: DriftAction.Update,
794
- fileName: "orders_contract_test.py",
795
- }], { stateFile });
796
- expect(result[0].beforeStatus).toBe("Pass");
842
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
843
+ testFile: baseVerdict.testFilePath,
844
+ executionBefore: { status: TestExecutionStatus.Fail, errors: ["schema drift"], duration: 4000 },
845
+ executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 3000 },
846
+ }], [{ ...baseVerdict, action: DriftAction.Regenerate }]);
847
+ const outputFile = path.join(tmpDir, "report.json");
848
+ const rejected = await handler({
849
+ summaryOutputFile: outputFile,
850
+ stateFile,
851
+ businessCaseAnalysis: "Test",
852
+ newTestsCreated: [],
853
+ testMaintenanceDetails: [{ testFilePath: baseVerdict.testFilePath, beforeDetails: "FAILED — schema drift", afterDetails: "" }], // afterDetails left blank
854
+ testResults: [],
855
+ issuesFound: [],
856
+ });
857
+ expect(rejected.isError).toBe(true);
858
+ expect(rejected.content[0].text).toContain("orders_contract_test.py (afterDetails)");
859
+ const result = await submitAndReadMaintenance(tmpDir, stateFile, [
860
+ { testFilePath: baseVerdict.testFilePath, beforeDetails: "FAILED — schema drift", afterDetails: "regenerated test now passes" },
861
+ ]);
862
+ expect(result[0].beforeStatus).toBe("Fail");
797
863
  expect(result[0].afterStatus).toBe("Pass");
798
864
  });
865
+ it("VERIFY with a recorded execution: requires details even though VERIFY is not a change action", async () => {
866
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
867
+ tmpDirs.push(tmpDir);
868
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
869
+ testFile: baseVerdict.testFilePath,
870
+ executionBefore: { status: TestExecutionStatus.Pass, errors: [], duration: 1500 },
871
+ }], [{ ...baseVerdict, action: DriftAction.Verify }]);
872
+ const outputFile = path.join(tmpDir, "report.json");
873
+ const rejected = await handler({
874
+ summaryOutputFile: outputFile,
875
+ stateFile,
876
+ businessCaseAnalysis: "Test",
877
+ newTestsCreated: [],
878
+ testMaintenanceDetails: [],
879
+ testResults: [],
880
+ issuesFound: [],
881
+ });
882
+ expect(rejected.isError).toBe(true);
883
+ expect(rejected.content[0].text).toContain("orders_contract_test.py (beforeDetails)");
884
+ const result = await submitAndReadMaintenance(tmpDir, stateFile, [
885
+ { testFilePath: baseVerdict.testFilePath, beforeDetails: "confirmed still passes, no changes needed", afterDetails: "" },
886
+ ]);
887
+ expect(result[0].beforeStatus).toBe("Pass");
888
+ });
889
+ it("DELETE with a spurious executionAfter recorded: afterDetails is still required despite DELETE's default afterStatus being Skipped", async () => {
890
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
891
+ tmpDirs.push(tmpDir);
892
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
893
+ testFile: baseVerdict.testFilePath,
894
+ executionBefore: { status: TestExecutionStatus.Pass, errors: [], duration: 2000 },
895
+ // executionAfter present even though the file should have been deleted — documents
896
+ // that the required-details gate keys off recorded executions, not the action type.
897
+ executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 1800 },
898
+ }], [{ ...baseVerdict, action: DriftAction.Delete }]);
899
+ const outputFile = path.join(tmpDir, "report.json");
900
+ const rejected = await handler({
901
+ summaryOutputFile: outputFile,
902
+ stateFile,
903
+ businessCaseAnalysis: "Test",
904
+ newTestsCreated: [],
905
+ testMaintenanceDetails: [{ testFilePath: baseVerdict.testFilePath, beforeDetails: "4 passed in 2.00s", afterDetails: "" }], // afterDetails left blank
906
+ testResults: [],
907
+ issuesFound: [],
908
+ });
909
+ expect(rejected.isError).toBe(true);
910
+ expect(rejected.content[0].text).toContain("orders_contract_test.py (afterDetails)");
911
+ });
799
912
  });
800
913
  it("testResult accepts videoPath field (optional)", async () => {
801
914
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
@@ -851,4 +964,210 @@ describe("registerSubmitReportTool", () => {
851
964
  expect(written.testResults).toHaveLength(1);
852
965
  expect(written.testResults[0].videoPath).toBeUndefined();
853
966
  });
967
+ describe("testMaintenance derived from state (SKYR-3906)", () => {
968
+ it("derives testMaintenance from stateData.maintenanceVerdicts when testMaintenance is omitted", async () => {
969
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
970
+ tmpDirs.push(tmpDir);
971
+ const outputFile = path.join(tmpDir, "report.json");
972
+ const stateFile = await writeReconciledStateFile(tmpDir, [{ testFile: "/repo/tests/orders_contract_test.py" }], [{ testFilePath: "/repo/tests/orders_contract_test.py", action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }]);
973
+ const result = await handler({
974
+ summaryOutputFile: outputFile,
975
+ stateFile,
976
+ businessCaseAnalysis: "Adds a field to orders",
977
+ newTestsCreated: [],
978
+ // testMaintenance intentionally omitted — should be derived from state.
979
+ testResults: [],
980
+ issuesFound: [],
981
+ });
982
+ expect(result.isError).toBeUndefined();
983
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
984
+ expect(written.testMaintenance).toEqual([
985
+ expect.objectContaining({
986
+ fileName: "orders_contract_test.py",
987
+ testType: TestType.CONTRACT,
988
+ endpoint: "GET /api/v1/orders",
989
+ action: DriftAction.Update,
990
+ description: "Field added",
991
+ }),
992
+ ]);
993
+ });
994
+ it("rejects when maintenanceVerdicts is undefined and existing tests were present — skyramp_actions was never called", async () => {
995
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
996
+ tmpDirs.push(tmpDir);
997
+ const outputFile = path.join(tmpDir, "report.json");
998
+ // Plain writeStateFile with no maintenanceVerdicts override — undefined, distinct
999
+ // from skyramp_actions having run and recorded an empty array.
1000
+ const stateFile = path.join(tmpDir, "analyze-changes-state.json");
1001
+ await fs.writeFile(stateFile, JSON.stringify({
1002
+ existingTests: [{ testFile: "/repo/tests/orders_contract_test.py" }],
1003
+ metadata: { sessionId: "test-session", stateType: "unified", repositoryPath: tmpDir, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
1004
+ }), "utf-8");
1005
+ const result = await handler({
1006
+ summaryOutputFile: outputFile,
1007
+ stateFile,
1008
+ businessCaseAnalysis: "Adds a field to orders",
1009
+ newTestsCreated: [],
1010
+ testResults: [],
1011
+ issuesFound: [],
1012
+ });
1013
+ expect(result.isError).toBe(true);
1014
+ expect(result.content[0].text).toContain("skyramp_actions was not called");
1015
+ });
1016
+ it("allows maintenanceVerdicts to be undefined when there were no existing tests to assess", async () => {
1017
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
1018
+ tmpDirs.push(tmpDir);
1019
+ const outputFile = path.join(tmpDir, "report.json");
1020
+ // No existing tests at all — nothing skyramp_actions could have lost, so skipping
1021
+ // it entirely (never writing maintenanceVerdicts) is a valid, not an error, state.
1022
+ const stateFile = path.join(tmpDir, "analyze-changes-state.json");
1023
+ await fs.writeFile(stateFile, JSON.stringify({
1024
+ existingTests: [],
1025
+ metadata: { sessionId: "test-session", stateType: "unified", repositoryPath: tmpDir, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
1026
+ }), "utf-8");
1027
+ const result = await handler({
1028
+ summaryOutputFile: outputFile,
1029
+ stateFile,
1030
+ businessCaseAnalysis: "Adds a new endpoint with no prior coverage",
1031
+ newTestsCreated: [],
1032
+ testResults: [],
1033
+ issuesFound: [],
1034
+ });
1035
+ expect(result.isError).toBeUndefined();
1036
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
1037
+ expect(written.testMaintenance).toBeUndefined();
1038
+ });
1039
+ it("rejects when a related repo has reconciled maintenanceVerdicts — testMaintenance has no repository field to carry them", async () => {
1040
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
1041
+ tmpDirs.push(tmpDir);
1042
+ const outputFile = path.join(tmpDir, "report.json");
1043
+ const stateFile = path.join(tmpDir, "analyze-changes-state.json");
1044
+ const now = "2026-01-01T00:00:00.000Z";
1045
+ await fs.writeFile(stateFile, JSON.stringify({
1046
+ existingTests: [],
1047
+ maintenanceVerdicts: [],
1048
+ metadata: { sessionId: "test-session", stateType: "unified", repositoryPath: tmpDir, createdAt: now, updatedAt: now },
1049
+ relatedRepos: {
1050
+ "acme/backend": {
1051
+ repositoryPath: "/repo/backend",
1052
+ data: {
1053
+ existingTests: [],
1054
+ maintenanceVerdicts: [
1055
+ { testFilePath: "/repo/backend/tests/orders_test.py", action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" },
1056
+ ],
1057
+ },
1058
+ },
1059
+ },
1060
+ }), "utf-8");
1061
+ const result = await handler({
1062
+ summaryOutputFile: outputFile,
1063
+ stateFile,
1064
+ businessCaseAnalysis: "Test",
1065
+ newTestsCreated: [],
1066
+ testResults: [],
1067
+ issuesFound: [],
1068
+ });
1069
+ expect(result.isError).toBe(true);
1070
+ expect(result.content[0].text).toContain("acme/backend");
1071
+ });
1072
+ it("rejects when a recorded execution has no matching testMaintenanceDetails entry at all", async () => {
1073
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
1074
+ tmpDirs.push(tmpDir);
1075
+ const outputFile = path.join(tmpDir, "report.json");
1076
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
1077
+ testFile: "/repo/tests/orders_contract_test.py",
1078
+ executionBefore: { status: TestExecutionStatus.Fail, errors: ["assert 403 got 200"], duration: 4200 },
1079
+ executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 5090 },
1080
+ }], [{ testFilePath: "/repo/tests/orders_contract_test.py", action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }]);
1081
+ const result = await handler({
1082
+ summaryOutputFile: outputFile,
1083
+ stateFile,
1084
+ businessCaseAnalysis: "Adds a field to orders",
1085
+ newTestsCreated: [],
1086
+ // testMaintenanceDetails omitted entirely, despite both executions being recorded.
1087
+ testResults: [],
1088
+ issuesFound: [],
1089
+ });
1090
+ expect(result.isError).toBe(true);
1091
+ expect(result.content[0].text).toContain("orders_contract_test.py (beforeDetails)");
1092
+ expect(result.content[0].text).toContain("orders_contract_test.py (afterDetails)");
1093
+ });
1094
+ it("rejects a whitespace-only testMaintenanceDetails summary as if it were missing", async () => {
1095
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
1096
+ tmpDirs.push(tmpDir);
1097
+ const outputFile = path.join(tmpDir, "report.json");
1098
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
1099
+ testFile: "/repo/tests/orders_contract_test.py",
1100
+ executionBefore: { status: TestExecutionStatus.Fail, errors: ["assert 403 got 200"], duration: 4200 },
1101
+ executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 5090 },
1102
+ }], [{ testFilePath: "/repo/tests/orders_contract_test.py", action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }]);
1103
+ const result = await handler({
1104
+ summaryOutputFile: outputFile,
1105
+ stateFile,
1106
+ businessCaseAnalysis: "Adds a field to orders",
1107
+ newTestsCreated: [],
1108
+ testMaintenanceDetails: [{
1109
+ testFilePath: "/repo/tests/orders_contract_test.py",
1110
+ beforeDetails: " ",
1111
+ afterDetails: "5 passed in 5.09s",
1112
+ }],
1113
+ testResults: [],
1114
+ issuesFound: [],
1115
+ });
1116
+ expect(result.isError).toBe(true);
1117
+ expect(result.content[0].text).toContain("orders_contract_test.py (beforeDetails)");
1118
+ });
1119
+ it("prefers the LLM-drafted testMaintenanceDetails summary when supplied", async () => {
1120
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
1121
+ tmpDirs.push(tmpDir);
1122
+ const outputFile = path.join(tmpDir, "report.json");
1123
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
1124
+ testFile: "/repo/tests/orders_contract_test.py",
1125
+ executionBefore: { status: TestExecutionStatus.Fail, errors: ["assert 403 got 200"], duration: 4200 },
1126
+ executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 5090 },
1127
+ }], [{ testFilePath: "/repo/tests/orders_contract_test.py", action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }]);
1128
+ const result = await handler({
1129
+ summaryOutputFile: outputFile,
1130
+ stateFile,
1131
+ businessCaseAnalysis: "Adds a field to orders",
1132
+ newTestsCreated: [],
1133
+ testMaintenanceDetails: [{
1134
+ testFilePath: "/repo/tests/orders_contract_test.py",
1135
+ beforeDetails: "FAILED test_orders_get — auth middleware not enforced for the new field",
1136
+ afterDetails: "5 passed in 5.09s",
1137
+ }],
1138
+ testResults: [],
1139
+ issuesFound: [],
1140
+ });
1141
+ expect(result.isError).toBeUndefined();
1142
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
1143
+ expect(written.testMaintenance[0].beforeDetails).toBe("FAILED test_orders_get — auth middleware not enforced for the new field");
1144
+ expect(written.testMaintenance[0].afterDetails).toBe("5 passed in 5.09s");
1145
+ });
1146
+ it("rejects when only one of beforeDetails/afterDetails is supplied but both executions were recorded", async () => {
1147
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
1148
+ tmpDirs.push(tmpDir);
1149
+ const outputFile = path.join(tmpDir, "report.json");
1150
+ const stateFile = await writeReconciledStateFile(tmpDir, [{
1151
+ testFile: "/repo/tests/orders_contract_test.py",
1152
+ executionBefore: { status: TestExecutionStatus.Fail, errors: ["assert 403 got 200"], duration: 4200 },
1153
+ executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 5090 },
1154
+ }], [{ testFilePath: "/repo/tests/orders_contract_test.py", action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }]);
1155
+ const result = await handler({
1156
+ summaryOutputFile: outputFile,
1157
+ stateFile,
1158
+ businessCaseAnalysis: "Adds a field to orders",
1159
+ newTestsCreated: [],
1160
+ testMaintenanceDetails: [{
1161
+ testFilePath: "/repo/tests/orders_contract_test.py",
1162
+ beforeDetails: "FAILED test_orders_get — auth middleware not enforced for the new field",
1163
+ afterDetails: "", // left blank, but an after-execution was recorded — must be rejected.
1164
+ }],
1165
+ testResults: [],
1166
+ issuesFound: [],
1167
+ });
1168
+ expect(result.isError).toBe(true);
1169
+ expect(result.content[0].text).toContain("orders_contract_test.py (afterDetails)");
1170
+ expect(result.content[0].text).not.toContain("beforeDetails");
1171
+ });
1172
+ });
854
1173
  });