@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.
@@ -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 { CONTRACT_PROVIDER_ASSERTIONS_PROMPT } from "../../prompts/enhance-assertions/contractProviderAssertionsPrompt.js";
5
- import { INTEGRATION_ASSERTIONS_PROMPT } from "../../prompts/enhance-assertions/integrationAssertionsPrompt.js";
6
- import { UI_ASSERTIONS_PROMPT } from "../../prompts/enhance-assertions/uiAssertionsPrompt.js";
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 = UI_ASSERTIONS_PROMPT;
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 = CONTRACT_PROVIDER_ASSERTIONS_PROMPT;
45
+ instructions = getContractProviderAssertionsPrompt(testFile, enhanceCtx);
47
46
  }
48
47
  else if (testType === TestType.INTEGRATION) {
49
- instructions = INTEGRATION_ASSERTIONS_PROMPT;
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: `**You MUST execute the following assertion enhancement instructions to modify the test file.**\n\n**Target file:** ${testFile}\n\n**Scope:** ${scope}\n\n${instructions}`,
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
- const testAnalysisResults = stateData.existingTests || [];
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
- // Include covered endpoints so the agent can cross-check resource paths before creating new files.
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
- for (const t of existingTests) {
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
- const existingTests = stateData.existingTests || [];
56
- logger.info(`Loaded ${existingTests.length} existing tests from state file`);
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
- export {};
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.22";
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