@skyramp/mcp 0.2.8 → 0.2.9
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 +0 -7
- package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +3 -1
- package/build/prompts/sut-setup/shared.d.ts +20 -0
- package/build/prompts/sut-setup/shared.js +69 -7
- package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +58 -3
- package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -22
- package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +14 -0
- package/build/prompts/test-maintenance/driftAnalysisShared.js +62 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +38 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +228 -0
- package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
- package/build/prompts/testbot/testbot-prompts.js +3 -2
- package/build/services/TestDiscoveryService.d.ts +12 -9
- package/build/services/TestDiscoveryService.js +125 -51
- package/build/services/TestDiscoveryService.test.js +235 -15
- package/build/services/TestExecutionService.d.ts +1 -1
- package/build/services/TestExecutionService.js +7 -7
- package/build/services/TestExecutionService.test.js +4 -1
- package/build/tools/submitReportTool.d.ts +5 -5
- package/build/tools/submitReportTool.js +11 -4
- package/build/tools/submitReportTool.test.js +2 -0
- package/build/tools/test-management/actionsTool.js +54 -36
- package/build/tools/test-management/analyzeChangesTool.js +11 -17
- package/build/tools/test-management/analyzeTestHealthTool.js +182 -8
- package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
- package/build/tools/test-management/analyzeTestHealthTool.test.js +468 -0
- package/build/tools/workspace/initializeWorkspaceTool.js +2 -9
- package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
- package/build/types/TestAnalysis.d.ts +3 -0
- package/build/types/TestbotReport.d.ts +1 -1
- package/build/utils/AnalysisStateManager.d.ts +3 -21
- package/build/utils/docker.test.js +1 -1
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/workspace/workspace.d.ts +21 -21
- package/build/workspace/workspace.js +7 -4
- package/build/workspace/workspace.test.js +65 -6
- package/package.json +2 -2
|
@@ -6,7 +6,6 @@ import * as fs from "fs";
|
|
|
6
6
|
import * as path from "path";
|
|
7
7
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
8
8
|
import { toolError } from "../../utils/utils.js";
|
|
9
|
-
import { stageGeneratedPaths } from "../../utils/gitStaging.js";
|
|
10
9
|
import { buildRenameStrategy, buildFileRenameStrategy, buildUpdateStrategy, buildRegenerateStrategy, buildUpdateFileInstruction, buildRegenerateFileInstruction, } from "../../prompts/test-maintenance/actionsInstructions.js";
|
|
11
10
|
/**
|
|
12
11
|
* Compute a suggested new filename when an endpoint is renamed.
|
|
@@ -49,7 +48,12 @@ const recommendationSchema = z.object({
|
|
|
49
48
|
testFile: z
|
|
50
49
|
.string()
|
|
51
50
|
.refine((p) => path.isAbsolute(p), { message: "testFile must be an absolute path" })
|
|
52
|
-
.describe("Absolute path to the test file — use the path as reported by skyramp_analyze_changes or skyramp_analyze_test_health"),
|
|
51
|
+
.describe("Absolute path to the test spec file — use the path as reported by skyramp_analyze_changes or skyramp_analyze_test_health. For POM-based tests, this is the spec file (shown in the report); set pomFile to the page object path to edit."),
|
|
52
|
+
pomFile: z
|
|
53
|
+
.string()
|
|
54
|
+
.optional()
|
|
55
|
+
.refine((p) => p === undefined || path.isAbsolute(p), { message: "pomFile must be an absolute path" })
|
|
56
|
+
.describe("Absolute path to the page object (POM) file to edit, when the spec delegates selectors to a page object. When set, skyramp_actions edits this file instead of testFile. testFile (the spec) is still used for catalog validation and the maintenance report."),
|
|
53
57
|
action: z
|
|
54
58
|
.nativeEnum(DriftAction)
|
|
55
59
|
.describe("Drift action assigned by the LLM health assessment"),
|
|
@@ -95,7 +99,7 @@ const actionsSchema = {
|
|
|
95
99
|
recommendations: z
|
|
96
100
|
.array(recommendationSchema)
|
|
97
101
|
.optional()
|
|
98
|
-
.describe("
|
|
102
|
+
.describe("Drift recommendations from skyramp_analyze_test_health. Omit IGNORE entries. Omitting this field results in no maintenance actions."),
|
|
99
103
|
};
|
|
100
104
|
const TOOL_NAME = "skyramp_actions";
|
|
101
105
|
export function registerActionsTool(server) {
|
|
@@ -123,10 +127,6 @@ export function registerActionsTool(server) {
|
|
|
123
127
|
}
|
|
124
128
|
// Resolve repo root for path normalization and security checks.
|
|
125
129
|
const repoRoot = repositoryPath ? path.resolve(repositoryPath) : "";
|
|
126
|
-
// Relevant external (user-written) tests: UPDATE is permitted; REGENERATE/DELETE
|
|
127
|
-
// are report-only (the LLM may recommend them but this tool will not apply them).
|
|
128
|
-
// Paths are stored relative to repositoryPath in the state file — re-absolutize.
|
|
129
|
-
const relevantExternalPaths = new Set((stateData.repositoryAnalysis?.relevantExternalTestPaths ?? []).map((p) => path.isAbsolute(p) ? p : path.resolve(repoRoot, p)));
|
|
130
130
|
// Allowlist: Skyramp-generated tests + relevant external tests.
|
|
131
131
|
// Using an allowlist (not a blocklist) catches hallucinated paths the LLM
|
|
132
132
|
// may supply that are not in the scanned catalog at all.
|
|
@@ -168,10 +168,9 @@ export function registerActionsTool(server) {
|
|
|
168
168
|
// all actions, including VERIFY and IGNORE, to keep the report consistent
|
|
169
169
|
// with what was actually discovered.
|
|
170
170
|
const isSkyramp = skyrampTestFiles.has(resolvedFile) || skyrampTestFiles.has(rec.testFile);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const isInAnyKnownCatalog = isSkyramp || isRelevantExternal
|
|
174
|
-
|| externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile);
|
|
171
|
+
// Catalog membership is the safety boundary — prevents editing non-test files.
|
|
172
|
+
const isRelevantExternal = (externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile));
|
|
173
|
+
const isInAnyKnownCatalog = isSkyramp || isRelevantExternal;
|
|
175
174
|
if (!isInAnyKnownCatalog) {
|
|
176
175
|
logger.warning(`Skipping ${rec.action} for unknown test (not in scanned catalog): ${rec.testFile}`);
|
|
177
176
|
return;
|
|
@@ -196,8 +195,21 @@ export function registerActionsTool(server) {
|
|
|
196
195
|
});
|
|
197
196
|
return;
|
|
198
197
|
}
|
|
198
|
+
// Resolve and validate pomFile with the same path-traversal guard as testFile.
|
|
199
|
+
let resolvedPomFile;
|
|
200
|
+
if (rec.pomFile) {
|
|
201
|
+
const rawPom = path.isAbsolute(rec.pomFile)
|
|
202
|
+
? rec.pomFile
|
|
203
|
+
: path.resolve(repoRoot ?? "", rec.pomFile);
|
|
204
|
+
resolvedPomFile = path.resolve(rawPom);
|
|
205
|
+
if (repoRoot && !resolvedPomFile.startsWith(repoRoot + path.sep) && resolvedPomFile !== repoRoot) {
|
|
206
|
+
logger.warning(`Skipping pomFile outside repo root: ${rec.pomFile}`);
|
|
207
|
+
resolvedPomFile = undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
199
210
|
recommendations.push({
|
|
200
211
|
testFile: resolvedFile,
|
|
212
|
+
pomFile: resolvedPomFile,
|
|
201
213
|
action: rec.action,
|
|
202
214
|
priority: rec.priority ?? RecommendationPriority.Medium,
|
|
203
215
|
rationale: rec.rationale ?? "",
|
|
@@ -207,16 +219,19 @@ export function registerActionsTool(server) {
|
|
|
207
219
|
});
|
|
208
220
|
});
|
|
209
221
|
// ── Process UPDATE and REGENERATE recommendations ──
|
|
210
|
-
//
|
|
211
|
-
//
|
|
222
|
+
// UPDATE dedup key: `${testFile}::${pomFile ?? ''}` — a spec may emit one
|
|
223
|
+
// UPDATE entry per page object (different pomFile values), so the same
|
|
224
|
+
// testFile can appear multiple times with different edit targets.
|
|
225
|
+
// REGENERATE dedup key: testFile only — REGENERATE covers the whole spec.
|
|
226
|
+
// Priority order: high > medium > low.
|
|
212
227
|
const priorityRank = {
|
|
213
228
|
[RecommendationPriority.High]: 2,
|
|
214
229
|
[RecommendationPriority.Medium]: 1,
|
|
215
230
|
[RecommendationPriority.Low]: 0,
|
|
216
231
|
};
|
|
217
|
-
// Build per-file winner maps. REGENERATE beats UPDATE for the same
|
|
232
|
+
// Build per-file winner maps. REGENERATE beats UPDATE for the same spec —
|
|
218
233
|
// if the LLM emits both, keep REGENERATE (higher severity) and drop UPDATE.
|
|
219
|
-
const
|
|
234
|
+
const updateByKey = new Map();
|
|
220
235
|
const regenerateByFile = new Map();
|
|
221
236
|
for (const rec of recommendations) {
|
|
222
237
|
if (rec.action === DriftAction.Regenerate) {
|
|
@@ -226,21 +241,25 @@ export function registerActionsTool(server) {
|
|
|
226
241
|
}
|
|
227
242
|
}
|
|
228
243
|
else if (rec.action === DriftAction.Update) {
|
|
229
|
-
// Only add
|
|
244
|
+
// Only add if no REGENERATE exists for this spec.
|
|
230
245
|
if (!regenerateByFile.has(rec.testFile)) {
|
|
231
|
-
const
|
|
246
|
+
const key = `${rec.testFile}::${rec.pomFile ?? ""}`;
|
|
247
|
+
const existing = updateByKey.get(key);
|
|
232
248
|
if (!existing || priorityRank[rec.priority] > priorityRank[existing.priority]) {
|
|
233
|
-
|
|
249
|
+
updateByKey.set(key, rec);
|
|
234
250
|
}
|
|
235
251
|
}
|
|
236
252
|
}
|
|
237
253
|
}
|
|
238
|
-
// Second pass: drop any UPDATE entries for
|
|
254
|
+
// Second pass: drop any UPDATE entries for specs that ended up with REGENERATE
|
|
239
255
|
// (handles ordering where UPDATE was inserted before REGENERATE was seen).
|
|
240
256
|
for (const file of regenerateByFile.keys()) {
|
|
241
|
-
|
|
257
|
+
for (const key of updateByKey.keys()) {
|
|
258
|
+
if (key.startsWith(`${file}::`))
|
|
259
|
+
updateByKey.delete(key);
|
|
260
|
+
}
|
|
242
261
|
}
|
|
243
|
-
const updateRecommendations = Array.from(
|
|
262
|
+
const updateRecommendations = Array.from(updateByKey.values());
|
|
244
263
|
const regenerateRecommendations = Array.from(regenerateByFile.values());
|
|
245
264
|
const fileInstructions = [];
|
|
246
265
|
const testFilesToUpdate = [];
|
|
@@ -251,23 +270,30 @@ export function registerActionsTool(server) {
|
|
|
251
270
|
logger.warning("Recommendation missing testFile", rec);
|
|
252
271
|
continue;
|
|
253
272
|
}
|
|
254
|
-
|
|
273
|
+
// When a POM file is specified, edit the POM (selector source of truth)
|
|
274
|
+
// rather than the spec. testFile (the spec) remains in the report.
|
|
275
|
+
const editTarget = rec.pomFile ?? rec.testFile;
|
|
276
|
+
testFilesToUpdate.push(editTarget);
|
|
255
277
|
try {
|
|
256
|
-
testFileContentMap.set(
|
|
278
|
+
testFileContentMap.set(editTarget, fs.readFileSync(editTarget, "utf-8"));
|
|
257
279
|
}
|
|
258
280
|
catch (error) {
|
|
259
|
-
logger.error(`Failed to read test file ${
|
|
281
|
+
logger.error(`Failed to read ${rec.pomFile ? "page object" : "test"} file ${editTarget}: ${error.message}`);
|
|
260
282
|
continue;
|
|
261
283
|
}
|
|
262
284
|
const renames = rec.renamedEndpoints || [];
|
|
263
285
|
const isRenameUpdate = renames.length > 0;
|
|
286
|
+
// Always derive the rename suggestion from the spec (testFile), not the
|
|
287
|
+
// POM (editTarget). fileRenames keys off rec.testFile so the spec is
|
|
288
|
+
// what gets renamed; computing from editTarget when pomFile is set
|
|
289
|
+
// would produce a name derived from the page-object path instead.
|
|
264
290
|
const suggestedNewFile = isRenameUpdate
|
|
265
291
|
? computeRenamedTestFile(rec.testFile, renames) ?? undefined
|
|
266
292
|
: undefined;
|
|
267
293
|
if (suggestedNewFile)
|
|
268
294
|
rec._suggestedNewFile = suggestedNewFile;
|
|
269
295
|
fileInstructions.push(buildUpdateFileInstruction({
|
|
270
|
-
testFile:
|
|
296
|
+
testFile: editTarget,
|
|
271
297
|
renames,
|
|
272
298
|
suggestedNewFile,
|
|
273
299
|
updateInstructions: rec.updateInstructions,
|
|
@@ -354,8 +380,9 @@ export function registerActionsTool(server) {
|
|
|
354
380
|
const updateContext = updateRecommendations
|
|
355
381
|
.filter((rec) => !!rec.updateInstructions)
|
|
356
382
|
.map((rec) => {
|
|
357
|
-
const
|
|
358
|
-
|
|
383
|
+
const editTarget = rec.pomFile ?? rec.testFile;
|
|
384
|
+
const current_content = testFileContentMap.get(editTarget);
|
|
385
|
+
return { file: editTarget, context: rec.updateInstructions, ...(current_content !== undefined && { current_content }) };
|
|
359
386
|
});
|
|
360
387
|
if (updateContext.length > 0) {
|
|
361
388
|
llmInstructionsObj.update_context = updateContext;
|
|
@@ -370,15 +397,6 @@ export function registerActionsTool(server) {
|
|
|
370
397
|
});
|
|
371
398
|
llmInstructionsObj.regenerate_strategy = buildRegenerateStrategy();
|
|
372
399
|
}
|
|
373
|
-
// Stage UPDATE and REGENERATE target paths so testbot's re-stage step
|
|
374
|
-
// commits the agent's edits even when files are outside testDirectory.
|
|
375
|
-
const stagedPaths = [
|
|
376
|
-
...updateRecommendations.map((r) => r.testFile),
|
|
377
|
-
...regenerateRecommendations.map((r) => r.testFile),
|
|
378
|
-
];
|
|
379
|
-
for (const p of stagedPaths) {
|
|
380
|
-
await stageGeneratedPaths(p).catch((e) => logger.warning(`Failed to stage maintained file ${p}: ${e.message}`));
|
|
381
|
-
}
|
|
382
400
|
const llmInstructions = JSON.stringify(llmInstructionsObj, null, 2);
|
|
383
401
|
const contentBlocks = [
|
|
384
402
|
{
|
|
@@ -690,12 +690,18 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
690
690
|
changedResources = fromChangedFiles.length > 0 ? fromChangedFiles : [];
|
|
691
691
|
}
|
|
692
692
|
}
|
|
693
|
+
// Compute changed frontend files once — reused for both discoverTests (component
|
|
694
|
+
// test promotion) and uiContext below (page enumeration). Uses classifiedEndpoints
|
|
695
|
+
// changedFiles so it reflects the same diff scope as the rest of the analysis.
|
|
696
|
+
const flutterSdkDep = hasFlutterSdkDep(params.repositoryPath);
|
|
697
|
+
const changedFrontendFiles = (classifiedEndpoints?.changedFiles ?? diffData?.changedFiles ?? [])
|
|
698
|
+
.filter(f => isFrontendFile(f, { hasFlutterSdkDep: flutterSdkDep }) && !isTestFile(f));
|
|
693
699
|
let existingTests = [];
|
|
694
700
|
let discoveredRelevantExternalPaths = [];
|
|
695
701
|
try {
|
|
696
702
|
const testDiscoveryService = new TestDiscoveryService();
|
|
697
703
|
setTestsRepoDir(params.testsRepoDir);
|
|
698
|
-
const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath, { changedResources });
|
|
704
|
+
const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath, { changedResources, hasFrontendChanges: changedFrontendFiles.length > 0 });
|
|
699
705
|
existingTests = discoveryResult.tests.map((test) => ({
|
|
700
706
|
testFile: test.testFile,
|
|
701
707
|
testType: test.testType,
|
|
@@ -1218,24 +1224,12 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1218
1224
|
// browser_blueprints — see uiInstructions below, which this tool returns
|
|
1219
1225
|
// so the agent captures element vocabulary for UI rec reasoning.
|
|
1220
1226
|
const uiContext = await (async () => {
|
|
1221
|
-
|
|
1222
|
-
if (
|
|
1227
|
+
// changedFrontendFiles computed above (before discoverTests) — reuse here.
|
|
1228
|
+
if (changedFrontendFiles.length === 0)
|
|
1223
1229
|
return undefined;
|
|
1224
|
-
|
|
1225
|
-
// pass isFrontendFile (any .ts under a frontend directory matches
|
|
1226
|
-
// the tier-3 rule) but aren't UI source we'd want to ground page
|
|
1227
|
-
// enumeration in.
|
|
1228
|
-
// Compute hasFlutterSdkDep once and pass it through so .dart files
|
|
1229
|
-
// in a Flutter project are recognised as frontend. See Confluence
|
|
1230
|
-
// "Flutter support in Testbot" — this is the budget-driving call site
|
|
1231
|
-
// that must thread the flag so a Flutter PR enters the UI pipeline.
|
|
1232
|
-
const flutterSdk = hasFlutterSdkDep(params.repositoryPath);
|
|
1233
|
-
const frontendFiles = changedFiles.filter((f) => isFrontendFile(f, { hasFlutterSdkDep: flutterSdk }) && !isTestFile(f));
|
|
1234
|
-
if (frontendFiles.length === 0)
|
|
1235
|
-
return undefined;
|
|
1236
|
-
const candidateUiPages = await enumerateCandidateUiPages(params.repositoryPath, frontendFiles);
|
|
1230
|
+
const candidateUiPages = await enumerateCandidateUiPages(params.repositoryPath, changedFrontendFiles);
|
|
1237
1231
|
return {
|
|
1238
|
-
changedFrontendFiles
|
|
1232
|
+
changedFrontendFiles,
|
|
1239
1233
|
candidateUiPages,
|
|
1240
1234
|
};
|
|
1241
1235
|
})();
|
|
@@ -2,24 +2,148 @@ import { z } from "zod";
|
|
|
2
2
|
import { logger } from "../../utils/logger.js";
|
|
3
3
|
import { StateManager, } from "../../utils/AnalysisStateManager.js";
|
|
4
4
|
import path from "path";
|
|
5
|
+
import * as fs from "fs/promises";
|
|
5
6
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
6
7
|
import { buildDriftAnalysisPrompt } from "../../prompts/test-maintenance/drift-analysis-prompt.js";
|
|
7
8
|
import { TestSource } from "../../types/TestAnalysis.js";
|
|
8
9
|
import { toolError } from "../../utils/utils.js";
|
|
9
10
|
const TOOL_NAME = "skyramp_analyze_test_health";
|
|
11
|
+
// UI test frameworks. "playwright-ui" and "cypress-ui" are the browser-interaction
|
|
12
|
+
// variants detected when the file uses the page fixture (not just the request fixture).
|
|
13
|
+
// Plain "playwright"/"cypress" without the -ui suffix = REST API usage — excluded.
|
|
14
|
+
const UI_COMPONENT_FRAMEWORKS = new Set([
|
|
15
|
+
"rtl", "react-testing-library",
|
|
16
|
+
"vue-test-utils", "angular-testbed", "svelte-testing-library",
|
|
17
|
+
"enzyme",
|
|
18
|
+
"flutter-widget", "flutter-test",
|
|
19
|
+
"xcuitest", "espresso", "appium",
|
|
20
|
+
"selenium",
|
|
21
|
+
"playwright-ui", "cypress-ui",
|
|
22
|
+
]);
|
|
23
|
+
// No REST API test is ever written in TSX/JSX — extension alone is sufficient.
|
|
24
|
+
const UI_TEST_EXTENSIONS = /\.(test|spec)\.(tsx|jsx)$/;
|
|
25
|
+
// Snapshot files (.snap) are secondary artifacts of UI tests — send them through
|
|
26
|
+
// the UI drift path so stale snapshots are detected when frontend selectors change.
|
|
27
|
+
const UI_SNAPSHOT_EXT = /\.snap$/;
|
|
28
|
+
// Playwright/Cypress without the "-ui" suffix = REST API usage — must not be routed
|
|
29
|
+
// to UI drift even when detectExternalTestType() labels the file as "e2e".
|
|
30
|
+
const API_ONLY_FRAMEWORKS = new Set(["playwright", "cypress"]);
|
|
31
|
+
function isUiTest(t) {
|
|
32
|
+
const fw = t.framework?.toLowerCase() ?? "";
|
|
33
|
+
// testType==="e2e" can be set by detectExternalTestType() for any file under
|
|
34
|
+
// playwright/ or cypress/ dirs, including API-only specs. Exclude those.
|
|
35
|
+
if (t.testType === "e2e" && API_ONLY_FRAMEWORKS.has(fw))
|
|
36
|
+
return false;
|
|
37
|
+
// .snap files are secondary artifacts — only route them to UI drift when
|
|
38
|
+
// accompanied by a UI testType or framework signal. API test snapshots
|
|
39
|
+
// (e.g. __snapshots__/orders_smoke.test.ts.snap) must remain in the API path.
|
|
40
|
+
const isUiSnap = UI_SNAPSHOT_EXT.test(t.testFile) &&
|
|
41
|
+
(t.testType === "ui" || UI_COMPONENT_FRAMEWORKS.has(fw));
|
|
42
|
+
return t.testType === "ui" || t.testType === "e2e" ||
|
|
43
|
+
UI_COMPONENT_FRAMEWORKS.has(fw) ||
|
|
44
|
+
UI_TEST_EXTENSIONS.test(t.testFile) ||
|
|
45
|
+
isUiSnap;
|
|
46
|
+
}
|
|
47
|
+
// Grep exported symbols + diff UI signals across external tests to find tests that
|
|
48
|
+
// reference changed frontend files when relevance scoring returns nothing (UI-only PRs).
|
|
49
|
+
const SYMBOL_GREP_MAX_READS = 150;
|
|
50
|
+
const SYMBOL_GREP_MAX_RESULTS = 20;
|
|
51
|
+
const GENERIC_SLUGS = new Set([
|
|
52
|
+
"index", "app", "main", "utils", "util", "lib", "helpers", "helper",
|
|
53
|
+
"common", "shared", "base", "core", "types", "constants", "config",
|
|
54
|
+
"hooks", "store", "context", "layout", "page", "view", "component",
|
|
55
|
+
]);
|
|
56
|
+
async function findUiTestsBySymbolGrep(changedFrontendFiles, allTests, repositoryPath, alreadyIncluded, diffFilePath) {
|
|
57
|
+
const symbols = new Set();
|
|
58
|
+
for (const file of changedFrontendFiles) {
|
|
59
|
+
const abs = path.isAbsolute(file) ? file : path.resolve(repositoryPath, file);
|
|
60
|
+
const content = await fs.readFile(abs, "utf-8").catch(() => "");
|
|
61
|
+
for (const m of content.matchAll(/export\s+(?:default\s+)?(?:function|class|const|let|var)\s+(\w{3,})/g)) {
|
|
62
|
+
symbols.add(m[1]);
|
|
63
|
+
}
|
|
64
|
+
for (const m of content.matchAll(/(?:name|__name):\s*['"](\w[\w-]{2,})['"]/g)) {
|
|
65
|
+
symbols.add(m[1]);
|
|
66
|
+
}
|
|
67
|
+
const slug = path.basename(file).replace(/\.\w+$/, "");
|
|
68
|
+
if (!GENERIC_SLUGS.has(slug.toLowerCase()) && (slug.includes("-") || slug.length > 6)) {
|
|
69
|
+
symbols.add(slug);
|
|
70
|
+
const pascal = slug.replace(/(^|[-_])(\w)/g, (_, _sep, c) => c.toUpperCase());
|
|
71
|
+
symbols.add(pascal);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Also extract quoted string literals from diff +/- lines for changed frontend files.
|
|
75
|
+
// This catches tests referencing OLD values (renamed testid, aria-label, CSS class) that
|
|
76
|
+
// only appear in `-` diff lines, not in the current file content.
|
|
77
|
+
if (diffFilePath) {
|
|
78
|
+
const diffContent = await fs.readFile(diffFilePath, "utf-8").catch(() => "");
|
|
79
|
+
// Exact path markers prevent basename substring false-positives
|
|
80
|
+
// (e.g. "personal-calendar-view.test.tsx" matching "personal-calendar-view.tsx").
|
|
81
|
+
const changedFileMarkers = changedFrontendFiles.flatMap(f => {
|
|
82
|
+
const rel = f.replace(/\\/g, "/");
|
|
83
|
+
return [` a/${rel} `, ` b/${rel}`];
|
|
84
|
+
});
|
|
85
|
+
let inChangedFile = false;
|
|
86
|
+
for (const line of diffContent.split("\n")) {
|
|
87
|
+
if (line.startsWith("diff --git ")) {
|
|
88
|
+
inChangedFile = changedFileMarkers.length > 0 &&
|
|
89
|
+
changedFileMarkers.some(marker => line.includes(marker));
|
|
90
|
+
}
|
|
91
|
+
if (!inChangedFile)
|
|
92
|
+
continue;
|
|
93
|
+
if (!line.startsWith("+") && !line.startsWith("-"))
|
|
94
|
+
continue;
|
|
95
|
+
// Min 10 chars skips generic values ("true","false","continue") that match
|
|
96
|
+
// hundreds of unrelated test files.
|
|
97
|
+
for (const m of line.matchAll(/["']([^"'\n]{10,60})["']/g)) {
|
|
98
|
+
symbols.add(m[1]);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (symbols.size === 0)
|
|
103
|
+
return [];
|
|
104
|
+
const symArray = [...symbols];
|
|
105
|
+
logger.debug(`Symbol grep: ${symArray.length} symbol(s) — ${symArray.slice(0, 5).join(", ")}${symArray.length > 5 ? "..." : ""}`);
|
|
106
|
+
// Normalize paths — stateData.existingTests may contain relative paths in
|
|
107
|
+
// older state files. Mirror the re-absolutization done for relevantExternalPaths.
|
|
108
|
+
const candidates = allTests
|
|
109
|
+
.filter(t => (t.source ?? TestSource.Skyramp) === TestSource.External &&
|
|
110
|
+
isUiTest(t) &&
|
|
111
|
+
!alreadyIncluded.has(t.testFile))
|
|
112
|
+
.map(t => ({
|
|
113
|
+
...t,
|
|
114
|
+
testFile: path.isAbsolute(t.testFile)
|
|
115
|
+
? t.testFile
|
|
116
|
+
: path.resolve(repositoryPath, t.testFile),
|
|
117
|
+
}))
|
|
118
|
+
.slice(0, SYMBOL_GREP_MAX_READS);
|
|
119
|
+
// Read in batches to avoid EMFILE/ENFILE errors on large test suites.
|
|
120
|
+
const BATCH = 20;
|
|
121
|
+
const contents = [];
|
|
122
|
+
for (let i = 0; i < candidates.length; i += BATCH) {
|
|
123
|
+
const batch = await Promise.all(candidates.slice(i, i + BATCH).map(t => fs.readFile(t.testFile, "utf-8").catch(() => "")));
|
|
124
|
+
contents.push(...batch);
|
|
125
|
+
}
|
|
126
|
+
return candidates
|
|
127
|
+
.filter((_, i) => symArray.some(sym => contents[i].includes(sym)))
|
|
128
|
+
.slice(0, SYMBOL_GREP_MAX_RESULTS);
|
|
129
|
+
}
|
|
10
130
|
export function registerAnalyzeTestHealthTool(server) {
|
|
11
131
|
server.registerTool(TOOL_NAME, {
|
|
12
132
|
annotations: {
|
|
13
|
-
readOnlyHint: false,
|
|
14
|
-
destructiveHint:
|
|
133
|
+
readOnlyHint: false,
|
|
134
|
+
destructiveHint: true, // deletes stale diff files via StateManager.cleanupOldFiles
|
|
15
135
|
idempotentHint: true,
|
|
16
136
|
openWorldHint: false,
|
|
17
137
|
},
|
|
18
|
-
description: `
|
|
138
|
+
description: `Assess existing tests for drift against the current branch diff and generate maintenance instructions (UPDATE, REGENERATE, DELETE, VERIFY, or IGNORE) for each test. Covers both API tests and UI tests (Playwright, Cypress, RTL, component tests). Requires a stateFile from skyramp_analyze_changes.`,
|
|
19
139
|
inputSchema: {
|
|
20
140
|
stateFile: z
|
|
21
141
|
.string()
|
|
22
142
|
.describe("Path to the analyze-changes-state.json file returned by skyramp_analyze_changes (the stateFile field in its output). Do NOT pass summaryOutputFile or testbot-result.txt here."),
|
|
143
|
+
blueprintCaptured: z
|
|
144
|
+
.boolean()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe("If hasFrontendChanges is true in the skyramp_analyze_changes output: call browser_navigate to the primary route affected by the diff, then call browser_blueprint to capture the current DOM, then call this tool with blueprintCaptured: true. If hasFrontendChanges is false: call this tool directly and omit this parameter. Passing true activates blueprint-diff analysis for browser UI tests (Playwright, Cypress, Selenium). Component/widget tests always use source-only analysis regardless of this flag."),
|
|
23
147
|
repository: z
|
|
24
148
|
.string()
|
|
25
149
|
.optional()
|
|
@@ -61,17 +185,67 @@ export function registerAnalyzeTestHealthTool(server) {
|
|
|
61
185
|
const skyrampCount = existingTests.filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External).length;
|
|
62
186
|
const externalCount = existingTests.length - skyrampCount;
|
|
63
187
|
logger.info(`Loaded ${skyrampCount} Skyramp + ${externalCount} relevant external tests from state file`);
|
|
64
|
-
//
|
|
65
|
-
// Pass [] for stateTypes so only skyramp-diff-*.diff files are deleted — state files
|
|
66
|
-
// (skyramp-analysis-*, skyramp-recommendation-*) must not be removed here because the
|
|
67
|
-
// caller still needs args.stateFile to pass to skyramp_actions.
|
|
188
|
+
// Delete stale diff files only — state files must remain for the caller's skyramp_actions call.
|
|
68
189
|
try {
|
|
69
190
|
await StateManager.cleanupOldFiles(24, undefined, []);
|
|
70
191
|
}
|
|
71
192
|
catch (error) {
|
|
72
193
|
logger.warning(`Failed to cleanup old diff files: ${error.message}`);
|
|
73
194
|
}
|
|
74
|
-
const
|
|
195
|
+
const uiTests = [];
|
|
196
|
+
const apiTests = [];
|
|
197
|
+
for (const t of existingTests) {
|
|
198
|
+
(isUiTest(t) ? uiTests : apiTests).push(t);
|
|
199
|
+
}
|
|
200
|
+
const uiContext = stateData.uiContext;
|
|
201
|
+
const hasUiFrontend = !!(uiContext?.changedFrontendFiles?.length);
|
|
202
|
+
// When frontend files changed, use the symbol grep to rank uiTests by relevance
|
|
203
|
+
// and cap the list to avoid token overflow in the drift prompt.
|
|
204
|
+
// All bulk-promoted UI tests are now in relevantExternalTestPaths and therefore
|
|
205
|
+
// in uiTests — the grep selects the most relevant subset rather than being the
|
|
206
|
+
// sole discovery path (which could silently drop tests that need UPDATE).
|
|
207
|
+
if (hasUiFrontend && uiTests.length >= SYMBOL_GREP_MAX_RESULTS) {
|
|
208
|
+
// Skyramp-generated UI tests always pass through — grep only ranks externals.
|
|
209
|
+
const skyrampUi = uiTests.filter(t => (t.source ?? TestSource.Skyramp) !== TestSource.External);
|
|
210
|
+
const externalUi = uiTests.filter(t => (t.source ?? TestSource.Skyramp) === TestSource.External);
|
|
211
|
+
const externalCap = Math.max(0, SYMBOL_GREP_MAX_RESULTS - skyrampUi.length);
|
|
212
|
+
// Grep the full discovered pool with empty alreadyIncluded so both:
|
|
213
|
+
// (a) externalUi members are ranked by relevance, and
|
|
214
|
+
// (b) tests not yet in uiTests (e.g. onboarding.e2e.ts missing from
|
|
215
|
+
// relevantExternalTestPaths) can still be found via diff signals.
|
|
216
|
+
// Previous bug: alreadyInUiTests excluded all candidates → grep returned [].
|
|
217
|
+
const grepped = await findUiTestsBySymbolGrep(uiContext.changedFrontendFiles, stateData.existingTests || [], repositoryPath, new Set(), stateData.repositoryAnalysis?.diffFilePath);
|
|
218
|
+
const rankedExternal = grepped.length > 0
|
|
219
|
+
? grepped.slice(0, externalCap)
|
|
220
|
+
: externalUi.slice(0, externalCap); // fallback: discovery order
|
|
221
|
+
if (grepped.length > 0) {
|
|
222
|
+
logger.info(`Symbol grep ranked ${Math.min(grepped.length, externalCap)} external UI test(s) from ${stateData.existingTests?.length ?? 0} candidates`);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
logger.info(`Symbol grep found no matches; capping ${externalUi.length} external UI tests to ${externalCap}`);
|
|
226
|
+
}
|
|
227
|
+
uiTests.splice(0, uiTests.length, ...skyrampUi, ...rankedExternal);
|
|
228
|
+
}
|
|
229
|
+
else if (hasUiFrontend && uiTests.length === 0) {
|
|
230
|
+
// No UI tests found — grep the full discovered pool.
|
|
231
|
+
// Pass empty alreadyIncluded: we want to discover tests from stateData.existingTests,
|
|
232
|
+
// not exclude them. uiTests is empty so there's nothing to avoid re-adding.
|
|
233
|
+
const grepped = await findUiTestsBySymbolGrep(uiContext.changedFrontendFiles, stateData.existingTests || [], repositoryPath, new Set(), stateData.repositoryAnalysis?.diffFilePath);
|
|
234
|
+
if (grepped.length > 0) {
|
|
235
|
+
logger.info(`Symbol grep found ${grepped.length} UI test(s) referencing changed frontend files`);
|
|
236
|
+
uiTests.push(...grepped);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
let uiDriftParams;
|
|
240
|
+
if (hasUiFrontend) {
|
|
241
|
+
logger.info(`Including UI drift analysis for ${uiTests.length} UI test(s), ${apiTests.length} API test(s)`);
|
|
242
|
+
uiDriftParams = {
|
|
243
|
+
tests: uiTests.map(t => ({ testFile: t.testFile, source: t.source })),
|
|
244
|
+
changedFrontendFiles: uiContext.changedFrontendFiles,
|
|
245
|
+
blueprintCaptured: args.blueprintCaptured,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const promptText = buildDriftAnalysisPrompt(stateManager.getStatePath(), apiTests.map((t) => ({ testFile: t.testFile, source: t.source })), uiDriftParams);
|
|
75
249
|
return {
|
|
76
250
|
structuredContent: { prompt: promptText },
|
|
77
251
|
content: [{ type: "text", text: "Drift analysis prompt generated. Follow the prompt field to assess each test." }],
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|