@skyramp/mcp 0.2.8 → 0.2.10-rc.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/index.js +1 -8
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
- package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
- package/build/prompts/personas.d.ts +2 -2
- package/build/prompts/personas.js +2 -2
- package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +6 -1
- package/build/prompts/sut-setup/shared.d.ts +20 -0
- package/build/prompts/sut-setup/shared.js +67 -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 +63 -3
- package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -25
- package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +20 -0
- package/build/prompts/test-maintenance/driftAnalysisShared.js +80 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +32 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +212 -0
- package/build/prompts/test-recommendation/recommendationShared.js +1 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
- package/build/prompts/testbot/testbot-prompts.js +10 -9
- package/build/resources/testbotResource.js +1 -1
- package/build/services/TestDiscoveryService.d.ts +12 -10
- package/build/services/TestDiscoveryService.js +137 -83
- package/build/services/TestDiscoveryService.test.js +370 -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/executeSkyrampTestTool.js +40 -71
- package/build/tools/submitReportTool.d.ts +25 -25
- package/build/tools/submitReportTool.js +123 -34
- package/build/tools/submitReportTool.test.js +413 -94
- package/build/tools/test-management/actionsTool.js +177 -77
- package/build/tools/test-management/actionsTool.test.d.ts +1 -0
- package/build/tools/test-management/actionsTool.test.js +297 -0
- package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
- package/build/tools/test-management/analyzeChangesTool.js +74 -169
- package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
- package/build/tools/test-management/analyzeTestHealthTool.js +63 -22
- package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
- package/build/tools/test-management/analyzeTestHealthTool.test.js +268 -0
- package/build/tools/workspace/initializeWorkspaceTool.js +37 -24
- package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
- package/build/types/FrontendIntegration.d.ts +25 -0
- package/build/types/FrontendIntegration.js +19 -0
- package/build/types/RepositoryAnalysis.d.ts +8 -8
- package/build/types/TestAnalysis.d.ts +18 -49
- package/build/types/TestAnalysis.js +0 -28
- package/build/types/TestTypes.d.ts +3 -1
- package/build/types/TestTypes.js +3 -1
- package/build/types/TestbotReport.d.ts +1 -1
- package/build/utils/AnalysisStateManager.d.ts +16 -22
- package/build/utils/docker.test.js +1 -1
- package/build/utils/featureFlags.d.ts +1 -1
- package/build/utils/featureFlags.js +1 -1
- package/build/utils/frontendIntegration.d.ts +9 -0
- package/build/utils/frontendIntegration.js +237 -0
- package/build/utils/frontendIntegration.test.d.ts +1 -0
- package/build/utils/frontendIntegration.test.js +229 -0
- package/build/utils/pr-comment-parser.d.ts +3 -3
- package/build/utils/pr-comment-parser.js +6 -6
- package/build/utils/pr-comment-parser.test.js +3 -3
- package/build/utils/repoScanner.d.ts +7 -0
- package/build/utils/repoScanner.js +14 -6
- package/build/utils/repoScanner.test.js +9 -9
- package/build/utils/routeParsers.d.ts +32 -0
- package/build/utils/routeParsers.js +205 -2
- package/build/utils/routeParsers.test.js +279 -29
- package/build/utils/sourceRouteExtractor.js +174 -5
- package/build/utils/sourceRouteExtractor.test.js +173 -0
- package/build/utils/utils.d.ts +11 -0
- package/build/utils/utils.js +19 -0
- package/build/utils/utils.test.js +23 -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/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
- package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
- package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +3 -3
|
@@ -2,11 +2,11 @@ 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";
|
|
8
|
-
import { toolError } from "../../utils/utils.js";
|
|
9
|
-
import { stageGeneratedPaths } from "../../utils/gitStaging.js";
|
|
9
|
+
import { toolError, testFileMatches } from "../../utils/utils.js";
|
|
10
10
|
import { buildRenameStrategy, buildFileRenameStrategy, buildUpdateStrategy, buildRegenerateStrategy, buildUpdateFileInstruction, buildRegenerateFileInstruction, } from "../../prompts/test-maintenance/actionsInstructions.js";
|
|
11
11
|
/**
|
|
12
12
|
* Compute a suggested new filename when an endpoint is renamed.
|
|
@@ -46,21 +46,36 @@ export function computeRenamedTestFile(testFile, renames) {
|
|
|
46
46
|
return newFilePath;
|
|
47
47
|
}
|
|
48
48
|
const recommendationSchema = z.object({
|
|
49
|
-
|
|
49
|
+
testFilePath: z
|
|
50
50
|
.string()
|
|
51
|
-
.refine((p) => path.isAbsolute(p), { message: "
|
|
52
|
-
.describe("Absolute path to the test file — use the path as reported by skyramp_analyze_changes or skyramp_analyze_test_health"),
|
|
51
|
+
.refine((p) => path.isAbsolute(p), { message: "testFilePath must be an absolute path" })
|
|
52
|
+
.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."),
|
|
53
|
+
pomFile: z
|
|
54
|
+
.string()
|
|
55
|
+
.optional()
|
|
56
|
+
.refine((p) => p === undefined || path.isAbsolute(p), { message: "pomFile must be an absolute path" })
|
|
57
|
+
.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 testFilePath. testFilePath (the spec) is still used for catalog validation and the maintenance report."),
|
|
53
58
|
action: z
|
|
54
59
|
.nativeEnum(DriftAction)
|
|
55
|
-
.
|
|
60
|
+
.refine((a) => a !== DriftAction.Ignore, {
|
|
61
|
+
message: "Use ignoredTestFiles for IGNORE tests; omit IGNORE entries from recommendations.",
|
|
62
|
+
})
|
|
63
|
+
.describe("Drift action assigned by the LLM health assessment. Never IGNORE — omit the recommendation and use ignoredTestFiles instead."),
|
|
64
|
+
testType: z
|
|
65
|
+
.nativeEnum(TestType)
|
|
66
|
+
.describe("Type of this test — carried through to the maintenance report."),
|
|
67
|
+
endpoint: z
|
|
68
|
+
.string()
|
|
69
|
+
.describe("HTTP verb and path this test covers, e.g. 'GET /api/v1/products'."),
|
|
56
70
|
priority: z
|
|
57
71
|
.nativeEnum(RecommendationPriority)
|
|
58
72
|
.optional()
|
|
59
73
|
.describe("Update priority"),
|
|
60
74
|
rationale: z
|
|
61
75
|
.string()
|
|
62
|
-
.
|
|
63
|
-
.
|
|
76
|
+
.trim()
|
|
77
|
+
.min(1)
|
|
78
|
+
.describe("1-2 sentence explanation of why this action is needed — becomes the maintenance report's description, so it must not be blank."),
|
|
64
79
|
estimatedWork: z
|
|
65
80
|
.nativeEnum(EstimatedWork)
|
|
66
81
|
.optional()
|
|
@@ -95,7 +110,16 @@ const actionsSchema = {
|
|
|
95
110
|
recommendations: z
|
|
96
111
|
.array(recommendationSchema)
|
|
97
112
|
.optional()
|
|
98
|
-
.describe("
|
|
113
|
+
.describe("Drift recommendations from skyramp_analyze_test_health. Omit IGNORE entries. Omitting this field results in no maintenance actions."),
|
|
114
|
+
ignoredTestFiles: z
|
|
115
|
+
.array(z.string().refine((p) => path.isAbsolute(p), {
|
|
116
|
+
message: "ignoredTestFiles entries must be absolute paths — use the exact path reported by skyramp_analyze_changes or skyramp_analyze_test_health. A bare filename is ambiguous when multiple discovered tests share that basename.",
|
|
117
|
+
}))
|
|
118
|
+
.optional()
|
|
119
|
+
.default([])
|
|
120
|
+
.describe("Absolute path of every existing test you assessed during drift analysis and decided needs no action — use the exact path reported by skyramp_analyze_changes or skyramp_analyze_test_health. " +
|
|
121
|
+
"Every test discovered by skyramp_analyze_changes must appear in either `recommendations` or here — " +
|
|
122
|
+
"otherwise this call fails, naming the test(s) you didn't account for."),
|
|
99
123
|
};
|
|
100
124
|
const TOOL_NAME = "skyramp_actions";
|
|
101
125
|
export function registerActionsTool(server) {
|
|
@@ -106,7 +130,7 @@ export function registerActionsTool(server) {
|
|
|
106
130
|
idempotentHint: false,
|
|
107
131
|
openWorldHint: true,
|
|
108
132
|
},
|
|
109
|
-
description: `Execute test maintenance actions — final step of the unified Test Health Analysis Flow.`,
|
|
133
|
+
description: `Execute test maintenance actions — final step of the unified Test Health Analysis Flow. Call this once per run even when no tests need action — it is the only point where every assessed existing test is reconciled (via \`recommendations\` + \`ignoredTestFiles\`) before the run moves on.`,
|
|
110
134
|
inputSchema: actionsSchema,
|
|
111
135
|
}, async (args) => {
|
|
112
136
|
let errorResult;
|
|
@@ -123,19 +147,19 @@ export function registerActionsTool(server) {
|
|
|
123
147
|
}
|
|
124
148
|
// Resolve repo root for path normalization and security checks.
|
|
125
149
|
const repoRoot = repositoryPath ? path.resolve(repositoryPath) : "";
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
const
|
|
150
|
+
// Related repos are read-only context for the agent (it never has write access to
|
|
151
|
+
// their checkout) — only the primary repo's tests can actually be edited.
|
|
152
|
+
const relatedRepoKeys = await stateManager.listRelatedRepos();
|
|
153
|
+
const isRelatedRepo = !!args.repository && relatedRepoKeys.includes(args.repository);
|
|
130
154
|
// Allowlist: Skyramp-generated tests + relevant external tests.
|
|
131
155
|
// Using an allowlist (not a blocklist) catches hallucinated paths the LLM
|
|
132
156
|
// may supply that are not in the scanned catalog at all.
|
|
133
157
|
const testAnalysisResults = (stateData.existingTests || []);
|
|
134
158
|
const skyrampTestFiles = new Set(testAnalysisResults
|
|
135
|
-
.filter((t) =>
|
|
159
|
+
.filter((t) => t.source !== TestSource.External)
|
|
136
160
|
.map((t) => t.testFile));
|
|
137
161
|
const externalTestFiles = new Set(testAnalysisResults
|
|
138
|
-
.filter((t) =>
|
|
162
|
+
.filter((t) => t.source === TestSource.External)
|
|
139
163
|
.map((t) => t.testFile));
|
|
140
164
|
// ── Build recommendations from LLM-supplied drift assessment ──
|
|
141
165
|
// The LLM performs the drift assessment in context after skyramp_analyze_test_health
|
|
@@ -147,38 +171,54 @@ export function registerActionsTool(server) {
|
|
|
147
171
|
// against repoRoot in case the LLM sends a relative path despite the schema.
|
|
148
172
|
// Normalize via path.resolve to collapse any `..` segments before the
|
|
149
173
|
// traversal guard — otherwise "/repo/../etc/passwd" would pass startsWith.
|
|
150
|
-
const rawFile = path.isAbsolute(rec.
|
|
151
|
-
? rec.
|
|
174
|
+
const rawFile = path.isAbsolute(rec.testFilePath)
|
|
175
|
+
? rec.testFilePath
|
|
152
176
|
: repoRoot
|
|
153
|
-
? path.resolve(repoRoot, rec.
|
|
154
|
-
: rec.
|
|
177
|
+
? path.resolve(repoRoot, rec.testFilePath)
|
|
178
|
+
: rec.testFilePath;
|
|
155
179
|
const resolvedFile = path.resolve(rawFile);
|
|
156
180
|
// Reject files outside the repo root (path-traversal guard).
|
|
157
181
|
// Exception: files already in the scanned test catalog (externalTestFiles / skyrampTestFiles)
|
|
158
182
|
// may legitimately live in a separate testsRepoDir outside repositoryPath — catalog
|
|
159
183
|
// membership is a sufficient provenance check for those paths.
|
|
160
|
-
const isInCatalog = skyrampTestFiles.has(resolvedFile) || skyrampTestFiles.has(rec.
|
|
161
|
-
|| externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.
|
|
184
|
+
const isInCatalog = skyrampTestFiles.has(resolvedFile) || skyrampTestFiles.has(rec.testFilePath)
|
|
185
|
+
|| externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFilePath);
|
|
162
186
|
if (repoRoot && !isInCatalog && !resolvedFile.startsWith(repoRoot + path.sep) && resolvedFile !== repoRoot) {
|
|
163
|
-
logger.warning(`Skipping recommendation for path outside repo root: ${rec.
|
|
187
|
+
logger.warning(`Skipping recommendation for path outside repo root: ${rec.testFilePath}`);
|
|
164
188
|
return;
|
|
165
189
|
}
|
|
166
190
|
// Guard: only files present in the scanned test catalog may receive any
|
|
167
191
|
// recommendation. Hallucinated paths (not in either set) are rejected for
|
|
168
192
|
// all actions, including VERIFY and IGNORE, to keep the report consistent
|
|
169
193
|
// with what was actually discovered.
|
|
170
|
-
const isSkyramp = skyrampTestFiles.has(resolvedFile) || skyrampTestFiles.has(rec.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const isInAnyKnownCatalog = isSkyramp || isRelevantExternal
|
|
174
|
-
|| externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile);
|
|
194
|
+
const isSkyramp = skyrampTestFiles.has(resolvedFile) || skyrampTestFiles.has(rec.testFilePath);
|
|
195
|
+
// Catalog membership is the safety boundary — prevents editing non-test files.
|
|
196
|
+
const isRelevantExternal = (externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFilePath));
|
|
197
|
+
const isInAnyKnownCatalog = isSkyramp || isRelevantExternal;
|
|
175
198
|
if (!isInAnyKnownCatalog) {
|
|
176
|
-
logger.warning(`Skipping ${rec.action} for unknown test (not in scanned catalog): ${rec.
|
|
199
|
+
logger.warning(`Skipping ${rec.action} for unknown test (not in scanned catalog): ${rec.testFilePath}`);
|
|
177
200
|
return;
|
|
178
201
|
}
|
|
179
202
|
const isActionable = [DriftAction.Update, DriftAction.Regenerate, DriftAction.Delete].includes(rec.action);
|
|
203
|
+
// No write access to a related repo's checkout — convert any edit-requiring
|
|
204
|
+
// action to VERIFY so the finding still surfaces without attempting a write that
|
|
205
|
+
// would fail (or land in a checkout that shouldn't be touched at all).
|
|
206
|
+
if (isRelatedRepo && isActionable) {
|
|
207
|
+
recommendations.push({
|
|
208
|
+
testFilePath: resolvedFile,
|
|
209
|
+
action: DriftAction.Verify,
|
|
210
|
+
testType: rec.testType,
|
|
211
|
+
endpoint: rec.endpoint,
|
|
212
|
+
priority: rec.priority ?? RecommendationPriority.Medium,
|
|
213
|
+
rationale: `[related repo — no write access, needs manual review] ${rec.rationale}`,
|
|
214
|
+
estimatedWork: rec.estimatedWork ?? EstimatedWork.Small,
|
|
215
|
+
updateInstructions: "",
|
|
216
|
+
renamedEndpoints: [],
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
180
220
|
if (isActionable && !isSkyramp && !isRelevantExternal) {
|
|
181
|
-
logger.warning(`Skipping ${rec.action} for irrelevant external test: ${rec.
|
|
221
|
+
logger.warning(`Skipping ${rec.action} for irrelevant external test: ${rec.testFilePath}`);
|
|
182
222
|
return;
|
|
183
223
|
}
|
|
184
224
|
// REGENERATE and DELETE on external tests are report-only — convert to VERIFY so
|
|
@@ -186,88 +226,156 @@ export function registerActionsTool(server) {
|
|
|
186
226
|
if (isRelevantExternal && !isSkyramp &&
|
|
187
227
|
(rec.action === DriftAction.Regenerate || rec.action === DriftAction.Delete)) {
|
|
188
228
|
recommendations.push({
|
|
189
|
-
|
|
229
|
+
testFilePath: resolvedFile,
|
|
190
230
|
action: DriftAction.Verify,
|
|
231
|
+
testType: rec.testType,
|
|
232
|
+
endpoint: rec.endpoint,
|
|
191
233
|
priority: rec.priority ?? RecommendationPriority.Medium,
|
|
192
|
-
rationale: `[external test — needs manual review] ${rec.rationale
|
|
234
|
+
rationale: `[external test — needs manual review] ${rec.rationale}`,
|
|
193
235
|
estimatedWork: rec.estimatedWork ?? EstimatedWork.Small,
|
|
194
236
|
updateInstructions: "",
|
|
195
237
|
renamedEndpoints: [],
|
|
196
238
|
});
|
|
197
239
|
return;
|
|
198
240
|
}
|
|
241
|
+
// Resolve and validate pomFile with the same path-traversal guard as testFilePath.
|
|
242
|
+
let resolvedPomFile;
|
|
243
|
+
if (rec.pomFile) {
|
|
244
|
+
const rawPom = path.isAbsolute(rec.pomFile)
|
|
245
|
+
? rec.pomFile
|
|
246
|
+
: path.resolve(repoRoot ?? "", rec.pomFile);
|
|
247
|
+
resolvedPomFile = path.resolve(rawPom);
|
|
248
|
+
if (repoRoot && !resolvedPomFile.startsWith(repoRoot + path.sep) && resolvedPomFile !== repoRoot) {
|
|
249
|
+
logger.warning(`Skipping pomFile outside repo root: ${rec.pomFile}`);
|
|
250
|
+
resolvedPomFile = undefined;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
199
253
|
recommendations.push({
|
|
200
|
-
|
|
254
|
+
testFilePath: resolvedFile,
|
|
255
|
+
pomFile: resolvedPomFile,
|
|
201
256
|
action: rec.action,
|
|
257
|
+
testType: rec.testType,
|
|
258
|
+
endpoint: rec.endpoint,
|
|
202
259
|
priority: rec.priority ?? RecommendationPriority.Medium,
|
|
203
|
-
rationale: rec.rationale
|
|
260
|
+
rationale: rec.rationale,
|
|
204
261
|
estimatedWork: rec.estimatedWork ?? EstimatedWork.Small,
|
|
205
262
|
updateInstructions: rec.updateInstructions ?? "",
|
|
206
263
|
renamedEndpoints: rec.renamedEndpoints ?? [],
|
|
207
264
|
});
|
|
208
265
|
});
|
|
266
|
+
// Use the validated `recommendations` array (built above), not the raw
|
|
267
|
+
// `args.recommendations` — a hallucinated/rejected path (not in the catalog, or
|
|
268
|
+
// outside the repo root) never made it into `recommendations`, so it must not
|
|
269
|
+
// count as "accounted for" here either. Counting the raw input would let a typo'd
|
|
270
|
+
// testFilePath mask a real file that never received a valid recommendation.
|
|
271
|
+
const accountedForNames = [
|
|
272
|
+
...recommendations.map((r) => r.testFilePath),
|
|
273
|
+
...(args.ignoredTestFiles ?? []),
|
|
274
|
+
];
|
|
275
|
+
const unaccountedFor = testAnalysisResults
|
|
276
|
+
.map((t) => t.testFile)
|
|
277
|
+
.filter((testFile) => !accountedForNames.some((name) => testFileMatches(testFile, name)));
|
|
278
|
+
if (unaccountedFor.length > 0) {
|
|
279
|
+
errorResult = toolError(`${unaccountedFor.length} existing test(s) assessed by skyramp_analyze_test_health are not accounted for: ${unaccountedFor.join(", ")}. ` +
|
|
280
|
+
"Add a recommendation for each one that needs UPDATE/REGENERATE/DELETE/VERIFY, or add its path to ignoredTestFiles if it needs no action.");
|
|
281
|
+
return errorResult;
|
|
282
|
+
}
|
|
283
|
+
// So skyramp_submit_report can read this back instead of re-asking the LLM.
|
|
284
|
+
// Must fail loud — edits below apply regardless, so a swallowed failure here
|
|
285
|
+
// would let edits ship with no testMaintenance in the final report.
|
|
286
|
+
try {
|
|
287
|
+
await stateManager.writeRepoData({
|
|
288
|
+
...stateData,
|
|
289
|
+
ignoredTestFiles: args.ignoredTestFiles ?? [],
|
|
290
|
+
maintenanceVerdicts: recommendations.map((r) => ({
|
|
291
|
+
testFilePath: r.testFilePath,
|
|
292
|
+
action: r.action,
|
|
293
|
+
rationale: r.rationale,
|
|
294
|
+
testType: r.testType,
|
|
295
|
+
endpoint: r.endpoint,
|
|
296
|
+
})),
|
|
297
|
+
}, { repo: args.repository, repositoryPath, step: "actions" });
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
errorResult = toolError(`Failed to persist maintenance reconciliation to state file: ${error.message}. Retry this call.`);
|
|
301
|
+
return errorResult;
|
|
302
|
+
}
|
|
209
303
|
// ── Process UPDATE and REGENERATE recommendations ──
|
|
210
|
-
//
|
|
211
|
-
//
|
|
304
|
+
// UPDATE dedup key: `${testFilePath}::${pomFile ?? ''}` — a spec may emit one
|
|
305
|
+
// UPDATE entry per page object (different pomFile values), so the same
|
|
306
|
+
// testFilePath can appear multiple times with different edit targets.
|
|
307
|
+
// REGENERATE dedup key: testFilePath only — REGENERATE covers the whole spec.
|
|
308
|
+
// Priority order: high > medium > low.
|
|
212
309
|
const priorityRank = {
|
|
213
310
|
[RecommendationPriority.High]: 2,
|
|
214
311
|
[RecommendationPriority.Medium]: 1,
|
|
215
312
|
[RecommendationPriority.Low]: 0,
|
|
216
313
|
};
|
|
217
|
-
// Build per-file winner maps. REGENERATE beats UPDATE for the same
|
|
314
|
+
// Build per-file winner maps. REGENERATE beats UPDATE for the same spec —
|
|
218
315
|
// if the LLM emits both, keep REGENERATE (higher severity) and drop UPDATE.
|
|
219
|
-
const
|
|
316
|
+
const updateByKey = new Map();
|
|
220
317
|
const regenerateByFile = new Map();
|
|
221
318
|
for (const rec of recommendations) {
|
|
222
319
|
if (rec.action === DriftAction.Regenerate) {
|
|
223
|
-
const existing = regenerateByFile.get(rec.
|
|
320
|
+
const existing = regenerateByFile.get(rec.testFilePath);
|
|
224
321
|
if (!existing || priorityRank[rec.priority] > priorityRank[existing.priority]) {
|
|
225
|
-
regenerateByFile.set(rec.
|
|
322
|
+
regenerateByFile.set(rec.testFilePath, rec);
|
|
226
323
|
}
|
|
227
324
|
}
|
|
228
325
|
else if (rec.action === DriftAction.Update) {
|
|
229
|
-
// Only add
|
|
230
|
-
if (!regenerateByFile.has(rec.
|
|
231
|
-
const
|
|
326
|
+
// Only add if no REGENERATE exists for this spec.
|
|
327
|
+
if (!regenerateByFile.has(rec.testFilePath)) {
|
|
328
|
+
const key = `${rec.testFilePath}::${rec.pomFile ?? ""}`;
|
|
329
|
+
const existing = updateByKey.get(key);
|
|
232
330
|
if (!existing || priorityRank[rec.priority] > priorityRank[existing.priority]) {
|
|
233
|
-
|
|
331
|
+
updateByKey.set(key, rec);
|
|
234
332
|
}
|
|
235
333
|
}
|
|
236
334
|
}
|
|
237
335
|
}
|
|
238
|
-
// Second pass: drop any UPDATE entries for
|
|
336
|
+
// Second pass: drop any UPDATE entries for specs that ended up with REGENERATE
|
|
239
337
|
// (handles ordering where UPDATE was inserted before REGENERATE was seen).
|
|
240
338
|
for (const file of regenerateByFile.keys()) {
|
|
241
|
-
|
|
339
|
+
for (const key of updateByKey.keys()) {
|
|
340
|
+
if (key.startsWith(`${file}::`))
|
|
341
|
+
updateByKey.delete(key);
|
|
342
|
+
}
|
|
242
343
|
}
|
|
243
|
-
const updateRecommendations = Array.from(
|
|
344
|
+
const updateRecommendations = Array.from(updateByKey.values());
|
|
244
345
|
const regenerateRecommendations = Array.from(regenerateByFile.values());
|
|
245
346
|
const fileInstructions = [];
|
|
246
347
|
const testFilesToUpdate = [];
|
|
247
348
|
const testFileContentMap = new Map();
|
|
248
349
|
// ── UPDATE: read file, emit targeted edit instructions ──
|
|
249
350
|
for (const rec of updateRecommendations) {
|
|
250
|
-
if (!rec.
|
|
251
|
-
logger.warning("Recommendation missing
|
|
351
|
+
if (!rec.testFilePath) {
|
|
352
|
+
logger.warning("Recommendation missing testFilePath", rec);
|
|
252
353
|
continue;
|
|
253
354
|
}
|
|
254
|
-
|
|
355
|
+
// When a POM file is specified, edit the POM (selector source of truth)
|
|
356
|
+
// rather than the spec. testFilePath (the spec) remains in the report.
|
|
357
|
+
const editTarget = rec.pomFile ?? rec.testFilePath;
|
|
358
|
+
testFilesToUpdate.push(editTarget);
|
|
255
359
|
try {
|
|
256
|
-
testFileContentMap.set(
|
|
360
|
+
testFileContentMap.set(editTarget, fs.readFileSync(editTarget, "utf-8"));
|
|
257
361
|
}
|
|
258
362
|
catch (error) {
|
|
259
|
-
logger.error(`Failed to read test file ${
|
|
363
|
+
logger.error(`Failed to read ${rec.pomFile ? "page object" : "test"} file ${editTarget}: ${error.message}`);
|
|
260
364
|
continue;
|
|
261
365
|
}
|
|
262
366
|
const renames = rec.renamedEndpoints || [];
|
|
263
367
|
const isRenameUpdate = renames.length > 0;
|
|
368
|
+
// Always derive the rename suggestion from the spec (testFilePath), not the
|
|
369
|
+
// POM (editTarget). fileRenames keys off rec.testFilePath so the spec is
|
|
370
|
+
// what gets renamed; computing from editTarget when pomFile is set
|
|
371
|
+
// would produce a name derived from the page-object path instead.
|
|
264
372
|
const suggestedNewFile = isRenameUpdate
|
|
265
|
-
? computeRenamedTestFile(rec.
|
|
373
|
+
? computeRenamedTestFile(rec.testFilePath, renames) ?? undefined
|
|
266
374
|
: undefined;
|
|
267
375
|
if (suggestedNewFile)
|
|
268
376
|
rec._suggestedNewFile = suggestedNewFile;
|
|
269
377
|
fileInstructions.push(buildUpdateFileInstruction({
|
|
270
|
-
testFile:
|
|
378
|
+
testFile: editTarget,
|
|
271
379
|
renames,
|
|
272
380
|
suggestedNewFile,
|
|
273
381
|
updateInstructions: rec.updateInstructions,
|
|
@@ -279,22 +387,22 @@ export function registerActionsTool(server) {
|
|
|
279
387
|
const testFilesToRegenerate = [];
|
|
280
388
|
const regenerateContentMap = new Map();
|
|
281
389
|
for (const rec of regenerateRecommendations) {
|
|
282
|
-
if (!rec.
|
|
283
|
-
logger.warning("Recommendation missing
|
|
390
|
+
if (!rec.testFilePath) {
|
|
391
|
+
logger.warning("Recommendation missing testFilePath", rec);
|
|
284
392
|
continue;
|
|
285
393
|
}
|
|
286
|
-
testFilesToRegenerate.push(rec.
|
|
394
|
+
testFilesToRegenerate.push(rec.testFilePath);
|
|
287
395
|
try {
|
|
288
|
-
regenerateContentMap.set(rec.
|
|
396
|
+
regenerateContentMap.set(rec.testFilePath, fs.readFileSync(rec.testFilePath, "utf-8"));
|
|
289
397
|
}
|
|
290
398
|
catch (error) {
|
|
291
|
-
logger.warning(`Could not read file for REGENERATE context ${rec.
|
|
399
|
+
logger.warning(`Could not read file for REGENERATE context ${rec.testFilePath}: ${error.message}`);
|
|
292
400
|
}
|
|
293
401
|
regenerateInstructions.push(buildRegenerateFileInstruction({
|
|
294
|
-
testFile: rec.
|
|
402
|
+
testFile: rec.testFilePath,
|
|
295
403
|
updateInstructions: rec.updateInstructions,
|
|
296
|
-
outputDir: path.dirname(rec.
|
|
297
|
-
outputFile: path.basename(rec.
|
|
404
|
+
outputDir: path.dirname(rec.testFilePath),
|
|
405
|
+
outputFile: path.basename(rec.testFilePath),
|
|
298
406
|
}));
|
|
299
407
|
}
|
|
300
408
|
// ── Build response text ──
|
|
@@ -314,7 +422,7 @@ export function registerActionsTool(server) {
|
|
|
314
422
|
}
|
|
315
423
|
if (otherRecs.length > 0) {
|
|
316
424
|
sections.push(`## Other Findings (${otherRecs.length})\n\n` +
|
|
317
|
-
otherRecs.map((rec) => `- **${rec.
|
|
425
|
+
otherRecs.map((rec) => `- **${rec.testFilePath}** — Action: ${rec.action}, Priority: ${rec.priority}` +
|
|
318
426
|
(rec.rationale ? ` — ${rec.rationale}` : "")).join("\n"));
|
|
319
427
|
}
|
|
320
428
|
else if (updateRecommendations.length === 0 && regenerateRecommendations.length === 0) {
|
|
@@ -341,7 +449,7 @@ export function registerActionsTool(server) {
|
|
|
341
449
|
const fileRenames = [];
|
|
342
450
|
for (const rec of updateRecommendations) {
|
|
343
451
|
if (rec._suggestedNewFile) {
|
|
344
|
-
fileRenames.push({ from: rec.
|
|
452
|
+
fileRenames.push({ from: rec.testFilePath, to: rec._suggestedNewFile });
|
|
345
453
|
}
|
|
346
454
|
}
|
|
347
455
|
if (fileRenames.length > 0) {
|
|
@@ -354,8 +462,9 @@ export function registerActionsTool(server) {
|
|
|
354
462
|
const updateContext = updateRecommendations
|
|
355
463
|
.filter((rec) => !!rec.updateInstructions)
|
|
356
464
|
.map((rec) => {
|
|
357
|
-
const
|
|
358
|
-
|
|
465
|
+
const editTarget = rec.pomFile ?? rec.testFilePath;
|
|
466
|
+
const current_content = testFileContentMap.get(editTarget);
|
|
467
|
+
return { file: editTarget, context: rec.updateInstructions, ...(current_content !== undefined && { current_content }) };
|
|
359
468
|
});
|
|
360
469
|
if (updateContext.length > 0) {
|
|
361
470
|
llmInstructionsObj.update_context = updateContext;
|
|
@@ -365,20 +474,11 @@ export function registerActionsTool(server) {
|
|
|
365
474
|
// endpoint URL, auth pattern, test type, and language to replicate.
|
|
366
475
|
if (regenerateRecommendations.length > 0) {
|
|
367
476
|
llmInstructionsObj.regenerate_context = regenerateRecommendations.map((rec) => {
|
|
368
|
-
const existing_content = regenerateContentMap.get(rec.
|
|
369
|
-
return { file: rec.
|
|
477
|
+
const existing_content = regenerateContentMap.get(rec.testFilePath);
|
|
478
|
+
return { file: rec.testFilePath, rationale: rec.updateInstructions || rec.rationale, ...(existing_content !== undefined && { existing_content }) };
|
|
370
479
|
});
|
|
371
480
|
llmInstructionsObj.regenerate_strategy = buildRegenerateStrategy();
|
|
372
481
|
}
|
|
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
482
|
const llmInstructions = JSON.stringify(llmInstructionsObj, null, 2);
|
|
383
483
|
const contentBlocks = [
|
|
384
484
|
{
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|