@skyramp/mcp 0.0.62 โ†’ 0.0.63-rc.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.
Files changed (28) hide show
  1. package/build/index.js +18 -26
  2. package/build/prompts/test-maintenance/drift-analysis-prompt.js +59 -0
  3. package/build/prompts/test-maintenance/driftAnalysisSections.js +153 -0
  4. package/build/prompts/test-recommendation/analysisOutputPrompt.js +21 -9
  5. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +34 -38
  6. package/build/prompts/test-recommendation/test-recommendation-prompt.js +56 -9
  7. package/build/prompts/testbot/testbot-prompts.js +113 -100
  8. package/build/services/DriftAnalysisService.js +1 -1
  9. package/build/services/ScenarioGenerationService.js +5 -1
  10. package/build/services/TestExecutionService.js +2 -24
  11. package/build/services/TestExecutionService.test.js +167 -0
  12. package/build/services/containerEnv.js +35 -0
  13. package/build/tools/generate-tests/generateScenarioRestTool.js +7 -1
  14. package/build/tools/submitReportTool.js +6 -6
  15. package/build/tools/test-management/actionsTool.js +396 -0
  16. package/build/tools/test-management/analyzeChangesTool.js +750 -0
  17. package/build/tools/test-management/analyzeTestHealthTool.js +132 -0
  18. package/build/tools/test-management/executeTestsTool.js +198 -0
  19. package/build/tools/test-management/index.js +5 -0
  20. package/build/tools/test-management/stateCleanupTool.js +163 -0
  21. package/build/tools/test-recommendation/recommendTestsTool.js +1 -1
  22. package/build/utils/analyze-openapi.js +2 -2
  23. package/build/utils/pr-comment-parser.js +157 -36
  24. package/build/utils/pr-comment-parser.test.js +427 -0
  25. package/package.json +1 -1
  26. package/build/tools/initTestbotTool.js +0 -187
  27. package/build/tools/initTestbotTool.test.js +0 -194
  28. package/build/tools/test-recommendation/analyzeRepositoryTool.js +0 -505
@@ -14,43 +14,167 @@ const TESTBOT_MARKERS = [
14
14
  "skyramp-testbot",
15
15
  ];
16
16
  const TEST_TYPE_PATTERN = /\b(Smoke|Contract|Integration|Fuzz|Load|E2E|UI)\b/gi;
17
- const ENDPOINT_PATTERN = /\b(GET|POST|PUT|PATCH|DELETE)\s+(\/\S+)/gi;
18
17
  const TEST_FILE_PATTERN = /[\w/.-]+(?:_test|_smoke|_contract|_fuzz|_integration|_load|_e2e|_ui)\.\w+/gi;
19
18
  const STATUS_PATTERN = /\b(Pass|Fail|Skipped)\b/gi;
20
- function isTestBotComment(body) {
21
- return TESTBOT_MARKERS.some((marker) => body.includes(marker));
19
+ function isTestBotComment(comment) {
20
+ const hasMarker = TESTBOT_MARKERS.some((marker) => comment.body.includes(marker));
21
+ if (!hasMarker)
22
+ return false;
23
+ // Require the comment author to be a bot account (suffix [bot]) to avoid
24
+ // false positives from human reviewers mentioning the bot by name.
25
+ const login = comment.user?.login ?? "";
26
+ return login.endsWith("[bot]") || login === "";
27
+ }
28
+ /**
29
+ * Section markers used by the bot's rendered report (see testbot/src/report.ts).
30
+ * We split on these to classify entries as implemented vs. recommended.
31
+ */
32
+ const SECTION_MARKERS = {
33
+ newTests: /New Tests Created/i,
34
+ additionalRecs: /Additional Recommendations/i,
35
+ testMaintenance: /Test Maintenance/i,
36
+ testResults: /Test Results/i,
37
+ issuesFound: /Issues Found/i,
38
+ };
39
+ /**
40
+ * Matches scenario names rendered as **`scenario-name`** in the Additional
41
+ * Recommendations section of the bot comment.
42
+ */
43
+ const SCENARIO_NAME_PATTERN = /\*\*`([^`]+)`\*\*/g;
44
+ /**
45
+ * Matches the multi-method endpoint format the report uses:
46
+ * "**Integration** for POST/GET/PUT/DELETE /items/ โ€” `test_file.py`"
47
+ * Captures the methods block and the path separately.
48
+ */
49
+ const MULTI_METHOD_ENDPOINT_PATTERN = /\b((?:GET|POST|PUT|PATCH|DELETE)(?:\/(?:GET|POST|PUT|PATCH|DELETE))*)\s+(\/[^\s`*\])>]+)/gi;
50
+ /**
51
+ * Split a comment body into named sections based on the rendered report headings.
52
+ */
53
+ function splitIntoSections(body) {
54
+ const lines = body.split("\n");
55
+ let currentSection = "rest";
56
+ const sections = { newTests: [], additionalRecs: [], rest: [] };
57
+ for (const line of lines) {
58
+ if (SECTION_MARKERS.newTests.test(line)) {
59
+ currentSection = "newTests";
60
+ continue;
61
+ }
62
+ if (SECTION_MARKERS.additionalRecs.test(line)) {
63
+ currentSection = "additionalRecs";
64
+ continue;
65
+ }
66
+ if (SECTION_MARKERS.testMaintenance.test(line) ||
67
+ SECTION_MARKERS.testResults.test(line) ||
68
+ SECTION_MARKERS.issuesFound.test(line)) {
69
+ currentSection = "rest";
70
+ continue;
71
+ }
72
+ sections[currentSection].push(line);
73
+ }
74
+ return {
75
+ newTests: sections.newTests.join("\n"),
76
+ additionalRecs: sections.additionalRecs.join("\n"),
77
+ rest: sections.rest.join("\n"),
78
+ };
79
+ }
80
+ /**
81
+ * Parse endpoints from a text block, handling the multi-method format
82
+ * (e.g. "POST/GET/PUT/DELETE /items/") by expanding into individual entries.
83
+ */
84
+ function parseEndpointsFromSection(text) {
85
+ const results = [];
86
+ for (const m of text.matchAll(MULTI_METHOD_ENDPOINT_PATTERN)) {
87
+ const methods = m[1].toUpperCase().split("/");
88
+ const path = m[2];
89
+ for (const method of methods) {
90
+ results.push({ method, path, index: m.index });
91
+ }
92
+ }
93
+ return results;
94
+ }
95
+ /**
96
+ * Find the regex match closest to `targetIndex` with distance strictly less
97
+ * than `maxDist` characters. Unlike Array.find (which returns the first
98
+ * match in array order), this returns the match with the smallest absolute
99
+ * distance.
100
+ */
101
+ function closestMatch(matches, targetIndex, maxDist) {
102
+ let best = null;
103
+ let bestDist = maxDist;
104
+ for (const m of matches) {
105
+ const dist = Math.abs(m.index - targetIndex);
106
+ if (dist < bestDist) {
107
+ best = m;
108
+ bestDist = dist;
109
+ }
110
+ }
111
+ return best;
22
112
  }
23
113
  function extractRecommendations(comment) {
24
114
  const results = [];
25
- const body = comment.body;
26
- const endpointMatches = [...body.matchAll(ENDPOINT_PATTERN)];
27
- const typeMatches = [...body.matchAll(TEST_TYPE_PATTERN)];
28
- const implementedFiles = [...body.matchAll(TEST_FILE_PATTERN)];
29
- const hasImplementedSection = body.includes("## New Tests Created") ||
30
- body.includes("newTestsCreated");
31
- for (const ep of endpointMatches) {
32
- const endpoint = `${ep[1].toUpperCase()} ${ep[2]}`;
33
- const nearbyType = typeMatches.find((t) => Math.abs(t.index - ep.index) < 200);
34
- const testType = nearbyType
35
- ? nearbyType[1].toLowerCase()
36
- : "unknown";
37
- const isImplemented = hasImplementedSection && implementedFiles.length > 0;
115
+ const commentId = String(comment.id);
116
+ const { newTests, additionalRecs } = splitIntoSections(comment.body);
117
+ // --- Implemented tests (from "New Tests Created" section) ---
118
+ const implEndpoints = parseEndpointsFromSection(newTests);
119
+ const implTypeMatches = [...newTests.matchAll(TEST_TYPE_PATTERN)];
120
+ for (const ep of implEndpoints) {
121
+ const nearbyType = closestMatch(implTypeMatches, ep.index, 200);
38
122
  results.push({
39
- testType,
40
- endpoint,
41
- status: isImplemented ? "implemented" : "recommended",
42
- commentId: String(comment.id),
123
+ testType: nearbyType ? nearbyType[1].toLowerCase() : "unknown",
124
+ endpoint: `${ep.method} ${ep.path}`,
125
+ status: "implemented",
126
+ commentId,
127
+ });
128
+ }
129
+ // --- Recommended-only tests (from "Additional Recommendations" section) ---
130
+ const recEndpoints = parseEndpointsFromSection(additionalRecs);
131
+ const recTypeMatches = [...additionalRecs.matchAll(TEST_TYPE_PATTERN)];
132
+ const scenarioNames = [...additionalRecs.matchAll(SCENARIO_NAME_PATTERN)];
133
+ for (const ep of recEndpoints) {
134
+ const nearbyType = closestMatch(recTypeMatches, ep.index, 200);
135
+ const nearbyScenario = closestMatch(scenarioNames, ep.index, 400);
136
+ results.push({
137
+ testType: nearbyType ? nearbyType[1].toLowerCase() : "unknown",
138
+ endpoint: `${ep.method} ${ep.path}`,
139
+ scenarioName: nearbyScenario ? nearbyScenario[1] : undefined,
140
+ status: "recommended",
141
+ commentId,
43
142
  });
44
143
  }
45
144
  return results;
46
145
  }
146
+ /**
147
+ * Extract the "Test Results" section text for isolated execution result parsing.
148
+ */
149
+ function extractTestResultsSection(fullBody) {
150
+ const lines = fullBody.split("\n");
151
+ let inTestResults = false;
152
+ const resultLines = [];
153
+ for (const line of lines) {
154
+ if (SECTION_MARKERS.testResults.test(line)) {
155
+ inTestResults = true;
156
+ continue;
157
+ }
158
+ if (inTestResults && (SECTION_MARKERS.testMaintenance.test(line) ||
159
+ SECTION_MARKERS.newTests.test(line) ||
160
+ SECTION_MARKERS.additionalRecs.test(line) ||
161
+ SECTION_MARKERS.issuesFound.test(line))) {
162
+ break;
163
+ }
164
+ if (inTestResults) {
165
+ resultLines.push(line);
166
+ }
167
+ }
168
+ return resultLines.join("\n");
169
+ }
47
170
  function extractExecutionResults(body) {
171
+ const testResultsText = extractTestResultsSection(body);
48
172
  const results = [];
49
- const fileMatches = [...body.matchAll(TEST_FILE_PATTERN)];
50
- const statusMatches = [...body.matchAll(STATUS_PATTERN)];
173
+ const fileMatches = [...testResultsText.matchAll(TEST_FILE_PATTERN)];
174
+ const statusMatches = [...testResultsText.matchAll(STATUS_PATTERN)];
51
175
  for (let i = 0; i < fileMatches.length; i++) {
52
176
  const file = fileMatches[i][0];
53
- const nearbyStatus = statusMatches.find((s) => Math.abs(s.index - fileMatches[i].index) < 150);
177
+ const nearbyStatus = closestMatch(statusMatches, fileMatches[i].index, 150);
54
178
  const statusStr = nearbyStatus?.[1].toLowerCase();
55
179
  const status = statusStr === "pass" ? "pass" : statusStr === "skipped" ? "skipped" : "fail";
56
180
  results.push({
@@ -62,7 +186,8 @@ function extractExecutionResults(body) {
62
186
  return results;
63
187
  }
64
188
  function extractImplementedFiles(body) {
65
- const matches = [...body.matchAll(TEST_FILE_PATTERN)];
189
+ const { newTests } = splitIntoSections(body);
190
+ const matches = [...newTests.matchAll(TEST_FILE_PATTERN)];
66
191
  return [...new Set(matches.map((m) => m[0]))];
67
192
  }
68
193
  /**
@@ -103,22 +228,18 @@ export async function parsePRComments(repoOwner, repoName, prNumber, _token) {
103
228
  logger.warning("Failed to parse PR comments JSON");
104
229
  return empty;
105
230
  }
106
- const testBotComments = comments.filter((c) => isTestBotComment(c.body));
231
+ const testBotComments = comments.filter((c) => isTestBotComment(c));
107
232
  if (testBotComments.length === 0) {
108
233
  return empty;
109
234
  }
110
- const allRecommendations = [];
111
- const allFiles = [];
112
- const allExecutionResults = [];
113
- for (const comment of testBotComments) {
114
- allRecommendations.push(...extractRecommendations(comment));
115
- allFiles.push(...extractImplementedFiles(comment.body));
116
- allExecutionResults.push(...extractExecutionResults(comment.body));
117
- }
235
+ // Use only the latest TestBot comment โ€” older comments represent stale
236
+ // state from previous bot runs. The latest comment has the most accurate
237
+ // picture of what was recommended, generated, and executed.
238
+ const latestComment = testBotComments[testBotComments.length - 1];
118
239
  return {
119
240
  prNumber,
120
- previousRecommendations: allRecommendations,
121
- implementedTestFiles: [...new Set(allFiles)],
122
- executionResults: allExecutionResults,
241
+ previousRecommendations: extractRecommendations(latestComment),
242
+ implementedTestFiles: extractImplementedFiles(latestComment.body),
243
+ executionResults: extractExecutionResults(latestComment.body),
123
244
  };
124
245
  }
@@ -0,0 +1,427 @@
1
+ import { parsePRComments } from "./pr-comment-parser.js";
2
+ import { execFileSync } from "child_process";
3
+ jest.mock("child_process", () => ({
4
+ execFileSync: jest.fn(),
5
+ }));
6
+ const mockedExecFileSync = execFileSync;
7
+ // ---------------------------------------------------------------------------
8
+ // Fixture helpers โ€” build PR comment bodies matching the real renderReport()
9
+ // output wrapped in the progress comment from testbot/src/progress.ts.
10
+ // ---------------------------------------------------------------------------
11
+ function progressWrapper(reportBody) {
12
+ return `### Skyramp Testbot Plan
13
+ Reviewing the Pull Request for test recommendations.
14
+
15
+ - [x] Analyzing code changes
16
+ - [x] Running tests
17
+ - [x] Generating report
18
+
19
+ ${reportBody}`;
20
+ }
21
+ /**
22
+ * Realistic full report from commit 1 of a PR.
23
+ * Matches the exact markdown format produced by renderReport() in testbot.
24
+ */
25
+ const FULL_REPORT_COMMIT_1 = progressWrapper(`### ๐Ÿ“‹ Business Case Analysis
26
+ This PR adds CRUD endpoints for the items resource and an orders endpoint.
27
+
28
+ ### ๐Ÿ’ก New Tests Created
29
+ - **Integration** for POST/GET/PUT/DELETE /api/v1/items/ โ€” \`test_items_integration.py\`
30
+ Tests full CRUD lifecycle on items
31
+ ๐Ÿ“Ž Scenario: \`tests/scenario_crud_items.json\`
32
+ - **Contract** for GET /api/v1/items/{item_id} โ€” \`test_items_contract.py\`
33
+ Validates response schema for single item retrieval
34
+ - **Fuzz** for POST /api/v1/items/ โ€” \`test_items_fuzz.py\`
35
+ Sends malformed payloads to item creation
36
+
37
+ ### ๐Ÿงช Test Results
38
+ | Test Type | Endpoint | Status | Details |
39
+ |-----------|----------|--------|---------|
40
+ | Integration | POST/GET/PUT/DELETE /api/v1/items/ | Pass | All 4 assertions passed |
41
+ | Contract | GET /api/v1/items/{item_id} | Fail | Schema mismatch on price field |
42
+ | Fuzz | POST /api/v1/items/ | Pass | 50 payloads, 0 crashes |
43
+
44
+ ### ๐Ÿ“Œ Additional Recommendations (3)
45
+ <details>
46
+ <summary>Expand to see recommended tests not generated in this run</summary>
47
+
48
+ #### Integration
49
+
50
+ **\`order-lifecycle\`**
51
+
52
+ Integration test for order creation workflow
53
+
54
+ 1. \`POST /api/v1/items/\` โ€” Create item
55
+ 2. \`POST /api/v1/orders/\` โ€” Create order referencing item
56
+ 3. \`GET /api/v1/orders/{order_id}\` โ€” Verify order details
57
+
58
+ #### Fuzz
59
+
60
+ **\`fuzz-orders\`**
61
+
62
+ Fuzz test for order creation
63
+
64
+ 1. \`POST /api/v1/orders/\` โ€” Send malformed order payloads
65
+
66
+ #### E2E
67
+
68
+ **\`checkout-flow\`**
69
+
70
+ End-to-end checkout flow requiring Playwright traces
71
+
72
+ 1. \`POST /api/v1/cart/\` โ€” Add item to cart
73
+ 2. \`POST /api/v1/checkout/\` โ€” Complete checkout
74
+
75
+ </details>
76
+
77
+ ### โš ๏ธ Issues Found
78
+ - Item price field returns string instead of number`);
79
+ /**
80
+ * Minimal report: no tests generated, no results, only business case + recommendations.
81
+ */
82
+ const MINIMAL_REPORT_NO_TESTS = progressWrapper(`### ๐Ÿ“‹ Business Case Analysis
83
+ Frontend-only PR. No backend changes detected.
84
+
85
+ ### ๐Ÿ“Œ Additional Recommendations (2)
86
+ <details>
87
+ <summary>Expand to see recommended tests not generated in this run</summary>
88
+
89
+ #### E2E
90
+
91
+ **\`login-flow\`**
92
+
93
+ E2E test for login page
94
+
95
+ 1. \`POST /api/v1/auth/login\` โ€” Authenticate user
96
+
97
+ #### UI
98
+
99
+ **\`dashboard-layout\`**
100
+
101
+ UI test for dashboard responsiveness
102
+
103
+ </details>`);
104
+ /**
105
+ * Report with maintenance results and collapsed sections.
106
+ */
107
+ const REPORT_WITH_MAINTENANCE = progressWrapper(`<details>
108
+ <summary>๐Ÿ“‹ Business Case Analysis</summary>
109
+
110
+ Minor endpoint rename from /products to /items.
111
+
112
+ </details>
113
+
114
+ <details>
115
+ <summary>๐Ÿ’ก New Tests Created</summary>
116
+
117
+ - **Contract** for GET /api/v1/items/ โ€” \`test_items_contract.py\`
118
+ Validates list items response schema
119
+
120
+ </details>
121
+
122
+ <details>
123
+ <summary>โœ… Test Maintenance</summary>
124
+
125
+ | File | Change | Before | After |
126
+ |------|--------|--------|-------|
127
+ | \`test_products_smoke.py\` | Updated endpoint /products โ†’ /items | Fail (404 Not Found) | Pass (200 OK) |
128
+
129
+ </details>
130
+
131
+ <details>
132
+ <summary>๐Ÿงช Test Results</summary>
133
+
134
+ | Test Type | Endpoint | Status | Details |
135
+ |-----------|----------|--------|---------|
136
+ | Contract | GET /api/v1/items/ | Pass | Schema valid |
137
+ | Smoke | GET /api/v1/items/ | Pass | After maintenance fix |
138
+
139
+ </details>`);
140
+ // ---------------------------------------------------------------------------
141
+ // Helper to build a gh CLI response (array of PR comments)
142
+ // ---------------------------------------------------------------------------
143
+ function ghResponse(comments) {
144
+ return JSON.stringify(comments.map((c) => ({
145
+ id: c.id,
146
+ body: c.body,
147
+ user: { login: c.login ?? "github-actions[bot]" },
148
+ created_at: new Date().toISOString(),
149
+ })));
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Tests
153
+ // ---------------------------------------------------------------------------
154
+ beforeEach(() => {
155
+ mockedExecFileSync.mockReset();
156
+ });
157
+ describe("parsePRComments โ€” error handling", () => {
158
+ it("returns empty context when PR has no comments", async () => {
159
+ mockedExecFileSync.mockReturnValue("[]");
160
+ const ctx = await parsePRComments("owner", "repo", 1);
161
+ expect(ctx.previousRecommendations).toEqual([]);
162
+ expect(ctx.implementedTestFiles).toEqual([]);
163
+ expect(ctx.executionResults).toEqual([]);
164
+ });
165
+ it("returns empty context when no TestBot comments exist", async () => {
166
+ mockedExecFileSync.mockReturnValue(ghResponse([
167
+ { id: 1, body: "LGTM!", login: "reviewer" },
168
+ { id: 2, body: "Please fix the typo", login: "reviewer" },
169
+ ]));
170
+ const ctx = await parsePRComments("owner", "repo", 42);
171
+ expect(ctx.previousRecommendations).toEqual([]);
172
+ expect(ctx.implementedTestFiles).toEqual([]);
173
+ expect(ctx.executionResults).toEqual([]);
174
+ });
175
+ it("uses only the latest TestBot comment when multiple exist", async () => {
176
+ const olderReport = progressWrapper(`### ๐Ÿ“‹ Business Case Analysis
177
+ Old report.
178
+
179
+ ### ๐Ÿ’ก New Tests Created
180
+ - **Smoke** for GET /api/v1/old/ โ€” \`test_old_smoke.py\``);
181
+ mockedExecFileSync.mockReturnValue(ghResponse([
182
+ { id: 100, body: olderReport },
183
+ { id: 200, body: FULL_REPORT_COMMIT_1 },
184
+ ]));
185
+ const ctx = await parsePRComments("owner", "repo", 42);
186
+ expect(ctx.implementedTestFiles).not.toContain("test_old_smoke.py");
187
+ expect(ctx.implementedTestFiles).toContain("test_items_integration.py");
188
+ });
189
+ it("returns empty context when gh CLI fails", async () => {
190
+ mockedExecFileSync.mockImplementation(() => {
191
+ throw new Error("gh: command not found");
192
+ });
193
+ const ctx = await parsePRComments("owner", "repo", 42);
194
+ expect(ctx.previousRecommendations).toEqual([]);
195
+ });
196
+ it("returns empty context when gh returns invalid JSON", async () => {
197
+ mockedExecFileSync.mockReturnValue("not valid json{");
198
+ const ctx = await parsePRComments("owner", "repo", 42);
199
+ expect(ctx.previousRecommendations).toEqual([]);
200
+ });
201
+ it("returns empty context when gh returns non-array JSON", async () => {
202
+ mockedExecFileSync.mockReturnValue('{"error": "not found"}');
203
+ const ctx = await parsePRComments("owner", "repo", 42);
204
+ expect(ctx.previousRecommendations).toEqual([]);
205
+ });
206
+ it("handles gh CLI timeout gracefully", async () => {
207
+ mockedExecFileSync.mockImplementation(() => {
208
+ const err = new Error("Command timed out");
209
+ err.status = null;
210
+ throw err;
211
+ });
212
+ const ctx = await parsePRComments("owner", "repo", 42);
213
+ expect(ctx.previousRecommendations).toEqual([]);
214
+ });
215
+ });
216
+ describe("full report parsing (commit 1 โ€” non-collapsed)", () => {
217
+ let ctx;
218
+ beforeAll(async () => {
219
+ mockedExecFileSync.mockReturnValue(ghResponse([{ id: 1, body: FULL_REPORT_COMMIT_1 }]));
220
+ ctx = await parsePRComments("owner", "repo", 42);
221
+ });
222
+ it("extracts implemented recommendations from New Tests Created", () => {
223
+ const implemented = ctx.previousRecommendations.filter((r) => r.status === "implemented");
224
+ // 3 test entries: Integration (4 methods), Contract (1), Fuzz (1) = at least 6
225
+ expect(implemented.length).toBeGreaterThanOrEqual(3);
226
+ });
227
+ it("expands POST/GET/PUT/DELETE into separate entries", () => {
228
+ const implemented = ctx.previousRecommendations.filter((r) => r.status === "implemented");
229
+ const methods = implemented
230
+ .filter((r) => r.endpoint.includes("/api/v1/items/") && r.testType === "integration")
231
+ .map((r) => r.endpoint.split(" ")[0]);
232
+ expect(methods).toContain("POST");
233
+ expect(methods).toContain("GET");
234
+ expect(methods).toContain("PUT");
235
+ expect(methods).toContain("DELETE");
236
+ });
237
+ it("assigns correct test types via proximity matching", () => {
238
+ const implemented = ctx.previousRecommendations.filter((r) => r.status === "implemented");
239
+ const fuzzEntry = implemented.find((r) => r.endpoint === "POST /api/v1/items/" && r.testType === "fuzz");
240
+ expect(fuzzEntry).toBeDefined();
241
+ const contractEntry = implemented.find((r) => r.endpoint.includes("/api/v1/items/{item_id}") && r.testType === "contract");
242
+ expect(contractEntry).toBeDefined();
243
+ });
244
+ it("extracts recommended endpoints from Additional Recommendations", () => {
245
+ const recommended = ctx.previousRecommendations.filter((r) => r.status === "recommended");
246
+ expect(recommended.length).toBeGreaterThanOrEqual(1);
247
+ const orderEndpoints = recommended.filter((r) => r.endpoint.includes("/api/v1/orders/"));
248
+ expect(orderEndpoints.length).toBeGreaterThanOrEqual(1);
249
+ });
250
+ it("extracts scenario names from Additional Recommendations", () => {
251
+ const recommended = ctx.previousRecommendations.filter((r) => r.status === "recommended");
252
+ const orderLifecycle = recommended.find((r) => r.scenarioName === "order-lifecycle");
253
+ expect(orderLifecycle).toBeDefined();
254
+ expect(orderLifecycle?.testType).toBe("integration");
255
+ const fuzzOrders = recommended.find((r) => r.scenarioName === "fuzz-orders");
256
+ expect(fuzzOrders).toBeDefined();
257
+ });
258
+ it("extracts implemented file names only from New Tests Created", () => {
259
+ expect(ctx.implementedTestFiles).toContain("test_items_integration.py");
260
+ expect(ctx.implementedTestFiles).toContain("test_items_contract.py");
261
+ expect(ctx.implementedTestFiles).toContain("test_items_fuzz.py");
262
+ expect(ctx.implementedTestFiles).toHaveLength(3);
263
+ });
264
+ it("sets prNumber correctly", () => {
265
+ expect(ctx.prNumber).toBe(42);
266
+ });
267
+ });
268
+ describe("minimal report parsing (no tests generated)", () => {
269
+ let ctx;
270
+ beforeAll(async () => {
271
+ mockedExecFileSync.mockReturnValue(ghResponse([{ id: 5, body: MINIMAL_REPORT_NO_TESTS }]));
272
+ ctx = await parsePRComments("owner", "repo", 10);
273
+ });
274
+ it("has no implemented recommendations", () => {
275
+ const implemented = ctx.previousRecommendations.filter((r) => r.status === "implemented");
276
+ expect(implemented).toEqual([]);
277
+ });
278
+ it("has no implemented files", () => {
279
+ expect(ctx.implementedTestFiles).toEqual([]);
280
+ });
281
+ it("extracts recommended endpoints from Additional Recommendations", () => {
282
+ const recommended = ctx.previousRecommendations.filter((r) => r.status === "recommended");
283
+ expect(recommended.length).toBeGreaterThanOrEqual(1);
284
+ const loginRec = recommended.find((r) => r.endpoint.includes("/api/v1/auth/login"));
285
+ expect(loginRec).toBeDefined();
286
+ });
287
+ it("extracts scenario names", () => {
288
+ const recommended = ctx.previousRecommendations.filter((r) => r.status === "recommended");
289
+ expect(recommended.find((r) => r.scenarioName === "login-flow")).toBeDefined();
290
+ // dashboard-layout has no endpoint pattern โ€” parser only captures recs with endpoints
291
+ expect(recommended.find((r) => r.scenarioName === "dashboard-layout")).toBeUndefined();
292
+ });
293
+ it("has no execution results", () => {
294
+ expect(ctx.executionResults).toEqual([]);
295
+ });
296
+ });
297
+ describe("collapsed report parsing (details/summary tags)", () => {
298
+ let ctx;
299
+ beforeAll(async () => {
300
+ mockedExecFileSync.mockReturnValue(ghResponse([{ id: 10, body: REPORT_WITH_MAINTENANCE }]));
301
+ ctx = await parsePRComments("owner", "repo", 99);
302
+ });
303
+ it("extracts implemented tests from collapsed sections", () => {
304
+ const implemented = ctx.previousRecommendations.filter((r) => r.status === "implemented");
305
+ expect(implemented.length).toBeGreaterThanOrEqual(1);
306
+ const contractEntry = implemented.find((r) => r.endpoint.includes("/api/v1/items/"));
307
+ expect(contractEntry).toBeDefined();
308
+ });
309
+ it("extracts implemented file names from collapsed sections", () => {
310
+ expect(ctx.implementedTestFiles).toContain("test_items_contract.py");
311
+ });
312
+ it("does not extract maintenance files as implemented test files", () => {
313
+ expect(ctx.implementedTestFiles).not.toContain("test_products_smoke.py");
314
+ });
315
+ });
316
+ describe("edge cases", () => {
317
+ it("parses POST/GET without trailing backtick in path", async () => {
318
+ const report = progressWrapper(`### ๐Ÿ’ก New Tests Created
319
+ - **Integration** for POST/GET /api/v1/products/ โ€” \`test_products_integration.py\``);
320
+ mockedExecFileSync.mockReturnValue(ghResponse([{ id: 1, body: report }]));
321
+ const ctx = await parsePRComments("owner", "repo", 1);
322
+ const implemented = ctx.previousRecommendations.filter((r) => r.status === "implemented");
323
+ const paths = implemented.map((r) => r.endpoint);
324
+ expect(paths).toContain("POST /api/v1/products/");
325
+ expect(paths).toContain("GET /api/v1/products/");
326
+ for (const p of paths) {
327
+ expect(p).not.toContain("`");
328
+ }
329
+ });
330
+ it("handles path params like {item_id}", async () => {
331
+ const report = progressWrapper(`### ๐Ÿ’ก New Tests Created
332
+ - **Contract** for GET /api/v1/items/{item_id} โ€” \`test_get_item_contract.py\``);
333
+ mockedExecFileSync.mockReturnValue(ghResponse([{ id: 1, body: report }]));
334
+ const ctx = await parsePRComments("owner", "repo", 1);
335
+ const impl = ctx.previousRecommendations.filter((r) => r.status === "implemented");
336
+ expect(impl).toEqual(expect.arrayContaining([
337
+ expect.objectContaining({ endpoint: "GET /api/v1/items/{item_id}" }),
338
+ ]));
339
+ });
340
+ it("de-duplicates implemented file names", async () => {
341
+ const report = progressWrapper(`### ๐Ÿ’ก New Tests Created
342
+ - **Integration** for POST /api/v1/items/ โ€” \`test_items_integration.py\`
343
+ - **Contract** for GET /api/v1/items/ โ€” \`test_items_integration.py\``);
344
+ mockedExecFileSync.mockReturnValue(ghResponse([{ id: 1, body: report }]));
345
+ const ctx = await parsePRComments("owner", "repo", 1);
346
+ const count = ctx.implementedTestFiles.filter((f) => f === "test_items_integration.py").length;
347
+ expect(count).toBe(1);
348
+ });
349
+ it("handles empty TestBot comment body", async () => {
350
+ mockedExecFileSync.mockReturnValue(ghResponse([{ id: 1, body: "Skyramp Testbot โ€” empty run" }]));
351
+ const ctx = await parsePRComments("owner", "repo", 1);
352
+ expect(ctx.previousRecommendations).toEqual([]);
353
+ expect(ctx.implementedTestFiles).toEqual([]);
354
+ expect(ctx.executionResults).toEqual([]);
355
+ });
356
+ it("ignores non-bot comments interspersed with bot comments", async () => {
357
+ const botComment = progressWrapper(`### ๐Ÿ’ก New Tests Created
358
+ - **Smoke** for GET /health โ€” \`test_health_smoke.py\``);
359
+ mockedExecFileSync.mockReturnValue(ghResponse([
360
+ { id: 1, body: "Looks good to me!", login: "reviewer" },
361
+ { id: 2, body: botComment },
362
+ { id: 3, body: "Can you add more tests?", login: "reviewer" },
363
+ ]));
364
+ const ctx = await parsePRComments("owner", "repo", 1);
365
+ expect(ctx.implementedTestFiles).toContain("test_health_smoke.py");
366
+ });
367
+ it("ignores human comments that mention bot markers", async () => {
368
+ const humanMentioningBot = "The Skyramp Testbot output above looks wrong, can you rerun?";
369
+ const realBotComment = progressWrapper(`### ๐Ÿ’ก New Tests Created
370
+ - **Contract** for GET /api/v1/items/ โ€” \`test_items_contract.py\``);
371
+ mockedExecFileSync.mockReturnValue(ghResponse([
372
+ { id: 1, body: realBotComment },
373
+ { id: 2, body: humanMentioningBot, login: "reviewer" },
374
+ ]));
375
+ const ctx = await parsePRComments("owner", "repo", 1);
376
+ expect(ctx.implementedTestFiles).toContain("test_items_contract.py");
377
+ expect(ctx.previousRecommendations.length).toBeGreaterThanOrEqual(1);
378
+ });
379
+ it("ignores all comments when only a human mentions bot markers", async () => {
380
+ const humanMentioningBot = "Hey team, check the Skyramp Testbot Plan from last time";
381
+ mockedExecFileSync.mockReturnValue(ghResponse([
382
+ { id: 1, body: humanMentioningBot, login: "developer123" },
383
+ ]));
384
+ const ctx = await parsePRComments("owner", "repo", 1);
385
+ expect(ctx.previousRecommendations).toEqual([]);
386
+ expect(ctx.implementedTestFiles).toEqual([]);
387
+ });
388
+ });
389
+ describe("roundtrip: renderReport format โ†’ parser extraction", () => {
390
+ it("parses the exact format from testbot report.test.ts validReport", async () => {
391
+ // Mirrors the validReport fixture from testbot/src/__tests__/report.test.ts
392
+ // rendered through renderReport() non-collapsed
393
+ const renderedReport = `### ๐Ÿ“‹ Business Case Analysis
394
+ Tests cover the checkout flow.
395
+
396
+ ### ๐Ÿ’ก New Tests Created
397
+ - **contract** for POST /orders โ€” \`test_orders_contract.py\`
398
+
399
+ ### โœ… Test Maintenance
400
+ - Updated auth header in existing tests
401
+
402
+ ### ๐Ÿงช Test Results
403
+ | Test Type | Endpoint | Status | Details |
404
+ |-----------|----------|--------|---------|
405
+ | contract | POST /orders | PASS | All assertions passed |
406
+ | fuzz | GET /products | FAIL | Unexpected 500 |
407
+
408
+ ### โš ๏ธ Issues Found
409
+ - Server returns 500 on empty query param`;
410
+ mockedExecFileSync.mockReturnValue(ghResponse([{ id: 1, body: progressWrapper(renderedReport) }]));
411
+ const ctx = await parsePRComments("owner", "repo", 1);
412
+ // Implemented: contract for POST /orders
413
+ const impl = ctx.previousRecommendations.filter((r) => r.status === "implemented");
414
+ expect(impl).toEqual(expect.arrayContaining([
415
+ expect.objectContaining({
416
+ testType: "contract",
417
+ endpoint: "POST /orders",
418
+ status: "implemented",
419
+ }),
420
+ ]));
421
+ // File name from New Tests section only
422
+ expect(ctx.implementedTestFiles).toContain("test_orders_contract.py");
423
+ // No Additional Recommendations section โ†’ no recommended entries
424
+ const recommended = ctx.previousRecommendations.filter((r) => r.status === "recommended");
425
+ expect(recommended).toEqual([]);
426
+ });
427
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.0.62",
3
+ "version": "0.0.63-rc.2",
4
4
  "main": "build/index.js",
5
5
  "type": "module",
6
6
  "bin": {