@skyramp/mcp 0.2.8-rc.1 → 0.2.9

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 (41) hide show
  1. package/build/index.js +0 -7
  2. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
  3. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +3 -1
  4. package/build/prompts/sut-setup/shared.d.ts +20 -0
  5. package/build/prompts/sut-setup/shared.js +69 -7
  6. package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
  7. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
  8. package/build/prompts/test-maintenance/drift-analysis-prompt.js +58 -3
  9. package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -22
  10. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +14 -0
  11. package/build/prompts/test-maintenance/driftAnalysisShared.js +62 -0
  12. package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +38 -0
  13. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +228 -0
  14. package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
  15. package/build/prompts/testbot/testbot-prompts.js +3 -2
  16. package/build/services/TestDiscoveryService.d.ts +12 -9
  17. package/build/services/TestDiscoveryService.js +125 -51
  18. package/build/services/TestDiscoveryService.test.js +235 -15
  19. package/build/services/TestExecutionService.d.ts +1 -1
  20. package/build/services/TestExecutionService.js +7 -7
  21. package/build/services/TestExecutionService.test.js +4 -1
  22. package/build/tools/submitReportTool.d.ts +5 -5
  23. package/build/tools/submitReportTool.js +11 -4
  24. package/build/tools/submitReportTool.test.js +2 -0
  25. package/build/tools/test-management/actionsTool.js +54 -36
  26. package/build/tools/test-management/analyzeChangesTool.js +11 -17
  27. package/build/tools/test-management/analyzeTestHealthTool.js +182 -8
  28. package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
  29. package/build/tools/test-management/analyzeTestHealthTool.test.js +468 -0
  30. package/build/tools/workspace/initializeWorkspaceTool.js +2 -9
  31. package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
  32. package/build/types/TestAnalysis.d.ts +3 -0
  33. package/build/types/TestbotReport.d.ts +1 -1
  34. package/build/utils/AnalysisStateManager.d.ts +3 -21
  35. package/build/utils/docker.test.js +1 -1
  36. package/build/utils/versions.d.ts +3 -3
  37. package/build/utils/versions.js +1 -1
  38. package/build/workspace/workspace.d.ts +21 -21
  39. package/build/workspace/workspace.js +7 -4
  40. package/build/workspace/workspace.test.js +65 -6
  41. package/package.json +2 -2
@@ -0,0 +1,468 @@
1
+ // @ts-nocheck - Jest ESM type inference issues
2
+ import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals";
3
+ import * as fs from "fs";
4
+ import * as os from "os";
5
+ import * as path from "path";
6
+ jest.unstable_mockModule("@modelcontextprotocol/sdk/server/mcp.js", () => ({
7
+ McpServer: jest.fn(),
8
+ }));
9
+ jest.unstable_mockModule("@modelcontextprotocol/sdk/types.js", () => ({
10
+ CallToolResult: {},
11
+ }));
12
+ jest.unstable_mockModule("../../utils/logger.js", () => ({
13
+ logger: {
14
+ info: jest.fn(),
15
+ debug: jest.fn(),
16
+ error: jest.fn(),
17
+ warning: jest.fn(),
18
+ },
19
+ }));
20
+ jest.unstable_mockModule("../../services/AnalyticsService.js", () => ({
21
+ AnalyticsService: { pushMCPToolEvent: jest.fn() },
22
+ }));
23
+ const mockBuildDriftAnalysisPrompt = jest.fn().mockImplementation((_stateFile, apiTests, ui) => {
24
+ const parts = [];
25
+ if ((apiTests?.length ?? 0) > 0 || !ui)
26
+ parts.push("<drift_analysis_rules>API drift</drift_analysis_rules>");
27
+ if (ui)
28
+ parts.push("<ui_drift_analysis_rules>UI drift</ui_drift_analysis_rules>");
29
+ return parts.join("\n\n");
30
+ });
31
+ jest.unstable_mockModule("../../prompts/test-maintenance/drift-analysis-prompt.js", () => ({
32
+ buildDriftAnalysisPrompt: mockBuildDriftAnalysisPrompt,
33
+ }));
34
+ const mockFromStatePath = jest.fn();
35
+ const mockCleanupOldFiles = jest.fn().mockImplementation(() => Promise.resolve());
36
+ jest.unstable_mockModule("../../utils/AnalysisStateManager.js", () => ({
37
+ StateManager: {
38
+ fromStatePath: mockFromStatePath,
39
+ cleanupOldFiles: mockCleanupOldFiles,
40
+ },
41
+ TestSource: { Skyramp: "skyramp", External: "external" },
42
+ }));
43
+ jest.unstable_mockModule("../../utils/utils.js", () => ({
44
+ toolError: (msg) => ({ content: [{ type: "text", text: `Error: ${msg}` }] }),
45
+ }));
46
+ const { registerAnalyzeTestHealthTool } = await import("./analyzeTestHealthTool.js");
47
+ // ---------------------------------------------------------------------------
48
+ // Helpers
49
+ // ---------------------------------------------------------------------------
50
+ function makeFakeServer() {
51
+ let capturedHandler = null;
52
+ let capturedSchema = null;
53
+ const server = {
54
+ registerTool: jest.fn((_name, config, handler) => {
55
+ capturedSchema = config.inputSchema;
56
+ capturedHandler = handler;
57
+ }),
58
+ getHandler: () => capturedHandler,
59
+ getSchema: () => capturedSchema,
60
+ };
61
+ return server;
62
+ }
63
+ function makeStateManager(stateData) {
64
+ return {
65
+ getStatePath: jest.fn().mockReturnValue("/tmp/skyramp-analysis-test.json"),
66
+ readData: jest.fn().mockResolvedValue(stateData),
67
+ readFullState: jest.fn().mockResolvedValue({ metadata: { repositoryPath: "/repo" } }),
68
+ // Multi-repo API added in SKYR-3786 — analyzeTestHealthTool now uses these.
69
+ readRepoData: jest.fn().mockResolvedValue(stateData),
70
+ getRepoRepositoryPath: jest.fn().mockResolvedValue("/repo"),
71
+ };
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Tests
75
+ // ---------------------------------------------------------------------------
76
+ describe("analyzeTestHealthTool — blueprintCaptured schema", () => {
77
+ it("registers blueprintCaptured as an optional boolean parameter", () => {
78
+ const server = makeFakeServer();
79
+ registerAnalyzeTestHealthTool(server);
80
+ const schema = server.getSchema();
81
+ expect(schema).toHaveProperty("blueprintCaptured");
82
+ expect(schema.blueprintCaptured.safeParse(undefined).success).toBe(true);
83
+ expect(schema.blueprintCaptured.safeParse(true).success).toBe(true);
84
+ });
85
+ });
86
+ describe("analyzeTestHealthTool — UI drift gate logic", () => {
87
+ let server;
88
+ beforeEach(() => {
89
+ jest.clearAllMocks();
90
+ server = makeFakeServer();
91
+ registerAnalyzeTestHealthTool(server);
92
+ });
93
+ it("includes UI drift when uiContext has changedFrontendFiles and a UI test", async () => {
94
+ const mockStateManager = makeStateManager({
95
+ existingTests: [
96
+ { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" },
97
+ ],
98
+ uiContext: {
99
+ changedFrontendFiles: ["/repo/src/components/Cart.tsx"],
100
+ candidateUiPages: [],
101
+ },
102
+ });
103
+ mockFromStatePath.mockReturnValue(mockStateManager);
104
+ const result = await server.getHandler()({ stateFile: "/tmp/state.json", blueprintCaptured: true });
105
+ expect(result).not.toHaveProperty("isError", true);
106
+ expect(mockBuildDriftAnalysisPrompt.mock.calls[0][2]).toBeDefined();
107
+ const testsArg = mockBuildDriftAnalysisPrompt.mock.calls[0][2].tests;
108
+ expect(testsArg).toContainEqual(expect.objectContaining({ testFile: "/repo/tests/cart.spec.ts" }));
109
+ });
110
+ it("passes blueprintCaptured=true to buildUiDriftAnalysisPrompt", async () => {
111
+ const mockStateManager = makeStateManager({
112
+ existingTests: [{ testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" }],
113
+ uiContext: { changedFrontendFiles: ["/repo/src/Cart.tsx"], candidateUiPages: [] },
114
+ });
115
+ mockFromStatePath.mockReturnValue(mockStateManager);
116
+ await server.getHandler()({ stateFile: "/tmp/state.json", blueprintCaptured: true });
117
+ const blueprintArg = mockBuildDriftAnalysisPrompt.mock.calls[0][2].blueprintCaptured;
118
+ expect(blueprintArg).toBe(true);
119
+ });
120
+ it("passes blueprintCaptured=false to buildUiDriftAnalysisPrompt", async () => {
121
+ const mockStateManager = makeStateManager({
122
+ existingTests: [{ testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" }],
123
+ uiContext: { changedFrontendFiles: ["/repo/src/Cart.tsx"], candidateUiPages: [] },
124
+ });
125
+ mockFromStatePath.mockReturnValue(mockStateManager);
126
+ await server.getHandler()({ stateFile: "/tmp/state.json", blueprintCaptured: false });
127
+ expect(mockBuildDriftAnalysisPrompt.mock.calls[0][2]).toBeDefined();
128
+ const blueprintArg = mockBuildDriftAnalysisPrompt.mock.calls[0][2].blueprintCaptured;
129
+ expect(blueprintArg).toBe(false);
130
+ });
131
+ it("excludes UI drift when uiContext is absent", async () => {
132
+ const mockStateManager = makeStateManager({ existingTests: [] });
133
+ mockFromStatePath.mockReturnValue(mockStateManager);
134
+ await server.getHandler()({ stateFile: "/tmp/state.json", blueprintCaptured: true });
135
+ expect(mockBuildDriftAnalysisPrompt.mock.calls[0][2]).toBeUndefined();
136
+ });
137
+ it("excludes UI drift when changedFrontendFiles is empty", async () => {
138
+ const mockStateManager = makeStateManager({
139
+ existingTests: [{ testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui" }],
140
+ uiContext: { changedFrontendFiles: [], candidateUiPages: [] },
141
+ });
142
+ mockFromStatePath.mockReturnValue(mockStateManager);
143
+ await server.getHandler()({ stateFile: "/tmp/state.json", blueprintCaptured: true });
144
+ expect(mockBuildDriftAnalysisPrompt.mock.calls[0][2]).toBeUndefined();
145
+ });
146
+ it("includes UI drift (empty test list) when frontend changed but no UI tests found — grep pre-scan will discover them", async () => {
147
+ const mockStateManager = makeStateManager({
148
+ existingTests: [
149
+ { testFile: "/repo/tests/products_contract_test.py", source: "skyramp", testType: "contract", framework: "pytest" },
150
+ { testFile: "/repo/tests/orders_smoke_test.py", source: "skyramp", testType: "smoke", framework: "pytest" },
151
+ ],
152
+ uiContext: { changedFrontendFiles: ["/repo/src/Cart.tsx"], candidateUiPages: [] },
153
+ });
154
+ mockFromStatePath.mockReturnValue(mockStateManager);
155
+ await server.getHandler()({ stateFile: "/tmp/state.json", blueprintCaptured: true });
156
+ // UI drift fires even with 0 UI tests — grep pre-scan discovers component tests dynamically
157
+ const uiParam = mockBuildDriftAnalysisPrompt.mock.calls[0][2];
158
+ expect(uiParam).toBeDefined();
159
+ expect(uiParam.tests).toHaveLength(0);
160
+ expect(uiParam.changedFrontendFiles).toEqual(["/repo/src/Cart.tsx"]);
161
+ });
162
+ it("includes UI drift for RTL tests (framework=rtl, testType=unknown)", async () => {
163
+ const mockStateManager = makeStateManager({
164
+ existingTests: [
165
+ { testFile: "/repo/src/components/Cart.test.tsx", source: "external", testType: "unknown", framework: "rtl" },
166
+ { testFile: "/repo/tests/orders_smoke_test.py", source: "skyramp", testType: "smoke", framework: "pytest" },
167
+ ],
168
+ repositoryAnalysis: { relevantExternalTestPaths: ["src/components/Cart.test.tsx"] },
169
+ uiContext: { changedFrontendFiles: ["/repo/src/components/Cart.tsx"], candidateUiPages: [] },
170
+ });
171
+ mockFromStatePath.mockReturnValue(mockStateManager);
172
+ mockFromStatePath.mock.results[0]?.value?.readFullState?.mockResolvedValue({
173
+ metadata: { repositoryPath: "/repo" },
174
+ });
175
+ await server.getHandler()({ stateFile: "/tmp/state.json", blueprintCaptured: false });
176
+ expect(mockBuildDriftAnalysisPrompt.mock.calls[0][2]).toBeDefined();
177
+ const testsArg = mockBuildDriftAnalysisPrompt.mock.calls[0][2].tests;
178
+ expect(testsArg).toHaveLength(1);
179
+ expect(testsArg[0]).toMatchObject({ testFile: "/repo/src/components/Cart.test.tsx" });
180
+ });
181
+ it("excludes RTL test from API drift prompt when UI drift is running", async () => {
182
+ const mockStateManager = makeStateManager({
183
+ existingTests: [
184
+ { testFile: "/repo/src/components/Cart.test.tsx", source: "external", testType: "unknown", framework: "rtl" },
185
+ { testFile: "/repo/tests/orders_smoke_test.py", source: "skyramp", testType: "smoke", framework: "pytest" },
186
+ ],
187
+ repositoryAnalysis: { relevantExternalTestPaths: ["src/components/Cart.test.tsx"] },
188
+ uiContext: { changedFrontendFiles: ["/repo/src/components/Cart.tsx"], candidateUiPages: [] },
189
+ });
190
+ mockFromStatePath.mockReturnValue(mockStateManager);
191
+ await server.getHandler()({ stateFile: "/tmp/state.json", blueprintCaptured: false });
192
+ const apiTestsArg = mockBuildDriftAnalysisPrompt.mock.calls[0][1];
193
+ expect(apiTestsArg).toHaveLength(1);
194
+ expect(apiTestsArg[0]).toMatchObject({ testFile: "/repo/tests/orders_smoke_test.py" });
195
+ });
196
+ it("passes only UI tests to buildUiDriftAnalysisPrompt when mixed test types exist", async () => {
197
+ const mockStateManager = makeStateManager({
198
+ existingTests: [
199
+ { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" },
200
+ { testFile: "/repo/tests/products_contract.py", source: "skyramp", testType: "contract", framework: "pytest" },
201
+ { testFile: "/repo/tests/smoke.py", source: "skyramp", testType: "smoke", framework: "pytest" },
202
+ ],
203
+ uiContext: { changedFrontendFiles: ["/repo/src/Cart.tsx"], candidateUiPages: [] },
204
+ });
205
+ mockFromStatePath.mockReturnValue(mockStateManager);
206
+ await server.getHandler()({ stateFile: "/tmp/state.json", blueprintCaptured: true });
207
+ expect(mockBuildDriftAnalysisPrompt.mock.calls[0][2]).toBeDefined();
208
+ const testsArg = mockBuildDriftAnalysisPrompt.mock.calls[0][2].tests;
209
+ expect(testsArg).toHaveLength(1);
210
+ expect(testsArg[0]).toMatchObject({ testFile: "/repo/tests/cart.spec.ts" });
211
+ });
212
+ it("excludes UI tests from API drift prompt when UI drift is also running", async () => {
213
+ const mockStateManager = makeStateManager({
214
+ existingTests: [
215
+ { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" },
216
+ { testFile: "/repo/tests/products_contract.py", source: "skyramp", testType: "contract", framework: "pytest" },
217
+ ],
218
+ uiContext: { changedFrontendFiles: ["/repo/src/Cart.tsx"], candidateUiPages: [] },
219
+ });
220
+ mockFromStatePath.mockReturnValue(mockStateManager);
221
+ await server.getHandler()({ stateFile: "/tmp/state.json", blueprintCaptured: true });
222
+ const apiTestsArg = mockBuildDriftAnalysisPrompt.mock.calls[0][1];
223
+ expect(apiTestsArg).toHaveLength(1);
224
+ expect(apiTestsArg[0]).toMatchObject({ testFile: "/repo/tests/products_contract.py" });
225
+ });
226
+ it("excludes UI tests from API drift prompt even when no frontend changes", async () => {
227
+ const mockStateManager = makeStateManager({
228
+ existingTests: [
229
+ { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" },
230
+ { testFile: "/repo/tests/products_contract.py", source: "skyramp", testType: "contract", framework: "pytest" },
231
+ ],
232
+ });
233
+ mockFromStatePath.mockReturnValue(mockStateManager);
234
+ await server.getHandler()({ stateFile: "/tmp/state.json" });
235
+ // UI tests are always partitioned out — they are never assessed by API drift checks
236
+ // (endpoint existence, response shape, auth) regardless of whether frontend changed.
237
+ const apiTestsArg = mockBuildDriftAnalysisPrompt.mock.calls[0][1];
238
+ expect(apiTestsArg).toHaveLength(1);
239
+ expect(apiTestsArg[0]).toMatchObject({ testFile: "/repo/tests/products_contract.py" });
240
+ });
241
+ });
242
+ describe("analyzeTestHealthTool — prompt concatenation", () => {
243
+ let server;
244
+ beforeEach(() => {
245
+ jest.clearAllMocks();
246
+ server = makeFakeServer();
247
+ registerAnalyzeTestHealthTool(server);
248
+ });
249
+ it("concatenates API and UI drift prompts when both are included", async () => {
250
+ const mockStateManager = makeStateManager({
251
+ existingTests: [
252
+ { testFile: "/repo/tests/cart.spec.ts", source: "skyramp", testType: "ui", framework: "playwright" },
253
+ { testFile: "/repo/tests/contract.py", source: "skyramp", testType: "contract", framework: "pytest" },
254
+ ],
255
+ uiContext: { changedFrontendFiles: ["/repo/src/Cart.tsx"], candidateUiPages: [] },
256
+ });
257
+ mockFromStatePath.mockReturnValue(mockStateManager);
258
+ const result = await server.getHandler()({ stateFile: "/tmp/state.json", blueprintCaptured: true });
259
+ expect(result.structuredContent?.prompt).toContain("API drift");
260
+ expect(result.structuredContent?.prompt).toContain("UI drift");
261
+ });
262
+ it("returns only API drift prompt when uiContext is absent", async () => {
263
+ const mockStateManager = makeStateManager({ existingTests: [] });
264
+ mockFromStatePath.mockReturnValue(mockStateManager);
265
+ const result = await server.getHandler()({ stateFile: "/tmp/state.json" });
266
+ expect(result.structuredContent?.prompt).toContain("API drift");
267
+ expect(result.structuredContent?.prompt).not.toContain("UI drift");
268
+ });
269
+ });
270
+ // ---------------------------------------------------------------------------
271
+ // Discovery layer tests — catch regressions in findUiTestsBySymbolGrep
272
+ // These tests use real temp files on disk to exercise the actual file-reading
273
+ // logic, catching bugs that mock-only tests would miss.
274
+ // ---------------------------------------------------------------------------
275
+ describe("analyzeTestHealthTool — findUiTestsBySymbolGrep discovery", () => {
276
+ let server;
277
+ let tmpDir;
278
+ beforeEach(() => {
279
+ jest.clearAllMocks();
280
+ server = makeFakeServer();
281
+ registerAnalyzeTestHealthTool(server);
282
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-grep-test-"));
283
+ });
284
+ afterEach(() => {
285
+ fs.rmSync(tmpDir, { recursive: true, force: true });
286
+ });
287
+ function writeFile(name, content) {
288
+ const filePath = path.join(tmpDir, name);
289
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
290
+ fs.writeFileSync(filePath, content, "utf-8");
291
+ return filePath;
292
+ }
293
+ function makeState(opts) {
294
+ return {
295
+ existingTests: opts.existingTests ?? [],
296
+ repositoryAnalysis: {
297
+ relevantExternalTestPaths: (opts.existingTests ?? []).map(t => t.testFile),
298
+ diffFilePath: opts.diffFilePath,
299
+ },
300
+ uiContext: {
301
+ changedFrontendFiles: opts.changedFrontendFiles,
302
+ candidateUiPages: [],
303
+ },
304
+ };
305
+ }
306
+ async function runHandler(state, repoPath) {
307
+ const stateManager = {
308
+ getStatePath: jest.fn().mockReturnValue("/tmp/state.json"),
309
+ readData: jest.fn().mockResolvedValue(state),
310
+ readFullState: jest.fn().mockResolvedValue({ metadata: { repositoryPath: repoPath } }),
311
+ readRepoData: jest.fn().mockResolvedValue(state),
312
+ getRepoRepositoryPath: jest.fn().mockResolvedValue(repoPath),
313
+ };
314
+ mockFromStatePath.mockReturnValue(stateManager);
315
+ await server.getHandler()({ stateFile: "/tmp/state.json" });
316
+ return mockBuildDriftAnalysisPrompt.mock.calls[0]?.[2]?.tests ?? [];
317
+ }
318
+ it("CC-UC02: finds e2e test via diff signal when testid was renamed", async () => {
319
+ // Use relative paths for changedFrontendFiles — matches how diffs are formatted
320
+ const relFrontend = "apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx";
321
+ writeFile(relFrontend, `data-testid="onboarding-submit-btn"`);
322
+ const e2eTest = writeFile("apps/web/playwright/onboarding.e2e.ts", `await page.getByTestId("onboarding-continue-btn").click();`);
323
+ const diffFile = writeFile("cc-uc02.diff", [
324
+ `diff --git a/${relFrontend} b/${relFrontend}`,
325
+ `- data-testid="onboarding-continue-btn"`,
326
+ `+ data-testid="onboarding-submit-btn"`,
327
+ ].join("\n"));
328
+ const state = makeState({
329
+ changedFrontendFiles: [relFrontend], // relative — code resolves against repositoryPath
330
+ existingTests: [{ testFile: e2eTest, testType: "e2e", framework: "playwright-ui", source: "external" }],
331
+ diffFilePath: diffFile,
332
+ });
333
+ state.repositoryAnalysis.relevantExternalTestPaths = []; // grep fallback fires
334
+ const uiTests = await runHandler(state, tmpDir);
335
+ expect(uiTests.some(t => t.testFile === e2eTest)).toBe(true);
336
+ });
337
+ it("does not extract diff strings shorter than 10 chars (min-length filter)", async () => {
338
+ const frontendFile = writeFile("button.tsx", ``);
339
+ const genericTest = writeFile("Button.test.tsx", `it("renders", () => { expect(true).toBe(true); /* continue finish true */ });`);
340
+ const diffFile = writeFile("short.diff", [
341
+ `diff --git a/button.tsx b/button.tsx`,
342
+ `- label="continue"`,
343
+ `+ label="finish"`,
344
+ `- value={true}`,
345
+ ].join("\n"));
346
+ const state = makeState({
347
+ changedFrontendFiles: [frontendFile],
348
+ existingTests: [{ testFile: genericTest, testType: "unknown", framework: "", source: "external" }],
349
+ diffFilePath: diffFile,
350
+ });
351
+ state.repositoryAnalysis.relevantExternalTestPaths = [];
352
+ const uiTests = await runHandler(state, tmpDir);
353
+ expect(uiTests.some(t => t.testFile === genericTest)).toBe(false);
354
+ });
355
+ it("GENERIC_SLUGS blocks generic slugs longer than 6 chars (operator precedence)", async () => {
356
+ // "component" is in GENERIC_SLUGS, length=9 — must NOT be added to symbols
357
+ const frontendFile = writeFile("component.tsx", ``);
358
+ const unrelatedTest = writeFile("Component.test.tsx", `import { component } from './utils'; describe("component", () => {});`);
359
+ const state = makeState({
360
+ changedFrontendFiles: [frontendFile],
361
+ existingTests: [{ testFile: unrelatedTest, testType: "unknown", framework: "rtl", source: "external" }],
362
+ });
363
+ state.repositoryAnalysis.relevantExternalTestPaths = [];
364
+ const uiTests = await runHandler(state, tmpDir);
365
+ expect(uiTests.some(t => t.testFile === unrelatedTest)).toBe(false);
366
+ });
367
+ it("grep fires when uiTests.length === SYMBOL_GREP_MAX_RESULTS (boundary >= not >)", async () => {
368
+ const relFrontend = "apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx";
369
+ writeFile(relFrontend, `export const PersonalCalendarView = () => {};`);
370
+ const targetTest = writeFile("apps/web/playwright/onboarding.e2e.ts", `await page.getByTestId("onboarding-continue-btn").click();`);
371
+ const diffFile = writeFile("rename.diff", [
372
+ `diff --git a/${relFrontend} b/${relFrontend}`,
373
+ `- data-testid="onboarding-continue-btn"`,
374
+ `+ data-testid="onboarding-submit-btn"`,
375
+ ].join("\n"));
376
+ // Exactly 20 wrong tests in relevantExternalTestPaths
377
+ const wrongTests = Array.from({ length: 20 }, (_, i) => {
378
+ const f = writeFile(`packages/ui/Test${i}.test.tsx`, `describe("Test${i}", () => {});`);
379
+ return { testFile: f, testType: "unknown", framework: "rtl", source: "external" };
380
+ });
381
+ const state = makeState({
382
+ changedFrontendFiles: [relFrontend],
383
+ existingTests: [
384
+ ...wrongTests,
385
+ // targetTest is in existingTests (discovered) but NOT in relevantExternalTestPaths
386
+ // (it's a .e2e.ts file not yet in the promoted set) — the >= branch must find it
387
+ { testFile: targetTest, testType: "e2e", framework: "playwright-ui", source: "external" },
388
+ ],
389
+ diffFilePath: diffFile,
390
+ });
391
+ // 20 wrong tests in relevantExternalTestPaths — targetTest deliberately excluded
392
+ state.repositoryAnalysis.relevantExternalTestPaths = wrongTests.map(t => t.testFile);
393
+ const uiTests = await runHandler(state, tmpDir);
394
+ // Grep must have fired (>= condition) and ranked targetTest to top
395
+ expect(uiTests.some(t => t.testFile === targetTest)).toBe(true);
396
+ expect(uiTests.length).toBeLessThanOrEqual(20);
397
+ });
398
+ it("exact path match prevents test diff sections from contaminating symbols", async () => {
399
+ const relFrontend = "apps/web/modules/onboarding/personal/calendar/personal-calendar-view.tsx";
400
+ writeFile(relFrontend, `export const PersonalCalendarView = () => {};`);
401
+ const targetTest = writeFile("apps/web/playwright/onboarding.e2e.ts", `await page.getByTestId("onboarding-continue-btn").click();`);
402
+ const falsePositiveTest = writeFile("apps/web/playwright/false-positive.e2e.ts", `await page.getByTestId("injected-from-test-file").click();`);
403
+ // Diff has TWO sections: the test file first (substring match trap), then the frontend file
404
+ const diffFile = writeFile("both-sections.diff", [
405
+ `diff --git a/personal-calendar-view.test.tsx b/personal-calendar-view.test.tsx`,
406
+ `+ expect(screen.getByTestId("injected-from-test-file")).toBeTruthy();`,
407
+ `diff --git a/${relFrontend} b/${relFrontend}`,
408
+ `- data-testid="onboarding-continue-btn"`,
409
+ ].join("\n"));
410
+ const state = makeState({
411
+ changedFrontendFiles: [relFrontend],
412
+ existingTests: [
413
+ { testFile: targetTest, testType: "e2e", framework: "playwright-ui", source: "external" },
414
+ { testFile: falsePositiveTest, testType: "e2e", framework: "playwright-ui", source: "external" },
415
+ ],
416
+ diffFilePath: diffFile,
417
+ });
418
+ state.repositoryAnalysis.relevantExternalTestPaths = [];
419
+ const uiTests = await runHandler(state, tmpDir);
420
+ expect(uiTests.some(t => t.testFile === targetTest)).toBe(true);
421
+ expect(uiTests.some(t => t.testFile === falsePositiveTest)).toBe(false);
422
+ });
423
+ // CC-UC04: bookings-results testid (>=10 chars) in diff → booking-pages.e2e.ts found
424
+ it("CC-UC04: finds e2e test when new data-testid added to diff + lines", async () => {
425
+ const relFrontend = "apps/web/modules/bookings/components/BookingList.tsx";
426
+ writeFile(relFrontend, `export function BookingList() { return <div data-testid="bookings-results" />; }`);
427
+ const bookingPagesTest = writeFile("apps/web/playwright/booking-pages.e2e.ts", `await page.getByTestId("bookings-results").waitFor({ state: "visible" });`);
428
+ const diffFile = writeFile("cc-uc04.diff", [
429
+ `diff --git a/${relFrontend} b/${relFrontend}`,
430
+ `+ <div data-testid="bookings-results" data-state={dataState}>`,
431
+ ].join("\n"));
432
+ const state = makeState({
433
+ changedFrontendFiles: [relFrontend],
434
+ existingTests: [{ testFile: bookingPagesTest, testType: "e2e", framework: "playwright-ui", source: "external" }],
435
+ diffFilePath: diffFile,
436
+ });
437
+ state.repositoryAnalysis.relevantExternalTestPaths = [];
438
+ const uiTests = await runHandler(state, tmpDir);
439
+ expect(uiTests.some(t => t.testFile === bookingPagesTest)).toBe(true);
440
+ });
441
+ // CC-UC03 known gap: booking-sheet-keyboard.e2e.ts NOT found for additive PR
442
+ // The PR adds aria-selected and booking-row-cell (new + lines, no - lines with old values).
443
+ // The test uses [role="dialog"] and booking-sheet-title — no overlap with diff signals.
444
+ // This documents the known limitation: additive PRs with no renamed selectors
445
+ // cannot be discovered via diff-signal extraction alone.
446
+ it("CC-UC03 known gap: additive-only diff (no removed selectors) does not find e2e test", async () => {
447
+ const relFrontend = "apps/web/components/booking/BookingListItem.tsx";
448
+ writeFile(relFrontend, `export function BookingListItem() { return <div aria-selected="false" />; }`);
449
+ const keyboardTest = writeFile("apps/web/playwright/booking-sheet-keyboard.e2e.ts", `const sheet = page.locator('[role="dialog"]');\nawait expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 1");`);
450
+ // Only + lines (additive) — no - lines with old values to match
451
+ const diffFile = writeFile("cc-uc03.diff", [
452
+ `diff --git a/${relFrontend} b/${relFrontend}`,
453
+ `+ aria-selected={isSelected ? "true" : "false"}`,
454
+ `+ data-testid="booking-row-cell"`,
455
+ ].join("\n"));
456
+ const state = makeState({
457
+ changedFrontendFiles: [relFrontend],
458
+ existingTests: [{ testFile: keyboardTest, testType: "e2e", framework: "playwright-ui", source: "external" }],
459
+ diffFilePath: diffFile,
460
+ });
461
+ state.repositoryAnalysis.relevantExternalTestPaths = [];
462
+ const uiTests = await runHandler(state, tmpDir);
463
+ // Known gap: additive-only PRs cannot surface tests via diff signals.
464
+ // The test content ([role="dialog"], booking-sheet-title) has no overlap with
465
+ // the added attributes (aria-selected, booking-row-cell).
466
+ expect(uiTests.some(t => t.testFile === keyboardTest)).toBe(false);
467
+ });
468
+ });
@@ -1,6 +1,5 @@
1
1
  import { z } from "zod";
2
- import { WorkspaceConfigManager, serviceSchema } from "../../workspace/workspace.js";
3
- import { createRequire } from "module";
2
+ import { WorkspaceConfigManager, serviceSchema, } from "../../workspace/workspace.js";
4
3
  import fs from "fs/promises";
5
4
  import yaml from "js-yaml";
6
5
  import { logger } from "../../utils/logger.js";
@@ -8,8 +7,6 @@ import { AnalyticsService } from "../../services/AnalyticsService.js";
8
7
  import { SKYRAMP_IMAGE_VERSION } from "../../utils/versions.js";
9
8
  import { validateAndConsumeScanToken } from "./initScanWorkspaceTool.js";
10
9
  import { upsertServicesByRepo } from "./serviceUpsert.js";
11
- const require = createRequire(import.meta.url);
12
- const MCP_VERSION = require("../../../package.json").version || "";
13
10
  function getExecutorVersion() {
14
11
  return SKYRAMP_IMAGE_VERSION;
15
12
  }
@@ -20,9 +17,7 @@ const TOOL_NAME = "skyramp_init_workspace";
20
17
  // set (incl. cookie/session/token) and the multi-repo `repository` field, so input
21
18
  // services are validated against it directly — no local schema extension needed.
22
19
  const initializeWorkspaceSchema = z.object({
23
- workspacePath: z
24
- .string()
25
- .describe("Path to workspace directory"),
20
+ workspacePath: z.string().describe("Path to workspace directory"),
26
21
  services: z
27
22
  .array(serviceSchema)
28
23
  .describe("Array of services discovered by skyramp_init_scan. Must contain at least one service."),
@@ -123,14 +118,12 @@ export function registerInitializeWorkspaceTool(server) {
123
118
  if (params.merge && alreadyExists) {
124
119
  config = await manager.read();
125
120
  config = await manager.updateMetadata({
126
- mcpVersion: MCP_VERSION,
127
121
  executorVersion: getExecutorVersion(),
128
122
  });
129
123
  }
130
124
  else {
131
125
  config = await manager.initialize();
132
126
  config = await manager.updateMetadata({
133
- mcpVersion: MCP_VERSION,
134
127
  executorVersion: getExecutorVersion(),
135
128
  });
136
129
  }
@@ -36,7 +36,9 @@ describe("initializeWorkspaceTool", () => {
36
36
  // Build a mock WorkspaceConfigManager
37
37
  mockManager = {
38
38
  exists: jest.fn().mockResolvedValue(false),
39
- getConfigPath: jest.fn().mockReturnValue(path.join(tmpDir, ".skyramp/workspace.yml")),
39
+ getConfigPath: jest
40
+ .fn()
41
+ .mockReturnValue(path.join(tmpDir, ".skyramp/workspace.yml")),
40
42
  initialize: jest.fn().mockResolvedValue({ workspace: {}, services: [] }),
41
43
  updateMetadata: jest.fn().mockImplementation(async (data) => ({
42
44
  workspace: {},
@@ -95,7 +97,6 @@ describe("initializeWorkspaceTool", () => {
95
97
  expect(result.content[0].text).toContain("Workspace initialized");
96
98
  expect(mockManager.initialize).toHaveBeenCalled();
97
99
  expect(mockManager.updateMetadata).toHaveBeenCalledWith(expect.objectContaining({
98
- mcpVersion: expect.any(String),
99
100
  executorVersion: expect.any(String),
100
101
  }));
101
102
  });
@@ -103,7 +104,9 @@ describe("initializeWorkspaceTool", () => {
103
104
  mockManager.exists.mockResolvedValue(true);
104
105
  const result = await handler({
105
106
  workspacePath: tmpDir,
106
- services: [{ serviceName: "svc", language: "python", testDirectory: "tests" }],
107
+ services: [
108
+ { serviceName: "svc", language: "python", testDirectory: "tests" },
109
+ ],
107
110
  scanToken: "valid-token",
108
111
  force: false,
109
112
  });
@@ -114,7 +117,9 @@ describe("initializeWorkspaceTool", () => {
114
117
  validateAndConsumeScanToken.mockReturnValue(false);
115
118
  const result = await handler({
116
119
  workspacePath: tmpDir,
117
- services: [{ serviceName: "svc", language: "python", testDirectory: "tests" }],
120
+ services: [
121
+ { serviceName: "svc", language: "python", testDirectory: "tests" },
122
+ ],
118
123
  scanToken: "bad-token",
119
124
  force: false,
120
125
  });
@@ -62,6 +62,9 @@ export declare enum EstimatedWork {
62
62
  /** Normalized internal recommendation built from LLM-supplied args.recommendations. */
63
63
  export interface DriftRecommendation {
64
64
  testFile: string;
65
+ /** When selectors are abstracted into a page object, pomFile is the POM path to edit.
66
+ * testFile remains the spec (for catalog lookup and report); pomFile is what gets patched. */
67
+ pomFile?: string;
65
68
  action: DriftAction;
66
69
  priority: RecommendationPriority;
67
70
  rationale: string;
@@ -14,7 +14,7 @@ export interface TestbotReport {
14
14
  endpoint: string;
15
15
  fileName: string;
16
16
  reasoning: string;
17
- description?: string;
17
+ description: string;
18
18
  scenarioFile?: string;
19
19
  traceFile?: string;
20
20
  frontendTrace?: string;
@@ -49,30 +49,12 @@ type TestTypeScores = Record<string, number>;
49
49
  * Map of test type → grouping/label information used by downstream tools.
50
50
  */
51
51
  type TestTypeMapping = Record<string, string | string[]>;
52
- /**
53
- * UI-specific analysis context populated when the diff contains frontend files.
54
- *
55
- * Computed deterministically by `skyramp_analyze_changes` (via `isFrontendFile`
56
- * in scopeAssessment.ts). Persisted so downstream consumers —
57
- * `skyramp_analyze_test_health`'s UI drift detection, recommendation prompt
58
- * rendering, etc. — don't have to re-derive the classification.
59
- *
60
- * Absent on backend-only PRs (no frontend files in the diff).
61
- */
62
52
  export interface UiAnalysisContext {
63
- /**
64
- * Subset of `repositoryAnalysis.diff.changedFiles` that classified as
65
- * frontend by `isFrontendFile()`. Empty when no frontend files changed —
66
- * callers can derive `hasFrontendChanges` from `changedFrontendFiles.length > 0`.
67
- */
53
+ /** Frontend source files changed in this diff. */
68
54
  changedFrontendFiles: string[];
69
55
  /**
70
- * Candidate pages the testbot agent should capture blueprints for, enumerated
71
- * by the strategy ladder in `uiPageEnumerator.ts` (framework route grep,
72
- * source-grounded routes, root fallback). Each entry carries the candidate
73
- * URL plus the strategy + source files that surfaced it. May be empty when
74
- * the workspace has no resolvable frontend baseUrl — caller should fall back
75
- * to the agent's prose-driven enumeration.
56
+ * Candidate pages for blueprint capture, enumerated from route analysis.
57
+ * Empty when no resolvable frontend baseUrl agent falls back to prose-driven enumeration.
76
58
  */
77
59
  candidateUiPages: CandidateUiPage[];
78
60
  }
@@ -55,7 +55,7 @@ describe("dockerImageExistsLocally", () => {
55
55
  });
56
56
  });
57
57
  describe("pullDockerImage", () => {
58
- const IMAGE = "skyramp/executor:v1.3.29";
58
+ const IMAGE = "skyramp/executor:v1.3.30";
59
59
  beforeEach(() => jest.clearAllMocks());
60
60
  describe("on amd64 host", () => {
61
61
  const originalArch = process.arch;
@@ -1,3 +1,3 @@
1
- export declare const SKYRAMP_IMAGE_VERSION = "v1.3.29";
2
- export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.29";
3
- export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.29";
1
+ export declare const SKYRAMP_IMAGE_VERSION = "v1.3.30";
2
+ export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.30";
3
+ export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.30";
@@ -1,3 +1,3 @@
1
- export const SKYRAMP_IMAGE_VERSION = "v1.3.29";
1
+ export const SKYRAMP_IMAGE_VERSION = "v1.3.30";
2
2
  export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
3
3
  export const WORKER_DOCKER_IMAGE = `skyramp/worker:${SKYRAMP_IMAGE_VERSION}`;