@skyramp/mcp 0.2.7 → 0.2.8

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 (90) hide show
  1. package/build/index.js +5 -2
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -5
  3. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.d.ts +2 -0
  4. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +114 -0
  5. package/build/prompts/sut-setup/modes/dockerComposePrompt.d.ts +2 -0
  6. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +292 -0
  7. package/build/prompts/sut-setup/shared.d.ts +39 -0
  8. package/build/prompts/sut-setup/shared.js +132 -0
  9. package/build/prompts/test-maintenance/driftAnalysisSections.js +9 -3
  10. package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -1
  11. package/build/prompts/test-recommendation/diffExecutionPlan.js +76 -2
  12. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +91 -0
  13. package/build/prompts/testbot/testbot-prompts.d.ts +16 -1
  14. package/build/prompts/testbot/testbot-prompts.js +113 -22
  15. package/build/prompts/testbot/testbot-prompts.test.js +95 -45
  16. package/build/resources/sutSetupResource.d.ts +2 -0
  17. package/build/resources/sutSetupResource.js +45 -0
  18. package/build/resources/testbotResource.js +2 -2
  19. package/build/services/TestDiscoveryService.d.ts +2 -2
  20. package/build/services/TestDiscoveryService.js +22 -12
  21. package/build/services/TestExecutionService.d.ts +10 -1
  22. package/build/services/TestExecutionService.js +158 -26
  23. package/build/services/TestExecutionService.test.js +306 -0
  24. package/build/tools/executeSkyrampTestTool.js +26 -7
  25. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +1 -1
  26. package/build/tools/submitReportTool.d.ts +10 -0
  27. package/build/tools/submitReportTool.js +106 -39
  28. package/build/tools/submitReportTool.test.js +296 -50
  29. package/build/tools/test-management/actionsTool.js +21 -7
  30. package/build/tools/test-management/analyzeChangesTool.d.ts +1 -1
  31. package/build/tools/test-management/analyzeChangesTool.js +106 -41
  32. package/build/tools/test-management/analyzeChangesTool.test.js +21 -41
  33. package/build/tools/test-management/analyzeTestHealthTool.js +16 -9
  34. package/build/tools/workspace/initializeWorkspaceTool.js +39 -22
  35. package/build/tools/workspace/serviceUpsert.d.ts +12 -0
  36. package/build/tools/workspace/serviceUpsert.js +23 -0
  37. package/build/tools/workspace/serviceUpsert.test.d.ts +1 -0
  38. package/build/tools/workspace/serviceUpsert.test.js +50 -0
  39. package/build/types/TestAnalysis.d.ts +2 -1
  40. package/build/types/TestExecution.d.ts +12 -2
  41. package/build/types/TestExecution.js +11 -1
  42. package/build/types/TestbotReport.d.ts +68 -0
  43. package/build/types/TestbotReport.js +1 -0
  44. package/build/types/index.d.ts +4 -0
  45. package/build/types/index.js +3 -0
  46. package/build/utils/AnalysisStateManager.d.ts +61 -1
  47. package/build/utils/AnalysisStateManager.js +144 -3
  48. package/build/utils/AnalysisStateManager.test.js +98 -0
  49. package/build/utils/docker.test.js +1 -1
  50. package/build/utils/routeParsers.d.ts +0 -10
  51. package/build/utils/routeParsers.js +31 -38
  52. package/build/utils/scenarioDrafting.d.ts +1 -1
  53. package/build/utils/scenarioDrafting.js +23 -1
  54. package/build/utils/utils.d.ts +7 -1
  55. package/build/utils/utils.js +10 -0
  56. package/build/utils/versions.d.ts +3 -3
  57. package/build/utils/versions.js +1 -1
  58. package/build/workspace/workspace.d.ts +10 -10
  59. package/build/workspace/workspace.js +7 -7
  60. package/build/workspace/workspace.test.js +6 -6
  61. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +31 -13
  62. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +88 -15
  63. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -3
  64. package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +23 -126
  65. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  66. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderApp.js +79 -19
  67. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/aliasRemapper.js +84 -0
  68. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/replayEngine.js +15 -4
  69. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-CPZKmjFR.js → codeMirrorModule-D0BjbCb7.js} +1 -1
  70. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-lvTRGFx-.js +193 -0
  71. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  72. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-B0JOjboO.js → codeMirrorModule-Cqp3cwEJ.js} +1 -1
  73. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-1anWeyDf.js → defaultSettingsView-CYf9adZh.js} +79 -79
  74. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.Cc5029a3.js → index.B_7ywgmr.js} +1 -1
  75. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  76. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  77. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.Wo5yvvVh.js → uiMode.u4_8VnCV.js} +1 -1
  78. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  79. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  80. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderApp.ts +129 -43
  81. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/aliasRemapper.ts +119 -0
  82. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/replayEngine.ts +33 -10
  83. package/node_modules/playwright/package.json +1 -1
  84. package/package.json +10 -3
  85. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/.bin/node-which +0 -52
  86. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/is-docker +0 -5
  87. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/mime +0 -46
  88. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/yaml +0 -11
  89. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-CsgPR2wy.js +0 -193
  90. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.CP3Y8Zsb.js +0 -2
@@ -2,11 +2,12 @@
2
2
  import { jest } from "@jest/globals";
3
3
  import { TestType } from "../types/TestTypes.js";
4
4
  import { DriftAction } from "../types/TestAnalysis.js";
5
+ import { TestExecutionStatus } from "../types/TestExecution.js";
5
6
  import * as fs from "fs/promises";
6
7
  import * as path from "path";
7
8
  import * as os from "os";
8
9
  jest.unstable_mockModule("../utils/logger.js", () => ({
9
- logger: { info: jest.fn(), error: jest.fn() },
10
+ logger: { info: jest.fn(), error: jest.fn(), warning: jest.fn(), debug: jest.fn() },
10
11
  }));
11
12
  jest.unstable_mockModule("../services/AnalyticsService.js", () => ({
12
13
  AnalyticsService: { pushMCPToolEvent: jest.fn().mockResolvedValue(undefined) },
@@ -25,14 +26,20 @@ function captureToolHandler() {
25
26
  registerSubmitReportTool(fakeServer);
26
27
  return handler;
27
28
  }
28
- function sampleReportParams(outputFile) {
29
+ async function writeStateFile(dir, existingTests) {
30
+ const stateFile = path.join(dir, "analyze-changes-state.json");
31
+ await fs.writeFile(stateFile, JSON.stringify({ existingTests }), "utf-8");
32
+ return stateFile;
33
+ }
34
+ function sampleReportParams(outputFile, stateFile) {
29
35
  return {
30
36
  summaryOutputFile: outputFile,
37
+ stateFile,
31
38
  businessCaseAnalysis: "This PR adds product search. Tests needed for filtering.",
32
39
  newTestsCreated: [
33
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" },
34
41
  ],
35
- testMaintenance: [{ action: "UPDATE", testType: TestType.CONTRACT, endpoint: "GET /api/v1/products", fileName: "products_contract_test.py", description: "Updated auth flow in existing tests", beforeStatus: "Fail", beforeDetails: "401 Unauthorized (1.5s)", afterStatus: "Pass", afterDetails: "All assertions passed (2.3s)" }],
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: "" }],
36
43
  testResults: [
37
44
  { testType: TestType.SMOKE, endpoint: "GET /api/v1/products", status: "Pass", details: "2.1s, products_smoke_test.py" },
38
45
  { testType: TestType.SMOKE, endpoint: "GET /api/v1/products/search", status: "Fail", details: "3.4s, search_smoke_test.py" },
@@ -67,7 +74,8 @@ describe("registerSubmitReportTool", () => {
67
74
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
68
75
  tmpDirs.push(tmpDir);
69
76
  const outputFile = path.join(tmpDir, "report.json");
70
- const result = await handler(sampleReportParams(outputFile));
77
+ const stateFile = await writeStateFile(tmpDir, []);
78
+ const result = await handler(sampleReportParams(outputFile, stateFile));
71
79
  expect(result.isError).toBeUndefined();
72
80
  expect(result.content[0].text).toContain("Report submitted successfully");
73
81
  const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
@@ -77,11 +85,44 @@ describe("registerSubmitReportTool", () => {
77
85
  expect(written.issuesFound).toHaveLength(1);
78
86
  expect(written.testMaintenance).toHaveLength(1);
79
87
  });
88
+ it("accepts and persists the per-item `repository` field", async () => {
89
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
90
+ tmpDirs.push(tmpDir);
91
+ const outputFile = path.join(tmpDir, "report.json");
92
+ const stateFile = await writeStateFile(tmpDir, []);
93
+ const params = sampleReportParams(outputFile, stateFile);
94
+ // Attribute one item to a related repo, leave others on the primary.
95
+ params.newTestsCreated[0] = { ...params.newTestsCreated[0], repository: "acme/backend" };
96
+ params.testResults[0] = { ...params.testResults[0], repository: "acme/backend" };
97
+ params.issuesFound[0] = { ...params.issuesFound[0], repository: "acme/backend" };
98
+ const result = await handler(params);
99
+ expect(result.isError).toBeUndefined();
100
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
101
+ expect(written.newTestsCreated[0].repository).toBe("acme/backend");
102
+ expect(written.testResults[0].repository).toBe("acme/backend");
103
+ expect(written.issuesFound[0].repository).toBe("acme/backend");
104
+ // Items without a repository stay primary (undefined → omitted from JSON).
105
+ expect(written.testResults[1].repository).toBeUndefined();
106
+ });
107
+ it("normalizes a blank/whitespace-only `repository` to undefined", async () => {
108
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
109
+ tmpDirs.push(tmpDir);
110
+ const outputFile = path.join(tmpDir, "report.json");
111
+ const stateFile = await writeStateFile(tmpDir, []);
112
+ const params = sampleReportParams(outputFile, stateFile);
113
+ params.testResults[0] = { ...params.testResults[0], repository: " " };
114
+ const result = await handler(params);
115
+ expect(result.isError).toBeUndefined();
116
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
117
+ // Blank string must not serialize as a distinct empty repo.
118
+ expect(written.testResults[0].repository).toBeUndefined();
119
+ });
80
120
  it("should create parent directories if they don't exist", async () => {
81
121
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
82
122
  tmpDirs.push(tmpDir);
83
123
  const outputFile = path.join(tmpDir, "nested", "dirs", "report.json");
84
- const result = await handler(sampleReportParams(outputFile));
124
+ const stateFile = await writeStateFile(tmpDir, []);
125
+ const result = await handler(sampleReportParams(outputFile, stateFile));
85
126
  expect(result.isError).toBeUndefined();
86
127
  const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
87
128
  expect(written.businessCaseAnalysis).toBeDefined();
@@ -90,11 +131,12 @@ describe("registerSubmitReportTool", () => {
90
131
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
91
132
  tmpDirs.push(tmpDir);
92
133
  const outputFile = path.join(tmpDir, "report.json");
134
+ const stateFile = await writeStateFile(tmpDir, []);
93
135
  const result = await handler({
94
136
  summaryOutputFile: outputFile,
137
+ stateFile,
95
138
  businessCaseAnalysis: "Simple config change.",
96
139
  newTestsCreated: [],
97
- testMaintenance: [],
98
140
  testResults: [
99
141
  { testType: TestType.SMOKE, endpoint: "GET /api/v1/products", status: "Pass", details: "1.0s, test.py" },
100
142
  ],
@@ -103,12 +145,15 @@ describe("registerSubmitReportTool", () => {
103
145
  expect(result.isError).toBeUndefined();
104
146
  const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
105
147
  expect(written.newTestsCreated).toEqual([]);
106
- expect(written.testMaintenance).toEqual([]);
148
+ expect(written.testMaintenance).toBeUndefined();
107
149
  expect(written.issuesFound).toEqual([]);
108
150
  expect(written.testResults).toHaveLength(1);
109
151
  });
110
152
  it("should return error for invalid file path", async () => {
111
- const result = await handler(sampleReportParams("/nonexistent/readonly/path/report.json"));
153
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
154
+ tmpDirs.push(tmpDir);
155
+ const stateFile = await writeStateFile(tmpDir, []);
156
+ const result = await handler(sampleReportParams("/nonexistent/readonly/path/report.json", stateFile));
112
157
  expect(result.isError).toBe(true);
113
158
  expect(result.content[0].text).toContain("Failed to write report");
114
159
  });
@@ -116,8 +161,9 @@ describe("registerSubmitReportTool", () => {
116
161
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
117
162
  tmpDirs.push(tmpDir);
118
163
  const outputFile = path.join(tmpDir, "report.json");
164
+ const stateFile = await writeStateFile(tmpDir, []);
119
165
  const result = await handler({
120
- ...sampleReportParams(outputFile),
166
+ ...sampleReportParams(outputFile, stateFile),
121
167
  commitMessage: "add contract tests for /products endpoint",
122
168
  });
123
169
  expect(result.isError).toBeUndefined();
@@ -128,7 +174,8 @@ describe("registerSubmitReportTool", () => {
128
174
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
129
175
  tmpDirs.push(tmpDir);
130
176
  const outputFile = path.join(tmpDir, "report.json");
131
- const result = await handler(sampleReportParams(outputFile));
177
+ const stateFile = await writeStateFile(tmpDir, []);
178
+ const result = await handler(sampleReportParams(outputFile, stateFile));
132
179
  expect(result.isError).toBeUndefined();
133
180
  const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
134
181
  expect(written.commitMessage).toBe("Added recommendations by Skyramp Testbot.");
@@ -137,8 +184,9 @@ describe("registerSubmitReportTool", () => {
137
184
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
138
185
  tmpDirs.push(tmpDir);
139
186
  const outputFile = path.join(tmpDir, "report.json");
187
+ const stateFile = await writeStateFile(tmpDir, []);
140
188
  const result = await handler({
141
- ...sampleReportParams(outputFile),
189
+ ...sampleReportParams(outputFile, stateFile),
142
190
  commitMessage: " line one\nline two\r\nline three ",
143
191
  });
144
192
  expect(result.isError).toBeUndefined();
@@ -150,9 +198,10 @@ describe("registerSubmitReportTool", () => {
150
198
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
151
199
  tmpDirs.push(tmpDir);
152
200
  const outputFile = path.join(tmpDir, "report.json");
201
+ const stateFile = await writeStateFile(tmpDir, []);
153
202
  const longMessage = "add contract and integration tests for the new PATCH /orders/{order_id} endpoint including discount recalculation and line-item edits";
154
203
  const result = await handler({
155
- ...sampleReportParams(outputFile),
204
+ ...sampleReportParams(outputFile, stateFile),
156
205
  commitMessage: longMessage,
157
206
  });
158
207
  expect(result.isError).toBeUndefined();
@@ -160,24 +209,13 @@ describe("registerSubmitReportTool", () => {
160
209
  expect(written.commitMessage).toBe(longMessage);
161
210
  expect(written.commitMessage.length).toBeGreaterThan(72);
162
211
  });
163
- it("should use default commitMessage when provided as empty string", async () => {
164
- const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
165
- tmpDirs.push(tmpDir);
166
- const outputFile = path.join(tmpDir, "report.json");
167
- const result = await handler({
168
- ...sampleReportParams(outputFile),
169
- commitMessage: "",
170
- });
171
- expect(result.isError).toBeUndefined();
172
- const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
173
- expect(written.commitMessage).toBe("Added recommendations by Skyramp Testbot.");
174
- });
175
212
  it("should use default commitMessage when whitespace-only", async () => {
176
213
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
177
214
  tmpDirs.push(tmpDir);
178
215
  const outputFile = path.join(tmpDir, "report.json");
216
+ const stateFile = await writeStateFile(tmpDir, []);
179
217
  const result = await handler({
180
- ...sampleReportParams(outputFile),
218
+ ...sampleReportParams(outputFile, stateFile),
181
219
  commitMessage: " \n\r\n ",
182
220
  });
183
221
  expect(result.isError).toBeUndefined();
@@ -188,8 +226,9 @@ describe("registerSubmitReportTool", () => {
188
226
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
189
227
  tmpDirs.push(tmpDir);
190
228
  const outputFile = path.join(tmpDir, "report.json");
229
+ const stateFile = await writeStateFile(tmpDir, []);
191
230
  const result = await handler({
192
- ...sampleReportParams(outputFile),
231
+ ...sampleReportParams(outputFile, stateFile),
193
232
  nextSteps: ["Check your targetSetupCommand — endpoints returned 404"],
194
233
  });
195
234
  expect(result.isError).toBeUndefined();
@@ -202,7 +241,8 @@ describe("registerSubmitReportTool", () => {
202
241
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
203
242
  tmpDirs.push(tmpDir);
204
243
  const outputFile = path.join(tmpDir, "report.json");
205
- const result = await handler(sampleReportParams(outputFile));
244
+ const stateFile = await writeStateFile(tmpDir, []);
245
+ const result = await handler(sampleReportParams(outputFile, stateFile));
206
246
  expect(result.isError).toBeUndefined();
207
247
  const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
208
248
  expect(written.nextSteps).toEqual([]);
@@ -211,7 +251,8 @@ describe("registerSubmitReportTool", () => {
211
251
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
212
252
  tmpDirs.push(tmpDir);
213
253
  const outputFile = path.join(tmpDir, "report.json");
214
- await handler(sampleReportParams(outputFile));
254
+ const stateFile = await writeStateFile(tmpDir, []);
255
+ await handler(sampleReportParams(outputFile, stateFile));
215
256
  const raw = await fs.readFile(outputFile, "utf-8");
216
257
  // Pretty-printed JSON should have indentation
217
258
  expect(raw).toContain(" ");
@@ -222,8 +263,9 @@ describe("registerSubmitReportTool", () => {
222
263
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
223
264
  tmpDirs.push(tmpDir);
224
265
  const outputFile = path.join(tmpDir, "report.json");
266
+ const stateFile = await writeStateFile(tmpDir, []);
225
267
  const params = {
226
- ...sampleReportParams(outputFile),
268
+ ...sampleReportParams(outputFile, stateFile),
227
269
  additionalRecommendations: [
228
270
  {
229
271
  testId: "integration-orders-patch-status",
@@ -516,14 +558,15 @@ describe("registerSubmitReportTool", () => {
516
558
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
517
559
  tmpDirs.push(tmpDir);
518
560
  const outputFile = path.join(tmpDir, "report.json");
519
- // sampleReportParams: 1 new test, 1 maintenance (Fail→Pass), 2 results
561
+ // sampleReportParams: 1 new test, 1 maintenance (no matching stateFile entry), 2 results
520
562
  // (1 Pass + 1 Fail), 1 issue (no severity).
521
- await handler(sampleReportParams(outputFile));
563
+ const stateFile = await writeStateFile(tmpDir, []);
564
+ await handler(sampleReportParams(outputFile, stateFile));
522
565
  const params = lastAnalyticsParams();
523
566
  expect(params.testsGenerated).toBe("1");
524
567
  expect(params.testsMaintained).toBe("1");
525
568
  expect(params.recommendationsCount).toBe("0");
526
- expect(params.maintenanceRecovered).toBe("1");
569
+ expect(params.maintenanceRecovered).toBe("0"); // no matching stateFile entry → no execution data
527
570
  expect(params.testsPassed).toBe("1");
528
571
  expect(params.testsFailed).toBe("1");
529
572
  expect(params.testsSkipped).toBe("0");
@@ -533,8 +576,9 @@ describe("registerSubmitReportTool", () => {
533
576
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
534
577
  tmpDirs.push(tmpDir);
535
578
  const outputFile = path.join(tmpDir, "report.json");
579
+ const stateFile = await writeStateFile(tmpDir, []);
536
580
  await handler({
537
- ...sampleReportParams(outputFile),
581
+ ...sampleReportParams(outputFile, stateFile),
538
582
  issuesFound: [
539
583
  { description: "Data corruption on checkout", severity: "critical" },
540
584
  { description: "Wrong total returned", severity: "high" },
@@ -554,8 +598,10 @@ describe("registerSubmitReportTool", () => {
554
598
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
555
599
  tmpDirs.push(tmpDir);
556
600
  const outputFile = path.join(tmpDir, "report.json");
601
+ const stateFile = await writeStateFile(tmpDir, []);
557
602
  await handler({
558
603
  summaryOutputFile: outputFile,
604
+ stateFile,
559
605
  businessCaseAnalysis: "Config-only change.",
560
606
  newTestsCreated: [],
561
607
  testMaintenance: [],
@@ -573,36 +619,236 @@ describe("registerSubmitReportTool", () => {
573
619
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
574
620
  tmpDirs.push(tmpDir);
575
621
  const outputFile = path.join(tmpDir, "report.json");
622
+ const stateFile = await writeStateFile(tmpDir, []);
576
623
  await handler({
577
- ...sampleReportParams(outputFile),
624
+ ...sampleReportParams(outputFile, stateFile),
578
625
  testMaintenance: [
579
626
  // Real change
580
- { action: "UPDATE", testType: TestType.CONTRACT, endpoint: "GET /api/v1/products", fileName: "products_contract_test.py", description: "Patched auth", beforeStatus: "Fail", beforeDetails: "401 (1.5s)", afterStatus: "Pass", afterDetails: "passed (2.3s)" },
581
- { action: "REGENERATE", testType: TestType.SMOKE, endpoint: "GET /api/v1/orders", fileName: "orders_smoke_test.py", description: "Rewrote for new shape", beforeStatus: "Fail", beforeDetails: "shape mismatch", afterStatus: "Pass", afterDetails: "passed (1.0s)" },
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: "" },
582
629
  // No-ops — assessed but not modified
583
- { action: "IGNORE", testType: TestType.SMOKE, endpoint: "GET /api/v1/reviews", fileName: "reviews_smoke_test.py", description: "No action required", beforeStatus: "Pass", beforeDetails: "drift score 0", afterStatus: "Skipped", afterDetails: "IGNORE: endpoint not in PR" },
584
- { action: "VERIFY", testType: TestType.SMOKE, endpoint: "GET /api/v1/coupons", fileName: "coupons_smoke_test.py", description: "Verified still valid", beforeStatus: "Pass", beforeDetails: "minor drift", afterStatus: "Skipped", afterDetails: "VERIFY: assertions still hold" },
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: "" },
585
632
  ],
586
633
  });
587
634
  const params = lastAnalyticsParams();
588
635
  // 2 change actions (UPDATE + REGENERATE), not 4
589
636
  expect(params.testsMaintained).toBe("2");
590
- // Both changes went Fail→Pass
591
- expect(params.maintenanceRecovered).toBe("2");
637
+ // No matching stateFile entries — execution data unavailable
638
+ expect(params.maintenanceRecovered).toBe("0");
592
639
  });
593
- it("counts UPDATE/REGENERATE/DELETE as maintenance changes, not VERIFY/IGNORE", async () => {
594
- const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
595
- tmpDirs.push(tmpDir);
640
+ });
641
+ // ─────────────────────────────────────────────────────────────────────────────
642
+ // testMaintenance output shapes
643
+ // Each case documents exactly what the MCP writes to JSON, which is what the
644
+ // testbot renderer reads. Keeping these in sync prevents silent regressions
645
+ // when toOutput or the stateFile enrichment path changes.
646
+ // ─────────────────────────────────────────────────────────────────────────────
647
+ describe("testMaintenance output shapes", () => {
648
+ const baseMaintenance = {
649
+ testType: TestType.CONTRACT,
650
+ endpoint: "GET /api/v1/orders",
651
+ fileName: "orders_contract_test.py",
652
+ description: "Updated discount fields in assertions",
653
+ beforeDetails: "",
654
+ afterDetails: "",
655
+ };
656
+ async function writeAndRead(tmpDir, maintenance, extra) {
596
657
  const outputFile = path.join(tmpDir, "report.json");
597
658
  await handler({
598
- ...sampleReportParams(outputFile),
599
- testMaintenance: [
600
- { testType: TestType.CONTRACT, endpoint: "GET /api/v1/products", fileName: "products_contract_test.py", action: DriftAction.Update, description: "Patched", beforeStatus: "Fail", beforeDetails: "401", afterStatus: "Pass", afterDetails: "passed" },
601
- { testType: TestType.SMOKE, endpoint: "GET /api/v1/reviews", fileName: "reviews_smoke_test.py", action: DriftAction.Ignore, description: "No action required", beforeStatus: "Pass", beforeDetails: "drift 0", afterStatus: "Skipped", afterDetails: "not in PR" },
602
- ],
659
+ summaryOutputFile: outputFile,
660
+ businessCaseAnalysis: "Test",
661
+ newTestsCreated: [],
662
+ testMaintenance: maintenance,
663
+ testResults: [],
664
+ issuesFound: [],
665
+ ...extra,
603
666
  });
604
- const params = lastAnalyticsParams();
605
- expect(params.testsMaintained).toBe("1");
667
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
668
+ return written.testMaintenance;
669
+ }
670
+ // ── No matching stateFile entry ───────────────────────────────────────────
671
+ it("UPDATE with no matching stateFile entry: beforeStatus=Unknown, afterStatus=Unknown", async () => {
672
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
673
+ tmpDirs.push(tmpDir);
674
+ const stateFile = await writeStateFile(tmpDir, []);
675
+ const result = await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Update }], { stateFile });
676
+ expect(result[0].beforeStatus).toBe("Unknown");
677
+ expect(result[0].afterStatus).toBe("Unknown");
678
+ expect(result[0].beforeDetails).toBe("");
679
+ expect(result[0].afterDetails).toBe("");
680
+ });
681
+ it("IGNORE with no matching stateFile entry: both Skipped", async () => {
682
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
683
+ tmpDirs.push(tmpDir);
684
+ const stateFile = await writeStateFile(tmpDir, []);
685
+ const result = await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Ignore }], { stateFile });
686
+ expect(result[0].beforeStatus).toBe("Skipped");
687
+ expect(result[0].afterStatus).toBe("Skipped");
688
+ expect(result[0].beforeDetails).toBe("");
689
+ expect(result[0].afterDetails).toBe("");
690
+ });
691
+ it("DELETE with no matching stateFile entry: beforeStatus=Unknown, afterStatus=Skipped", async () => {
692
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
693
+ tmpDirs.push(tmpDir);
694
+ const stateFile = await writeStateFile(tmpDir, []);
695
+ const result = await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Delete }], { stateFile });
696
+ expect(result[0].beforeStatus).toBe("Unknown");
697
+ expect(result[0].afterStatus).toBe("Skipped");
606
698
  });
699
+ // ── With matching stateFile entry ─────────────────────────────────────────
700
+ it("UPDATE with before=Pass after=Pass: status from stateFile, details from LLM", async () => {
701
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
702
+ tmpDirs.push(tmpDir);
703
+ const stateFile = await writeStateFile(tmpDir, [{
704
+ testFile: "/repo/backend/tests/orders_contract_test.py",
705
+ executionBefore: { status: TestExecutionStatus.Pass, errors: [], duration: 15090 },
706
+ 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 });
713
+ expect(result[0].beforeStatus).toBe("Pass");
714
+ expect(result[0].beforeDetails).toBe("4 passed in 15.09s");
715
+ expect(result[0].afterStatus).toBe("Pass");
716
+ expect(result[0].afterDetails).toBe("5 passed in 12.88s");
717
+ });
718
+ it("UPDATE with before=Pass after=Fail: status from stateFile, details from LLM", async () => {
719
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
720
+ tmpDirs.push(tmpDir);
721
+ const stateFile = await writeStateFile(tmpDir, [{
722
+ testFile: "/repo/backend/tests/orders_contract_test.py",
723
+ 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 });
731
+ expect(result[0].beforeStatus).toBe("Pass");
732
+ expect(result[0].afterStatus).toBe("Fail");
733
+ expect(result[0].afterDetails).toBe("FAILED test_orders_get — check_schema fails, test isolation bug");
734
+ });
735
+ it("UPDATE with crashed execution: status is Error", async () => {
736
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
737
+ tmpDirs.push(tmpDir);
738
+ const stateFile = await writeStateFile(tmpDir, [{
739
+ testFile: "/repo/backend/tests/orders_contract_test.py",
740
+ executionBefore: { status: TestExecutionStatus.Error, errors: ["Connection refused"], duration: 500 },
741
+ executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 2000 },
742
+ }]);
743
+ const result = await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Update }], { stateFile });
744
+ expect(result[0].beforeStatus).toBe("Error");
745
+ expect(result[0].afterStatus).toBe("Pass");
746
+ });
747
+ it("UPDATE Fail→Pass: counted as maintenanceRecovered in analytics", async () => {
748
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
749
+ tmpDirs.push(tmpDir);
750
+ const stateFile = await writeStateFile(tmpDir, [{
751
+ testFile: "/repo/backend/tests/orders_contract_test.py",
752
+ executionBefore: { status: TestExecutionStatus.Fail, errors: ["FAILED test_foo - assert False"], duration: 5000 },
753
+ executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 5000 },
754
+ }]);
755
+ await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Update }], { stateFile });
756
+ expect(pushMCPToolEventMock.mock.calls.at(-1)?.[2].maintenanceRecovered).toBe("1");
757
+ });
758
+ it("UPDATE with no before-execution recorded: beforeStatus=Unknown, afterStatus=Pass", async () => {
759
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
760
+ tmpDirs.push(tmpDir);
761
+ const stateFile = await writeStateFile(tmpDir, [{
762
+ testFile: "/repo/backend/tests/orders_contract_test.py",
763
+ // executionBefore absent — agent skipped before-phase
764
+ executionAfter: { status: TestExecutionStatus.Pass, errors: [], duration: 2000 },
765
+ }]);
766
+ const result = await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Update }], { stateFile });
767
+ expect(result[0].beforeStatus).toBe("Unknown");
768
+ expect(result[0].afterStatus).toBe("Pass");
769
+ });
770
+ it("DELETE with before-execution, no after (file deleted): beforeStatus=Pass, afterStatus=Skipped", async () => {
771
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
772
+ tmpDirs.push(tmpDir);
773
+ const stateFile = await writeStateFile(tmpDir, [{
774
+ testFile: "/repo/backend/tests/orders_contract_test.py",
775
+ executionBefore: { status: TestExecutionStatus.Pass, errors: [], duration: 2000 },
776
+ // executionAfter absent — file was deleted, nothing to run
777
+ }]);
778
+ const result = await writeAndRead(tmpDir, [{ ...baseMaintenance, action: DriftAction.Delete }], { stateFile });
779
+ expect(result[0].beforeStatus).toBe("Pass");
780
+ expect(result[0].afterStatus).toBe("Skipped");
781
+ });
782
+ it("fileName matched by suffix: absolute stateFile path vs relative fileName", async () => {
783
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
784
+ 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");
797
+ expect(result[0].afterStatus).toBe("Pass");
798
+ });
799
+ });
800
+ it("testResult accepts videoPath field (optional)", async () => {
801
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
802
+ tmpDirs.push(tmpDir);
803
+ const outputFile = path.join(tmpDir, "report.json");
804
+ const stateFile = await writeStateFile(tmpDir, []);
805
+ const result = await handler({
806
+ summaryOutputFile: outputFile,
807
+ stateFile,
808
+ businessCaseAnalysis: "UI changes for login flow",
809
+ newTestsCreated: [],
810
+ testMaintenance: [],
811
+ testResults: [
812
+ {
813
+ testType: TestType.UI,
814
+ endpoint: "GET /login",
815
+ status: "Pass",
816
+ details: "5.2s, login_ui_test.spec.ts",
817
+ videoPath: "/workspace/.skyramp/videos/login_ui_test-abc12345/video.webm",
818
+ },
819
+ ],
820
+ issuesFound: [],
821
+ });
822
+ expect(result.isError).toBeUndefined();
823
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
824
+ expect(written.testResults).toHaveLength(1);
825
+ expect(written.testResults[0].videoPath).toBe("/workspace/.skyramp/videos/login_ui_test-abc12345/video.webm");
826
+ });
827
+ it("testResult accepts entry without videoPath (optional field)", async () => {
828
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
829
+ tmpDirs.push(tmpDir);
830
+ const outputFile = path.join(tmpDir, "report.json");
831
+ const stateFile = await writeStateFile(tmpDir, []);
832
+ const result = await handler({
833
+ summaryOutputFile: outputFile,
834
+ stateFile,
835
+ businessCaseAnalysis: "API endpoint changes",
836
+ newTestsCreated: [],
837
+ testMaintenance: [],
838
+ testResults: [
839
+ {
840
+ testType: TestType.CONTRACT,
841
+ endpoint: "POST /api/v1/products",
842
+ status: "Pass",
843
+ details: "2.1s, products_contract_test.py",
844
+ // videoPath intentionally omitted
845
+ },
846
+ ],
847
+ issuesFound: [],
848
+ });
849
+ expect(result.isError).toBeUndefined();
850
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
851
+ expect(written.testResults).toHaveLength(1);
852
+ expect(written.testResults[0].videoPath).toBeUndefined();
607
853
  });
608
854
  });
@@ -6,6 +6,7 @@ import * as fs from "fs";
6
6
  import * as path from "path";
7
7
  import { AnalyticsService } from "../../services/AnalyticsService.js";
8
8
  import { toolError } from "../../utils/utils.js";
9
+ import { stageGeneratedPaths } from "../../utils/gitStaging.js";
9
10
  import { buildRenameStrategy, buildFileRenameStrategy, buildUpdateStrategy, buildRegenerateStrategy, buildUpdateFileInstruction, buildRegenerateFileInstruction, } from "../../prompts/test-maintenance/actionsInstructions.js";
10
11
  /**
11
12
  * Compute a suggested new filename when an endpoint is renamed.
@@ -87,6 +88,10 @@ const actionsSchema = {
87
88
  .string()
88
89
  .refine((p) => path.isAbsolute(p), { message: "stateFile must be an absolute path" })
89
90
  .describe("Path to state file from skyramp_analyze_changes"),
91
+ repository: z
92
+ .string()
93
+ .optional()
94
+ .describe("The owner/repo whose analysis section to act on (e.g. 'letsramp/api-insight'). In a multi-repo run, ALWAYS set it — the primary's owner/repo for the primary repo's tests, or a related repo's owner/repo for that repo's section — within the run-scoped stateFile. Omit only for a single-repo run."),
90
95
  recommendations: z
91
96
  .array(recommendationSchema)
92
97
  .optional()
@@ -106,12 +111,12 @@ export function registerActionsTool(server) {
106
111
  }, async (args) => {
107
112
  let errorResult;
108
113
  try {
109
- logger.info("Performing Actions (unified test-management)");
110
- // Load UnifiedAnalysisState from state file
114
+ logger.info(`Performing Actions (unified test-management)${args.repository ? ` for repository ${args.repository}` : ""}`);
115
+ // Load UnifiedAnalysisState for the requested repo section (primary when
116
+ // repository omitted/matches the root) from the run-scoped state file.
111
117
  const stateManager = StateManager.fromStatePath(args.stateFile);
112
- const stateData = await stateManager.readData();
113
- const fullState = await stateManager.readFullState();
114
- const repositoryPath = fullState?.metadata.repositoryPath || "";
118
+ const stateData = await stateManager.readRepoData(args.repository);
119
+ const repositoryPath = (await stateManager.getRepoRepositoryPath(args.repository)) || "";
115
120
  if (!stateData) {
116
121
  errorResult = toolError(`State file is empty or invalid: ${args.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
117
122
  return errorResult;
@@ -365,6 +370,15 @@ export function registerActionsTool(server) {
365
370
  });
366
371
  llmInstructionsObj.regenerate_strategy = buildRegenerateStrategy();
367
372
  }
373
+ // Stage UPDATE and REGENERATE target paths so testbot's re-stage step
374
+ // commits the agent's edits even when files are outside testDirectory.
375
+ const stagedPaths = [
376
+ ...updateRecommendations.map((r) => r.testFile),
377
+ ...regenerateRecommendations.map((r) => r.testFile),
378
+ ];
379
+ for (const p of stagedPaths) {
380
+ await stageGeneratedPaths(p).catch((e) => logger.warning(`Failed to stage maintained file ${p}: ${e.message}`));
381
+ }
368
382
  const llmInstructions = JSON.stringify(llmInstructionsObj, null, 2);
369
383
  const contentBlocks = [
370
384
  {
@@ -394,8 +408,8 @@ export function registerActionsTool(server) {
394
408
  finally {
395
409
  let repositoryPath = "";
396
410
  try {
397
- const fullState = await StateManager.fromStatePath(args.stateFile).readFullState();
398
- repositoryPath = fullState?.metadata.repositoryPath || "";
411
+ repositoryPath =
412
+ (await StateManager.fromStatePath(args.stateFile).getRepoRepositoryPath(args.repository)) || "";
399
413
  }
400
414
  catch (analyticsError) {
401
415
  logger.error(`Failed to read state for analytics in actions tool: ${analyticsError?.message ?? analyticsError}`, analyticsError);
@@ -18,7 +18,7 @@ export declare const analyzeChangesInputSchema: {
18
18
  topN: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
19
19
  maxGenerate: z.ZodOptional<z.ZodNumber>;
20
20
  prNumber: z.ZodOptional<z.ZodNumber>;
21
- stateOutputFile: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
21
+ repository: z.ZodOptional<z.ZodString>;
22
22
  testsRepoDir: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
23
23
  };
24
24
  export declare const NO_UI_INSTRUCTIONS = "No UI changes detected \u2014 no blueprint capture needed.";