@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.
- package/build/index.js +18 -26
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +59 -0
- package/build/prompts/test-maintenance/driftAnalysisSections.js +153 -0
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +21 -9
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +34 -38
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +56 -9
- package/build/prompts/testbot/testbot-prompts.js +113 -100
- package/build/services/DriftAnalysisService.js +1 -1
- package/build/services/ScenarioGenerationService.js +5 -1
- package/build/services/TestExecutionService.js +2 -24
- package/build/services/TestExecutionService.test.js +167 -0
- package/build/services/containerEnv.js +35 -0
- package/build/tools/generate-tests/generateScenarioRestTool.js +7 -1
- package/build/tools/submitReportTool.js +6 -6
- package/build/tools/test-management/actionsTool.js +396 -0
- package/build/tools/test-management/analyzeChangesTool.js +750 -0
- package/build/tools/test-management/analyzeTestHealthTool.js +132 -0
- package/build/tools/test-management/executeTestsTool.js +198 -0
- package/build/tools/test-management/index.js +5 -0
- package/build/tools/test-management/stateCleanupTool.js +163 -0
- package/build/tools/test-recommendation/recommendTestsTool.js +1 -1
- package/build/utils/analyze-openapi.js +2 -2
- package/build/utils/pr-comment-parser.js +157 -36
- package/build/utils/pr-comment-parser.test.js +427 -0
- package/package.json +1 -1
- package/build/tools/initTestbotTool.js +0 -187
- package/build/tools/initTestbotTool.test.js +0 -194
- 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(
|
|
21
|
-
|
|
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
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
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:
|
|
42
|
-
commentId
|
|
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 = [...
|
|
50
|
-
const statusMatches = [...
|
|
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
|
|
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
|
|
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
|
|
231
|
+
const testBotComments = comments.filter((c) => isTestBotComment(c));
|
|
107
232
|
if (testBotComments.length === 0) {
|
|
108
233
|
return empty;
|
|
109
234
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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:
|
|
121
|
-
implementedTestFiles:
|
|
122
|
-
executionResults:
|
|
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
|
+
});
|