@skyramp/mcp 0.2.1-rc.1 → 0.2.1
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/playwright/registerPlaywrightTools.js +1 -0
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +98 -87
- package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +92 -60
- package/build/prompts/test-maintenance/driftAnalysisSections.js +139 -197
- package/build/prompts/testbot/testbot-prompts.js +4 -7
- package/build/prompts/testbot/testbot-prompts.test.js +17 -22
- package/build/services/TestDiscoveryService.js +39 -9
- package/build/tools/test-management/actionsTool.js +166 -148
- package/build/tools/test-management/analyzeChangesTool.js +2 -10
- package/build/tools/test-management/analyzeTestHealthTool.js +10 -22
- package/build/utils/docker.test.js +1 -1
- package/build/utils/versions.js +1 -1
- package/node_modules/playwright/lib/mcp/skyramp/assertApiRequestTool.js +46 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +298 -51
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +5 -0
- package/package.json +2 -2
- package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +0 -261
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
+
import { simpleGit } from "simple-git";
|
|
3
4
|
import { logger } from "../utils/logger.js";
|
|
4
5
|
import { TestSource } from "../types/TestAnalysis.js";
|
|
5
6
|
import fg from "fast-glob";
|
|
@@ -53,8 +54,11 @@ export class TestDiscoveryService {
|
|
|
53
54
|
/[\\/]__tests__[\\/]/,
|
|
54
55
|
/[\\/]spec[\\/]/,
|
|
55
56
|
];
|
|
57
|
+
// Cache git client and repo status per repository
|
|
58
|
+
gitClientCache = new Map();
|
|
59
|
+
isGitRepoCache = new Map();
|
|
56
60
|
/**
|
|
57
|
-
* Discover all tests
|
|
61
|
+
* Discover all tests in a repository — both Skyramp-generated and external (user-written).
|
|
58
62
|
* Uses fast-glob for cross-platform file scanning, then classifies discovered files
|
|
59
63
|
* as Skyramp-generated tests, external tests, or not-a-test during processing.
|
|
60
64
|
*
|
|
@@ -64,17 +68,19 @@ export class TestDiscoveryService {
|
|
|
64
68
|
* rather than flooding context with irrelevant files.
|
|
65
69
|
* - `undefined` (full-repo mode, no diff): cap at MAX_EXTERNAL_FULL_REPO.
|
|
66
70
|
*/
|
|
67
|
-
async discoverTests(
|
|
68
|
-
logger.info(`Starting test discovery in: ${
|
|
69
|
-
if (!fs.existsSync(
|
|
70
|
-
throw new Error(`
|
|
71
|
+
async discoverTests(repositoryPath, options = {}) {
|
|
72
|
+
logger.info(`Starting test discovery in: ${repositoryPath}`);
|
|
73
|
+
if (!fs.existsSync(repositoryPath)) {
|
|
74
|
+
throw new Error(`Repository path does not exist: ${repositoryPath}`);
|
|
71
75
|
}
|
|
72
|
-
const stats = fs.statSync(
|
|
76
|
+
const stats = fs.statSync(repositoryPath);
|
|
73
77
|
if (!stats.isDirectory()) {
|
|
74
|
-
throw new Error(`Path is not a directory: ${
|
|
78
|
+
throw new Error(`Path is not a directory: ${repositoryPath}`);
|
|
75
79
|
}
|
|
76
|
-
//
|
|
77
|
-
|
|
80
|
+
// Initialize git client cache for this repository
|
|
81
|
+
await this.initializeGitClient(repositoryPath);
|
|
82
|
+
// File classification: skyramp vs external vs not-a-test (carries content forward)
|
|
83
|
+
const classified = this.classifyTestFiles(repositoryPath);
|
|
78
84
|
logger.info(`Found ${classified.skyramp.length} Skyramp test files, ${classified.external.length} external test files`);
|
|
79
85
|
// Process Skyramp tests (content already cached from classification)
|
|
80
86
|
const skyrampTests = await this.processFilesInBatches(classified.skyramp, false, classified.contentCache);
|
|
@@ -133,6 +139,9 @@ export class TestDiscoveryService {
|
|
|
133
139
|
}));
|
|
134
140
|
const externalTests = [...relevantExternalTests, ...otherExternalTests];
|
|
135
141
|
logger.info(`Discovered ${skyrampTests.length} Skyramp tests, ${externalTests.length} external tests`);
|
|
142
|
+
// Clean up caches to free memory
|
|
143
|
+
this.gitClientCache.clear();
|
|
144
|
+
this.isGitRepoCache.clear();
|
|
136
145
|
return {
|
|
137
146
|
tests: [...skyrampTests, ...externalTests],
|
|
138
147
|
// Expose the relevant file paths so callers can build read instructions for the LLM.
|
|
@@ -177,6 +186,27 @@ export class TestDiscoveryService {
|
|
|
177
186
|
}
|
|
178
187
|
return { relevant, other };
|
|
179
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Initialize git client and check if repository is a git repo
|
|
191
|
+
*/
|
|
192
|
+
async initializeGitClient(repositoryPath) {
|
|
193
|
+
try {
|
|
194
|
+
const git = simpleGit(repositoryPath);
|
|
195
|
+
this.gitClientCache.set(repositoryPath, git);
|
|
196
|
+
const isRepo = await git.checkIsRepo();
|
|
197
|
+
this.isGitRepoCache.set(repositoryPath, isRepo);
|
|
198
|
+
if (isRepo) {
|
|
199
|
+
logger.debug(`Git repository detected at: ${repositoryPath}`);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
logger.debug(`Not a git repository: ${repositoryPath}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
logger.debug(`Could not initialize git client: ${error.message}`);
|
|
207
|
+
this.isGitRepoCache.set(repositoryPath, false);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
180
210
|
/**
|
|
181
211
|
* Process test files in parallel batches with concurrency control
|
|
182
212
|
* @param isExternal When true, uses external test metadata extraction
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { logger } from "../../utils/logger.js";
|
|
3
3
|
import { StateManager, } from "../../utils/AnalysisStateManager.js";
|
|
4
4
|
import { TestSource, DriftAction, RecommendationPriority, EstimatedWork } from "../../types/TestAnalysis.js";
|
|
5
|
+
import { TestType } from "../../types/TestTypes.js";
|
|
5
6
|
import * as fs from "fs";
|
|
6
7
|
import * as path from "path";
|
|
7
8
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
@@ -43,6 +44,22 @@ export function computeRenamedTestFile(testFile, renames) {
|
|
|
43
44
|
}
|
|
44
45
|
return newFilePath;
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Select test types to generate based on HTTP method.
|
|
49
|
+
*/
|
|
50
|
+
function selectTestTypesForEndpoint(method) {
|
|
51
|
+
switch (method.toUpperCase()) {
|
|
52
|
+
case "POST":
|
|
53
|
+
case "PUT":
|
|
54
|
+
case "PATCH":
|
|
55
|
+
return [TestType.INTEGRATION, TestType.CONTRACT];
|
|
56
|
+
case "DELETE":
|
|
57
|
+
return [TestType.INTEGRATION, TestType.SMOKE];
|
|
58
|
+
case "GET":
|
|
59
|
+
default:
|
|
60
|
+
return [TestType.CONTRACT, TestType.SMOKE];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
46
63
|
const recommendationSchema = z.object({
|
|
47
64
|
testFile: z
|
|
48
65
|
.string()
|
|
@@ -100,17 +117,20 @@ export function registerActionsTool(server) {
|
|
|
100
117
|
idempotentHint: false,
|
|
101
118
|
openWorldHint: true,
|
|
102
119
|
},
|
|
103
|
-
description: `Execute test maintenance actions — final step of the unified Test Health Analysis Flow.
|
|
120
|
+
description: `Execute test maintenance and generation actions — final step of the unified Test Health Analysis Flow.
|
|
104
121
|
|
|
105
122
|
**PREREQUISITE:** Call \`skyramp_analyze_changes\` (produces the stateFile), then \`skyramp_analyze_test_health\` (runs the drift assessment). This tool reads the stateFile from \`skyramp_analyze_changes\`.
|
|
106
123
|
|
|
124
|
+
Call this tool after completing the drift assessment. It executes maintenance actions automatically from the stateFile — no user confirmation required.
|
|
125
|
+
|
|
107
126
|
**EXECUTING ACTIONS:**
|
|
108
|
-
- UPDATE:
|
|
109
|
-
- REGENERATE:
|
|
110
|
-
-
|
|
127
|
+
- UPDATE: Tests with drift — emits targeted per-file edit instructions driven by updateInstructions and renamedEndpoints
|
|
128
|
+
- REGENERATE: Emits file-level summary; follow up by calling the appropriate generation tool (e.g. skyramp_integration_test_generation) with the same filename to overwrite
|
|
129
|
+
- VERIFY: Emits file-level summary for human review — no automated edits
|
|
130
|
+
- ADD: Auto-generates tests for new endpoints via LLM instructions
|
|
111
131
|
|
|
112
132
|
**OUTPUT:**
|
|
113
|
-
|
|
133
|
+
Comprehensive report with executed actions, summary, and instructions for ADD recommendations
|
|
114
134
|
`,
|
|
115
135
|
inputSchema: actionsSchema,
|
|
116
136
|
}, async (args) => {
|
|
@@ -126,22 +146,17 @@ Per-file instructions for UPDATE and REGENERATE actions, plus a structured \`LLM
|
|
|
126
146
|
errorResult = toolError(`State file is empty or invalid: ${args.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
|
|
127
147
|
return errorResult;
|
|
128
148
|
}
|
|
149
|
+
// External tests must not be candidates for UPDATE/REGENERATE/DELETE actions.
|
|
150
|
+
// Default source to Skyramp for backwards compat with state files created before the source field existed.
|
|
151
|
+
const testAnalysisResults = (stateData.existingTests || []).filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External);
|
|
152
|
+
const newEndpoints = stateData.newEndpoints || [];
|
|
129
153
|
// Resolve repo root for path normalization and security checks.
|
|
130
154
|
const repoRoot = repositoryPath ? path.resolve(repositoryPath) : "";
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// Using an allowlist (not a blocklist) catches hallucinated paths the LLM
|
|
137
|
-
// may supply that are not in the scanned catalog at all.
|
|
138
|
-
const testAnalysisResults = (stateData.existingTests || []);
|
|
139
|
-
const skyrampTestFiles = new Set(testAnalysisResults
|
|
140
|
-
.filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External)
|
|
141
|
-
.map((t) => t.testFile));
|
|
142
|
-
const externalTestFiles = new Set(testAnalysisResults
|
|
143
|
-
.filter((t) => (t.source ?? TestSource.Skyramp) === TestSource.External)
|
|
144
|
-
.map((t) => t.testFile));
|
|
155
|
+
// Set of non-external (Skyramp-generated) test file paths — the only files
|
|
156
|
+
// that may receive UPDATE/REGENERATE/DELETE actions. Using the allowlist rather
|
|
157
|
+
// than a blocklist catches both external tests AND hallucinated paths the LLM
|
|
158
|
+
// may supply that are not present in the scanned catalog at all.
|
|
159
|
+
const skyrampTestFiles = new Set(testAnalysisResults.map((t) => t.testFile));
|
|
145
160
|
// ── Build recommendations from LLM-supplied drift assessment ──
|
|
146
161
|
// The LLM performs the drift assessment in context after skyramp_analyze_test_health
|
|
147
162
|
// and passes results here directly — analyzeTestHealthTool never writes assessment
|
|
@@ -150,55 +165,23 @@ Per-file instructions for UPDATE and REGENERATE actions, plus a structured \`LLM
|
|
|
150
165
|
(args.recommendations ?? []).forEach((rec) => {
|
|
151
166
|
// Schema requires absolute paths; resolve any relative paths defensively
|
|
152
167
|
// against repoRoot in case the LLM sends a relative path despite the schema.
|
|
153
|
-
|
|
154
|
-
// traversal guard — otherwise "/repo/../etc/passwd" would pass startsWith.
|
|
155
|
-
const rawFile = path.isAbsolute(rec.testFile)
|
|
168
|
+
const resolvedFile = path.isAbsolute(rec.testFile)
|
|
156
169
|
? rec.testFile
|
|
157
170
|
: repoRoot
|
|
158
171
|
? path.resolve(repoRoot, rec.testFile)
|
|
159
172
|
: rec.testFile;
|
|
160
|
-
const resolvedFile = path.resolve(rawFile);
|
|
161
173
|
// Reject files outside the repo root (path-traversal guard).
|
|
162
|
-
|
|
163
|
-
// may legitimately live in a separate testsRepoDir outside repositoryPath — catalog
|
|
164
|
-
// membership is a sufficient provenance check for those paths.
|
|
165
|
-
const isInCatalog = skyrampTestFiles.has(resolvedFile) || skyrampTestFiles.has(rec.testFile)
|
|
166
|
-
|| externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile);
|
|
167
|
-
if (repoRoot && !isInCatalog && !resolvedFile.startsWith(repoRoot + path.sep) && resolvedFile !== repoRoot) {
|
|
174
|
+
if (repoRoot && !resolvedFile.startsWith(repoRoot + path.sep) && resolvedFile !== repoRoot) {
|
|
168
175
|
logger.warning(`Skipping recommendation for path outside repo root: ${rec.testFile}`);
|
|
169
176
|
return;
|
|
170
177
|
}
|
|
171
|
-
// Guard: only
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
const isSkyramp = skyrampTestFiles.has(resolvedFile) || skyrampTestFiles.has(rec.testFile);
|
|
176
|
-
const isRelevantExternal = (externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile)) &&
|
|
177
|
-
(relevantExternalPaths.has(resolvedFile) || relevantExternalPaths.has(rec.testFile));
|
|
178
|
-
const isInAnyKnownCatalog = isSkyramp || isRelevantExternal
|
|
179
|
-
|| externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile);
|
|
180
|
-
if (!isInAnyKnownCatalog) {
|
|
181
|
-
logger.warning(`Skipping ${rec.action} for unknown test (not in scanned catalog): ${rec.testFile}`);
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
178
|
+
// Guard: only Skyramp-generated tests may receive UPDATE/REGENERATE/DELETE.
|
|
179
|
+
// Using an allowlist (skyrampTestFiles) rather than a blocklist catches both
|
|
180
|
+
// external tests and hallucinated paths the LLM may supply that are not in
|
|
181
|
+
// the scanned catalog. IGNORE/VERIFY are informational and pass through.
|
|
184
182
|
const isActionable = [DriftAction.Update, DriftAction.Regenerate, DriftAction.Delete].includes(rec.action);
|
|
185
|
-
if (isActionable && !
|
|
186
|
-
logger.warning(`Skipping ${rec.action} for
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
// REGENERATE and DELETE on external tests are report-only — convert to VERIFY so
|
|
190
|
-
// the finding surfaces to the developer without touching the file.
|
|
191
|
-
if (isRelevantExternal && !isSkyramp &&
|
|
192
|
-
(rec.action === DriftAction.Regenerate || rec.action === DriftAction.Delete)) {
|
|
193
|
-
recommendations.push({
|
|
194
|
-
testFile: resolvedFile,
|
|
195
|
-
action: DriftAction.Verify,
|
|
196
|
-
priority: rec.priority ?? RecommendationPriority.Medium,
|
|
197
|
-
rationale: `[external test — needs manual review] ${rec.rationale ?? ""}`.trimEnd(),
|
|
198
|
-
estimatedWork: rec.estimatedWork ?? EstimatedWork.Small,
|
|
199
|
-
updateInstructions: "",
|
|
200
|
-
renamedEndpoints: [],
|
|
201
|
-
});
|
|
183
|
+
if (isActionable && !skyrampTestFiles.has(resolvedFile) && !skyrampTestFiles.has(rec.testFile)) {
|
|
184
|
+
logger.warning(`Skipping ${rec.action} for non-Skyramp or unknown test: ${rec.testFile}`);
|
|
202
185
|
return;
|
|
203
186
|
}
|
|
204
187
|
recommendations.push({
|
|
@@ -211,7 +194,7 @@ Per-file instructions for UPDATE and REGENERATE actions, plus a structured \`LLM
|
|
|
211
194
|
renamedEndpoints: rec.renamedEndpoints ?? [],
|
|
212
195
|
});
|
|
213
196
|
});
|
|
214
|
-
// ── Process UPDATE
|
|
197
|
+
// ── Process UPDATE recommendations ──
|
|
215
198
|
// Deduplicate by testFile — keep the highest-priority entry when the LLM
|
|
216
199
|
// repeats a file. Priority order: high > medium > low.
|
|
217
200
|
const priorityRank = {
|
|
@@ -219,44 +202,28 @@ Per-file instructions for UPDATE and REGENERATE actions, plus a structured \`LLM
|
|
|
219
202
|
[RecommendationPriority.Medium]: 1,
|
|
220
203
|
[RecommendationPriority.Low]: 0,
|
|
221
204
|
};
|
|
222
|
-
// Build per-file winner maps. REGENERATE beats UPDATE for the same file —
|
|
223
|
-
// if the LLM emits both, keep REGENERATE (higher severity) and drop UPDATE.
|
|
224
205
|
const updateByFile = new Map();
|
|
225
|
-
const regenerateByFile = new Map();
|
|
226
206
|
for (const rec of recommendations) {
|
|
227
|
-
if (rec.action
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
else if (rec.action === DriftAction.Update) {
|
|
234
|
-
// Only add to updateByFile if no REGENERATE exists for this file.
|
|
235
|
-
if (!regenerateByFile.has(rec.testFile)) {
|
|
236
|
-
const existing = updateByFile.get(rec.testFile);
|
|
237
|
-
if (!existing || priorityRank[rec.priority] > priorityRank[existing.priority]) {
|
|
238
|
-
updateByFile.set(rec.testFile, rec);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
207
|
+
if (rec.action !== DriftAction.Update)
|
|
208
|
+
continue;
|
|
209
|
+
const existing = updateByFile.get(rec.testFile);
|
|
210
|
+
if (!existing || priorityRank[rec.priority] > priorityRank[existing.priority]) {
|
|
211
|
+
updateByFile.set(rec.testFile, rec);
|
|
241
212
|
}
|
|
242
213
|
}
|
|
243
|
-
// Second pass: drop any UPDATE entries for files that ended up with REGENERATE
|
|
244
|
-
// (handles ordering where UPDATE was inserted before REGENERATE was seen).
|
|
245
|
-
for (const file of regenerateByFile.keys()) {
|
|
246
|
-
updateByFile.delete(file);
|
|
247
|
-
}
|
|
248
214
|
const updateRecommendations = Array.from(updateByFile.values());
|
|
249
|
-
const regenerateRecommendations = Array.from(regenerateByFile.values());
|
|
250
215
|
const fileInstructions = [];
|
|
251
216
|
const testFilesToUpdate = [];
|
|
252
217
|
const testFileContentMap = new Map();
|
|
253
|
-
// ── UPDATE: read file, emit targeted edit instructions ──
|
|
254
218
|
for (const rec of updateRecommendations) {
|
|
255
219
|
if (!rec.testFile) {
|
|
256
220
|
logger.warning("Recommendation missing testFile", rec);
|
|
257
221
|
continue;
|
|
258
222
|
}
|
|
259
223
|
testFilesToUpdate.push(rec.testFile);
|
|
224
|
+
const testData = testAnalysisResults.find((t) => t.testFile === rec.testFile);
|
|
225
|
+
const driftData = testData?.drift;
|
|
226
|
+
const driftChanges = driftData?.changes || [];
|
|
260
227
|
let testFileContent = "";
|
|
261
228
|
try {
|
|
262
229
|
testFileContent = fs.readFileSync(rec.testFile, "utf-8");
|
|
@@ -269,6 +236,9 @@ Per-file instructions for UPDATE and REGENERATE actions, plus a structured \`LLM
|
|
|
269
236
|
const renames = rec.renamedEndpoints || [];
|
|
270
237
|
const isRenameUpdate = renames.length > 0;
|
|
271
238
|
let instruction = `\n### ${rec.testFile}\n\n`;
|
|
239
|
+
instruction += `**Priority:** ${rec.priority} | `;
|
|
240
|
+
instruction += `**Estimated Effort:** ${rec.estimatedWork || EstimatedWork.Small}\n\n`;
|
|
241
|
+
instruction += `**Why Update Needed:** ${rec.rationale}\n\n`;
|
|
272
242
|
if (isRenameUpdate) {
|
|
273
243
|
instruction += `**Endpoint Rename Detected — Path Substitution Required:**\n\n`;
|
|
274
244
|
instruction += `| Old Path | New Path | Method |\n`;
|
|
@@ -294,41 +264,79 @@ Per-file instructions for UPDATE and REGENERATE actions, plus a structured \`LLM
|
|
|
294
264
|
instruction += `Preserve all existing test logic — only add or adjust what is described above.\n\n`;
|
|
295
265
|
}
|
|
296
266
|
else if (!isRenameUpdate) {
|
|
297
|
-
|
|
298
|
-
if (fallbackRationale) {
|
|
299
|
-
instruction += `**Why:** ${fallbackRationale}\n\n`;
|
|
300
|
-
}
|
|
301
|
-
instruction += `**Action:** Update this test file based on the rationale above. `;
|
|
267
|
+
instruction += `**Action:** Update this test file per the rationale above. `;
|
|
302
268
|
instruction += `Match the assertion style already used in the file. `;
|
|
303
269
|
instruction += `Preserve all existing test logic — only add or adjust the minimum required assertions.\n\n`;
|
|
304
270
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const testFilesToRegenerate = [];
|
|
310
|
-
const regenerateContentMap = new Map();
|
|
311
|
-
for (const rec of regenerateRecommendations) {
|
|
312
|
-
if (!rec.testFile) {
|
|
313
|
-
logger.warning("Recommendation missing testFile", rec);
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
testFilesToRegenerate.push(rec.testFile);
|
|
317
|
-
let existingContent = "";
|
|
318
|
-
try {
|
|
319
|
-
existingContent = fs.readFileSync(rec.testFile, "utf-8");
|
|
320
|
-
regenerateContentMap.set(rec.testFile, existingContent);
|
|
271
|
+
if (driftData) {
|
|
272
|
+
instruction += `**Analysis:**\n`;
|
|
273
|
+
instruction += `- Changes Detected: ${driftData.changes?.length || 0}\n`;
|
|
274
|
+
instruction += `- Affected Files: ${driftData.affectedFiles.files?.length || 0}\n\n`;
|
|
321
275
|
}
|
|
322
|
-
|
|
323
|
-
|
|
276
|
+
if (driftChanges.length > 0) {
|
|
277
|
+
instruction += `**Changes Detected:**\n`;
|
|
278
|
+
driftChanges.forEach((change) => {
|
|
279
|
+
instruction += `**${change.type}** (Severity: ${change.severity}): ${change.description}\n`;
|
|
280
|
+
if (change.details) {
|
|
281
|
+
instruction += ` └─ ${change.details}\n`;
|
|
282
|
+
}
|
|
283
|
+
if (change.file) {
|
|
284
|
+
instruction += ` └─ In: \`${change.file}\`\n`;
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
instruction += `\n`;
|
|
324
288
|
}
|
|
325
|
-
|
|
326
|
-
instruction
|
|
327
|
-
|
|
328
|
-
|
|
289
|
+
// File content is provided in LLM_INSTRUCTIONS.update_context.current_content — omit here to avoid duplication.
|
|
290
|
+
fileInstructions.push(instruction);
|
|
291
|
+
}
|
|
292
|
+
// ── Build ADD section for new endpoints ──
|
|
293
|
+
const wsBaseUrl = stateData.repositoryAnalysis?.wsBaseUrl || "";
|
|
294
|
+
const wsSchemaPath = stateData.repositoryAnalysis?.wsSchemaPath || "";
|
|
295
|
+
const primaryLanguage = stateData.repositoryAnalysis?.projectMeta?.primaryLanguage ||
|
|
296
|
+
"python";
|
|
297
|
+
const primaryFramework = stateData.repositoryAnalysis?.projectMeta?.primaryFramework ||
|
|
298
|
+
"pytest";
|
|
299
|
+
// Determine output directory from workspace config or repo path
|
|
300
|
+
const outputDir = repositoryPath
|
|
301
|
+
? path.join(repositoryPath, "tests", "skyramp")
|
|
302
|
+
: "./tests/skyramp";
|
|
303
|
+
const addSummaryLines = [];
|
|
304
|
+
const llmToolCalls = [];
|
|
305
|
+
for (const ep of newEndpoints) {
|
|
306
|
+
const testTypes = selectTestTypesForEndpoint(ep.method);
|
|
307
|
+
const endpointURL = wsBaseUrl
|
|
308
|
+
? wsBaseUrl.replace(/\/$/, "") + ep.path
|
|
309
|
+
: ep.path;
|
|
310
|
+
addSummaryLines.push(`- ${ep.method} ${ep.path} → ${testTypes.join(", ")} tests`);
|
|
311
|
+
for (const testType of testTypes) {
|
|
312
|
+
let toolName = "";
|
|
313
|
+
switch (testType) {
|
|
314
|
+
case TestType.CONTRACT:
|
|
315
|
+
toolName = "skyramp_contract_test_generation";
|
|
316
|
+
break;
|
|
317
|
+
case TestType.INTEGRATION:
|
|
318
|
+
toolName = "skyramp_integration_test_generation";
|
|
319
|
+
break;
|
|
320
|
+
case TestType.SMOKE:
|
|
321
|
+
toolName = "skyramp_smoke_test_generation";
|
|
322
|
+
break;
|
|
323
|
+
default:
|
|
324
|
+
toolName = "skyramp_contract_test_generation";
|
|
325
|
+
}
|
|
326
|
+
llmToolCalls.push({
|
|
327
|
+
tool: toolName,
|
|
328
|
+
params: {
|
|
329
|
+
endpointURL,
|
|
330
|
+
method: ep.method,
|
|
331
|
+
language: primaryLanguage,
|
|
332
|
+
framework: primaryFramework,
|
|
333
|
+
outputDir,
|
|
334
|
+
...(wsSchemaPath ? { apiSchema: wsSchemaPath } : {}),
|
|
335
|
+
},
|
|
336
|
+
endpoint: `${ep.method} ${ep.path}`,
|
|
337
|
+
testType: testType,
|
|
338
|
+
});
|
|
329
339
|
}
|
|
330
|
-
instruction += `Call the appropriate generation tool (e.g. \`skyramp_integration_test_generation\`, \`skyramp_contract_test_generation\`) with \`outputDir: "${path.dirname(rec.testFile)}"\` and \`output: "${path.basename(rec.testFile)}"\` to overwrite this file from scratch. Use the existing file content in \`LLM_INSTRUCTIONS.regenerate_context\` for context on the endpoint, auth pattern, and test structure — replicate the test type and language.\n\n`;
|
|
331
|
-
regenerateInstructions.push(instruction);
|
|
332
340
|
}
|
|
333
341
|
// ── Build response text ──
|
|
334
342
|
let responseText = `# Test Actions Report\n\n`;
|
|
@@ -340,26 +348,37 @@ Per-file instructions for UPDATE and REGENERATE actions, plus a structured \`LLM
|
|
|
340
348
|
responseText += `\n---\n`;
|
|
341
349
|
responseText += fileInstructions.join("\n---\n");
|
|
342
350
|
}
|
|
343
|
-
if (
|
|
344
|
-
responseText += `\n## Tests
|
|
345
|
-
|
|
346
|
-
responseText += `${
|
|
351
|
+
if (newEndpoints.length > 0) {
|
|
352
|
+
responseText += `\n## New Endpoint Tests to Generate (${newEndpoints.length} endpoints)\n\n`;
|
|
353
|
+
addSummaryLines.forEach((line) => {
|
|
354
|
+
responseText += `${line}\n`;
|
|
347
355
|
});
|
|
348
|
-
responseText += `\n
|
|
349
|
-
responseText += regenerateInstructions.join("\n---\n");
|
|
356
|
+
responseText += `\nThe following tests will be generated automatically.\n`;
|
|
350
357
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
responseText += `
|
|
358
|
-
|
|
359
|
-
}
|
|
358
|
+
if (updateRecommendations.length === 0 && newEndpoints.length === 0) {
|
|
359
|
+
const otherRecs = recommendations.filter((rec) => rec.action !== DriftAction.Update);
|
|
360
|
+
if (otherRecs.length > 0) {
|
|
361
|
+
responseText += `## Recommendations (${otherRecs.length})\n\n`;
|
|
362
|
+
otherRecs.forEach((rec) => {
|
|
363
|
+
responseText += `- **${rec.testFile}** — Action: ${rec.action}, Priority: ${rec.priority}\n`;
|
|
364
|
+
responseText += ` ${rec.rationale}\n`;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
responseText += `No action required. All existing tests appear healthy.\n`;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
responseText += `\n\n## Next Steps\n\n`;
|
|
372
|
+
responseText += `The AI assistant will:\n`;
|
|
373
|
+
let stepNumber = 1;
|
|
374
|
+
if (updateRecommendations.length > 0) {
|
|
375
|
+
responseText += `${stepNumber++}. Review the changes and issues for each test\n`;
|
|
376
|
+
responseText += `${stepNumber++}. Update test files to fix compatibility issues\n`;
|
|
377
|
+
responseText += `${stepNumber++}. Preserve original test logic and structure\n`;
|
|
378
|
+
responseText += `${stepNumber++}. Show you the changes made\n`;
|
|
360
379
|
}
|
|
361
|
-
|
|
362
|
-
responseText +=
|
|
380
|
+
if (newEndpoints.length > 0) {
|
|
381
|
+
responseText += `${stepNumber++}. Generate new tests for new endpoints\n`;
|
|
363
382
|
}
|
|
364
383
|
responseText += `\n**This tool is currently in Early Preview stage. Please verify the results.**\n`;
|
|
365
384
|
// ── Build LLM instructions for UPDATE ──
|
|
@@ -378,8 +397,6 @@ Per-file instructions for UPDATE and REGENERATE actions, plus a structured \`LLM
|
|
|
378
397
|
auto_proceed: true,
|
|
379
398
|
files_to_update: testFilesToUpdate,
|
|
380
399
|
update_count: updateRecommendations.length,
|
|
381
|
-
files_to_regenerate: testFilesToRegenerate,
|
|
382
|
-
regenerate_count: regenerateRecommendations.length,
|
|
383
400
|
};
|
|
384
401
|
if (uniqueRenames.length > 0) {
|
|
385
402
|
llmInstructionsObj.endpoint_renames = uniqueRenames;
|
|
@@ -411,22 +428,6 @@ Per-file instructions for UPDATE and REGENERATE actions, plus a structured \`LLM
|
|
|
411
428
|
llmInstructionsObj.update_strategy =
|
|
412
429
|
"For each file in update_context, apply the changes described in context to the provided current_content. Write the result using the Edit tool. Do NOT re-read the file first. Match the assertion style already used in the file. Preserve all existing test logic. After applying all edits, call skyramp_enhance_assertions with each updated file path to strengthen the assertions.";
|
|
413
430
|
}
|
|
414
|
-
// REGENERATE context: existing file content gives the generation tool the
|
|
415
|
-
// endpoint URL, auth pattern, test type, and language to replicate.
|
|
416
|
-
const regenerateContextFiles = [];
|
|
417
|
-
for (const rec of regenerateRecommendations) {
|
|
418
|
-
const existing_content = regenerateContentMap.get(rec.testFile);
|
|
419
|
-
regenerateContextFiles.push({
|
|
420
|
-
file: rec.testFile,
|
|
421
|
-
rationale: rec.updateInstructions || rec.rationale,
|
|
422
|
-
...(existing_content !== undefined && { existing_content }),
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
if (regenerateContextFiles.length > 0) {
|
|
426
|
-
llmInstructionsObj.regenerate_context = regenerateContextFiles;
|
|
427
|
-
llmInstructionsObj.regenerate_strategy =
|
|
428
|
-
"For each file in regenerate_context, call the appropriate generation tool (skyramp_integration_test_generation or skyramp_contract_test_generation) with outputDir set to the file's directory and output set to the filename. Use existing_content to determine the test type, endpoint, auth pattern, and language. The generation tool will overwrite the file. Do NOT use skyramp_ui_test_generation here — UI test regeneration requires a recorded trace (playwrightInput) and must be handled separately.";
|
|
429
|
-
}
|
|
430
431
|
const llmInstructions = `<!-- LLM_INSTRUCTIONS:\n${JSON.stringify(llmInstructionsObj, null, 2)}\n-->\n`;
|
|
431
432
|
const contentBlocks = [
|
|
432
433
|
{
|
|
@@ -444,6 +445,23 @@ Per-file instructions for UPDATE and REGENERATE actions, plus a structured \`LLM
|
|
|
444
445
|
},
|
|
445
446
|
},
|
|
446
447
|
];
|
|
448
|
+
// ── Build ADD instructions for new endpoints ──
|
|
449
|
+
if (newEndpoints.length > 0 && llmToolCalls.length > 0) {
|
|
450
|
+
const addInstructionsObj = {
|
|
451
|
+
workflow: "add_tests_for_new_endpoints",
|
|
452
|
+
auto_proceed: true,
|
|
453
|
+
instruction: "Call each tool in tool_calls immediately and in order. Do NOT ask for confirmation. If endpointURL is a bare path, read .skyramp/workspace.yml for baseUrl and prepend it.",
|
|
454
|
+
tool_calls: llmToolCalls,
|
|
455
|
+
};
|
|
456
|
+
const addInstructions = `<!-- LLM_INSTRUCTIONS:\n${JSON.stringify(addInstructionsObj, null, 2)}\n-->\n`;
|
|
457
|
+
contentBlocks.push({
|
|
458
|
+
type: "text",
|
|
459
|
+
text: addInstructions,
|
|
460
|
+
annotations: {
|
|
461
|
+
audience: ["assistant"],
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
}
|
|
447
465
|
return {
|
|
448
466
|
content: contentBlocks,
|
|
449
467
|
};
|
|
@@ -538,14 +538,6 @@ Combines API endpoint scanning, branch diff computation, and test discovery into
|
|
|
538
538
|
}
|
|
539
539
|
await sendProgress(50, 100, "Discovering existing tests...");
|
|
540
540
|
// ── Step 3: Discover existing tests ──
|
|
541
|
-
// Resolve testDir to scope the file scan — prefer explicit testsRepoDir param,
|
|
542
|
-
// then workspace.yml testDirectory. repositoryPath remains the repo root for
|
|
543
|
-
// git operations; testDir only limits which files are classified as tests.
|
|
544
|
-
const wsConfigEarly = await readWorkspaceConfigRaw(params.repositoryPath);
|
|
545
|
-
const wsTestDir = wsConfigEarly?.services?.[0]?.testDirectory;
|
|
546
|
-
const testDir = params.testsRepoDir
|
|
547
|
-
?? (params.testDirectory ? path.resolve(params.repositoryPath, params.testDirectory) : undefined)
|
|
548
|
-
?? (wsTestDir ? path.resolve(params.repositoryPath, wsTestDir) : undefined);
|
|
549
541
|
// Compute changedResources from classified endpoints for test discovery filtering.
|
|
550
542
|
// undefined → full-repo mode (no diff context)
|
|
551
543
|
// [] → PR mode, no endpoints found → skip external tests
|
|
@@ -585,7 +577,8 @@ Combines API endpoint scanning, branch diff computation, and test discovery into
|
|
|
585
577
|
try {
|
|
586
578
|
const testDiscoveryService = new TestDiscoveryService();
|
|
587
579
|
setTestsRepoDir(params.testsRepoDir);
|
|
588
|
-
const
|
|
580
|
+
const testScanPath = params.testsRepoDir ?? params.repositoryPath;
|
|
581
|
+
const discoveryResult = await testDiscoveryService.discoverTests(testScanPath, { changedResources });
|
|
589
582
|
existingTests = discoveryResult.tests.map((test) => ({
|
|
590
583
|
testFile: test.testFile,
|
|
591
584
|
testType: test.testType,
|
|
@@ -1189,7 +1182,6 @@ Combines API endpoint scanning, branch diff computation, and test discovery into
|
|
|
1189
1182
|
sessionId,
|
|
1190
1183
|
routerMountContext,
|
|
1191
1184
|
candidateRouteFiles,
|
|
1192
|
-
relevantExternalTestPaths,
|
|
1193
1185
|
},
|
|
1194
1186
|
};
|
|
1195
1187
|
// Clean up old state files (>24 hours) before creating new one
|
|
@@ -15,17 +15,16 @@ export function registerAnalyzeTestHealthTool(server) {
|
|
|
15
15
|
idempotentHint: true,
|
|
16
16
|
openWorldHint: false,
|
|
17
17
|
},
|
|
18
|
-
description: `Generate drift assessment instructions for existing tests — second step of the unified Test Health Analysis Flow.
|
|
18
|
+
description: `Generate drift and health assessment instructions for existing tests — second step of the unified Test Health Analysis Flow.
|
|
19
19
|
|
|
20
20
|
**PREREQUISITE:** Call \`skyramp_analyze_changes\` first to get a stateFile.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
This tool reads existing tests, the branch diff, and scanned endpoints from the stateFile,
|
|
23
|
+
then returns a structured prompt for the LLM to assess each test for drift and health.
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
The LLM follows the returned prompt to assign drift details and actions (UPDATE / REGENERATE / VERIFY / DELETE / IGNORE) for each test, then calls \`skyramp_actions\`.
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
(Optional) Execute tests using \`skyramp_execute_test\` with \`stateFile\` before \`skyramp_actions\` to validate live.`,
|
|
27
|
+
(Optional) Execute tests using \`skyramp_execute_test\` with \`stateFile\` parameter before \`skyramp_actions\` to validate tests live.`,
|
|
29
28
|
inputSchema: {
|
|
30
29
|
stateFile: z
|
|
31
30
|
.string()
|
|
@@ -46,24 +45,14 @@ The LLM follows the returned prompt (Action Decision Tree pre-scan → Endpoint
|
|
|
46
45
|
if (!stateData) {
|
|
47
46
|
return toolError(`State file is empty or invalid: ${args.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
|
|
48
47
|
}
|
|
48
|
+
// Only Skyramp tests are candidates for drift analysis and maintenance actions.
|
|
49
|
+
// External (user-written) tests are used only for recommendation deduplication.
|
|
50
|
+
// Default source to Skyramp for backwards compat with state files created before the source field existed.
|
|
51
|
+
const existingTests = (stateData.existingTests || []).filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External);
|
|
52
|
+
logger.info(`Loaded ${existingTests.length} existing Skyramp tests from state file (excluded external)`);
|
|
49
53
|
if (!repositoryPath || typeof repositoryPath !== "string") {
|
|
50
54
|
return toolError(`repositoryPath not found in state file metadata. The state file was likely created by an older version — re-run skyramp_analyze_changes to regenerate it.`);
|
|
51
55
|
}
|
|
52
|
-
// Skyramp tests: full drift analysis + all actions permitted.
|
|
53
|
-
// Relevant external tests (user-written, relevant to this PR's endpoints): drift analysis
|
|
54
|
-
// + UPDATE only — REGENERATE and DELETE are report-only (enforced in skyramp_actions).
|
|
55
|
-
// Other external tests: excluded entirely (deduplication only, not analysed).
|
|
56
|
-
// relevantExternalTestPaths are stored relative to repositoryPath in the state file.
|
|
57
|
-
// Re-absolutize here so has() comparisons against t.testFile (absolute) work correctly.
|
|
58
|
-
const relevantExternalPaths = new Set((stateData.repositoryAnalysis?.relevantExternalTestPaths ?? []).map((p) => path.isAbsolute(p) ? p : path.resolve(repositoryPath, p)));
|
|
59
|
-
const existingTests = (stateData.existingTests || []).filter((t) => {
|
|
60
|
-
if ((t.source ?? TestSource.Skyramp) !== TestSource.External)
|
|
61
|
-
return true;
|
|
62
|
-
return relevantExternalPaths.has(t.testFile);
|
|
63
|
-
});
|
|
64
|
-
const skyrampCount = existingTests.filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External).length;
|
|
65
|
-
const externalCount = existingTests.length - skyrampCount;
|
|
66
|
-
logger.info(`Loaded ${skyrampCount} Skyramp + ${externalCount} relevant external tests from state file`);
|
|
67
56
|
const absoluteRepoPath = path.resolve(repositoryPath);
|
|
68
57
|
const scannedEndpoints = stateData.repositoryAnalysis?.skeletonEndpoints || [];
|
|
69
58
|
const routerMountContext = stateData.repositoryAnalysis?.routerMountContext;
|
|
@@ -87,7 +76,6 @@ The LLM follows the returned prompt (Action Decision Tree pre-scan → Endpoint
|
|
|
87
76
|
routerMountContext,
|
|
88
77
|
candidateRouteFiles,
|
|
89
78
|
diffFilePath,
|
|
90
|
-
relevantExternalTestPaths: [...relevantExternalPaths],
|
|
91
79
|
});
|
|
92
80
|
return {
|
|
93
81
|
structuredContent: { prompt: promptText },
|
|
@@ -54,7 +54,7 @@ describe("dockerImageExistsLocally", () => {
|
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
56
|
describe("pullDockerImage", () => {
|
|
57
|
-
const IMAGE = "skyramp/executor:v1.3.
|
|
57
|
+
const IMAGE = "skyramp/executor:v1.3.26";
|
|
58
58
|
beforeEach(() => jest.clearAllMocks());
|
|
59
59
|
describe("on amd64 host", () => {
|
|
60
60
|
const originalArch = process.arch;
|