@skyramp/mcp 0.2.8 → 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.
- package/build/index.js +0 -7
- package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +3 -1
- package/build/prompts/sut-setup/shared.d.ts +20 -0
- package/build/prompts/sut-setup/shared.js +69 -7
- package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +58 -3
- package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -22
- package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +14 -0
- package/build/prompts/test-maintenance/driftAnalysisShared.js +62 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +38 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +228 -0
- package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
- package/build/prompts/testbot/testbot-prompts.js +3 -2
- package/build/services/TestDiscoveryService.d.ts +12 -9
- package/build/services/TestDiscoveryService.js +125 -51
- package/build/services/TestDiscoveryService.test.js +235 -15
- package/build/services/TestExecutionService.d.ts +1 -1
- package/build/services/TestExecutionService.js +7 -7
- package/build/services/TestExecutionService.test.js +4 -1
- package/build/tools/submitReportTool.d.ts +5 -5
- package/build/tools/submitReportTool.js +11 -4
- package/build/tools/submitReportTool.test.js +2 -0
- package/build/tools/test-management/actionsTool.js +54 -36
- package/build/tools/test-management/analyzeChangesTool.js +11 -17
- package/build/tools/test-management/analyzeTestHealthTool.js +182 -8
- package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
- package/build/tools/test-management/analyzeTestHealthTool.test.js +468 -0
- package/build/tools/workspace/initializeWorkspaceTool.js +2 -9
- package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
- package/build/types/TestAnalysis.d.ts +3 -0
- package/build/types/TestbotReport.d.ts +1 -1
- package/build/utils/AnalysisStateManager.d.ts +3 -21
- package/build/utils/docker.test.js +1 -1
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/workspace/workspace.d.ts +21 -21
- package/build/workspace/workspace.js +7 -4
- package/build/workspace/workspace.test.js +65 -6
- 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
|
|
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: [
|
|
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: [
|
|
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;
|
|
@@ -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
|
|
71
|
-
*
|
|
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.
|
|
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.
|
|
2
|
-
export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.
|
|
3
|
-
export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.
|
|
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";
|
package/build/utils/versions.js
CHANGED