@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.
Files changed (88) hide show
  1. package/build/index.js +1 -8
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
  3. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
  4. package/build/prompts/personas.d.ts +2 -2
  5. package/build/prompts/personas.js +2 -2
  6. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
  7. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +6 -1
  8. package/build/prompts/sut-setup/shared.d.ts +20 -0
  9. package/build/prompts/sut-setup/shared.js +67 -7
  10. package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
  11. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
  12. package/build/prompts/test-maintenance/drift-analysis-prompt.js +63 -3
  13. package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -25
  14. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +20 -0
  15. package/build/prompts/test-maintenance/driftAnalysisShared.js +80 -0
  16. package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +32 -0
  17. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +212 -0
  18. package/build/prompts/test-recommendation/recommendationShared.js +1 -1
  19. package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
  20. package/build/prompts/testbot/testbot-prompts.js +10 -9
  21. package/build/resources/testbotResource.js +1 -1
  22. package/build/services/TestDiscoveryService.d.ts +12 -10
  23. package/build/services/TestDiscoveryService.js +137 -83
  24. package/build/services/TestDiscoveryService.test.js +370 -15
  25. package/build/services/TestExecutionService.d.ts +1 -1
  26. package/build/services/TestExecutionService.js +7 -7
  27. package/build/services/TestExecutionService.test.js +4 -1
  28. package/build/tools/executeSkyrampTestTool.js +40 -71
  29. package/build/tools/submitReportTool.d.ts +25 -25
  30. package/build/tools/submitReportTool.js +123 -34
  31. package/build/tools/submitReportTool.test.js +413 -94
  32. package/build/tools/test-management/actionsTool.js +177 -77
  33. package/build/tools/test-management/actionsTool.test.d.ts +1 -0
  34. package/build/tools/test-management/actionsTool.test.js +297 -0
  35. package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
  36. package/build/tools/test-management/analyzeChangesTool.js +74 -169
  37. package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
  38. package/build/tools/test-management/analyzeTestHealthTool.js +63 -22
  39. package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
  40. package/build/tools/test-management/analyzeTestHealthTool.test.js +268 -0
  41. package/build/tools/workspace/initializeWorkspaceTool.js +37 -24
  42. package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
  43. package/build/types/FrontendIntegration.d.ts +25 -0
  44. package/build/types/FrontendIntegration.js +19 -0
  45. package/build/types/RepositoryAnalysis.d.ts +8 -8
  46. package/build/types/TestAnalysis.d.ts +18 -49
  47. package/build/types/TestAnalysis.js +0 -28
  48. package/build/types/TestTypes.d.ts +3 -1
  49. package/build/types/TestTypes.js +3 -1
  50. package/build/types/TestbotReport.d.ts +1 -1
  51. package/build/utils/AnalysisStateManager.d.ts +16 -22
  52. package/build/utils/docker.test.js +1 -1
  53. package/build/utils/featureFlags.d.ts +1 -1
  54. package/build/utils/featureFlags.js +1 -1
  55. package/build/utils/frontendIntegration.d.ts +9 -0
  56. package/build/utils/frontendIntegration.js +237 -0
  57. package/build/utils/frontendIntegration.test.d.ts +1 -0
  58. package/build/utils/frontendIntegration.test.js +229 -0
  59. package/build/utils/pr-comment-parser.d.ts +3 -3
  60. package/build/utils/pr-comment-parser.js +6 -6
  61. package/build/utils/pr-comment-parser.test.js +3 -3
  62. package/build/utils/repoScanner.d.ts +7 -0
  63. package/build/utils/repoScanner.js +14 -6
  64. package/build/utils/repoScanner.test.js +9 -9
  65. package/build/utils/routeParsers.d.ts +32 -0
  66. package/build/utils/routeParsers.js +205 -2
  67. package/build/utils/routeParsers.test.js +279 -29
  68. package/build/utils/sourceRouteExtractor.js +174 -5
  69. package/build/utils/sourceRouteExtractor.test.js +173 -0
  70. package/build/utils/utils.d.ts +11 -0
  71. package/build/utils/utils.js +19 -0
  72. package/build/utils/utils.test.js +23 -1
  73. package/build/utils/versions.d.ts +3 -3
  74. package/build/utils/versions.js +1 -1
  75. package/build/workspace/workspace.d.ts +21 -21
  76. package/build/workspace/workspace.js +7 -4
  77. package/build/workspace/workspace.test.js +65 -6
  78. package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
  79. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  80. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
  81. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
  82. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
  83. package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
  84. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
  85. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  86. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  87. package/node_modules/playwright/package.json +1 -1
  88. 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
- testFile: z
49
+ testFilePath: z
50
50
  .string()
51
- .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
+ .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
- .describe("Drift action assigned by the LLM health assessment"),
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
- .optional()
63
- .describe("1-2 sentence explanation of why this action is needed"),
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("LLM Drift assessment produced by skyramp_analyze_test_health one entry per test assessed, including IGNORE and VERIFY entries. Required for UPDATE instructions to be emitted; omitting results in no maintenance actions. For [external] tests: UPDATE edits are applied automatically; REGENERATE and DELETE are surfaced as report-only findings and never rewrite or delete a user-authored file."),
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
- // 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)));
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) => (t.source ?? TestSource.Skyramp) !== TestSource.External)
159
+ .filter((t) => t.source !== TestSource.External)
136
160
  .map((t) => t.testFile));
137
161
  const externalTestFiles = new Set(testAnalysisResults
138
- .filter((t) => (t.source ?? TestSource.Skyramp) === TestSource.External)
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.testFile)
151
- ? rec.testFile
174
+ const rawFile = path.isAbsolute(rec.testFilePath)
175
+ ? rec.testFilePath
152
176
  : repoRoot
153
- ? path.resolve(repoRoot, rec.testFile)
154
- : rec.testFile;
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.testFile)
161
- || externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile);
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.testFile}`);
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.testFile);
171
- const isRelevantExternal = (externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile)) &&
172
- (relevantExternalPaths.has(resolvedFile) || relevantExternalPaths.has(rec.testFile));
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.testFile}`);
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.testFile}`);
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
- testFile: resolvedFile,
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 ?? ""}`.trimEnd(),
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
- testFile: resolvedFile,
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
- // Deduplicate by testFile keep the highest-priority entry when the LLM
211
- // repeats a file. Priority order: high > medium > low.
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 file
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 updateByFile = new Map();
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.testFile);
320
+ const existing = regenerateByFile.get(rec.testFilePath);
224
321
  if (!existing || priorityRank[rec.priority] > priorityRank[existing.priority]) {
225
- regenerateByFile.set(rec.testFile, rec);
322
+ regenerateByFile.set(rec.testFilePath, rec);
226
323
  }
227
324
  }
228
325
  else if (rec.action === DriftAction.Update) {
229
- // Only add to updateByFile if no REGENERATE exists for this file.
230
- if (!regenerateByFile.has(rec.testFile)) {
231
- const existing = updateByFile.get(rec.testFile);
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
- updateByFile.set(rec.testFile, rec);
331
+ updateByKey.set(key, rec);
234
332
  }
235
333
  }
236
334
  }
237
335
  }
238
- // Second pass: drop any UPDATE entries for files that ended up with REGENERATE
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
- updateByFile.delete(file);
339
+ for (const key of updateByKey.keys()) {
340
+ if (key.startsWith(`${file}::`))
341
+ updateByKey.delete(key);
342
+ }
242
343
  }
243
- const updateRecommendations = Array.from(updateByFile.values());
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.testFile) {
251
- logger.warning("Recommendation missing testFile", rec);
351
+ if (!rec.testFilePath) {
352
+ logger.warning("Recommendation missing testFilePath", rec);
252
353
  continue;
253
354
  }
254
- testFilesToUpdate.push(rec.testFile);
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(rec.testFile, fs.readFileSync(rec.testFile, "utf-8"));
360
+ testFileContentMap.set(editTarget, fs.readFileSync(editTarget, "utf-8"));
257
361
  }
258
362
  catch (error) {
259
- logger.error(`Failed to read test file ${rec.testFile}: ${error.message}`);
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.testFile, renames) ?? undefined
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: rec.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.testFile) {
283
- logger.warning("Recommendation missing testFile", rec);
390
+ if (!rec.testFilePath) {
391
+ logger.warning("Recommendation missing testFilePath", rec);
284
392
  continue;
285
393
  }
286
- testFilesToRegenerate.push(rec.testFile);
394
+ testFilesToRegenerate.push(rec.testFilePath);
287
395
  try {
288
- regenerateContentMap.set(rec.testFile, fs.readFileSync(rec.testFile, "utf-8"));
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.testFile}: ${error.message}`);
399
+ logger.warning(`Could not read file for REGENERATE context ${rec.testFilePath}: ${error.message}`);
292
400
  }
293
401
  regenerateInstructions.push(buildRegenerateFileInstruction({
294
- testFile: rec.testFile,
402
+ testFile: rec.testFilePath,
295
403
  updateInstructions: rec.updateInstructions,
296
- outputDir: path.dirname(rec.testFile),
297
- outputFile: path.basename(rec.testFile),
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.testFile}** — Action: ${rec.action}, Priority: ${rec.priority}` +
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.testFile, to: rec._suggestedNewFile });
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 current_content = testFileContentMap.get(rec.testFile);
358
- return { file: rec.testFile, context: rec.updateInstructions, ...(current_content !== undefined && { current_content }) };
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.testFile);
369
- return { file: rec.testFile, rationale: rec.updateInstructions || rec.rationale, ...(existing_content !== undefined && { existing_content }) };
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 {};