@skyramp/mcp 0.0.57 → 0.0.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/prompts/testbot/testbot-prompts.js +63 -20
- package/build/services/DriftAnalysisService.js +139 -13
- package/build/services/DriftAnalysisService.test.js +168 -0
- package/build/services/TestExecutionService.js +1 -1
- package/build/services/TestHealthService.js +38 -3
- package/build/services/TestHealthService.test.js +211 -0
- package/build/tools/submitReportTool.js +10 -2
- package/build/tools/test-maintenance/actionsTool.js +115 -9
- package/build/tools/test-maintenance/actionsTool.test.js +93 -0
- package/build/tools/test-recommendation/analyzeRepositoryTool.js +24 -15
- package/build/tools/test-recommendation/recommendTestsTool.js +27 -1
- package/package.json +2 -2
|
@@ -2,7 +2,7 @@ import { ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { logger } from "../../utils/logger.js";
|
|
4
4
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
5
|
-
function getTestbotPrompt(prTitle, prDescription, diffFile, testDirectory, summaryOutputFile, repositoryPath) {
|
|
5
|
+
function getTestbotPrompt(prTitle, prDescription, diffFile, testDirectory, summaryOutputFile, repositoryPath, baseBranch) {
|
|
6
6
|
return `<TITLE>${prTitle}</TITLE>
|
|
7
7
|
<DESCRIPTION>${prDescription}</DESCRIPTION>
|
|
8
8
|
<CODE CHANGES>${diffFile}</CODE CHANGES>
|
|
@@ -11,35 +11,72 @@ function getTestbotPrompt(prTitle, prDescription, diffFile, testDirectory, summa
|
|
|
11
11
|
|
|
12
12
|
For all the following work, use the tools offered by Skyramp MCP server.
|
|
13
13
|
|
|
14
|
-
First analyze the pull request title, description, and code changes to determine a business case
|
|
15
|
-
justification for this code change.
|
|
16
|
-
|
|
17
14
|
Then perform ALL of the following tasks. Every task is MANDATORY — do NOT skip any task based on your own judgment unless the task itself gives you an explicit condition to skip.
|
|
18
15
|
|
|
19
|
-
## Task 1: Recommend New Tests (MANDATORY)
|
|
16
|
+
## Task 1: Recommend New Tests (MANDATORY — but skip if no application code changed)
|
|
17
|
+
|
|
18
|
+
Read the diff at \`${diffFile}\`. Classify EVERY changed file using these categories:
|
|
19
|
+
|
|
20
|
+
**Non-application files (DO NOT generate tests for these):**
|
|
21
|
+
- CI/CD workflow files (.github/workflows/*.yml, .gitlab-ci.yml, Jenkinsfile, etc.)
|
|
22
|
+
- Markdown documentation (.md files, README, CHANGELOG, CONTRIBUTING, etc.)
|
|
23
|
+
- Dependency lock files (package-lock.json, yarn.lock, Pipfile.lock, poetry.lock, Gemfile.lock, go.sum, etc.)
|
|
24
|
+
- Configuration-only files (.gitignore, .editorconfig, .prettierrc, renovate.json, dependabot.yml, etc.)
|
|
25
|
+
- License files (LICENSE, NOTICE, etc.)
|
|
20
26
|
|
|
21
|
-
|
|
27
|
+
**Application source code (generate tests for these):**
|
|
28
|
+
- Routes, controllers, handlers, API endpoints
|
|
29
|
+
- Models, schemas, validators, serializers, DTOs
|
|
30
|
+
- Business logic, services, middleware, utilities
|
|
31
|
+
- Test helpers and test fixtures
|
|
32
|
+
- Any file with a source extension (.py, .ts, .js, .java, .go, .rb, .cs, .kt, .swift, etc.) that is NOT in the non-application list above
|
|
22
33
|
|
|
23
|
-
**
|
|
34
|
+
**SKIP RULE — THIS IS MANDATORY:**
|
|
35
|
+
If EVERY changed file in the diff falls into the "non-application files" category above, you MUST skip steps 1–6 entirely. Do NOT call \`skyramp_analyze_repository\`, do NOT call \`skyramp_map_tests\`, do NOT generate any tests. Instead, proceed directly to Task 2. In your report, state: "Task 1 skipped: PR contains only non-application changes (CI/docs/config)."
|
|
36
|
+
|
|
37
|
+
**When in doubt:** If even ONE changed file looks like it could be application source code, run steps 1–6.
|
|
24
38
|
|
|
25
39
|
1. Call \`skyramp_analyze_repository\` with:
|
|
26
40
|
- \`repositoryPath\`: "${repositoryPath}"
|
|
27
|
-
- \`analysisScope\`: "current_branch_diff"
|
|
41
|
+
- \`analysisScope\`: "current_branch_diff"${baseBranch ? `\n - \`baseBranch\`: "${baseBranch}"` : ''}
|
|
28
42
|
2. MANDATORY: Call \`skyramp_map_tests\` with \`stateFile\` (the state file path returned above) and \`analysisScope: "current_branch_diff"\`.
|
|
29
43
|
3. MANDATORY: Call \`skyramp_recommend_tests\` with the \`stateFile\` returned by \`skyramp_map_tests\`. Use the priority summary and the specific endpoints/files that changed to determine exactly what to test.
|
|
30
44
|
4. Generate tests using the Skyramp MCP generate tools, in priority order (minimum 3 test types).
|
|
31
45
|
5. Use Skyramp MCP to execute the generated tests and validate the results.
|
|
46
|
+
6. **E2E / UI Test Generation from Trace Files**: Search the repository for existing Skyramp trace files that can be used for E2E or UI test generation. Look for:
|
|
47
|
+
- Backend trace files: files matching patterns like \`**/skyramp*trace*.json\`, \`**/skyramp-traces.json\`, or \`**/*trace*.json\` in test directories
|
|
48
|
+
- Playwright UI trace files: files matching patterns like \`**/skyramp*playwright*.zip\`, \`**/*playwright*.zip\`, or \`**/*ui*trace*.zip\`
|
|
49
|
+
Search in the test directory (\`${testDirectory}\`), the repository root, and any \`.skyramp/\` directories.
|
|
50
|
+
- If you find BOTH a backend trace file AND a Playwright trace ZIP, call \`skyramp_e2e_test_generation\` with both files to generate an E2E test.
|
|
51
|
+
- If you find ONLY a Playwright trace ZIP (no backend trace), call \`skyramp_ui_test_generation\` with the Playwright file to generate a UI test.
|
|
52
|
+
- When generating E2E/UI tests, use the same language and framework as other tests in the repository. Default to Python with pytest if no convention is detected.
|
|
53
|
+
- Execute any generated E2E/UI tests to validate them. Note: Playwright browsers are pre-installed in the CI environment.
|
|
54
|
+
|
|
55
|
+
**IMPORTANT — Endpoint Renames:** If the diff shows an endpoint path was renamed (e.g. \`/products\` changed to \`/items\`) and existing tests already cover that endpoint under the old name, do NOT generate new tests for the renamed endpoint. The existing tests will be updated with the new path in Task 2 (Test Maintenance). Only generate new tests for genuinely new endpoints that have no existing test coverage under any name.
|
|
32
56
|
|
|
33
57
|
## Task 2: Existing Test Maintenance (MANDATORY)
|
|
34
58
|
|
|
35
|
-
You MUST always run steps
|
|
59
|
+
You MUST always run the steps below. Do NOT skip this task based on your own assessment of whether tests exist or are relevant — use the tools to determine that.
|
|
36
60
|
|
|
37
61
|
1. Call \`skyramp_discover_tests\` with \`repositoryPath\`: "${repositoryPath}" to find all existing Skyramp-generated tests.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
62
|
+
You may skip the rest of this task ONLY if it explicitly returns zero Skyramp-generated tests.
|
|
63
|
+
|
|
64
|
+
2. **Baseline — check for parallel CI first:**
|
|
65
|
+
a. Read the workflow files in \`.github/workflows/\` and check if any workflow (other than the Skyramp Testbot workflow) is triggered on \`pull_request\` AND runs tests against the test directory (look for commands like \`pytest\`, \`jest\`, \`npm test\`, \`go test\`, \`skyramp test\`, or similar test execution commands).
|
|
66
|
+
b. If such a workflow exists, run: \`gh run list --commit $(git rev-parse HEAD) --workflow <workflow-filename> --json status,conclusion --limit 1\` to check if it has completed for the current commit.
|
|
67
|
+
c. If the parallel workflow completed successfully — record beforeStatus as "Pass" for the discovered tests and note "baseline from CI workflow <workflow-name>" in beforeDetails. Skip to step 3.
|
|
68
|
+
d. If the parallel workflow completed with failure — record beforeStatus as "Fail" and capture the failure context in beforeDetails. Skip to step 3.
|
|
69
|
+
e. If no parallel test workflow exists, it hasn't completed yet, or the \`gh\` command fails for any reason (e.g. permissions, CLI not available) — execute ALL discovered tests AS-IS (before any modifications) using \`skyramp_execute_tests_batch\` or \`skyramp_execute_test\`. Record each test's status and details as the "before" results. In beforeDetails, describe the execution result (e.g. "Pass (10.8s)" or "Fail (404 Not Found)"). If you could not query CI, just note "unable to query existing CI pipeline" — do NOT expose internal details like authentication errors.
|
|
70
|
+
|
|
71
|
+
3. Call \`skyramp_analyze_test_drift\` with the \`stateFile\` returned by \`skyramp_discover_tests\`.
|
|
72
|
+
4. Call \`skyramp_calculate_health_scores\` with the \`stateFile\` from the previous step.
|
|
73
|
+
5. Call \`skyramp_actions\` with the updated \`stateFile\`. This tool returns instructions describing what needs to change in each test file — it does NOT modify the files itself.
|
|
74
|
+
6. **You MUST modify the existing test files in-place using your file editing tools.** Read the instructions from \`skyramp_actions\`, cross-reference with the code diff, and edit each test file directly.
|
|
75
|
+
- If \`skyramp_actions\` returns endpoint rename mappings (old path → new path), apply them as simple find-and-replace on the test file URLs. Do NOT regenerate or restructure the test — only update the paths.
|
|
76
|
+
- If \`skyramp_actions\` suggests file renames (e.g. \`products_smoke_test.py\` → \`items_smoke_test.py\`), rename the files using \`git mv\` after updating their content.
|
|
77
|
+
- The goal is to fix the discovered tests so they pass with the new code, preserving the original test structure and logic. Do NOT create new test files as a substitute for fixing existing ones.
|
|
78
|
+
7. Execute the modified tests using Skyramp MCP and validate the results. This includes E2E and UI tests — Playwright browsers are pre-installed in the CI environment, so E2E/UI test execution is fully supported. Record each test's status and details as the "after" results.
|
|
79
|
+
8. For each maintained test, report BOTH the before and after results in the \`testMaintenance\` array of the report (using the fileName, beforeStatus, beforeDetails, afterStatus, afterDetails fields), so the user has full visibility into whether the code change or the existing test was at fault.
|
|
43
80
|
|
|
44
81
|
## Task 3: Submit Report (MANDATORY)
|
|
45
82
|
|
|
@@ -55,6 +92,8 @@ Do NOT write the report to a file yourself. Do NOT skip this step. The skyramp_s
|
|
|
55
92
|
|
|
56
93
|
## Report Guidelines
|
|
57
94
|
|
|
95
|
+
**businessCaseAnalysis:** Base this ONLY on facts from the PR title, description, and what the tools reported. If \`skyramp_analyze_repository\` reported 0 new endpoints, do NOT claim new endpoints were added — instead describe the change accurately (e.g. "frontend changes to consume existing API endpoints", "refactored service layer", "updated test configuration"). Never infer new backend endpoints from frontend fetch/API calls in the diff.
|
|
96
|
+
|
|
58
97
|
When reporting test results, if you chose to skip executing a test, you MUST explain WHY you skipped it.
|
|
59
98
|
NEVER use the phrase "CI timeout" or imply a timeout occurred unless a tool call actually timed out.
|
|
60
99
|
Instead, set the status to "Skipped" and provide an honest reason in the details, for example:
|
|
@@ -70,9 +109,7 @@ export function registerTestbotPrompt(server) {
|
|
|
70
109
|
description: "Run Skyramp TestBot to generate test recommendations and perform test maintenance for a pull request.",
|
|
71
110
|
argsSchema: {
|
|
72
111
|
prTitle: z.string().describe("Pull request title"),
|
|
73
|
-
prDescription: z
|
|
74
|
-
.string()
|
|
75
|
-
.describe("Pull request description/body"),
|
|
112
|
+
prDescription: z.string().describe("Pull request description/body"),
|
|
76
113
|
diffFile: z.string().describe("Path to the git diff file"),
|
|
77
114
|
testDirectory: z
|
|
78
115
|
.string()
|
|
@@ -85,9 +122,13 @@ export function registerTestbotPrompt(server) {
|
|
|
85
122
|
.string()
|
|
86
123
|
.default(".")
|
|
87
124
|
.describe("Absolute path to the repository being analyzed"),
|
|
125
|
+
baseBranch: z
|
|
126
|
+
.string()
|
|
127
|
+
.optional()
|
|
128
|
+
.describe("PR base branch name (e.g. 'main' or 'develop'). When provided, analyzeRepository diffs against this branch instead of auto-detecting."),
|
|
88
129
|
},
|
|
89
130
|
}, (args) => {
|
|
90
|
-
const prompt = getTestbotPrompt(args.prTitle, args.prDescription, args.diffFile, args.testDirectory, args.summaryOutputFile, args.repositoryPath);
|
|
131
|
+
const prompt = getTestbotPrompt(args.prTitle, args.prDescription, args.diffFile, args.testDirectory, args.summaryOutputFile, args.repositoryPath, args.baseBranch);
|
|
91
132
|
AnalyticsService.pushMCPToolEvent("skyramp_testbot_prompt", undefined, {}).catch(() => { });
|
|
92
133
|
return {
|
|
93
134
|
messages: [
|
|
@@ -109,14 +150,16 @@ export function registerTestbotResource(server) {
|
|
|
109
150
|
// fails on empty query-param values (e.g. prDescription=).
|
|
110
151
|
// We then parse query params from the URL object which handles URL-decoding
|
|
111
152
|
// and empty values correctly.
|
|
112
|
-
const template = new ResourceTemplate("skyramp://prompts/testbot{+rest}", {
|
|
153
|
+
const template = new ResourceTemplate("skyramp://prompts/testbot{+rest}", {
|
|
154
|
+
list: undefined,
|
|
155
|
+
});
|
|
113
156
|
server.registerResource("skyramp_testbot", template, {
|
|
114
157
|
title: "Skyramp TestBot Prompt",
|
|
115
158
|
description: "Returns task instructions for PR test analysis, generation, and maintenance.",
|
|
116
159
|
mimeType: "text/plain",
|
|
117
160
|
}, (uri) => {
|
|
118
161
|
const param = (name, fallback) => uri.searchParams.get(name) ?? fallback;
|
|
119
|
-
const prompt = getTestbotPrompt(param("prTitle", ""), param("prDescription", ""), param("diffFile", ".skyramp_git_diff"), param("testDirectory", "tests"), param("summaryOutputFile", ""), param("repositoryPath", "."));
|
|
162
|
+
const prompt = getTestbotPrompt(param("prTitle", ""), param("prDescription", ""), param("diffFile", ".skyramp_git_diff"), param("testDirectory", "tests"), param("summaryOutputFile", ""), param("repositoryPath", "."), uri.searchParams.get("baseBranch") || undefined);
|
|
120
163
|
AnalyticsService.pushMCPToolEvent("skyramp_testbot_prompt", undefined, {}).catch(() => { });
|
|
121
164
|
return {
|
|
122
165
|
contents: [
|
|
@@ -394,29 +394,71 @@ export class EnhancedDriftAnalysisService {
|
|
|
394
394
|
const newParsed = JSON.parse(newSchema);
|
|
395
395
|
const changes = {
|
|
396
396
|
endpointsRemoved: [],
|
|
397
|
+
endpointsRenamed: [],
|
|
397
398
|
endpointsModified: [],
|
|
398
399
|
authenticationChanged: false,
|
|
399
400
|
};
|
|
400
401
|
const oldPaths = oldParsed.paths || {};
|
|
401
402
|
const newPaths = newParsed.paths || {};
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
403
|
+
// Collect removed endpoints (old path not in new schema)
|
|
404
|
+
const removedEndpoints = [];
|
|
405
|
+
for (const pathStr in oldPaths) {
|
|
406
|
+
if (!newPaths[pathStr]) {
|
|
407
|
+
for (const method in oldPaths[pathStr]) {
|
|
408
|
+
removedEndpoints.push({ path: pathStr, method });
|
|
407
409
|
}
|
|
408
410
|
}
|
|
409
411
|
}
|
|
412
|
+
// Collect added endpoints (new path not in old schema)
|
|
413
|
+
const addedEndpoints = [];
|
|
414
|
+
for (const pathStr in newPaths) {
|
|
415
|
+
if (!oldPaths[pathStr]) {
|
|
416
|
+
for (const method in newPaths[pathStr]) {
|
|
417
|
+
addedEndpoints.push({ path: pathStr, method });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// Detect renames: match removed endpoints to added endpoints
|
|
422
|
+
const matchedRemoved = new Set();
|
|
423
|
+
const matchedAdded = new Set();
|
|
424
|
+
for (const removed of removedEndpoints) {
|
|
425
|
+
const removedKey = `${removed.path}::${removed.method}`;
|
|
426
|
+
if (matchedRemoved.has(removedKey))
|
|
427
|
+
continue;
|
|
428
|
+
for (const added of addedEndpoints) {
|
|
429
|
+
const addedKey = `${added.path}::${added.method}`;
|
|
430
|
+
if (matchedAdded.has(addedKey))
|
|
431
|
+
continue;
|
|
432
|
+
if (this.isEndpointRename(removed.path, added.path, removed.method, added.method, oldPaths, newPaths)) {
|
|
433
|
+
changes.endpointsRenamed.push({
|
|
434
|
+
oldPath: removed.path,
|
|
435
|
+
newPath: added.path,
|
|
436
|
+
method: removed.method,
|
|
437
|
+
});
|
|
438
|
+
matchedRemoved.add(removedKey);
|
|
439
|
+
matchedAdded.add(addedKey);
|
|
440
|
+
logger.info(`Detected endpoint rename: ${removed.method} ${removed.path} -> ${added.path}`);
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// Remaining unmatched removals are true removals
|
|
446
|
+
for (const removed of removedEndpoints) {
|
|
447
|
+
const removedKey = `${removed.path}::${removed.method}`;
|
|
448
|
+
if (!matchedRemoved.has(removedKey)) {
|
|
449
|
+
changes.endpointsRemoved.push(removed);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
410
452
|
// Find modified endpoints and removed methods from existing paths
|
|
411
|
-
for (const
|
|
412
|
-
if (newPaths[
|
|
413
|
-
for (const method in oldPaths[
|
|
414
|
-
if (newPaths[
|
|
415
|
-
const oldEndpoint = JSON.stringify(oldPaths[
|
|
416
|
-
const newEndpoint = JSON.stringify(newPaths[
|
|
453
|
+
for (const pathStr in oldPaths) {
|
|
454
|
+
if (newPaths[pathStr]) {
|
|
455
|
+
for (const method in oldPaths[pathStr]) {
|
|
456
|
+
if (newPaths[pathStr][method]) {
|
|
457
|
+
const oldEndpoint = JSON.stringify(oldPaths[pathStr][method]);
|
|
458
|
+
const newEndpoint = JSON.stringify(newPaths[pathStr][method]);
|
|
417
459
|
if (oldEndpoint !== newEndpoint) {
|
|
418
460
|
changes.endpointsModified.push({
|
|
419
|
-
path,
|
|
461
|
+
path: pathStr,
|
|
420
462
|
method,
|
|
421
463
|
changes: ["Parameters or response modified"],
|
|
422
464
|
});
|
|
@@ -424,7 +466,7 @@ export class EnhancedDriftAnalysisService {
|
|
|
424
466
|
}
|
|
425
467
|
else {
|
|
426
468
|
// Method exists in old schema but not in new schema
|
|
427
|
-
changes.endpointsRemoved.push({ path, method });
|
|
469
|
+
changes.endpointsRemoved.push({ path: pathStr, method });
|
|
428
470
|
}
|
|
429
471
|
}
|
|
430
472
|
}
|
|
@@ -448,6 +490,73 @@ export class EnhancedDriftAnalysisService {
|
|
|
448
490
|
return undefined;
|
|
449
491
|
}
|
|
450
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* Determine if a removed endpoint and an added endpoint represent a rename.
|
|
495
|
+
*
|
|
496
|
+
* Heuristics:
|
|
497
|
+
* 1. Must have the same HTTP method
|
|
498
|
+
* 2. Must have the same path structure (same number of segments, same param names)
|
|
499
|
+
* 3. The operations must be structurally similar (same response codes, similar params)
|
|
500
|
+
*/
|
|
501
|
+
isEndpointRename(oldPath, newPath, oldMethod, newMethod, oldPaths, newPaths) {
|
|
502
|
+
// Must be the same HTTP method
|
|
503
|
+
if (oldMethod !== newMethod)
|
|
504
|
+
return false;
|
|
505
|
+
const oldSegments = oldPath.split("/").filter((s) => s.length > 0);
|
|
506
|
+
const newSegments = newPath.split("/").filter((s) => s.length > 0);
|
|
507
|
+
// Must have same number of path segments
|
|
508
|
+
if (oldSegments.length !== newSegments.length)
|
|
509
|
+
return false;
|
|
510
|
+
// Path parameters (e.g., {product_id}) must be in the same positions
|
|
511
|
+
const paramPattern = /^\{[^}]+\}$/;
|
|
512
|
+
let staticDiffs = 0;
|
|
513
|
+
for (let i = 0; i < oldSegments.length; i++) {
|
|
514
|
+
const oldIsParam = paramPattern.test(oldSegments[i]);
|
|
515
|
+
const newIsParam = paramPattern.test(newSegments[i]);
|
|
516
|
+
if (oldIsParam !== newIsParam)
|
|
517
|
+
return false; // Structural mismatch
|
|
518
|
+
if (oldIsParam && newIsParam) {
|
|
519
|
+
// Both are params — param names may differ but structure matches
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (oldSegments[i] !== newSegments[i]) {
|
|
523
|
+
staticDiffs++;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// At least one static segment must differ (otherwise paths are identical)
|
|
527
|
+
// But not too many — more than half differing suggests unrelated endpoints
|
|
528
|
+
if (staticDiffs === 0)
|
|
529
|
+
return false;
|
|
530
|
+
const staticSegments = oldSegments.filter((s) => !paramPattern.test(s));
|
|
531
|
+
if (staticDiffs > Math.max(1, Math.ceil(staticSegments.length / 2))) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
// Compare operation structure: same response codes is a strong signal
|
|
535
|
+
const oldOp = oldPaths[oldPath]?.[oldMethod];
|
|
536
|
+
const newOp = newPaths[newPath]?.[newMethod];
|
|
537
|
+
if (oldOp && newOp) {
|
|
538
|
+
const oldResponses = Object.keys(oldOp.responses || {}).sort();
|
|
539
|
+
const newResponses = Object.keys(newOp.responses || {}).sort();
|
|
540
|
+
if (oldResponses.length > 0 &&
|
|
541
|
+
newResponses.length > 0 &&
|
|
542
|
+
JSON.stringify(oldResponses) === JSON.stringify(newResponses)) {
|
|
543
|
+
return true; // Same method, similar structure, same response codes
|
|
544
|
+
}
|
|
545
|
+
// Fallback: if response codes differ, check parameter count similarity
|
|
546
|
+
const oldParamCount = (oldOp.parameters || []).length;
|
|
547
|
+
const newParamCount = (newOp.parameters || []).length;
|
|
548
|
+
const hasOldBody = !!oldOp.requestBody;
|
|
549
|
+
const hasNewBody = !!newOp.requestBody;
|
|
550
|
+
if (oldParamCount === newParamCount && hasOldBody === hasNewBody) {
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
// Operation data exists but doesn't match — not a rename
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
// If we can't access operations, rely on structural match alone
|
|
557
|
+
// (same segments, same params, only 1 static segment differs)
|
|
558
|
+
return staticDiffs === 1;
|
|
559
|
+
}
|
|
451
560
|
/**
|
|
452
561
|
* Extract API schema path from test file comments/metadata
|
|
453
562
|
*/
|
|
@@ -635,6 +744,17 @@ export class EnhancedDriftAnalysisService {
|
|
|
635
744
|
severity: "high",
|
|
636
745
|
});
|
|
637
746
|
}
|
|
747
|
+
if (apiSchemaChanges.endpointsRenamed.length > 0) {
|
|
748
|
+
for (const renamed of apiSchemaChanges.endpointsRenamed) {
|
|
749
|
+
changes.push({
|
|
750
|
+
type: "endpoint_renamed",
|
|
751
|
+
file: "API Schema",
|
|
752
|
+
description: `Endpoint renamed: ${renamed.method} ${renamed.oldPath} -> ${renamed.newPath}`,
|
|
753
|
+
severity: "high",
|
|
754
|
+
details: `Path changed from ${renamed.oldPath} to ${renamed.newPath}. Test endpoint URLs must be updated.`,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
638
758
|
if (apiSchemaChanges.endpointsModified.length > 0) {
|
|
639
759
|
changes.push({
|
|
640
760
|
type: "endpoint_modified",
|
|
@@ -828,6 +948,7 @@ export class EnhancedDriftAnalysisService {
|
|
|
828
948
|
// API schema changes
|
|
829
949
|
if (apiSchemaChanges) {
|
|
830
950
|
score += apiSchemaChanges.endpointsRemoved.length * 15;
|
|
951
|
+
score += apiSchemaChanges.endpointsRenamed.length * 12;
|
|
831
952
|
score += apiSchemaChanges.endpointsModified.length * 10;
|
|
832
953
|
if (apiSchemaChanges.authenticationChanged)
|
|
833
954
|
score += 25;
|
|
@@ -870,6 +991,11 @@ export class EnhancedDriftAnalysisService {
|
|
|
870
991
|
}
|
|
871
992
|
// Specific recommendations
|
|
872
993
|
if (apiSchemaChanges) {
|
|
994
|
+
if (apiSchemaChanges.endpointsRenamed.length > 0) {
|
|
995
|
+
for (const renamed of apiSchemaChanges.endpointsRenamed) {
|
|
996
|
+
recommendations.push(`🔄 Endpoint renamed: ${renamed.method} ${renamed.oldPath} -> ${renamed.newPath} — update test URL paths`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
873
999
|
if (apiSchemaChanges.endpointsRemoved.length > 0) {
|
|
874
1000
|
recommendations.push(`⚠️ ${apiSchemaChanges.endpointsRemoved.length} API endpoint(s) removed - update test`);
|
|
875
1001
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { EnhancedDriftAnalysisService } from "./DriftAnalysisService.js";
|
|
2
|
+
describe("DriftAnalysisService", () => {
|
|
3
|
+
let service;
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
service = new EnhancedDriftAnalysisService();
|
|
6
|
+
});
|
|
7
|
+
describe("isEndpointRename", () => {
|
|
8
|
+
// Helper to call the private method
|
|
9
|
+
function isRename(oldPath, newPath, oldMethod, newMethod, oldPaths = {}, newPaths = {}) {
|
|
10
|
+
return service["isEndpointRename"](oldPath, newPath, oldMethod, newMethod, oldPaths, newPaths);
|
|
11
|
+
}
|
|
12
|
+
// --- Basic rename detection ---
|
|
13
|
+
it("should detect a simple prefix rename", () => {
|
|
14
|
+
const oldPaths = {
|
|
15
|
+
"/api/v1/products": {
|
|
16
|
+
get: { responses: { "200": {}, "404": {} } },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const newPaths = {
|
|
20
|
+
"/api/v1/items": {
|
|
21
|
+
get: { responses: { "200": {}, "404": {} } },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
expect(isRename("/api/v1/products", "/api/v1/items", "get", "get", oldPaths, newPaths)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
it("should detect rename with path parameters", () => {
|
|
27
|
+
const oldPaths = {
|
|
28
|
+
"/api/v1/products/{product_id}": {
|
|
29
|
+
get: { responses: { "200": {}, "404": {} } },
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const newPaths = {
|
|
33
|
+
"/api/v1/items/{product_id}": {
|
|
34
|
+
get: { responses: { "200": {}, "404": {} } },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
expect(isRename("/api/v1/products/{product_id}", "/api/v1/items/{product_id}", "get", "get", oldPaths, newPaths)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
it("should detect version bump as rename", () => {
|
|
40
|
+
const oldPaths = {
|
|
41
|
+
"/api/v1/products": {
|
|
42
|
+
get: { responses: { "200": {} } },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
const newPaths = {
|
|
46
|
+
"/api/v2/products": {
|
|
47
|
+
get: { responses: { "200": {} } },
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
expect(isRename("/api/v1/products", "/api/v2/products", "get", "get", oldPaths, newPaths)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
it("should detect rename across multiple HTTP methods independently", () => {
|
|
53
|
+
const oldPaths = {
|
|
54
|
+
"/api/v1/products": {
|
|
55
|
+
post: { responses: { "201": {} }, requestBody: {} },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const newPaths = {
|
|
59
|
+
"/api/v1/items": {
|
|
60
|
+
post: { responses: { "201": {} }, requestBody: {} },
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
expect(isRename("/api/v1/products", "/api/v1/items", "post", "post", oldPaths, newPaths)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
// --- Should NOT match ---
|
|
66
|
+
it("should not match different HTTP methods", () => {
|
|
67
|
+
expect(isRename("/api/v1/products", "/api/v1/items", "get", "post")).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
it("should not match paths with different segment counts", () => {
|
|
70
|
+
expect(isRename("/api/v1/products", "/api/v1/items/catalog", "get", "get")).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
it("should not match identical paths", () => {
|
|
73
|
+
expect(isRename("/api/v1/products", "/api/v1/products", "get", "get")).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
it("should not match when a static segment becomes a parameter", () => {
|
|
76
|
+
expect(isRename("/api/v1/products", "/api/v1/{resource}", "get", "get")).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
it("should not match paths where too many segments differ", () => {
|
|
79
|
+
// 3 out of 3 static segments differ — clearly unrelated endpoints
|
|
80
|
+
const oldPaths = {
|
|
81
|
+
"/api/v1/products": {
|
|
82
|
+
get: { responses: { "200": {} } },
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const newPaths = {
|
|
86
|
+
"/rest/v2/orders": {
|
|
87
|
+
get: { responses: { "200": {} } },
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
expect(isRename("/api/v1/products", "/rest/v2/orders", "get", "get", oldPaths, newPaths)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
it("should not match when response codes differ and params differ", () => {
|
|
93
|
+
const oldPaths = {
|
|
94
|
+
"/api/v1/products": {
|
|
95
|
+
get: { responses: { "200": {} }, parameters: [{ name: "limit" }] },
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
const newPaths = {
|
|
99
|
+
"/api/v1/items": {
|
|
100
|
+
get: { responses: { "201": {} } },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
expect(isRename("/api/v1/products", "/api/v1/items", "get", "get", oldPaths, newPaths)).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
// --- Structural fallback (no operation data) ---
|
|
106
|
+
it("should match with single static segment diff when no operation data", () => {
|
|
107
|
+
// Only 1 segment differs, no operation data — should match on structure alone
|
|
108
|
+
expect(isRename("/api/v1/products", "/api/v1/items", "get", "get", {}, {})).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
it("should not match with 2+ static segment diffs when no operation data", () => {
|
|
111
|
+
expect(isRename("/api/v1/products", "/api/v2/items", "get", "get", {}, {})).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
// --- Edge cases ---
|
|
114
|
+
it("should handle root-level path rename", () => {
|
|
115
|
+
const oldPaths = { "/products": { get: { responses: { "200": {} } } } };
|
|
116
|
+
const newPaths = { "/items": { get: { responses: { "200": {} } } } };
|
|
117
|
+
expect(isRename("/products", "/items", "get", "get", oldPaths, newPaths)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
it("should handle deeply nested paths", () => {
|
|
120
|
+
const oldPaths = {
|
|
121
|
+
"/api/v1/store/products/{id}/reviews": {
|
|
122
|
+
get: { responses: { "200": {} } },
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const newPaths = {
|
|
126
|
+
"/api/v1/store/items/{id}/reviews": {
|
|
127
|
+
get: { responses: { "200": {} } },
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
expect(isRename("/api/v1/store/products/{id}/reviews", "/api/v1/store/items/{id}/reviews", "get", "get", oldPaths, newPaths)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
it("should match when param names differ but positions match", () => {
|
|
133
|
+
const oldPaths = {
|
|
134
|
+
"/api/v1/products/{product_id}": {
|
|
135
|
+
get: { responses: { "200": {} } },
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
const newPaths = {
|
|
139
|
+
"/api/v1/items/{item_id}": {
|
|
140
|
+
get: { responses: { "200": {} } },
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
expect(isRename("/api/v1/products/{product_id}", "/api/v1/items/{item_id}", "get", "get", oldPaths, newPaths)).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
it("should match based on parameter count and request body similarity", () => {
|
|
146
|
+
const oldPaths = {
|
|
147
|
+
"/api/v1/products": {
|
|
148
|
+
post: {
|
|
149
|
+
responses: { "201": {} },
|
|
150
|
+
parameters: [{ name: "x" }],
|
|
151
|
+
requestBody: { content: {} },
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
const newPaths = {
|
|
156
|
+
"/api/v1/items": {
|
|
157
|
+
post: {
|
|
158
|
+
responses: { "200": {} }, // Different response code
|
|
159
|
+
parameters: [{ name: "y" }],
|
|
160
|
+
requestBody: { content: {} },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
// Response codes differ, but param count and body presence match
|
|
165
|
+
expect(isRename("/api/v1/products", "/api/v1/items", "post", "post", oldPaths, newPaths)).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -6,7 +6,7 @@ import { stripVTControlCharacters } from "util";
|
|
|
6
6
|
import { logger } from "../utils/logger.js";
|
|
7
7
|
const DEFAULT_TIMEOUT = 300000; // 5 minutes
|
|
8
8
|
const MAX_CONCURRENT_EXECUTIONS = 5;
|
|
9
|
-
export const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.
|
|
9
|
+
export const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.11";
|
|
10
10
|
const DOCKER_PLATFORM = "linux/amd64";
|
|
11
11
|
const EXECUTION_PROGRESS_INTERVAL = 10000; // 10 seconds between progress updates during execution
|
|
12
12
|
// Files and directories to exclude when mounting workspace to Docker container
|
|
@@ -68,7 +68,7 @@ export class TestHealthService {
|
|
|
68
68
|
? await this.extractEndpointFromTest(testFile, apiSchema)
|
|
69
69
|
: undefined;
|
|
70
70
|
// Generate recommendation
|
|
71
|
-
const recommendation = this.generateRecommendation(testFile, healthScore, drift?.driftScore, execution, issues, apiEndpoint);
|
|
71
|
+
const recommendation = this.generateRecommendation(testFile, healthScore, drift?.driftScore, execution, issues, apiEndpoint, drift?.apiSchemaChanges);
|
|
72
72
|
return {
|
|
73
73
|
testFile,
|
|
74
74
|
healthScore,
|
|
@@ -265,6 +265,15 @@ export class TestHealthService {
|
|
|
265
265
|
details: `${drift.affectedFiles?.files.length || 0} file(s) changed`,
|
|
266
266
|
});
|
|
267
267
|
}
|
|
268
|
+
const endpointsRenamed = drift.changes.filter((c) => c.type === "endpoint_renamed");
|
|
269
|
+
if (endpointsRenamed.length > 0) {
|
|
270
|
+
issues.push({
|
|
271
|
+
type: "endpoints_renamed",
|
|
272
|
+
severity: "high",
|
|
273
|
+
description: `${endpointsRenamed.length} API endpoint(s) renamed`,
|
|
274
|
+
details: endpointsRenamed.map((c) => c.description).join("; "),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
268
277
|
const endpointsRemoved = drift.changes.filter((c) => c.type === "endpoint_removed");
|
|
269
278
|
if (endpointsRemoved.length > 0) {
|
|
270
279
|
issues.push({
|
|
@@ -306,7 +315,7 @@ export class TestHealthService {
|
|
|
306
315
|
*
|
|
307
316
|
* Execution failures enhance rationale but don't change primary action
|
|
308
317
|
*/
|
|
309
|
-
generateRecommendation(testFile, healthScore, driftScore, execution, issues, apiEndpoint) {
|
|
318
|
+
generateRecommendation(testFile, healthScore, driftScore, execution, issues, apiEndpoint, apiSchemaChanges) {
|
|
310
319
|
const drift = driftScore !== undefined ? driftScore : -1; // -1 means no drift data
|
|
311
320
|
let action;
|
|
312
321
|
let priority;
|
|
@@ -352,6 +361,21 @@ export class TestHealthService {
|
|
|
352
361
|
estimatedWork = "SMALL";
|
|
353
362
|
}
|
|
354
363
|
}
|
|
364
|
+
else if (issues && issues.some((i) => i.type === "endpoints_renamed")) {
|
|
365
|
+
// Endpoint renamed -> UPDATE with path substitution (regardless of drift score)
|
|
366
|
+
action = "UPDATE";
|
|
367
|
+
priority = "HIGH";
|
|
368
|
+
rationale =
|
|
369
|
+
"Endpoint path renamed - test URLs must be updated to match new path";
|
|
370
|
+
estimatedWork = "SMALL";
|
|
371
|
+
const renameIssue = issues.find((i) => i.type === "endpoints_renamed");
|
|
372
|
+
if (renameIssue?.details) {
|
|
373
|
+
rationale += `. ${renameIssue.details}`;
|
|
374
|
+
}
|
|
375
|
+
if (execution && !execution.passed) {
|
|
376
|
+
rationale += ". Test is currently failing due to the path change";
|
|
377
|
+
}
|
|
378
|
+
}
|
|
355
379
|
else if (drift > 70) {
|
|
356
380
|
// High drift -> REGENERATE
|
|
357
381
|
action = "REGENERATE";
|
|
@@ -391,6 +415,7 @@ export class TestHealthService {
|
|
|
391
415
|
const schemaChanges = issues?.filter((i) => [
|
|
392
416
|
"schema_changes",
|
|
393
417
|
"endpoints_removed",
|
|
418
|
+
"endpoints_renamed",
|
|
394
419
|
"authentication_changed",
|
|
395
420
|
].includes(i.type));
|
|
396
421
|
if (schemaChanges && schemaChanges.length > 0) {
|
|
@@ -439,7 +464,11 @@ export class TestHealthService {
|
|
|
439
464
|
}
|
|
440
465
|
// Determine endpoint status
|
|
441
466
|
let endpointStatus;
|
|
442
|
-
|
|
467
|
+
const renameIssues = issues?.filter((i) => i.type === "endpoints_renamed");
|
|
468
|
+
if (renameIssues && renameIssues.length > 0) {
|
|
469
|
+
endpointStatus = "renamed";
|
|
470
|
+
}
|
|
471
|
+
else if (apiEndpoint === undefined) {
|
|
443
472
|
endpointStatus = undefined;
|
|
444
473
|
}
|
|
445
474
|
else if (apiEndpoint.exists) {
|
|
@@ -448,6 +477,11 @@ export class TestHealthService {
|
|
|
448
477
|
else {
|
|
449
478
|
endpointStatus = "missing";
|
|
450
479
|
}
|
|
480
|
+
// Extract rename mappings from apiSchemaChanges for downstream tools
|
|
481
|
+
const renamedEndpoints = apiSchemaChanges?.endpointsRenamed &&
|
|
482
|
+
apiSchemaChanges.endpointsRenamed.length > 0
|
|
483
|
+
? apiSchemaChanges.endpointsRenamed
|
|
484
|
+
: undefined;
|
|
451
485
|
return {
|
|
452
486
|
testFile,
|
|
453
487
|
action,
|
|
@@ -459,6 +493,7 @@ export class TestHealthService {
|
|
|
459
493
|
driftScore: drift,
|
|
460
494
|
executionPassed: execution?.passed,
|
|
461
495
|
endpointStatus,
|
|
496
|
+
renamedEndpoints,
|
|
462
497
|
},
|
|
463
498
|
};
|
|
464
499
|
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { TestHealthService } from "./TestHealthService.js";
|
|
2
|
+
describe("TestHealthService", () => {
|
|
3
|
+
let service;
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
service = new TestHealthService();
|
|
6
|
+
});
|
|
7
|
+
describe("identifyIssues - endpoint rename detection", () => {
|
|
8
|
+
function identifyIssues(execution, drift) {
|
|
9
|
+
return service["identifyIssues"](execution, drift);
|
|
10
|
+
}
|
|
11
|
+
it("should create an endpoints_renamed issue when drift has endpoint_renamed changes", () => {
|
|
12
|
+
const drift = {
|
|
13
|
+
testFile: "products_smoke_test.py",
|
|
14
|
+
lastCommit: "abc123",
|
|
15
|
+
currentCommit: "def456",
|
|
16
|
+
driftScore: 30,
|
|
17
|
+
changes: [
|
|
18
|
+
{
|
|
19
|
+
type: "endpoint_renamed",
|
|
20
|
+
file: "API Schema",
|
|
21
|
+
description: "Endpoint renamed: get /api/v1/products -> /api/v1/items",
|
|
22
|
+
severity: "high",
|
|
23
|
+
details: "Path changed from /api/v1/products to /api/v1/items",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
affectedFiles: { files: ["src/routers/product.py"] },
|
|
27
|
+
analysisTimestamp: new Date().toISOString(),
|
|
28
|
+
};
|
|
29
|
+
const issues = identifyIssues(undefined, drift);
|
|
30
|
+
const renameIssue = issues.find((i) => i.type === "endpoints_renamed");
|
|
31
|
+
expect(renameIssue).toBeDefined();
|
|
32
|
+
expect(renameIssue?.severity).toBe("high");
|
|
33
|
+
expect(renameIssue?.description).toContain("1 API endpoint(s) renamed");
|
|
34
|
+
});
|
|
35
|
+
it("should not create endpoints_renamed issue when no renames in drift", () => {
|
|
36
|
+
const drift = {
|
|
37
|
+
testFile: "products_smoke_test.py",
|
|
38
|
+
lastCommit: "abc123",
|
|
39
|
+
currentCommit: "def456",
|
|
40
|
+
driftScore: 15,
|
|
41
|
+
changes: [
|
|
42
|
+
{
|
|
43
|
+
type: "endpoint_removed",
|
|
44
|
+
file: "API Schema",
|
|
45
|
+
description: "1 endpoint(s) removed",
|
|
46
|
+
severity: "high",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
affectedFiles: { files: [] },
|
|
50
|
+
analysisTimestamp: new Date().toISOString(),
|
|
51
|
+
};
|
|
52
|
+
const issues = identifyIssues(undefined, drift);
|
|
53
|
+
const renameIssue = issues.find((i) => i.type === "endpoints_renamed");
|
|
54
|
+
expect(renameIssue).toBeUndefined();
|
|
55
|
+
const removeIssue = issues.find((i) => i.type === "endpoints_removed");
|
|
56
|
+
expect(removeIssue).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
it("should handle multiple rename changes", () => {
|
|
59
|
+
const drift = {
|
|
60
|
+
testFile: "products_smoke_test.py",
|
|
61
|
+
lastCommit: "abc123",
|
|
62
|
+
currentCommit: "def456",
|
|
63
|
+
driftScore: 40,
|
|
64
|
+
changes: [
|
|
65
|
+
{
|
|
66
|
+
type: "endpoint_renamed",
|
|
67
|
+
file: "API Schema",
|
|
68
|
+
description: "Endpoint renamed: get /api/v1/products -> /api/v1/items",
|
|
69
|
+
severity: "high",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
type: "endpoint_renamed",
|
|
73
|
+
file: "API Schema",
|
|
74
|
+
description: "Endpoint renamed: post /api/v1/products -> /api/v1/items",
|
|
75
|
+
severity: "high",
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
affectedFiles: { files: [] },
|
|
79
|
+
analysisTimestamp: new Date().toISOString(),
|
|
80
|
+
};
|
|
81
|
+
const issues = identifyIssues(undefined, drift);
|
|
82
|
+
const renameIssue = issues.find((i) => i.type === "endpoints_renamed");
|
|
83
|
+
expect(renameIssue).toBeDefined();
|
|
84
|
+
expect(renameIssue?.description).toContain("2 API endpoint(s) renamed");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe("generateRecommendation - endpoint rename handling", () => {
|
|
88
|
+
function generateRecommendation(testFile, driftScore, execution, issues, apiEndpoint, apiSchemaChanges) {
|
|
89
|
+
const healthScore = service["calculateHealthScore"](execution
|
|
90
|
+
? service["calculateExecutionScore"](execution).score
|
|
91
|
+
: undefined, driftScore);
|
|
92
|
+
return service["generateRecommendation"](testFile, healthScore, driftScore, execution, issues, apiEndpoint, apiSchemaChanges);
|
|
93
|
+
}
|
|
94
|
+
it("should return UPDATE action for endpoint renames regardless of drift score", () => {
|
|
95
|
+
const issues = [
|
|
96
|
+
{
|
|
97
|
+
type: "endpoints_renamed",
|
|
98
|
+
severity: "high",
|
|
99
|
+
description: "1 API endpoint(s) renamed",
|
|
100
|
+
details: "Endpoint renamed: get /api/v1/products -> /api/v1/items",
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
const apiSchemaChanges = {
|
|
104
|
+
endpointsRemoved: [],
|
|
105
|
+
endpointsRenamed: [
|
|
106
|
+
{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" },
|
|
107
|
+
],
|
|
108
|
+
endpointsModified: [],
|
|
109
|
+
authenticationChanged: false,
|
|
110
|
+
};
|
|
111
|
+
// Even with low drift score, renames should trigger UPDATE
|
|
112
|
+
const rec = generateRecommendation("products_smoke_test.py", 12, // low drift
|
|
113
|
+
undefined, issues, undefined, apiSchemaChanges);
|
|
114
|
+
expect(rec.action).toBe("UPDATE");
|
|
115
|
+
expect(rec.priority).toBe("HIGH");
|
|
116
|
+
expect(rec.rationale).toContain("renamed");
|
|
117
|
+
expect(rec.estimatedWork).toBe("SMALL");
|
|
118
|
+
});
|
|
119
|
+
it("should include renamedEndpoints in recommendation details", () => {
|
|
120
|
+
const issues = [
|
|
121
|
+
{
|
|
122
|
+
type: "endpoints_renamed",
|
|
123
|
+
severity: "high",
|
|
124
|
+
description: "1 API endpoint(s) renamed",
|
|
125
|
+
details: "Endpoint renamed: get /api/v1/products -> /api/v1/items",
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
const apiSchemaChanges = {
|
|
129
|
+
endpointsRemoved: [],
|
|
130
|
+
endpointsRenamed: [
|
|
131
|
+
{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" },
|
|
132
|
+
],
|
|
133
|
+
endpointsModified: [],
|
|
134
|
+
authenticationChanged: false,
|
|
135
|
+
};
|
|
136
|
+
const rec = generateRecommendation("products_smoke_test.py", 30, undefined, issues, undefined, apiSchemaChanges);
|
|
137
|
+
expect(rec.details?.endpointStatus).toBe("renamed");
|
|
138
|
+
expect(rec.details?.renamedEndpoints).toBeDefined();
|
|
139
|
+
expect(rec.details?.renamedEndpoints).toHaveLength(1);
|
|
140
|
+
expect(rec.details?.renamedEndpoints?.[0]).toEqual({
|
|
141
|
+
oldPath: "/api/v1/products",
|
|
142
|
+
newPath: "/api/v1/items",
|
|
143
|
+
method: "get",
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
it("should mention test failure in rationale when test is failing due to rename", () => {
|
|
147
|
+
const issues = [
|
|
148
|
+
{
|
|
149
|
+
type: "endpoints_renamed",
|
|
150
|
+
severity: "high",
|
|
151
|
+
description: "1 API endpoint(s) renamed",
|
|
152
|
+
details: "Endpoint renamed: get /api/v1/products -> /api/v1/items",
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
const execution = {
|
|
156
|
+
testFile: "products_smoke_test.py",
|
|
157
|
+
executedAt: new Date().toISOString(),
|
|
158
|
+
passed: false,
|
|
159
|
+
duration: 10000,
|
|
160
|
+
errors: ["404 Not Found"],
|
|
161
|
+
warnings: [],
|
|
162
|
+
crashed: false,
|
|
163
|
+
};
|
|
164
|
+
const apiSchemaChanges = {
|
|
165
|
+
endpointsRemoved: [],
|
|
166
|
+
endpointsRenamed: [
|
|
167
|
+
{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" },
|
|
168
|
+
],
|
|
169
|
+
endpointsModified: [],
|
|
170
|
+
authenticationChanged: false,
|
|
171
|
+
};
|
|
172
|
+
const rec = generateRecommendation("products_smoke_test.py", 30, execution, issues, undefined, apiSchemaChanges);
|
|
173
|
+
expect(rec.action).toBe("UPDATE");
|
|
174
|
+
expect(rec.rationale).toContain("failing");
|
|
175
|
+
});
|
|
176
|
+
it("should not set renamedEndpoints when there are no renames", () => {
|
|
177
|
+
const rec = generateRecommendation("orders_smoke_test.py", 5, undefined, [], { exists: true }, undefined);
|
|
178
|
+
expect(rec.action).toBe("VERIFY");
|
|
179
|
+
expect(rec.details?.renamedEndpoints).toBeUndefined();
|
|
180
|
+
expect(rec.details?.endpointStatus).toBe("exists");
|
|
181
|
+
});
|
|
182
|
+
it("should prefer rename handling over high-drift REGENERATE", () => {
|
|
183
|
+
// If drift is > 70 but it's caused by a rename, we should UPDATE not REGENERATE
|
|
184
|
+
const issues = [
|
|
185
|
+
{
|
|
186
|
+
type: "endpoints_renamed",
|
|
187
|
+
severity: "high",
|
|
188
|
+
description: "5 API endpoint(s) renamed",
|
|
189
|
+
details: "Multiple renames",
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
const apiSchemaChanges = {
|
|
193
|
+
endpointsRemoved: [],
|
|
194
|
+
endpointsRenamed: [
|
|
195
|
+
{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" },
|
|
196
|
+
{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "post" },
|
|
197
|
+
{ oldPath: "/api/v1/products/{id}", newPath: "/api/v1/items/{id}", method: "get" },
|
|
198
|
+
{ oldPath: "/api/v1/products/{id}", newPath: "/api/v1/items/{id}", method: "put" },
|
|
199
|
+
{ oldPath: "/api/v1/products/{id}", newPath: "/api/v1/items/{id}", method: "delete" },
|
|
200
|
+
],
|
|
201
|
+
endpointsModified: [],
|
|
202
|
+
authenticationChanged: false,
|
|
203
|
+
};
|
|
204
|
+
const rec = generateRecommendation("products_smoke_test.py", 75, // would normally trigger REGENERATE
|
|
205
|
+
undefined, issues, undefined, apiSchemaChanges);
|
|
206
|
+
// Rename detection should take priority over drift threshold
|
|
207
|
+
expect(rec.action).toBe("UPDATE");
|
|
208
|
+
expect(rec.estimatedWork).toBe("SMALL"); // renames are simple substitutions
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -19,6 +19,14 @@ const newTestSchema = z.object({
|
|
|
19
19
|
const descriptionSchema = z.object({
|
|
20
20
|
description: z.string().describe("One-line description"),
|
|
21
21
|
});
|
|
22
|
+
const testMaintenanceSchema = z.object({
|
|
23
|
+
fileName: z.string().describe("Test file that was maintained, e.g. 'products_smoke_test.py'"),
|
|
24
|
+
description: z.string().describe("What was changed and why"),
|
|
25
|
+
beforeStatus: z.enum(["Pass", "Fail", "Error"]).describe("Test result BEFORE modification"),
|
|
26
|
+
beforeDetails: z.string().describe("Execution output/timing before modification, or 'baseline from CI workflow <name>' if a parallel workflow provided the baseline"),
|
|
27
|
+
afterStatus: z.enum(["Pass", "Fail", "Error", "Skipped"]).describe("Test result AFTER modification"),
|
|
28
|
+
afterDetails: z.string().describe("Execution output/timing after modification"),
|
|
29
|
+
});
|
|
22
30
|
export function registerSubmitReportTool(server) {
|
|
23
31
|
server.registerTool(TOOL_NAME, {
|
|
24
32
|
description: "Submit the final testbot report. Call this tool once after completing all test analysis, generation, and execution. " +
|
|
@@ -34,8 +42,8 @@ export function registerSubmitReportTool(server) {
|
|
|
34
42
|
.array(newTestSchema)
|
|
35
43
|
.describe("List of new tests created. Use empty array [] if none."),
|
|
36
44
|
testMaintenance: z
|
|
37
|
-
.array(
|
|
38
|
-
.describe("List of existing test modifications. Use empty array [] if none."),
|
|
45
|
+
.array(testMaintenanceSchema)
|
|
46
|
+
.describe("List of existing test modifications with before/after execution results. Use empty array [] if none."),
|
|
39
47
|
testResults: z
|
|
40
48
|
.array(testResultSchema)
|
|
41
49
|
.describe("List of ALL test execution results. One entry per test executed."),
|
|
@@ -2,7 +2,60 @@ import { z } from "zod";
|
|
|
2
2
|
import { logger } from "../../utils/logger.js";
|
|
3
3
|
import { StateManager, } from "../../utils/AnalysisStateManager.js";
|
|
4
4
|
import * as fs from "fs";
|
|
5
|
+
import * as path from "path";
|
|
5
6
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
7
|
+
/**
|
|
8
|
+
* Compute a suggested new filename when an endpoint is renamed.
|
|
9
|
+
*
|
|
10
|
+
* Extracts the differing static segments between oldPath and newPath,
|
|
11
|
+
* then replaces occurrences in the filename.
|
|
12
|
+
*
|
|
13
|
+
* Example:
|
|
14
|
+
* testFile: "/repo/tests/python/products_smoke_test.py"
|
|
15
|
+
* oldPath: "/api/v1/products"
|
|
16
|
+
* newPath: "/api/v1/items"
|
|
17
|
+
* result: "/repo/tests/python/items_smoke_test.py"
|
|
18
|
+
*/
|
|
19
|
+
export function computeRenamedTestFile(testFile, renames) {
|
|
20
|
+
const basename = path.basename(testFile);
|
|
21
|
+
let newBasename = basename;
|
|
22
|
+
for (const rename of renames) {
|
|
23
|
+
const oldSegments = rename.oldPath.split("/").filter((s) => s.length > 0);
|
|
24
|
+
const newSegments = rename.newPath.split("/").filter((s) => s.length > 0);
|
|
25
|
+
if (oldSegments.length !== newSegments.length)
|
|
26
|
+
continue;
|
|
27
|
+
const paramPattern = /^\{[^}]+\}$/;
|
|
28
|
+
for (let i = 0; i < oldSegments.length; i++) {
|
|
29
|
+
if (paramPattern.test(oldSegments[i]))
|
|
30
|
+
continue;
|
|
31
|
+
if (oldSegments[i] !== newSegments[i]) {
|
|
32
|
+
// Replace the old segment name in the filename with the new one
|
|
33
|
+
// Handle both exact matches and common variations:
|
|
34
|
+
// "products" in "products_smoke_test.py"
|
|
35
|
+
// "product" in "product_smoke_test.py" (singular)
|
|
36
|
+
const oldName = oldSegments[i].toLowerCase();
|
|
37
|
+
const newName = newSegments[i].toLowerCase();
|
|
38
|
+
if (newBasename.toLowerCase().includes(oldName)) {
|
|
39
|
+
// Case-preserving replace
|
|
40
|
+
const idx = newBasename.toLowerCase().indexOf(oldName);
|
|
41
|
+
newBasename =
|
|
42
|
+
newBasename.substring(0, idx) +
|
|
43
|
+
newName +
|
|
44
|
+
newBasename.substring(idx + oldName.length);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (newBasename === basename)
|
|
50
|
+
return null; // No change needed
|
|
51
|
+
const newFilePath = path.join(path.dirname(testFile), newBasename);
|
|
52
|
+
// Don't suggest a rename if the target file already exists
|
|
53
|
+
if (fs.existsSync(newFilePath)) {
|
|
54
|
+
logger.info(`Skipping file rename suggestion: ${newFilePath} already exists`);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return newFilePath;
|
|
58
|
+
}
|
|
6
59
|
const actionsSchema = {
|
|
7
60
|
stateFile: z
|
|
8
61
|
.string()
|
|
@@ -70,6 +123,7 @@ Comprehensive report with executed actions, summary, and detailed analysis
|
|
|
70
123
|
rationale: test.recommendation.rationale,
|
|
71
124
|
estimatedWork: test.recommendation.estimatedWork,
|
|
72
125
|
issues: test.issues || [],
|
|
126
|
+
renamedEndpoints: test.recommendation.details?.renamedEndpoints || [],
|
|
73
127
|
});
|
|
74
128
|
}
|
|
75
129
|
});
|
|
@@ -130,11 +184,33 @@ Comprehensive report with executed actions, summary, and detailed analysis
|
|
|
130
184
|
logger.error(`Failed to read test file ${rec.testFile}: ${error.message}`);
|
|
131
185
|
continue;
|
|
132
186
|
}
|
|
187
|
+
// Check if this is a rename-driven update
|
|
188
|
+
const renames = rec.renamedEndpoints || [];
|
|
189
|
+
const isRenameUpdate = renames.length > 0;
|
|
133
190
|
// Build update instructions
|
|
134
191
|
let instruction = `\n### ${rec.testFile}\n\n`;
|
|
135
192
|
instruction += `**Priority:** ${rec.priority} | `;
|
|
136
193
|
instruction += `**Estimated Effort:** ${rec.estimatedWork || "Small"}\n\n`;
|
|
137
194
|
instruction += `**Why Update Needed:** ${rec.rationale}\n\n`;
|
|
195
|
+
if (isRenameUpdate) {
|
|
196
|
+
instruction += `**🔄 Endpoint Rename Detected — Path Substitution Required:**\n\n`;
|
|
197
|
+
instruction += `| Old Path | New Path | Method |\n`;
|
|
198
|
+
instruction += `|----------|----------|--------|\n`;
|
|
199
|
+
for (const rename of renames) {
|
|
200
|
+
instruction += `| \`${rename.oldPath}\` | \`${rename.newPath}\` | ${rename.method} |\n`;
|
|
201
|
+
}
|
|
202
|
+
instruction += `\n`;
|
|
203
|
+
instruction += `**Action:** Find-and-replace all occurrences of the old path with the new path in this test file. `;
|
|
204
|
+
instruction += `Do NOT change any test logic, assertions, or structure — only update the URL paths.\n\n`;
|
|
205
|
+
// Compute suggested file rename
|
|
206
|
+
const suggestedNewFile = computeRenamedTestFile(rec.testFile, renames);
|
|
207
|
+
if (suggestedNewFile) {
|
|
208
|
+
instruction += `**📁 File Rename:** After updating the paths, rename this file:\n`;
|
|
209
|
+
instruction += `- From: \`${path.basename(rec.testFile)}\`\n`;
|
|
210
|
+
instruction += `- To: \`${path.basename(suggestedNewFile)}\`\n\n`;
|
|
211
|
+
rec._suggestedNewFile = suggestedNewFile;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
138
214
|
if (driftData) {
|
|
139
215
|
instruction += `**Analysis:**\n`;
|
|
140
216
|
instruction += `- Drift Score: ${driftData.driftScore ?? "N/A"}\n`;
|
|
@@ -184,16 +260,46 @@ Comprehensive report with executed actions, summary, and detailed analysis
|
|
|
184
260
|
responseText += `4. Show you the changes made\n\n`;
|
|
185
261
|
responseText += `5. At the end of the tool execution, MUST display the below message\n`;
|
|
186
262
|
responseText += `**This tool is currently in Early Preview stage. Please verify the results.**\n\n`;
|
|
263
|
+
// Collect all rename mappings across recommendations
|
|
264
|
+
const allRenames = [];
|
|
265
|
+
for (const rec of updateRecommendations) {
|
|
266
|
+
if (rec.renamedEndpoints && rec.renamedEndpoints.length > 0) {
|
|
267
|
+
allRenames.push(...rec.renamedEndpoints);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Deduplicate renames
|
|
271
|
+
const uniqueRenames = allRenames.filter((r, i, arr) => arr.findIndex((x) => x.oldPath === r.oldPath &&
|
|
272
|
+
x.newPath === r.newPath &&
|
|
273
|
+
x.method === r.method) === i);
|
|
187
274
|
// Build LLM-only instructions (hidden from users)
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
275
|
+
const llmInstructionsObj = {
|
|
276
|
+
workflow: "test_maintenance",
|
|
277
|
+
action: "execute_updates",
|
|
278
|
+
auto_proceed: true,
|
|
279
|
+
files_to_update: testFilesToUpdate,
|
|
280
|
+
update_count: updateRecommendations.length,
|
|
281
|
+
};
|
|
282
|
+
if (uniqueRenames.length > 0) {
|
|
283
|
+
llmInstructionsObj.endpoint_renames = uniqueRenames;
|
|
284
|
+
llmInstructionsObj.rename_strategy =
|
|
285
|
+
"For each file, find-and-replace all occurrences of oldPath with newPath. Do NOT regenerate or restructure the test — only update the URL paths.";
|
|
286
|
+
// Collect file rename suggestions
|
|
287
|
+
const fileRenames = [];
|
|
288
|
+
for (const rec of updateRecommendations) {
|
|
289
|
+
if (rec._suggestedNewFile) {
|
|
290
|
+
fileRenames.push({
|
|
291
|
+
from: rec.testFile,
|
|
292
|
+
to: rec._suggestedNewFile,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (fileRenames.length > 0) {
|
|
297
|
+
llmInstructionsObj.file_renames = fileRenames;
|
|
298
|
+
llmInstructionsObj.file_rename_strategy =
|
|
299
|
+
"After updating path content in each file, rename the file using 'mv' or equivalent. Use git mv if the repo tracks the file.";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const llmInstructions = `<!-- LLM_INSTRUCTIONS:\n${JSON.stringify(llmInstructionsObj, null, 2)}\n-->\n`;
|
|
197
303
|
return {
|
|
198
304
|
content: [
|
|
199
305
|
{
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Mock modules that use ESM-only features (import.meta) before importing actionsTool
|
|
2
|
+
jest.mock("../../services/AnalyticsService.js", () => ({
|
|
3
|
+
AnalyticsService: { pushMCPToolEvent: jest.fn() },
|
|
4
|
+
}));
|
|
5
|
+
jest.mock("../../utils/logger.js", () => ({
|
|
6
|
+
logger: { info: jest.fn(), warning: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
7
|
+
}));
|
|
8
|
+
jest.mock("../../utils/AnalysisStateManager.js", () => ({
|
|
9
|
+
StateManager: { fromStatePath: jest.fn() },
|
|
10
|
+
}));
|
|
11
|
+
jest.mock("fs");
|
|
12
|
+
// @ts-ignore
|
|
13
|
+
import { computeRenamedTestFile } from "./actionsTool.js";
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
const mockExistsSync = fs.existsSync;
|
|
16
|
+
describe("computeRenamedTestFile", () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
// Default: target file does not exist
|
|
19
|
+
mockExistsSync.mockReturnValue(false);
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
jest.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
// --- Basic renames ---
|
|
25
|
+
it("should rename products_smoke_test.py to items_smoke_test.py", () => {
|
|
26
|
+
const result = computeRenamedTestFile("/repo/tests/python/products_smoke_test.py", [{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" }]);
|
|
27
|
+
expect(result).toBe("/repo/tests/python/items_smoke_test.py");
|
|
28
|
+
});
|
|
29
|
+
it("should rename products_contract_test.py to items_contract_test.py", () => {
|
|
30
|
+
const result = computeRenamedTestFile("/repo/tests/python/products_contract_test.py", [{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" }]);
|
|
31
|
+
expect(result).toBe("/repo/tests/python/items_contract_test.py");
|
|
32
|
+
});
|
|
33
|
+
it("should rename products_integration_test.py to items_integration_test.py", () => {
|
|
34
|
+
const result = computeRenamedTestFile("/repo/tests/python/products_integration_test.py", [{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "post" }]);
|
|
35
|
+
expect(result).toBe("/repo/tests/python/items_integration_test.py");
|
|
36
|
+
});
|
|
37
|
+
it("should rename products_fuzz_test.py to items_fuzz_test.py", () => {
|
|
38
|
+
const result = computeRenamedTestFile("/repo/tests/python/products_fuzz_test.py", [{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" }]);
|
|
39
|
+
expect(result).toBe("/repo/tests/python/items_fuzz_test.py");
|
|
40
|
+
});
|
|
41
|
+
it("should rename products_load_test.py to items_load_test.py", () => {
|
|
42
|
+
const result = computeRenamedTestFile("/repo/tests/python/products_load_test.py", [{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" }]);
|
|
43
|
+
expect(result).toBe("/repo/tests/python/items_load_test.py");
|
|
44
|
+
});
|
|
45
|
+
// --- Different file extensions ---
|
|
46
|
+
it("should work with .ts test files", () => {
|
|
47
|
+
const result = computeRenamedTestFile("/repo/tests/products.test.ts", [{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" }]);
|
|
48
|
+
expect(result).toBe("/repo/tests/items.test.ts");
|
|
49
|
+
});
|
|
50
|
+
it("should work with .js test files", () => {
|
|
51
|
+
const result = computeRenamedTestFile("/repo/tests/products_smoke.test.js", [{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" }]);
|
|
52
|
+
expect(result).toBe("/repo/tests/items_smoke.test.js");
|
|
53
|
+
});
|
|
54
|
+
// --- Returns null when no rename needed ---
|
|
55
|
+
it("should return null when filename does not contain old segment", () => {
|
|
56
|
+
const result = computeRenamedTestFile("/repo/tests/python/orders_smoke_test.py", [{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" }]);
|
|
57
|
+
expect(result).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
it("should return null when target file already exists", () => {
|
|
60
|
+
mockExistsSync.mockReturnValue(true);
|
|
61
|
+
const result = computeRenamedTestFile("/repo/tests/python/products_smoke_test.py", [{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" }]);
|
|
62
|
+
expect(result).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
it("should return null when segments have different lengths", () => {
|
|
65
|
+
const result = computeRenamedTestFile("/repo/tests/python/products_smoke_test.py", [{ oldPath: "/api/v1/products", newPath: "/api/v2/catalog/items", method: "get" }]);
|
|
66
|
+
// Different segment counts — no substitution attempted
|
|
67
|
+
expect(result).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
it("should return null with empty renames array", () => {
|
|
70
|
+
const result = computeRenamedTestFile("/repo/tests/python/products_smoke_test.py", []);
|
|
71
|
+
expect(result).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
// --- Multiple renames ---
|
|
74
|
+
it("should apply multiple rename mappings", () => {
|
|
75
|
+
// Unlikely but possible: two segments change
|
|
76
|
+
const result = computeRenamedTestFile("/repo/tests/python/products_smoke_test.py", [
|
|
77
|
+
{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" },
|
|
78
|
+
{ oldPath: "/api/v1/products/{product_id}", newPath: "/api/v1/items/{item_id}", method: "get" },
|
|
79
|
+
]);
|
|
80
|
+
expect(result).toBe("/repo/tests/python/items_smoke_test.py");
|
|
81
|
+
});
|
|
82
|
+
// --- Preserves directory structure ---
|
|
83
|
+
it("should preserve the directory path", () => {
|
|
84
|
+
const result = computeRenamedTestFile("/home/runner/work/api-insight/api-insight/tests/python/products_smoke_test.py", [{ oldPath: "/api/v1/products", newPath: "/api/v1/items", method: "get" }]);
|
|
85
|
+
expect(result).toBe("/home/runner/work/api-insight/api-insight/tests/python/items_smoke_test.py");
|
|
86
|
+
});
|
|
87
|
+
// --- Version bump rename ---
|
|
88
|
+
it("should handle version segment rename in filename if present", () => {
|
|
89
|
+
const result = computeRenamedTestFile("/repo/tests/v1_products_test.py", [{ oldPath: "/api/v1/products", newPath: "/api/v2/products", method: "get" }]);
|
|
90
|
+
// "v1" in filename gets replaced with "v2"
|
|
91
|
+
expect(result).toBe("/repo/tests/v2_products_test.py");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -119,7 +119,7 @@ function parseEndpointsFromDiff(diffData) {
|
|
|
119
119
|
affectedServices,
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
|
-
async function computeBranchDiff(repositoryPath) {
|
|
122
|
+
async function computeBranchDiff(repositoryPath, providedBaseBranch) {
|
|
123
123
|
const git = simpleGit(repositoryPath);
|
|
124
124
|
const isRepo = await git.checkIsRepo();
|
|
125
125
|
if (!isRepo) {
|
|
@@ -127,22 +127,27 @@ async function computeBranchDiff(repositoryPath) {
|
|
|
127
127
|
}
|
|
128
128
|
const branchInfo = await git.branch();
|
|
129
129
|
const currentBranch = branchInfo.current || "HEAD";
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
130
|
+
let baseBranch;
|
|
131
|
+
if (providedBaseBranch) {
|
|
132
|
+
// Use the PR's base branch when explicitly provided (e.g. from testbot)
|
|
133
|
+
baseBranch = `origin/${providedBaseBranch}`;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
// Fall back to auto-detecting origin/main or origin/master
|
|
137
|
+
baseBranch = "origin/main";
|
|
138
|
+
try {
|
|
139
|
+
const remoteBranches = await git.branch(["-r"]);
|
|
140
|
+
if (remoteBranches.all.some((b) => b.endsWith("/main"))) {
|
|
141
|
+
baseBranch = "origin/main";
|
|
142
|
+
}
|
|
143
|
+
else if (remoteBranches.all.some((b) => b.endsWith("/master"))) {
|
|
144
|
+
baseBranch = "origin/master";
|
|
145
|
+
}
|
|
138
146
|
}
|
|
139
|
-
|
|
140
|
-
|
|
147
|
+
catch {
|
|
148
|
+
logger.debug("Could not determine remote default branch, falling back to origin/main");
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
|
-
catch {
|
|
144
|
-
logger.debug("Could not determine remote default branch, falling back to origin/main");
|
|
145
|
-
}
|
|
146
151
|
const changedFilesRaw = await git.diff([
|
|
147
152
|
`${baseBranch}...HEAD`,
|
|
148
153
|
"--name-only",
|
|
@@ -180,6 +185,10 @@ const analyzeRepositorySchema = z.object({
|
|
|
180
185
|
.array(z.string())
|
|
181
186
|
.optional()
|
|
182
187
|
.describe("Optional: Specific areas to focus on (e.g., ['api', 'frontend', 'infrastructure'])"),
|
|
188
|
+
baseBranch: z
|
|
189
|
+
.string()
|
|
190
|
+
.optional()
|
|
191
|
+
.describe("Optional: PR base branch name (e.g. 'main', 'develop'). When provided, the diff is computed against origin/<baseBranch> instead of auto-detecting the default branch. Useful when the PR targets a non-default branch."),
|
|
183
192
|
});
|
|
184
193
|
const TOOL_NAME = "skyramp_analyze_repository";
|
|
185
194
|
export function registerAnalyzeRepositoryTool(server) {
|
|
@@ -240,7 +249,7 @@ Output: Detailed RepositoryAnalysis JSON object with all repository characterist
|
|
|
240
249
|
let diffData;
|
|
241
250
|
if (analysisScope === "current_branch_diff") {
|
|
242
251
|
try {
|
|
243
|
-
diffData = await computeBranchDiff(params.repositoryPath);
|
|
252
|
+
diffData = await computeBranchDiff(params.repositoryPath, params.baseBranch);
|
|
244
253
|
logger.info("Branch diff computed via git", {
|
|
245
254
|
currentBranch: diffData.currentBranch,
|
|
246
255
|
baseBranch: diffData.baseBranch,
|
|
@@ -137,6 +137,32 @@ ${diff.changedFiles.map((f) => `- \`${f}\``).join("\n")}
|
|
|
137
137
|
.join("\n");
|
|
138
138
|
const highActions = buildActionList(mapping.summary.highPriority);
|
|
139
139
|
const mediumActions = buildActionList(mapping.summary.mediumPriority);
|
|
140
|
+
// Check if E2E or UI tests are in the priority lists
|
|
141
|
+
const allPriority = [
|
|
142
|
+
...mapping.summary.highPriority,
|
|
143
|
+
...mapping.summary.mediumPriority,
|
|
144
|
+
];
|
|
145
|
+
const hasE2EOrUI = allPriority.some((t) => t === TestType.E2E || t === TestType.UI);
|
|
146
|
+
const traceGuidance = hasE2EOrUI
|
|
147
|
+
? `
|
|
148
|
+
### Trace Files for E2E/UI Tests
|
|
149
|
+
|
|
150
|
+
E2E and UI test generation requires pre-recorded trace files. Search the repository for:
|
|
151
|
+
- Backend traces: \`**/skyramp*trace*.json\`, \`**/skyramp-traces.json\`
|
|
152
|
+
- Playwright traces: \`**/skyramp*playwright*.zip\`, \`**/*playwright*.zip\`
|
|
153
|
+
|
|
154
|
+
Look in the test directory, repository root, and \`.skyramp/\` directories.
|
|
155
|
+
|
|
156
|
+
**IMPORTANT — Verify trace relevance before using it:**
|
|
157
|
+
Before passing a trace file to a test generation tool, inspect its contents to confirm it actually exercises the UI components or pages affected by the PR. A trace recorded before the current changes will not cover new UI elements. If the trace does NOT cover the changed UI:
|
|
158
|
+
- Do NOT use it for generating tests for the new changes.
|
|
159
|
+
- Report in \`issuesFound\`: "A Playwright trace file was found (<filename>) but it does not cover the new UI changes in this PR. To generate UI tests for the new functionality, record a new trace that exercises the changed pages/components and commit it, then re-run the Testbot."
|
|
160
|
+
|
|
161
|
+
- **Both found and relevant** → call \`skyramp_e2e_test_generation\` with both trace files
|
|
162
|
+
- **Only Playwright ZIP found and relevant** → call \`skyramp_ui_test_generation\` with the Playwright file
|
|
163
|
+
- **No traces found** → do NOT silently skip. Include in \`issuesFound\` when submitting your report: "E2E/UI tests were recommended but could not be generated because no Playwright trace file (.zip) was found in the repository. To enable E2E/UI test generation, record a Playwright trace and commit the .zip file, then re-run the Testbot."
|
|
164
|
+
`
|
|
165
|
+
: "";
|
|
140
166
|
const nextActionsSection = mapping.summary.highPriority.length > 0 ||
|
|
141
167
|
mapping.summary.mediumPriority.length > 0
|
|
142
168
|
? `
|
|
@@ -148,7 +174,7 @@ Do NOT skip any. Do NOT just run existing tests — generate new ones.
|
|
|
148
174
|
### High Priority (call these first)
|
|
149
175
|
${highActions || "none"}
|
|
150
176
|
|
|
151
|
-
${mediumActions ? `### Medium Priority (call after high)\n${mediumActions}\n` : ""}${isDiffScope && ((analysis?.branchDiffContext?.newEndpoints?.length ?? 0) + (analysis?.branchDiffContext?.modifiedEndpoints?.length ?? 0)) > 0 ? `\nTarget the changed endpoint(s) listed above for each generated test. Use the full URL (including base URL) as the \`endpointURL\` parameter when calling generate tools.` : ""}
|
|
177
|
+
${mediumActions ? `### Medium Priority (call after high)\n${mediumActions}\n` : ""}${isDiffScope && ((analysis?.branchDiffContext?.newEndpoints?.length ?? 0) + (analysis?.branchDiffContext?.modifiedEndpoints?.length ?? 0)) > 0 ? `\nTarget the changed endpoint(s) listed above for each generated test. Use the full URL (including base URL) as the \`endpointURL\` parameter when calling generate tools.` : ""}${traceGuidance}
|
|
152
178
|
`
|
|
153
179
|
: "";
|
|
154
180
|
const output = `# Test Recommendations
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyramp/mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.59",
|
|
4
4
|
"main": "build/index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
48
48
|
"@playwright/test": "^1.55.0",
|
|
49
|
-
"@skyramp/skyramp": "1.3.
|
|
49
|
+
"@skyramp/skyramp": "1.3.11",
|
|
50
50
|
"dockerode": "^4.0.6",
|
|
51
51
|
"fast-glob": "^3.3.3",
|
|
52
52
|
"simple-git": "^3.30.0",
|