@skyramp/mcp 0.1.0 → 0.1.2
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/prompts/enhance-assertions/contractProviderAssertionsPrompt.js +28 -110
- package/build/prompts/enhance-assertions/integrationAssertionsPrompt.js +35 -128
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +212 -0
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +217 -78
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +146 -27
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +202 -5
- package/build/prompts/testbot/testbot-prompts.js +10 -9
- package/build/services/TestDiscoveryService.js +417 -58
- package/build/services/TestDiscoveryService.test.js +361 -0
- package/build/tools/code-refactor/enhanceAssertionsTool.js +8 -10
- package/build/tools/test-management/actionsTool.js +4 -1
- package/build/tools/test-management/analyzeChangesTool.js +76 -9
- package/build/tools/test-management/analyzeTestHealthTool.js +6 -2
- package/build/types/RepositoryAnalysis.js +1 -0
- package/build/types/TestAnalysis.js +6 -1
- package/build/utils/docker.test.js +1 -1
- package/build/utils/routeParsers.js +7 -0
- package/build/utils/routeParsers.test.js +29 -1
- package/build/utils/versions.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// Mock heavy dependencies
|
|
2
|
+
jest.mock("simple-git", () => ({
|
|
3
|
+
simpleGit: jest.fn(() => ({
|
|
4
|
+
checkIsRepo: jest.fn().mockResolvedValue(false),
|
|
5
|
+
})),
|
|
6
|
+
}));
|
|
7
|
+
jest.mock("../utils/logger.js", () => ({
|
|
8
|
+
logger: {
|
|
9
|
+
info: jest.fn(),
|
|
10
|
+
debug: jest.fn(),
|
|
11
|
+
error: jest.fn(),
|
|
12
|
+
warning: jest.fn(),
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
// fast-glob must return real file paths from the temp directory.
|
|
16
|
+
// We use the real implementation by not mocking it.
|
|
17
|
+
// Jest resolves .js → .ts via moduleNameMapper in jest.config.
|
|
18
|
+
import * as fs from "fs";
|
|
19
|
+
import * as path from "path";
|
|
20
|
+
import * as os from "os";
|
|
21
|
+
import { TestDiscoveryService } from "./TestDiscoveryService.js";
|
|
22
|
+
import { TestSource } from "../types/TestAnalysis.js";
|
|
23
|
+
describe("TestDiscoveryService", () => {
|
|
24
|
+
let service;
|
|
25
|
+
let tmpDir;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
service = new TestDiscoveryService();
|
|
28
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-discovery-"));
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
function writeFile(relPath, content) {
|
|
34
|
+
const fullPath = path.join(tmpDir, relPath);
|
|
35
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
36
|
+
fs.writeFileSync(fullPath, content, "utf-8");
|
|
37
|
+
return fullPath;
|
|
38
|
+
}
|
|
39
|
+
// ── classifyTestFiles ──
|
|
40
|
+
describe("classifyTestFiles", () => {
|
|
41
|
+
it("classifies files with Skyramp marker as skyramp", async () => {
|
|
42
|
+
writeFile("tests/test_orders_smoke.py", '# Generated by Skyramp\nimport skyramp\nskyramp generate smoke rest');
|
|
43
|
+
const result = await service.discoverTests(tmpDir);
|
|
44
|
+
const skyrampTests = result.tests.filter((t) => t.source === TestSource.Skyramp);
|
|
45
|
+
expect(skyrampTests.length).toBe(1);
|
|
46
|
+
expect(skyrampTests[0].testFile).toContain("test_orders_smoke.py");
|
|
47
|
+
});
|
|
48
|
+
it("classifies test files without Skyramp marker as external", async () => {
|
|
49
|
+
writeFile("tests/test_orders.py", 'import pytest\nimport requests\nresponse = requests.get("/api/orders")');
|
|
50
|
+
const result = await service.discoverTests(tmpDir);
|
|
51
|
+
const externalTests = result.tests.filter((t) => t.source === TestSource.External);
|
|
52
|
+
expect(externalTests.length).toBe(1);
|
|
53
|
+
expect(externalTests[0].testFile).toContain("test_orders.py");
|
|
54
|
+
});
|
|
55
|
+
it("skips non-test files that lack the Skyramp marker", async () => {
|
|
56
|
+
writeFile("src/utils.py", 'def helper():\n return True');
|
|
57
|
+
const result = await service.discoverTests(tmpDir);
|
|
58
|
+
expect(result.tests.length).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
it("handles both Skyramp and external tests in the same repo", async () => {
|
|
61
|
+
writeFile("tests/test_orders_smoke.py", '# Generated by Skyramp\nskyramp generate smoke rest');
|
|
62
|
+
writeFile("tests/test_api.py", 'import pytest\nrequests.get("/api/users")');
|
|
63
|
+
const result = await service.discoverTests(tmpDir);
|
|
64
|
+
expect(result.tests.length).toBe(2);
|
|
65
|
+
expect(result.tests.filter((t) => t.source === TestSource.Skyramp).length).toBe(1);
|
|
66
|
+
expect(result.tests.filter((t) => t.source === TestSource.External).length).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
// ── isExternalTestFile (via classification) ──
|
|
70
|
+
describe("external test file detection", () => {
|
|
71
|
+
const patterns = [
|
|
72
|
+
["test_orders.py", true],
|
|
73
|
+
["orders_test.py", true],
|
|
74
|
+
["orders.test.ts", true],
|
|
75
|
+
["orders.spec.js", true],
|
|
76
|
+
["OrdersTest.java", true],
|
|
77
|
+
["orders_test.go", true],
|
|
78
|
+
["orders_spec.rb", true],
|
|
79
|
+
["OrdersTest.kt", true],
|
|
80
|
+
["OrdersTests.cs", true],
|
|
81
|
+
["orders_test.rs", true],
|
|
82
|
+
["OrdersTest.php", true],
|
|
83
|
+
["test_orders.rb", true],
|
|
84
|
+
["utils.py", false],
|
|
85
|
+
["main.ts", false],
|
|
86
|
+
["main.go", false],
|
|
87
|
+
];
|
|
88
|
+
it.each(patterns)("file %s → isExternal: %s", async (filename, expected) => {
|
|
89
|
+
writeFile(filename, 'import pytest\ndef test_something():\n pass');
|
|
90
|
+
const result = await service.discoverTests(tmpDir);
|
|
91
|
+
const externalTests = result.tests.filter((t) => t.source === TestSource.External);
|
|
92
|
+
if (expected) {
|
|
93
|
+
expect(externalTests.length).toBe(1);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
expect(externalTests.length).toBe(0);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
it("detects files in test directories", async () => {
|
|
100
|
+
writeFile("tests/helpers.py", 'import requests\nrequests.get("/api/health")');
|
|
101
|
+
const result = await service.discoverTests(tmpDir);
|
|
102
|
+
const externalTests = result.tests.filter((t) => t.source === TestSource.External);
|
|
103
|
+
expect(externalTests.length).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
it("detects files in __tests__ directory", async () => {
|
|
106
|
+
writeFile("src/__tests__/api.ts", 'import axios from "axios";\naxios.get("/api/items");');
|
|
107
|
+
const result = await service.discoverTests(tmpDir);
|
|
108
|
+
const externalTests = result.tests.filter((t) => t.source === TestSource.External);
|
|
109
|
+
expect(externalTests.length).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
// ── extractExternalEndpoints ──
|
|
113
|
+
describe("extractExternalEndpoints", () => {
|
|
114
|
+
it("extracts endpoints from Python requests library", async () => {
|
|
115
|
+
writeFile("test_api.py", 'import requests\nrequests.get("/api/orders")\nrequests.post("/api/orders")');
|
|
116
|
+
const result = await service.discoverTests(tmpDir);
|
|
117
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
118
|
+
expect(ext).toBeDefined();
|
|
119
|
+
expect(ext.apiEndpoint).toContain("GET /api/orders");
|
|
120
|
+
expect(ext.apiEndpoint).toContain("POST /api/orders");
|
|
121
|
+
});
|
|
122
|
+
it("extracts endpoints from Python test client", async () => {
|
|
123
|
+
writeFile("test_api.py", 'def test_orders(client):\n client.get("/api/orders")\n client.post("/api/orders")');
|
|
124
|
+
const result = await service.discoverTests(tmpDir);
|
|
125
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
126
|
+
expect(ext).toBeDefined();
|
|
127
|
+
expect(ext.apiEndpoint).toContain("GET /api/orders");
|
|
128
|
+
});
|
|
129
|
+
it("extracts endpoints from axios", async () => {
|
|
130
|
+
writeFile("api.test.ts", 'import axios from "axios";\naxios.get("/api/items");\naxios.post("/api/items");');
|
|
131
|
+
const result = await service.discoverTests(tmpDir);
|
|
132
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
133
|
+
expect(ext).toBeDefined();
|
|
134
|
+
expect(ext.apiEndpoint).toContain("GET /api/items");
|
|
135
|
+
expect(ext.apiEndpoint).toContain("POST /api/items");
|
|
136
|
+
});
|
|
137
|
+
it("extracts endpoints from supertest", async () => {
|
|
138
|
+
writeFile("api.test.ts", 'import supertest from "supertest";\nconst req = supertest(app);\nreq.get("/api/users");\nreq.post("/api/users");');
|
|
139
|
+
const result = await service.discoverTests(tmpDir);
|
|
140
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
141
|
+
expect(ext).toBeDefined();
|
|
142
|
+
expect(ext.apiEndpoint).toContain("GET /api/users");
|
|
143
|
+
expect(ext.apiEndpoint).toContain("POST /api/users");
|
|
144
|
+
});
|
|
145
|
+
it("extracts endpoints from fetch with explicit method", async () => {
|
|
146
|
+
writeFile("api.test.ts", 'fetch("/api/orders", { method: "POST" });\nfetch("/api/orders");');
|
|
147
|
+
const result = await service.discoverTests(tmpDir);
|
|
148
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
149
|
+
expect(ext).toBeDefined();
|
|
150
|
+
expect(ext.apiEndpoint).toContain("POST /api/orders");
|
|
151
|
+
expect(ext.apiEndpoint).toContain("GET /api/orders");
|
|
152
|
+
});
|
|
153
|
+
it("strips base URL from endpoints", async () => {
|
|
154
|
+
writeFile("test_api.py", 'import requests\nrequests.get("http://localhost:8000/api/orders")');
|
|
155
|
+
const result = await service.discoverTests(tmpDir);
|
|
156
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
157
|
+
expect(ext).toBeDefined();
|
|
158
|
+
expect(ext.apiEndpoint).toContain("GET /api/orders");
|
|
159
|
+
expect(ext.apiEndpoint).not.toContain("localhost");
|
|
160
|
+
});
|
|
161
|
+
it("extracts endpoints from Java RestAssured", async () => {
|
|
162
|
+
writeFile("OrdersTest.java", 'import io.restassured.*;\ngiven().when().get("/api/orders").then();');
|
|
163
|
+
const result = await service.discoverTests(tmpDir);
|
|
164
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
165
|
+
expect(ext).toBeDefined();
|
|
166
|
+
expect(ext.apiEndpoint).toContain("GET /api/orders");
|
|
167
|
+
});
|
|
168
|
+
it("extracts endpoints from Go http.NewRequest", async () => {
|
|
169
|
+
writeFile("orders_test.go", 'package main\nimport "testing"\nimport "net/http"\nfunc TestOrders(t *testing.T) {\n req, _ := http.NewRequest("GET", "/api/orders", nil)\n http.NewRequest("POST", "/api/orders", body)\n}');
|
|
170
|
+
const result = await service.discoverTests(tmpDir);
|
|
171
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
172
|
+
expect(ext).toBeDefined();
|
|
173
|
+
expect(ext.apiEndpoint).toContain("GET /api/orders");
|
|
174
|
+
expect(ext.apiEndpoint).toContain("POST /api/orders");
|
|
175
|
+
});
|
|
176
|
+
it("extracts endpoints from C# HttpClient", async () => {
|
|
177
|
+
writeFile("OrdersTests.cs", 'using Xunit;\npublic class OrdersTests {\n [Fact]\n public async Task GetOrders() {\n await client.GetAsync("/api/orders");\n await client.PostAsync("/api/orders", content);\n }\n}');
|
|
178
|
+
const result = await service.discoverTests(tmpDir);
|
|
179
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
180
|
+
expect(ext).toBeDefined();
|
|
181
|
+
expect(ext.apiEndpoint).toContain("GET /api/orders");
|
|
182
|
+
expect(ext.apiEndpoint).toContain("POST /api/orders");
|
|
183
|
+
});
|
|
184
|
+
it("extracts endpoints from Ruby Net::HTTP", async () => {
|
|
185
|
+
writeFile("orders_spec.rb", 'require "rspec"\nRSpec.describe "Orders" do\n it "lists" do\n Net::HTTP.get("/api/orders")\n Net::HTTP.post("/api/orders", body)\n end\nend');
|
|
186
|
+
const result = await service.discoverTests(tmpDir);
|
|
187
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
188
|
+
expect(ext).toBeDefined();
|
|
189
|
+
expect(ext.apiEndpoint).toContain("GET /api/orders");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
// ── detectExternalTestType ──
|
|
193
|
+
describe("detectExternalTestType", () => {
|
|
194
|
+
it("detects integration from filename", async () => {
|
|
195
|
+
writeFile("test_orders_integration.py", "import pytest\ndef test(): pass");
|
|
196
|
+
const result = await service.discoverTests(tmpDir);
|
|
197
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
198
|
+
expect(ext.testType).toBe("integration");
|
|
199
|
+
});
|
|
200
|
+
it("detects e2e from filename", async () => {
|
|
201
|
+
writeFile("test_e2e_flow.py", "import pytest\ndef test(): pass");
|
|
202
|
+
const result = await service.discoverTests(tmpDir);
|
|
203
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
204
|
+
expect(ext.testType).toBe("e2e");
|
|
205
|
+
});
|
|
206
|
+
it("detects contract from filename", async () => {
|
|
207
|
+
writeFile("test_contract.py", "import pytest\ndef test(): pass");
|
|
208
|
+
const result = await service.discoverTests(tmpDir);
|
|
209
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
210
|
+
expect(ext.testType).toBe("contract");
|
|
211
|
+
});
|
|
212
|
+
it("falls back to integration for files with HTTP calls", async () => {
|
|
213
|
+
writeFile("test_api.py", 'import requests\nrequests.get("/api/orders")');
|
|
214
|
+
const result = await service.discoverTests(tmpDir);
|
|
215
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
216
|
+
expect(ext.testType).toBe("integration");
|
|
217
|
+
});
|
|
218
|
+
it("returns unknown when no heuristic matches", async () => {
|
|
219
|
+
writeFile("test_utils.py", "def test_helper():\n assert True");
|
|
220
|
+
const result = await service.discoverTests(tmpDir);
|
|
221
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
222
|
+
expect(ext.testType).toBe("unknown");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
// ── detectExternalFramework ──
|
|
226
|
+
describe("detectExternalFramework", () => {
|
|
227
|
+
it("detects pytest", async () => {
|
|
228
|
+
writeFile("test_api.py", "import pytest\ndef test(): pass");
|
|
229
|
+
const result = await service.discoverTests(tmpDir);
|
|
230
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
231
|
+
expect(ext.framework).toBe("pytest");
|
|
232
|
+
});
|
|
233
|
+
it("detects jest", async () => {
|
|
234
|
+
writeFile("api.test.ts", 'import { jest } from "jest";\ndescribe("api", () => {});');
|
|
235
|
+
const result = await service.discoverTests(tmpDir);
|
|
236
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
237
|
+
expect(ext.framework).toBe("jest");
|
|
238
|
+
});
|
|
239
|
+
it("detects junit", async () => {
|
|
240
|
+
writeFile("OrdersTest.java", "import org.junit.Test;\n@Test\npublic void test() {}");
|
|
241
|
+
const result = await service.discoverTests(tmpDir);
|
|
242
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
243
|
+
expect(ext.framework).toBe("junit");
|
|
244
|
+
});
|
|
245
|
+
it("detects supertest", async () => {
|
|
246
|
+
writeFile("api.test.ts", 'import supertest from "supertest";\nconst req = supertest(app);');
|
|
247
|
+
const result = await service.discoverTests(tmpDir);
|
|
248
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
249
|
+
expect(ext.framework).toBe("supertest");
|
|
250
|
+
});
|
|
251
|
+
it("detects go-testing", async () => {
|
|
252
|
+
writeFile("orders_test.go", 'package main\nimport "testing"\nfunc TestOrders(t *testing.T) {}');
|
|
253
|
+
const result = await service.discoverTests(tmpDir);
|
|
254
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
255
|
+
expect(ext.framework).toBe("go-testing");
|
|
256
|
+
});
|
|
257
|
+
it("detects rspec", async () => {
|
|
258
|
+
writeFile("orders_spec.rb", 'require "rspec"\nRSpec.describe "Orders" do\n it "works" do; end\nend');
|
|
259
|
+
const result = await service.discoverTests(tmpDir);
|
|
260
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
261
|
+
expect(ext.framework).toBe("rspec");
|
|
262
|
+
});
|
|
263
|
+
it("detects xunit", async () => {
|
|
264
|
+
writeFile("OrdersTests.cs", 'using Xunit;\npublic class OrdersTests {\n [Fact]\n public void Test() {}\n}');
|
|
265
|
+
const result = await service.discoverTests(tmpDir);
|
|
266
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
267
|
+
expect(ext.framework).toBe("xunit");
|
|
268
|
+
});
|
|
269
|
+
it("detects phpunit", async () => {
|
|
270
|
+
writeFile("OrdersTest.php", '<?php\nuse PHPUnit\\Framework\\TestCase;\nclass OrdersTest extends TestCase {\n public function testList() {}\n}');
|
|
271
|
+
const result = await service.discoverTests(tmpDir);
|
|
272
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
273
|
+
expect(ext.framework).toBe("phpunit");
|
|
274
|
+
});
|
|
275
|
+
it("detects rust-test", async () => {
|
|
276
|
+
writeFile("orders_test.rs", '#[cfg(test)]\nmod tests {\n #[test]\n fn test_orders() {}\n}');
|
|
277
|
+
const result = await service.discoverTests(tmpDir);
|
|
278
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
279
|
+
expect(ext.framework).toBe("rust-test");
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
// ── source field ──
|
|
283
|
+
describe("source field tagging", () => {
|
|
284
|
+
it("tags Skyramp tests with source: skyramp", async () => {
|
|
285
|
+
writeFile("test_smoke.py", '# Generated by Skyramp\nskyramp generate smoke rest');
|
|
286
|
+
const result = await service.discoverTests(tmpDir);
|
|
287
|
+
expect(result.tests[0].source).toBe(TestSource.Skyramp);
|
|
288
|
+
});
|
|
289
|
+
it("tags external tests with source: external", async () => {
|
|
290
|
+
writeFile("test_api.py", "import pytest\ndef test(): pass");
|
|
291
|
+
const result = await service.discoverTests(tmpDir);
|
|
292
|
+
expect(result.tests[0].source).toBe(TestSource.External);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
// ── relevance-based partitioning ──
|
|
296
|
+
describe("relevance-based external test partitioning", () => {
|
|
297
|
+
it("returns all external test files without a hard cap in PR mode", async () => {
|
|
298
|
+
for (let i = 0; i < 60; i++) {
|
|
299
|
+
writeFile(`test_file_${i}.py`, `def test_${i}(): pass`);
|
|
300
|
+
}
|
|
301
|
+
const result = await service.discoverTests(tmpDir, { changedResources: ["file"] });
|
|
302
|
+
const externalTests = result.tests.filter((t) => t.source === TestSource.External);
|
|
303
|
+
expect(externalTests.length).toBe(60);
|
|
304
|
+
});
|
|
305
|
+
it("extracts endpoints only for relevant files when changedResources provided", async () => {
|
|
306
|
+
writeFile("test_orders_api.py", 'import requests\nrequests.get("/api/orders")');
|
|
307
|
+
writeFile("test_products_api.py", 'import requests\nrequests.get("/api/products")');
|
|
308
|
+
const result = await service.discoverTests(tmpDir, { changedResources: ["orders"] });
|
|
309
|
+
const externalTests = result.tests.filter((t) => t.source === TestSource.External);
|
|
310
|
+
expect(externalTests.length).toBe(2);
|
|
311
|
+
const ordersTest = externalTests.find(t => t.testFile.includes("orders"));
|
|
312
|
+
const productsTest = externalTests.find(t => t.testFile.includes("products"));
|
|
313
|
+
// Relevant file gets endpoint extraction
|
|
314
|
+
expect(ordersTest?.apiEndpoint).toContain("GET /api/orders");
|
|
315
|
+
// Low-relevance file gets name-only entry (no endpoint extraction)
|
|
316
|
+
expect(productsTest?.apiEndpoint).toBe("");
|
|
317
|
+
});
|
|
318
|
+
it("returns relevantExternalTestPaths including files where extraction may have failed", async () => {
|
|
319
|
+
writeFile("test_orders_api.py", 'import requests\nrequests.get("/api/orders")');
|
|
320
|
+
writeFile("test_orders_helpers.py", 'def create_order(): pass'); // no extractable endpoints
|
|
321
|
+
writeFile("test_products_api.py", 'import requests\nrequests.get("/api/products")');
|
|
322
|
+
const result = await service.discoverTests(tmpDir, { changedResources: ["orders"] });
|
|
323
|
+
// Both orders-related files should be in relevantExternalTestPaths
|
|
324
|
+
expect(result.relevantExternalTestPaths.some(p => p.includes("test_orders_api"))).toBe(true);
|
|
325
|
+
expect(result.relevantExternalTestPaths.some(p => p.includes("test_orders_helpers"))).toBe(true);
|
|
326
|
+
// Products file should NOT be in relevantExternalTestPaths
|
|
327
|
+
expect(result.relevantExternalTestPaths.some(p => p.includes("test_products_api"))).toBe(false);
|
|
328
|
+
});
|
|
329
|
+
it("does full extraction for all files when no changedResources provided", async () => {
|
|
330
|
+
writeFile("test_orders_api.py", 'import requests\nrequests.get("/api/orders")');
|
|
331
|
+
const result = await service.discoverTests(tmpDir);
|
|
332
|
+
const ext = result.tests.find((t) => t.source === TestSource.External);
|
|
333
|
+
expect(ext?.apiEndpoint).toContain("GET /api/orders");
|
|
334
|
+
});
|
|
335
|
+
it("caps full-repo mode at MAX_EXTERNAL_FULL_REPO for full extraction", async () => {
|
|
336
|
+
// Create 12 external test files to test the boundary (MAX_EXTERNAL_FULL_REPO=100 is too
|
|
337
|
+
// large to create in a unit test, so we verify the partitioning logic is wired correctly)
|
|
338
|
+
for (let i = 0; i < 12; i++) {
|
|
339
|
+
writeFile(`test_api_${i}.py`, `import requests\nrequests.get("/api/items/${i}")`);
|
|
340
|
+
}
|
|
341
|
+
const result = await service.discoverTests(tmpDir);
|
|
342
|
+
const externalTests = result.tests.filter(t => t.source === TestSource.External);
|
|
343
|
+
// All 12 files should be discovered
|
|
344
|
+
expect(externalTests.length).toBe(12);
|
|
345
|
+
// In full-repo mode (no changedResources) all files under cap go to relevantExternal
|
|
346
|
+
expect(result.relevantExternalTestPaths.length).toBe(12);
|
|
347
|
+
// All relevant files get full extraction (apiEndpoint populated)
|
|
348
|
+
const withEndpoints = externalTests.filter(t => t.apiEndpoint !== "");
|
|
349
|
+
expect(withEndpoints.length).toBe(12);
|
|
350
|
+
});
|
|
351
|
+
it("low-relevance files have empty apiEndpoint and empty framework in PR mode", async () => {
|
|
352
|
+
writeFile("test_orders_api.py", 'import requests\nrequests.get("/api/orders")');
|
|
353
|
+
writeFile("test_products_api.py", 'import requests\nrequests.get("/api/products")');
|
|
354
|
+
const result = await service.discoverTests(tmpDir, { changedResources: ["orders"] });
|
|
355
|
+
const productsTest = result.tests.find(t => t.testFile.includes("test_products_api"));
|
|
356
|
+
expect(productsTest?.source).toBe(TestSource.External);
|
|
357
|
+
expect(productsTest?.apiEndpoint).toBe("");
|
|
358
|
+
expect(productsTest?.framework).toBe("");
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { TestType } from "../../types/TestTypes.js";
|
|
3
3
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { getContractProviderAssertionsPrompt } from "../../prompts/enhance-assertions/contractProviderAssertionsPrompt.js";
|
|
5
|
+
import { getIntegrationAssertionsPrompt } from "../../prompts/enhance-assertions/integrationAssertionsPrompt.js";
|
|
6
|
+
import { getUIAssertionsPrompt } from "../../prompts/enhance-assertions/uiAssertionsPrompt.js";
|
|
7
7
|
const TOOL_NAME = "skyramp_enhance_assertions";
|
|
8
|
-
const SCOPE_GENERATION = "Apply to every test function in the generated file.";
|
|
9
|
-
const SCOPE_MAINTENANCE = "Apply to **new test functions you are adding** and **existing functions that cover endpoints changed in the diff** only. Do NOT touch existing functions for endpoints unrelated to the diff.";
|
|
10
8
|
const TESTBOT_UI_CHECKS = `
|
|
11
9
|
### Additional Testbot-Specific Checks
|
|
12
10
|
- If no suitable selector exists in the generated file for an assertion you need to add, go back and call \`browser_assert\` on the live page to record it with a valid selector, then re-export and regenerate.
|
|
@@ -35,28 +33,28 @@ export function registerEnhanceAssertionsTool(server) {
|
|
|
35
33
|
inputSchema: enhanceAssertionsSchema,
|
|
36
34
|
}, async (params) => {
|
|
37
35
|
const { testFile, testType, enhanceType } = params;
|
|
36
|
+
const enhanceCtx = enhanceType;
|
|
38
37
|
let instructions;
|
|
39
38
|
if (testType === TestType.UI) {
|
|
40
|
-
instructions =
|
|
39
|
+
instructions = getUIAssertionsPrompt(testFile, enhanceCtx);
|
|
41
40
|
if (process.env.SKYRAMP_FEATURE_TESTBOT === "1") {
|
|
42
41
|
instructions += TESTBOT_UI_CHECKS;
|
|
43
42
|
}
|
|
44
43
|
}
|
|
45
44
|
else if (testType === TestType.CONTRACT) {
|
|
46
|
-
instructions =
|
|
45
|
+
instructions = getContractProviderAssertionsPrompt(testFile, enhanceCtx);
|
|
47
46
|
}
|
|
48
47
|
else if (testType === TestType.INTEGRATION) {
|
|
49
|
-
instructions =
|
|
48
|
+
instructions = getIntegrationAssertionsPrompt(testFile, enhanceCtx);
|
|
50
49
|
}
|
|
51
50
|
else {
|
|
52
51
|
throw new Error(`Unsupported testType for ${TOOL_NAME}: ${testType}`);
|
|
53
52
|
}
|
|
54
|
-
const scope = enhanceType === "maintenance" ? SCOPE_MAINTENANCE : SCOPE_GENERATION;
|
|
55
53
|
const result = {
|
|
56
54
|
content: [
|
|
57
55
|
{
|
|
58
56
|
type: "text",
|
|
59
|
-
text:
|
|
57
|
+
text: instructions,
|
|
60
58
|
},
|
|
61
59
|
],
|
|
62
60
|
isError: false,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { logger } from "../../utils/logger.js";
|
|
3
3
|
import { StateManager, } from "../../utils/AnalysisStateManager.js";
|
|
4
|
+
import { TestSource } from "../../types/TestAnalysis.js";
|
|
4
5
|
import { TestType } from "../../types/TestTypes.js";
|
|
5
6
|
import * as fs from "fs";
|
|
6
7
|
import * as path from "path";
|
|
@@ -106,7 +107,9 @@ Comprehensive report with executed actions, summary, and instructions for ADD re
|
|
|
106
107
|
};
|
|
107
108
|
return errorResult;
|
|
108
109
|
}
|
|
109
|
-
|
|
110
|
+
// External tests must not be candidates for UPDATE/REGENERATE/DELETE actions.
|
|
111
|
+
// Default source to Skyramp for backwards compat with state files created before the source field existed.
|
|
112
|
+
const testAnalysisResults = (stateData.existingTests || []).filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External);
|
|
110
113
|
const newEndpoints = stateData.newEndpoints || [];
|
|
111
114
|
// ── Build recommendations from existing tests ──
|
|
112
115
|
const recommendations = [];
|
|
@@ -12,12 +12,13 @@ import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-r
|
|
|
12
12
|
import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
|
|
13
13
|
import { ScenarioSource, AnalysisScope } from "../../types/RepositoryAnalysis.js";
|
|
14
14
|
import { computeBranchDiff } from "../../utils/branchDiff.js";
|
|
15
|
-
import { parseEndpointsFromDiff, resolveEndpointPaths, } from "../../utils/routeParsers.js";
|
|
15
|
+
import { parseEndpointsFromDiff, resolveEndpointPaths, extractResourceFromPath, } from "../../utils/routeParsers.js";
|
|
16
16
|
import { scanAllRepoEndpoints, scanRelatedEndpoints, grepRouterMountingContext, findCandidateRouteFiles, } from "../../utils/repoScanner.js";
|
|
17
17
|
import { detectProjectMetadata } from "../../utils/projectMetadata.js";
|
|
18
18
|
import { draftScenariosFromEndpoints } from "../../utils/scenarioDrafting.js";
|
|
19
19
|
import { buildAnalysisOutputText } from "../../prompts/test-recommendation/analysisOutputPrompt.js";
|
|
20
20
|
import { parseTraceFile, discoverTraceFiles, discoverPlaywrightZips, } from "../../utils/trace-parser.js";
|
|
21
|
+
import { TestSource } from "../../types/TestAnalysis.js";
|
|
21
22
|
import { parsePRComments } from "../../utils/pr-comment-parser.js";
|
|
22
23
|
const TOOL_NAME = "skyramp_analyze_changes";
|
|
23
24
|
// Must match testbot/src/constants.ts BOT_EMAIL
|
|
@@ -318,10 +319,21 @@ to produce a unified state file for the test health workflow.
|
|
|
318
319
|
}
|
|
319
320
|
await sendProgress(50, 100, "Discovering existing tests...");
|
|
320
321
|
// ── Step 3: Discover existing tests ──
|
|
322
|
+
// In PR mode, extract resource names from the diff endpoints so that
|
|
323
|
+
// TestDiscoveryService can limit full extraction to relevant files only.
|
|
324
|
+
const changedResources = parsedDiff
|
|
325
|
+
? [
|
|
326
|
+
...parsedDiff.newEndpoints,
|
|
327
|
+
...parsedDiff.modifiedEndpoints,
|
|
328
|
+
]
|
|
329
|
+
.map(ep => extractResourceFromPath(ep.path))
|
|
330
|
+
.filter((r, i, arr) => r !== "unknown" && arr.indexOf(r) === i)
|
|
331
|
+
: [];
|
|
321
332
|
let existingTests = [];
|
|
333
|
+
let discoveredRelevantExternalPaths = [];
|
|
322
334
|
try {
|
|
323
335
|
const testDiscoveryService = new TestDiscoveryService();
|
|
324
|
-
const discoveryResult = await testDiscoveryService.discoverTests(params.repositoryPath);
|
|
336
|
+
const discoveryResult = await testDiscoveryService.discoverTests(params.repositoryPath, { changedResources });
|
|
325
337
|
existingTests = discoveryResult.tests.map((test) => ({
|
|
326
338
|
testFile: test.testFile,
|
|
327
339
|
testType: test.testType,
|
|
@@ -329,10 +341,15 @@ to produce a unified state file for the test health workflow.
|
|
|
329
341
|
framework: test.framework,
|
|
330
342
|
apiSchema: test.apiSchema,
|
|
331
343
|
apiEndpoint: test.apiEndpoint,
|
|
344
|
+
source: test.source,
|
|
332
345
|
generatedAt: test.generatedAt,
|
|
333
346
|
}));
|
|
347
|
+
// Use the authoritative list from discovery — includes relevant files even
|
|
348
|
+
// when regex endpoint extraction failed (e.g. helper-abstracted HTTP calls).
|
|
349
|
+
discoveredRelevantExternalPaths = discoveryResult.relevantExternalTestPaths;
|
|
334
350
|
logger.info("Test discovery completed", {
|
|
335
351
|
totalTests: existingTests.length,
|
|
352
|
+
relevantExternal: discoveredRelevantExternalPaths.length,
|
|
336
353
|
});
|
|
337
354
|
}
|
|
338
355
|
catch (err) {
|
|
@@ -562,18 +579,67 @@ to produce a unified state file for the test health workflow.
|
|
|
562
579
|
await sendProgress(80, 100, "Building unified state...");
|
|
563
580
|
// ── Step 10: Build full RepositoryAnalysis for ranked recommendations ──
|
|
564
581
|
const sessionId = crypto.randomUUID();
|
|
565
|
-
// Build existing test locations map (type → file list) for deduplication in recommendations
|
|
566
|
-
//
|
|
582
|
+
// Build existing test locations map (type → file list) for deduplication in recommendations.
|
|
583
|
+
// Order per type: relevant external (with covers) → Skyramp → low-relevance external (name-only).
|
|
584
|
+
// This ensures the LLM and programmatic dedup encounter external coverage signals first.
|
|
585
|
+
// testFile is kept absolute for filesystem use; displayPath is relativized for prompt display.
|
|
586
|
+
const relevantExternalSet = new Set(discoveredRelevantExternalPaths);
|
|
587
|
+
const displayPath = (absPath) => path.relative(params.repositoryPath, absPath);
|
|
567
588
|
const testLocationsByType = {};
|
|
568
|
-
|
|
569
|
-
const type = t.testType || "unknown";
|
|
570
|
-
const entry = t.apiEndpoint
|
|
571
|
-
? `${t.testFile} (covers: ${t.apiEndpoint})`
|
|
572
|
-
: t.testFile;
|
|
589
|
+
const addEntry = (type, entry) => {
|
|
573
590
|
testLocationsByType[type] = testLocationsByType[type]
|
|
574
591
|
? `${testLocationsByType[type]}, ${entry}`
|
|
575
592
|
: entry;
|
|
593
|
+
};
|
|
594
|
+
// Pass 1: relevant external tests with extracted endpoints
|
|
595
|
+
for (const t of existingTests) {
|
|
596
|
+
if (t.source !== TestSource.External)
|
|
597
|
+
continue;
|
|
598
|
+
if (!relevantExternalSet.has(t.testFile))
|
|
599
|
+
continue;
|
|
600
|
+
const dp = displayPath(t.testFile);
|
|
601
|
+
const entry = t.apiEndpoint
|
|
602
|
+
? `${dp} [external] (covers: ${t.apiEndpoint})`
|
|
603
|
+
: `${dp} [external]`;
|
|
604
|
+
addEntry(t.testType || "unknown", entry);
|
|
605
|
+
}
|
|
606
|
+
// Pass 2: Skyramp tests — tagged [skyramp] so the LLM can positively identify the source
|
|
607
|
+
for (const t of existingTests) {
|
|
608
|
+
if (t.source === TestSource.External)
|
|
609
|
+
continue;
|
|
610
|
+
const dp = displayPath(t.testFile);
|
|
611
|
+
const entry = t.apiEndpoint
|
|
612
|
+
? `${dp} [skyramp] (covers: ${t.apiEndpoint})`
|
|
613
|
+
: `${dp} [skyramp]`;
|
|
614
|
+
addEntry(t.testType || "unknown", entry);
|
|
615
|
+
}
|
|
616
|
+
// Pass 3: low-relevance external tests (name-only) — capped to avoid context overflow
|
|
617
|
+
const LOW_RELEVANCE_CAP = 20;
|
|
618
|
+
let lowRelevanceCount = 0;
|
|
619
|
+
let lowRelevanceSuppressed = 0;
|
|
620
|
+
for (const t of existingTests) {
|
|
621
|
+
if (t.source !== TestSource.External)
|
|
622
|
+
continue;
|
|
623
|
+
if (relevantExternalSet.has(t.testFile))
|
|
624
|
+
continue;
|
|
625
|
+
if (lowRelevanceCount < LOW_RELEVANCE_CAP) {
|
|
626
|
+
addEntry(t.testType || "unknown", `${displayPath(t.testFile)} [external]`);
|
|
627
|
+
lowRelevanceCount++;
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
lowRelevanceSuppressed++;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (lowRelevanceSuppressed > 0) {
|
|
634
|
+
// Append a summary so the LLM knows more files exist
|
|
635
|
+
const summaryType = "unknown";
|
|
636
|
+
addEntry(summaryType, `[${lowRelevanceSuppressed} more external test file(s) not shown — low relevance to this PR]`);
|
|
576
637
|
}
|
|
638
|
+
// Use the authoritative relevant paths from discovery — includes files where
|
|
639
|
+
// regex extraction failed but path/name scoring identified as PR-relevant.
|
|
640
|
+
// Relativize against repositoryPath to avoid leaking absolute machine paths
|
|
641
|
+
// into the prompt, state file, and PR reports.
|
|
642
|
+
const relevantExternalTestPaths = discoveredRelevantExternalPaths.map(p => path.relative(params.repositoryPath, p));
|
|
577
643
|
// Build the full RepositoryAnalysis object — same structure as analyzeRepositoryTool
|
|
578
644
|
// so buildRecommendationPrompt can reason over enriched endpoint + scenario data
|
|
579
645
|
const diffContext = parsedDiff ? {
|
|
@@ -661,6 +727,7 @@ to produce a unified state file for the test health workflow.
|
|
|
661
727
|
coverage: { unit: 0, integration: 0, e2e: 0, ui: 0, load: 0, contract: 0, smoke: 0 },
|
|
662
728
|
testLocations: testLocationsByType,
|
|
663
729
|
hasCoverageReports: false,
|
|
730
|
+
relevantExternalTestPaths,
|
|
664
731
|
},
|
|
665
732
|
...(diffContext ? { branchDiffContext: diffContext } : {}),
|
|
666
733
|
};
|
|
@@ -4,6 +4,7 @@ import { StateManager, } from "../../utils/AnalysisStateManager.js";
|
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
6
6
|
import { buildDriftAnalysisPrompt } from "../../prompts/test-maintenance/drift-analysis-prompt.js";
|
|
7
|
+
import { TestSource } from "../../types/TestAnalysis.js";
|
|
7
8
|
const TOOL_NAME = "skyramp_analyze_test_health";
|
|
8
9
|
export function registerAnalyzeTestHealthTool(server) {
|
|
9
10
|
server.registerTool(TOOL_NAME, {
|
|
@@ -52,8 +53,11 @@ exist only in the LLM's reasoning context and are acted on by \`skyramp_actions\
|
|
|
52
53
|
};
|
|
53
54
|
return errorResult;
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
// Only Skyramp tests are candidates for drift analysis and maintenance actions.
|
|
57
|
+
// External (user-written) tests are used only for recommendation deduplication.
|
|
58
|
+
// Default source to Skyramp for backwards compat with state files created before the source field existed.
|
|
59
|
+
const existingTests = (stateData.existingTests || []).filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External);
|
|
60
|
+
logger.info(`Loaded ${existingTests.length} existing Skyramp tests from state file (excluded external)`);
|
|
57
61
|
if (!repositoryPath || typeof repositoryPath !== "string") {
|
|
58
62
|
errorResult = {
|
|
59
63
|
content: [
|
|
@@ -229,6 +229,7 @@ export const repositoryAnalysisSchema = z.object({
|
|
|
229
229
|
testLocations: z.record(z.string()),
|
|
230
230
|
hasCoverageReports: z.boolean(),
|
|
231
231
|
estimatedCoverage: z.number().optional(),
|
|
232
|
+
relevantExternalTestPaths: z.array(z.string()).optional(),
|
|
232
233
|
}),
|
|
233
234
|
branchDiffContext: branchDiffContextSchema.optional(),
|
|
234
235
|
});
|
|
@@ -1 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
/** Origin of a test file — whether it was generated by Skyramp or is user/third-party maintained. */
|
|
2
|
+
export var TestSource;
|
|
3
|
+
(function (TestSource) {
|
|
4
|
+
TestSource["Skyramp"] = "skyramp";
|
|
5
|
+
TestSource["External"] = "external";
|
|
6
|
+
})(TestSource || (TestSource = {}));
|
|
@@ -54,7 +54,7 @@ describe("dockerImageExistsLocally", () => {
|
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
56
|
describe("pullDockerImage", () => {
|
|
57
|
-
const IMAGE = "skyramp/executor:v1.3.
|
|
57
|
+
const IMAGE = "skyramp/executor:v1.3.23";
|
|
58
58
|
beforeEach(() => jest.clearAllMocks());
|
|
59
59
|
describe("on amd64 host", () => {
|
|
60
60
|
const originalArch = process.arch;
|
|
@@ -323,6 +323,13 @@ export function parseEndpointsFromDiff(diffData) {
|
|
|
323
323
|
affectedServices,
|
|
324
324
|
};
|
|
325
325
|
}
|
|
326
|
+
export const SKIP_PATH_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
|
|
327
|
+
/** Extract the primary resource name from an endpoint path (e.g. "/api/v1/orders/{id}" → "orders"). */
|
|
328
|
+
export function extractResourceFromPath(endpointPath) {
|
|
329
|
+
const segments = endpointPath.split("/").filter(Boolean);
|
|
330
|
+
const meaningful = segments.filter(s => !s.startsWith("{") && !SKIP_PATH_SEGMENTS.has(s));
|
|
331
|
+
return meaningful[meaningful.length - 1] || "unknown";
|
|
332
|
+
}
|
|
326
333
|
/**
|
|
327
334
|
* Resolve incomplete diff-parsed endpoint paths against the authoritative
|
|
328
335
|
* scanned endpoint catalog. Route decorators in diffs often contain only the
|