@skyramp/mcp 0.3.2 → 0.3.3

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 (137) hide show
  1. package/build/index.js +47 -2
  2. package/build/playwright/PlaywrightTraceService.d.ts +8 -0
  3. package/build/playwright/PlaywrightTraceService.js +1 -0
  4. package/build/prompts/pom-aware-code-reuse.js +17 -8
  5. package/build/prompts/testbot/testbot-prompts.d.ts +2 -12
  6. package/build/prompts/testbot/testbot-prompts.js +24 -5
  7. package/build/resources/testbotResource.js +21 -4
  8. package/build/services/TestExecutionService.d.ts +13 -8
  9. package/build/services/TestExecutionService.js +48 -25
  10. package/build/services/containerEnv.js +24 -0
  11. package/build/tools/queryProxyMocksTool.js +0 -1
  12. package/build/tools/submitReportTool.js +10 -1
  13. package/build/tools/test-management/actionsTool.js +12 -2
  14. package/build/types/TestAnalysis.d.ts +7 -3
  15. package/build/types/TestbotPromptOptions.d.ts +35 -0
  16. package/build/types/TestbotPromptOptions.js +1 -0
  17. package/build/types/index.d.ts +1 -0
  18. package/build/utils/frontendSelectors.js +0 -1
  19. package/build/utils/gitStaging.d.ts +5 -0
  20. package/build/utils/gitStaging.js +1 -1
  21. package/build/utils/pom-catalog.d.ts +23 -0
  22. package/build/utils/pom-catalog.js +30 -0
  23. package/build/utils/pom-scope/pom-files.d.ts +14 -0
  24. package/build/utils/pom-scope/pom-files.js +32 -6
  25. package/build/utils/pom-scope/testIdDiscovery.d.ts +40 -0
  26. package/build/utils/pom-scope/testIdDiscovery.js +104 -0
  27. package/build/utils/reportVerification.d.ts +7 -2
  28. package/build/utils/reportVerification.js +9 -3
  29. package/build/utils/versions.d.ts +3 -3
  30. package/build/utils/versions.js +1 -1
  31. package/node_modules/playwright/ThirdPartyNotices.txt +319 -266
  32. package/node_modules/playwright/lib/dom-analyzer/blueprint.js +154 -27
  33. package/node_modules/playwright/lib/dom-analyzer/crawler.js +2 -2
  34. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +1 -1
  35. package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +6 -2
  36. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +3 -2
  37. package/node_modules/playwright/lib/mcp/skyramp/resultCode.js +4 -3
  38. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +15 -3
  39. package/node_modules/playwright/lib/mcp/skyramp/specImport.js +781 -0
  40. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +14 -3
  41. package/node_modules/playwright/lib/mcp/test/resultCode.test.js +2 -1
  42. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +1 -1
  43. package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +30 -0
  44. package/node_modules/playwright/lib/transform/babelBundleImpl.js +200 -199
  45. package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +3 -3
  46. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/.package-lock.json +3 -3
  47. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/adapter/aws-lambda/handler.js +16 -25
  48. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/adapter/bun/websocket.js +3 -1
  49. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/adapter/lambda-edge/handler.js +20 -4
  50. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/adapter/aws-lambda/handler.js +16 -25
  51. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/adapter/bun/websocket.js +3 -1
  52. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/adapter/lambda-edge/handler.js +20 -4
  53. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/client/client.js +10 -1
  54. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/client/utils.js +1 -1
  55. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/helper/css/common.js +3 -1
  56. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/helper/css/index.js +9 -1
  57. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/jsx/base.js +8 -14
  58. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/jsx/components.js +41 -21
  59. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/jsx/context.js +131 -5
  60. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/jsx/streaming.js +9 -7
  61. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/bearer-auth/index.js +1 -1
  62. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/cache/index.js +1 -1
  63. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/compress/index.js +2 -1
  64. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/cors/index.js +2 -5
  65. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/etag/index.js +2 -1
  66. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/language/language.js +10 -32
  67. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/method-override/index.js +5 -3
  68. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/serve-static/index.js +2 -2
  69. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/timing/timing.js +3 -1
  70. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/router/trie-router/node.js +9 -0
  71. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/utils/body.js +12 -4
  72. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/utils/buffer.js +2 -1
  73. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/utils/ipaddr.js +6 -1
  74. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/validator/validator.js +3 -3
  75. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/client/client.js +10 -1
  76. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/client/utils.js +1 -1
  77. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/helper/css/common.js +3 -1
  78. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/helper/css/index.js +9 -1
  79. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/jsx/base.js +15 -15
  80. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/jsx/components.js +42 -22
  81. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/jsx/context.js +129 -5
  82. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/jsx/streaming.js +10 -8
  83. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/bearer-auth/index.js +1 -1
  84. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/cache/index.js +1 -1
  85. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/compress/index.js +2 -1
  86. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/cors/index.js +2 -5
  87. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/etag/index.js +2 -1
  88. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/language/language.js +10 -32
  89. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/method-override/index.js +5 -3
  90. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/serve-static/index.js +2 -2
  91. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/timing/timing.js +3 -1
  92. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/router/trie-router/node.js +9 -0
  93. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/adapter/aws-lambda/handler.d.ts +1 -1
  94. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/adapter/lambda-edge/handler.d.ts +1 -1
  95. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/helper/websocket/index.d.ts +1 -1
  96. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/jsx/base.d.ts +1 -3
  97. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/jsx/context.d.ts +39 -0
  98. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/middleware/context-storage/index.d.ts +2 -2
  99. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/middleware/language/language.d.ts +18 -0
  100. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/utils/body.d.ts +1 -1
  101. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/utils/types.d.ts +1 -1
  102. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/utils/body.js +12 -4
  103. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/utils/buffer.js +2 -1
  104. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/utils/ipaddr.js +6 -1
  105. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/validator/validator.js +3 -3
  106. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/package.json +29 -22
  107. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/package-lock.json +3 -3
  108. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  109. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +5 -1
  110. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/specReader.js +781 -0
  111. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderApp.js +25 -6
  112. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/replayEngine.js +3 -1
  113. package/node_modules/playwright/node_modules/playwright-core/lib/utils.js +2 -0
  114. package/node_modules/playwright/node_modules/playwright-core/lib/vite/htmlReport/index.html +253 -27
  115. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-D0BjbCb7.js → codeMirrorModule-DtudTj_v.js} +1 -1
  116. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BpDwp16L.js +422 -0
  117. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  118. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-Bzd72-bG.js → codeMirrorModule-FNMuBzX1.js} +1 -1
  119. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Co9upU5h.js +1035 -0
  120. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.DXNIQ_dx.js +2 -0
  121. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  122. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CIKB3XSv.js +5 -0
  123. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  124. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  125. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  126. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +4 -0
  127. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/specReader.ts +1028 -0
  128. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderApp.ts +31 -8
  129. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/replayEngine.ts +1 -0
  130. package/node_modules/playwright/node_modules/playwright-core/src/utils.ts +1 -0
  131. package/node_modules/playwright/package.json +1 -1
  132. package/package.json +9 -6
  133. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/tsconfig.build.tsbuildinfo +0 -1
  134. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-lvTRGFx-.js +0 -193
  135. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-DzxTioTK.js +0 -809
  136. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.BGc30U3S.js +0 -2
  137. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.IaDrb29A.js +0 -5
package/build/index.js CHANGED
@@ -45,6 +45,8 @@ import { registerProgressResource } from "./resources/progressResource.js";
45
45
  import { AnalyticsService } from "./services/AnalyticsService.js";
46
46
  import { registerInitTriggerOnMCPInitialized } from "./utils/initAgent.js";
47
47
  import { isTestbotEnabled, isLocalDevEnabled } from "./utils/featureFlags.js";
48
+ import { findRoot, readTestIdAttribute } from "./utils/pom-scope/index.js";
49
+ import { discoverTestIdAttribute } from "./utils/pom-scope/testIdDiscovery.js";
48
50
  import { registerPlaywrightTools, registerTraceRecordingPrompt, getPlaywrightTraceService, } from "./playwright/index.js";
49
51
  // One-click routing instructions — injected into MCP server instructions so agents
50
52
  // know to delegate to the one-click tool rather than self-orchestrating.
@@ -212,8 +214,51 @@ if (isTestbotEnabled()) {
212
214
  logger.info("Testbot tools enabled via SKYRAMP_FEATURE_TESTBOT");
213
215
  }
214
216
  infrastructureTools.forEach((registerTool) => registerTool(server));
215
- // Register Playwright browser tools (trace recording via browser automation)
216
- registerPlaywrightTools(server).catch((err) => {
217
+ // Register Playwright browser tools (trace recording via browser automation).
218
+ //
219
+ // Tell the capture which attribute this repository uses for test ids. Without
220
+ // it the capture assumes Playwright's `data-testid`, and a repo using anything
221
+ // else gets no test-id anchor and a positional xpath for every element — which
222
+ // resolves at capture time and breaks later, so the miss never announces itself
223
+ // (SKYR-4136). This server is the only component that knows which repository it
224
+ // is running against.
225
+ //
226
+ // A declared `use.testIdAttribute` is authoritative. Failing that, the
227
+ // repository's own tests name the attribute whenever they select on it, so read
228
+ // it out of their selectors rather than guessing from a list of popular names.
229
+ // Never fatal: no answer leaves the capture on Playwright's default.
230
+ (async () => {
231
+ let testIdAttribute;
232
+ try {
233
+ const root = await findRoot(process.cwd());
234
+ testIdAttribute = await readTestIdAttribute(root);
235
+ if (testIdAttribute) {
236
+ logger.info(`testIdAttribute declared by the Playwright config: ${testIdAttribute}`);
237
+ }
238
+ else {
239
+ const discovered = await discoverTestIdAttribute(root);
240
+ testIdAttribute = discovered.attribute;
241
+ if (testIdAttribute) {
242
+ // Log the runner-up counts: on a part-migrated repo the most-used
243
+ // attribute wins, and a wrong pick should be visible here rather than
244
+ // showing up later as brittle selectors.
245
+ logger.info(`testIdAttribute discovered from test selectors: ${testIdAttribute}`, { counts: discovered.counts, filesScanned: discovered.filesScanned });
246
+ }
247
+ else {
248
+ // No name rule can reach an arbitrary convention like `xyz-id`, so name
249
+ // what the selectors did contain and the variable that overrides it.
250
+ // Editing the repo's own Playwright config is the wrong instruction: a
251
+ // suite may have several, and none of them at the root.
252
+ logger.info("No testIdAttribute declared or discovered — capture stays on Playwright's data-testid. " +
253
+ "Set PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE to override.", { attributesSeenInSelectors: discovered.observed, filesScanned: discovered.filesScanned });
254
+ }
255
+ }
256
+ }
257
+ catch (err) {
258
+ logger.warning("Could not determine this repository's testIdAttribute", { error: err });
259
+ }
260
+ await registerPlaywrightTools(server, { testIdAttribute });
261
+ })().catch((err) => {
217
262
  logger.error("Failed to register Playwright tools", { error: err });
218
263
  });
219
264
  // Global error handlers for crash telemetry
@@ -17,6 +17,14 @@ export interface PlaywrightTraceOptions {
17
17
  headless?: boolean;
18
18
  /** Directory for output files (zip, HAR). Defaults to cwd. */
19
19
  outputDir?: string;
20
+ /**
21
+ * The attribute this repository uses for test ids, when its Playwright config
22
+ * declares one. Forwarded to the blueprint capture, which otherwise only
23
+ * recognises the common conventions and falls back to positional xpaths —
24
+ * brittle, and silently so. Only this server knows which repository it runs
25
+ * against, so only this server can supply it.
26
+ */
27
+ testIdAttribute?: string;
20
28
  }
21
29
  export declare class PlaywrightTraceService {
22
30
  private _backend;
@@ -26,6 +26,7 @@ export class PlaywrightTraceService {
26
26
  this._backend = new TraceRecordingBackend({
27
27
  headless: this._options.headless,
28
28
  outputDir: this._options.outputDir || process.cwd(),
29
+ testIdAttribute: this._options.testIdAttribute,
29
30
  });
30
31
  // Initialize with a synthetic client info
31
32
  await this._backend.initialize({
@@ -1,5 +1,9 @@
1
1
  import { generateSkyrampHeader, SKYRAMP_UTILS_HEADER } from "../utils/utils.js";
2
+ import { resolvePomCatalogPath } from "../utils/pom-catalog.js";
2
3
  export function getPomAwareCodeReusePrompt(testFile, language = "typescript", scopedPoms) {
4
+ // Resolved here, not left to the agent: the catalog only survives to the next
5
+ // run if it lands where delivery stages it (SKYR-4141).
6
+ const catalogPath = resolvePomCatalogPath(testFile);
3
7
  const isJs = language.toLowerCase() === "javascript";
4
8
  const ext = isJs ? "js" : "ts";
5
9
  const utilsFile = isJs ? "skyrampUtils.js" : "skyrampUtils.ts";
@@ -27,8 +31,6 @@ ${scopedPoms.tier2.map(renderPom).join("\n") || "(none)"}
27
31
 
28
32
  Do NOT glob or scan for other POM files. Proceed to STEP 2 and catalog exactly these files (plus any file they import or extend that you find essential while reading them).
29
33
 
30
- **Catalog-completeness guard for STEP 2a:** when checking for an existing \`skyramp-pom-catalog.md\`, it is only valid for THIS run if it already contains an entry for EVERY file listed above. If any listed file is missing from the catalog, do NOT reuse it wholesale — keep its existing entries and generate + append entries for the missing listed files (then use the updated catalog).
31
-
32
34
  **Decision:** continue to STEP 2.`
33
35
  : `## STEP 1: DETECT POM CLASSES IN THE PROJECT
34
36
 
@@ -89,10 +91,13 @@ ${step1Section}
89
91
 
90
92
  **Step 2a: Check for existing catalog (MANDATORY FIRST STEP)**
91
93
 
92
- Look for \`skyramp-pom-catalog.md\` walking UP from the test file's directory to the repository root; use the FIRST one found (catalogs from earlier runs may live at legacy locations — a found catalog is valid wherever it lives):
93
- - **If it exists:** Read it and verify it starts with \`<!-- skyramp-pom-catalog: auto-generated -->\`
94
- - Valid → Output: *"Using existing POM catalog from /path/to/skyramp-pom-catalog.md"* and **skip to STEP 2b**
95
- - Invalid → Continue to full catalog generation below
94
+ Read the catalog at EXACTLY this path the one canonical location. Do not search for it anywhere else:
95
+
96
+ \`${catalogPath}\`
97
+
98
+ - **If it exists:** verify it starts with \`<!-- skyramp-pom-catalog: auto-generated -->\` AND already contains an entry for EVERY POM file STEP 1 identified — the files listed in STEP 1 above, or the files your own glob found, whichever applies.
99
+ - Valid and complete → Output: *"Using existing POM catalog from ${catalogPath}"* and **skip to STEP 2b**
100
+ - Invalid, or missing an entry for any STEP 1 file → do NOT reuse it wholesale — keep its existing entries and continue to full catalog generation below, generating and appending entries only for the missing files
96
101
  - **If it doesn't exist:** Continue to full catalog generation below
97
102
 
98
103
  ---
@@ -121,10 +126,14 @@ Before writing a single line of refactored code, do the following:
121
126
  - Whether the method wraps a multi-step sequence using sub-properties or components (e.g. \`searchAssetsByCondition\` internally calls \`searchConditionArea.expandWhenClosed()\`, \`clearBtn.click()\`, \`selectItemName()\`, \`valueKeyword.fill()\`, \`searchBtn.click()\`) — flag these as **wrapping methods**
122
127
  - Flag methods as **timing-aware**, **network-aware**, or **wrapping** as appropriate — all three are high-value substitution targets
123
128
 
124
- 4. **Write the POM catalog — MANDATORY** — Write the complete catalog to \`skyramp-pom-catalog.md\` at the CANONICAL location: the top of the configured test directory (when this run's instructions declare one containing the test file), otherwise the nearest ancestor directory of the test file that contains a \`package.json\`, otherwise the repository root. Never invent a new location per run — determinism is what lets later runs reuse this file. This gives you a clean structured reference for STEP 2b and leaves a record for debugging.
129
+ 4. **Write the POM catalog — MANDATORY** — Write the complete catalog to EXACTLY this path, already resolved for this run, creating the parent directory first if it does not already exist:
130
+
131
+ \`${catalogPath}\`
132
+
133
+ Use it verbatim — do not re-derive it, shorten it, or pick a directory that looks more natural. Delivery commits this location; a catalog written anywhere else is discarded without warning, and the next run pays full POM rediscovery. This gives you a clean structured reference for STEP 2b and leaves a record for debugging.
125
134
 
126
135
  **BLOCKING: Do not proceed to STEP 2b until you have written this file and confirmed the path in a single output line:**
127
- *"POM catalog written to /path/to/skyramp-pom-catalog.md"*
136
+ *"POM catalog written to ${catalogPath}"*
128
137
 
129
138
  **Verification gate:** Before continuing to STEP 2b, confirm you either (a) found and used an existing catalog in Step 2a, OR (b) just wrote a new catalog in step 4 above. If neither is true, STOP and complete step 4 now.
130
139
 
@@ -1,21 +1,11 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { type Service } from "../../workspace/workspace.js";
3
- /** One related repository to analyze as shared context in multi-repo mode. */
4
- export interface RelatedRepository {
5
- repositoryPath: string;
6
- baseBranch?: string;
7
- /** `owner/repo` slug, resolved by the action from the checkout's git remote.
8
- * Used verbatim as the `repository` argument/attribution for this repo so the
9
- * agent never has to infer it. */
10
- repo: string;
11
- }
2
+ import type { RelatedRepository, TestbotPromptOptions } from "../../types/TestbotPromptOptions.js";
12
3
  /**
13
4
  * Parse the JSON-encoded `relatedRepositories` argument passed via the testbot
14
5
  * prompt/resource. Returns undefined for missing/blank input or malformed JSON so the
15
6
  * caller cleanly falls back to single-repo behavior.
16
7
  */
17
8
  export declare function parseRelatedRepositories(raw: string | undefined): RelatedRepository[] | undefined;
18
- export declare function getTestbotPrompt(prTitle: string, prDescription: string, summaryOutputFile: string, repositoryPath: string, baseBranch?: string, maxRecommendations?: number, maxGenerate?: number, _maxCritical?: number, // Reserved — accepted for API compat but not yet wired into prompt
19
- prNumber?: number, userPrompt?: string, services?: Service[], uiCredentials?: string, testsRepoDir?: string, relatedRepositories?: RelatedRepository[], primaryRepo?: string, planOnly?: boolean, language?: string): string;
9
+ export declare function getTestbotPrompt(opts: TestbotPromptOptions): string;
20
10
  export declare function buildWorkspaceRecoveryPrefix(repositoryPath: string): string;
21
11
  export declare function registerTestbotPrompt(server: McpServer): void;
@@ -54,9 +54,10 @@ export function parseRelatedRepositories(raw) {
54
54
  return undefined;
55
55
  }
56
56
  }
57
- export function getTestbotPrompt(prTitle, prDescription, summaryOutputFile, repositoryPath, baseBranch, maxRecommendations = MAX_RECOMMENDATIONS, maxGenerate = MAX_TESTS_TO_GENERATE, _maxCritical = MAX_CRITICAL_TESTS, // Reserved — accepted for API compat but not yet wired into prompt
58
- prNumber, userPrompt, services, uiCredentials, testsRepoDir, relatedRepositories, primaryRepo, planOnly = false, language) {
59
- maxGenerate = Math.min(Math.max(maxGenerate, 0), maxRecommendations);
57
+ export function getTestbotPrompt(opts) {
58
+ const { prTitle, prDescription, summaryOutputFile, repositoryPath, baseBranch, maxRecommendations = MAX_RECOMMENDATIONS, prNumber, userPrompt, services, uiCredentials, testsRepoDir, relatedRepositories, primaryRepo, planOnly = false, language, } = opts;
59
+ // maxCritical is intentionally unused (reserved) — see TestbotPromptOptions.
60
+ const maxGenerate = Math.min(Math.max(opts.maxGenerate ?? MAX_TESTS_TO_GENERATE, 0), maxRecommendations);
60
61
  // TODO(SKYR-3636 follow-up): migrate Task 1 + Task 2 step bodies to PromptPlan
61
62
  // (src/prompts/test-recommendation/promptPlan.ts) so step numbers don't have
62
63
  // to be hand-maintained when steps are added or reordered.
@@ -552,7 +553,7 @@ ${CONTRACT_MODE_GUIDANCE}
552
553
 
553
554
  4. **After** the action: \`browser_blueprint\` again. The response shape depends on whether the action navigated:
554
555
  - **Same URL (modal/tab/in-place mutation):** \`{ isFullCapture: false, pageHash, previousPageHash, delta, possibleAssertions }\`. The \`delta\` field contains \`elementsAdded\`, \`elementsRemoved\`, \`textChanges\`, \`repeatingCountChanges\`. The \`possibleAssertions\` field is a mechanical translation of those entries into Playwright \`expect(...)\` candidates — see step 5. An empty delta (all arrays empty) is itself a meaningful signal: the action did not change observable DOM (e.g. a silent failure the test should catch).
555
- - **Navigated to a new URL** (e.g. router transition, link click, programmatic \`browser_navigate\`): \`{ isFullCapture: true, pageHash, blueprint }\` — a fresh full capture of the new page. No \`possibleAssertions\` here (no delta to translate). Search the new blueprint for the elements your assertion will target.
556
+ - **Navigated to a new URL** (e.g. router transition, link click, programmatic \`browser_navigate\`): \`{ isFullCapture: true, pageHash, blueprint, possibleAssertions }\` — a fresh full capture of the new page. \`possibleAssertions\` is present here too, but short — there is no previous capture to compare against, so nothing in it tells you what the action changed. Search the new blueprint for the elements your assertion will target.
556
557
 
557
558
  5. **The second \`browser_blueprint\` response (the capture after the action) includes a \`possibleAssertions[]\` array — mechanical translations of delta entries into ready-to-use Playwright \`expect(...)\` candidates.** Each entry has \`{ code, rationale, tier }\`. Use tier to drive your assertion choices:
558
559
 
@@ -733,7 +734,25 @@ export function registerTestbotPrompt(server) {
733
734
  },
734
735
  }, async (args) => {
735
736
  const services = await readWorkspaceServices(args.repositoryPath);
736
- let prompt = getTestbotPrompt(args.prTitle, args.prDescription, args.summaryOutputFile, args.repositoryPath, args.baseBranch, args.maxRecommendations, args.maxGenerate, args.maxCritical, args.prNumber, args.userPrompt, services.length ? services : undefined, args.uiCredentials, args.testsRepoDir, parseRelatedRepositories(args.relatedRepositories), args.primaryRepo, args.planOnly, args.language);
737
+ let prompt = getTestbotPrompt({
738
+ prTitle: args.prTitle,
739
+ prDescription: args.prDescription,
740
+ summaryOutputFile: args.summaryOutputFile,
741
+ repositoryPath: args.repositoryPath,
742
+ baseBranch: args.baseBranch,
743
+ maxRecommendations: args.maxRecommendations,
744
+ maxGenerate: args.maxGenerate,
745
+ maxCritical: args.maxCritical,
746
+ prNumber: args.prNumber,
747
+ userPrompt: args.userPrompt,
748
+ services: services.length ? services : undefined,
749
+ uiCredentials: args.uiCredentials,
750
+ testsRepoDir: args.testsRepoDir,
751
+ relatedRepositories: parseRelatedRepositories(args.relatedRepositories),
752
+ primaryRepo: args.primaryRepo,
753
+ planOnly: args.planOnly,
754
+ language: args.language,
755
+ });
737
756
  if (args.workspaceValidationFailed) {
738
757
  prompt = buildWorkspaceRecoveryPrefix(args.repositoryPath) + prompt;
739
758
  }
@@ -26,10 +26,27 @@ export function registerTestbotResource(server) {
26
26
  const maxCrit = parseInt(uri.searchParams.get("maxCritical") || "", 10);
27
27
  const repositoryPath = param("repositoryPath", ".");
28
28
  const services = await readWorkspaceServices(repositoryPath);
29
- const prompt = getTestbotPrompt(param("prTitle", ""), param("prDescription", ""), param("summaryOutputFile", ""), repositoryPath, uri.searchParams.get("baseBranch") || undefined, isNaN(maxRec) ? MAX_RECOMMENDATIONS : maxRec, isNaN(maxGen) ? MAX_TESTS_TO_GENERATE : maxGen, isNaN(maxCrit) ? MAX_CRITICAL_TESTS : maxCrit, isNaN(prNum) ? undefined : prNum, uri.searchParams.get("userPrompt") || undefined, services.length ? services : undefined, uri.searchParams.get("uiCredentials") || undefined, uri.searchParams.get("testsRepoDir") || undefined, parseRelatedRepositories(uri.searchParams.get("relatedRepositories") || undefined), uri.searchParams.get("primaryRepo") || undefined,
30
- // Plan-only eval lane (SKYR-3879): recommendation phase only, nothing
31
- // generated or executed. Same accepted spellings as the prompt schema.
32
- ["true", "1"].includes(uri.searchParams.get("planOnly") ?? ""), uri.searchParams.get("language") || undefined);
29
+ const prompt = getTestbotPrompt({
30
+ prTitle: param("prTitle", ""),
31
+ prDescription: param("prDescription", ""),
32
+ summaryOutputFile: param("summaryOutputFile", ""),
33
+ repositoryPath,
34
+ baseBranch: uri.searchParams.get("baseBranch") || undefined,
35
+ maxRecommendations: isNaN(maxRec) ? MAX_RECOMMENDATIONS : maxRec,
36
+ maxGenerate: isNaN(maxGen) ? MAX_TESTS_TO_GENERATE : maxGen,
37
+ maxCritical: isNaN(maxCrit) ? MAX_CRITICAL_TESTS : maxCrit,
38
+ prNumber: isNaN(prNum) ? undefined : prNum,
39
+ userPrompt: uri.searchParams.get("userPrompt") || undefined,
40
+ services: services.length ? services : undefined,
41
+ uiCredentials: uri.searchParams.get("uiCredentials") || undefined,
42
+ testsRepoDir: uri.searchParams.get("testsRepoDir") || undefined,
43
+ relatedRepositories: parseRelatedRepositories(uri.searchParams.get("relatedRepositories") || undefined),
44
+ primaryRepo: uri.searchParams.get("primaryRepo") || undefined,
45
+ // Plan-only eval lane (SKYR-3879): recommendation phase only, nothing
46
+ // generated or executed. Same accepted spellings as the prompt schema.
47
+ planOnly: ["true", "1"].includes(uri.searchParams.get("planOnly") ?? ""),
48
+ language: uri.searchParams.get("language") || undefined,
49
+ });
33
50
  AnalyticsService.pushMCPToolEvent("skyramp_testbot_prompt", undefined, {}).catch(() => { });
34
51
  // Return the original URI — clients may use it to re-fetch the resource,
35
52
  // and the caller already has these params. Credentials never appear in
@@ -2,7 +2,7 @@ import { TestExecutionResult, BatchExecutionResult, TestExecutionOptions, Progre
2
2
  import { EXECUTOR_DOCKER_IMAGE } from "../utils/versions.js";
3
3
  export { EXECUTOR_DOCKER_IMAGE };
4
4
  export declare const PLAYWRIGHT_CONFIG_FILES: string[];
5
- export declare const EXCLUDED_MOUNT_ITEMS: string[];
5
+ export declare const MOUNT_SCAN_SKIP_DIRS: string[];
6
6
  export declare const TEST_REPO_CONTAINER_SUBDIR = ".skyramp-test-repo";
7
7
  export declare const MOUNT_NULL_ITEMS: string[];
8
8
  /**
@@ -38,18 +38,23 @@ export declare function detectSessionFiles(testFilePath: string): string[];
38
38
  */
39
39
  export declare function findExcludedPaths(dir: string, excludedItems: string[]): string[];
40
40
  /**
41
- * Find every nested `node_modules` directory under `dir` i.e. all except the
42
- * top-level `<dir>/node_modules` (which the workspace mount already excludes).
43
- * Never descends into a `node_modules`. Used to shadow repo-local node_modules
44
- * that ride into the executor inside a parent directory's wholesale bind-mount;
45
- * a leaked `@playwright/test` becomes a second physical copy and trips
46
- * Playwright's "Requiring @playwright/test second time" guard.
41
+ * Find every `node_modules` directory under `dir`, including the top-level
42
+ * `<dir>/node_modules`. Never descends into a `node_modules`. Used to shadow
43
+ * SHADOWED_NESTED_PACKAGES inside each one: a repo-local `@playwright/test`
44
+ * becomes a second physical copy and trips Playwright's "Requiring
45
+ * @playwright/test second time" guard.
46
+ *
47
+ * The top-level one is deliberately NOT special-cased (SKYR-4140). When
48
+ * `workspacePath` is itself an npm package root — the standard `tests/<suite>/`
49
+ * layout — its node_modules IS the only one, so skipping it left the shadow
50
+ * machinery with nothing to act on and no customer dependency resolvable.
51
+ *
47
52
  * Skips `.git` (nothing importable lives there) and does not follow symlinks —
48
53
  * by design (a symlinked nested node_modules either dangles in-container,
49
54
  * falling through to NODE_PATH's global copy, or its real-path target is
50
55
  * walked directly).
51
56
  */
52
- export declare function findNestedNodeModules(dir: string): string[];
57
+ export declare function findNodeModulesDirs(dir: string): string[];
53
58
  export declare class TestExecutionService {
54
59
  private docker;
55
60
  private imageReady;
@@ -54,8 +54,11 @@ export const PLAYWRIGHT_CONFIG_FILES = [
54
54
  "playwright.config.js",
55
55
  "playwright.config.mjs",
56
56
  ];
57
- // Directories to skip mounting entirely (cannot bind-mount /dev/null to a directory)
58
- export const EXCLUDED_MOUNT_ITEMS = [
57
+ // Directories the recursive shadow scans must never descend into. node_modules
58
+ // IS mounted (see mountRepoTree) — but walking it would shadow every package's
59
+ // own package.json with empty JSON via MOUNT_NULL_ITEMS, breaking resolution
60
+ // for the whole tree.
61
+ export const MOUNT_SCAN_SKIP_DIRS = [
59
62
  "node_modules",
60
63
  ];
61
64
  // Reserved container subdir under /home/user where a cross-repo test repository
@@ -354,7 +357,7 @@ export function detectSessionFiles(testFilePath) {
354
357
  */
355
358
  export function findExcludedPaths(dir, excludedItems) {
356
359
  const results = [];
357
- const shouldSkipDir = (entry) => excludedItems.includes(entry.name) || EXCLUDED_MOUNT_ITEMS.includes(entry.name);
360
+ const shouldSkipDir = (entry) => excludedItems.includes(entry.name) || MOUNT_SCAN_SKIP_DIRS.includes(entry.name);
358
361
  for (const [entry, fullPath] of walkDir(dir, { shouldSkipDir })) {
359
362
  // Only shadow files — mounting /dev/null to a directory target causes Docker errors
360
363
  if (excludedItems.includes(entry.name)) {
@@ -364,18 +367,23 @@ export function findExcludedPaths(dir, excludedItems) {
364
367
  return results;
365
368
  }
366
369
  /**
367
- * Find every nested `node_modules` directory under `dir` i.e. all except the
368
- * top-level `<dir>/node_modules` (which the workspace mount already excludes).
369
- * Never descends into a `node_modules`. Used to shadow repo-local node_modules
370
- * that ride into the executor inside a parent directory's wholesale bind-mount;
371
- * a leaked `@playwright/test` becomes a second physical copy and trips
372
- * Playwright's "Requiring @playwright/test second time" guard.
370
+ * Find every `node_modules` directory under `dir`, including the top-level
371
+ * `<dir>/node_modules`. Never descends into a `node_modules`. Used to shadow
372
+ * SHADOWED_NESTED_PACKAGES inside each one: a repo-local `@playwright/test`
373
+ * becomes a second physical copy and trips Playwright's "Requiring
374
+ * @playwright/test second time" guard.
375
+ *
376
+ * The top-level one is deliberately NOT special-cased (SKYR-4140). When
377
+ * `workspacePath` is itself an npm package root — the standard `tests/<suite>/`
378
+ * layout — its node_modules IS the only one, so skipping it left the shadow
379
+ * machinery with nothing to act on and no customer dependency resolvable.
380
+ *
373
381
  * Skips `.git` (nothing importable lives there) and does not follow symlinks —
374
382
  * by design (a symlinked nested node_modules either dangles in-container,
375
383
  * falling through to NODE_PATH's global copy, or its real-path target is
376
384
  * walked directly).
377
385
  */
378
- export function findNestedNodeModules(dir) {
386
+ export function findNodeModulesDirs(dir) {
379
387
  const results = [];
380
388
  const walk = (current) => {
381
389
  let entries;
@@ -392,8 +400,7 @@ export function findNestedNodeModules(dir) {
392
400
  continue;
393
401
  const full = path.join(current, entry.name);
394
402
  if (entry.name === "node_modules") {
395
- if (current !== dir)
396
- results.push(full); // skip the top-level node_modules
403
+ results.push(full);
397
404
  continue; // never descend into a node_modules
398
405
  }
399
406
  walk(full);
@@ -593,17 +600,16 @@ export class TestExecutionService {
593
600
  hostConfig.Mounts.push(mount);
594
601
  };
595
602
  // Mount one repo tree (the workspace, or a cross-repo test repository) into
596
- // the container, skipping EXCLUDED_MOUNT_ITEMS completely.
603
+ // the container.
597
604
  //
598
605
  // Each top-level entry is bind-mounted at the canonical container path and —
599
606
  // when mirrorToHost is set — at its host-absolute path too. The dual mount
600
607
  // lets the test resolve any absolute reference the codegen happens to embed
601
608
  // (storageState, fixture paths, snapshots) — including the host workspace
602
609
  // path that Playwright's TypeScript loader sometimes produces from
603
- // `__dirname` — without needing source-code detection. EXCLUDED_MOUNT_ITEMS
604
- // (node_modules) stays excluded at both targets; MOUNT_NULL_ITEMS shadows
605
- // (package.json empty JSON, etc.), nested-package shadows, and
606
- // PLAYWRIGHT_CONFIG_FILES shadows (minimal config) are applied at both
610
+ // `__dirname` — without needing source-code detection. MOUNT_NULL_ITEMS
611
+ // shadows (package.json empty JSON, etc.), node_modules package shadows,
612
+ // and PLAYWRIGHT_CONFIG_FILES shadows (minimal config) are applied at both
607
613
  // targets too so the protections survive regardless of which path the test
608
614
  // resolves to.
609
615
  //
@@ -611,7 +617,7 @@ export class TestExecutionService {
611
617
  // later mounts over earlier ones, so the top-level entry mounts must be
612
618
  // pushed BEFORE the shadow mounts that override paths inside them.
613
619
  //
614
- // The three recursive scans (findExcludedPaths × 2 + findNestedNodeModules)
620
+ // The three recursive scans (findExcludedPaths × 2 + findNodeModulesDirs)
615
621
  // are deliberately kept as separate walks rather than fused into one pass:
616
622
  // each skips node_modules/.git so the cost is small, and separate walks keep
617
623
  // each shadow category's semantics independently testable.
@@ -623,11 +629,13 @@ export class TestExecutionService {
623
629
  // otherwise the entry mount would win the dedup race and the repo's own
624
630
  // config (which may import dotenv, or simply isn't the minimal/generated
625
631
  // one) would leak into the container.
632
+ //
633
+ // node_modules is NOT filtered out (SKYR-4140): it carries the customer's
634
+ // test-support deps, and the per-package shadows below neutralise the only
635
+ // copies that must not load from the repo.
626
636
  const entriesToMount = fs
627
637
  .readdirSync(hostRoot)
628
- .filter((file) => !EXCLUDED_MOUNT_ITEMS.includes(file) &&
629
- !MOUNT_NULL_ITEMS.includes(file) &&
630
- !PLAYWRIGHT_CONFIG_FILES.includes(file));
638
+ .filter((file) => !MOUNT_NULL_ITEMS.includes(file) && !PLAYWRIGHT_CONFIG_FILES.includes(file));
631
639
  for (const file of entriesToMount) {
632
640
  const source = path.join(hostRoot, file);
633
641
  pushMount({ Type: "bind", Target: path.join(containerRoot, file), Source: source });
@@ -644,10 +652,9 @@ export class TestExecutionService {
644
652
  pushMount({ Type: "bind", Source: source, Target: absolutePath });
645
653
  }
646
654
  }
647
- // Shadow SHADOWED_NESTED_PACKAGES inside every nested node_modules leaked in
648
- // via a parent-directory bind-mount (see findNestedNodeModules) — the
649
- // top-level node_modules is already excluded above via EXCLUDED_MOUNT_ITEMS.
650
- for (const nested of findNestedNodeModules(hostRoot)) {
655
+ // Shadow SHADOWED_NESTED_PACKAGES inside every mounted node_modules the
656
+ // repo's own top-level one included (see findNodeModulesDirs).
657
+ for (const nested of findNodeModulesDirs(hostRoot)) {
651
658
  for (const pkg of SHADOWED_NESTED_PACKAGES) {
652
659
  const pkgPath = path.join(nested, pkg);
653
660
  if (!fs.existsSync(pkgPath))
@@ -711,6 +718,22 @@ export class TestExecutionService {
711
718
  if (crossRepoTestRoot) {
712
719
  mountRepoTree(crossRepoTestRoot, path.join(containerMountPath, TEST_REPO_CONTAINER_SUBDIR), true);
713
720
  }
721
+ // runner.sh invokes `npx playwright test`, and npx resolves binaries from
722
+ // <cwd>/node_modules/.bin before PATH — cwd is always containerMountPath. Now
723
+ // that node_modules is mounted (SKYR-4140), a repo-local .bin/playwright would
724
+ // run instead of the image's global CLI. The per-package shadows above do not
725
+ // cover it under a pnpm layout, where .bin/playwright symlinks into .pnpm/
726
+ // rather than into node_modules/playwright. Shadow this one .bin — the only
727
+ // one npx can reach — so binary resolution falls through to PATH as before.
728
+ // Pushed after mountRepoTree so it layers over the node_modules entry mount.
729
+ if (fs.existsSync(path.join(workspacePath, "node_modules", ".bin"))) {
730
+ pushMount({
731
+ Type: "bind",
732
+ Source: EMPTY_DIR_PATH,
733
+ Target: path.join(containerMountPath, "node_modules", ".bin"),
734
+ ReadOnly: true,
735
+ });
736
+ }
714
737
  // Mount video output directory for browser tests (writable)
715
738
  if (browserTest && videoHostDir) {
716
739
  pushMount({
@@ -44,6 +44,30 @@ export function buildContainerEnv(options, saveStoragePath, hostEnv = process.en
44
44
  env.push(`${key}=${shouldRewrite ? rewriteLocalhostForDocker(val) : val}`);
45
45
  }
46
46
  }
47
+ // Credentials that generated UI tests read from the environment instead of
48
+ // carrying as literals (SKYR-3862). Without this the container never sees
49
+ // them and every generated login fails its guard.
50
+ //
51
+ // Forwarded on presence, not truthiness: the generated guard deliberately
52
+ // distinguishes an unset variable (a misconfiguration, fails fast) from one
53
+ // set to "" (valid on forms that accept a blank password). A truthy check —
54
+ // as used for the URL vars above — would drop the empty case and collapse it
55
+ // into the failure case.
56
+ //
57
+ // TODO(SKYR-4110): only ONE credential pair is forwarded, because testbot
58
+ // exports only the first credential from its `uiCredentials` input. That input
59
+ // was designed to carry several, so the agent can pick per test case by
60
+ // authorization role — so a multi-role suite can get a credential its test did
61
+ // not expect, surfacing as a false 403. The fix is a keyed form
62
+ // (SKYRAMP_UI_PASSWORD_<KEY>), which needs no new forwarding logic here: the
63
+ // loop above already forwards SKYRAMP_TEST_SERVICE_URL_* by prefix, so the
64
+ // same approach applies. Keep the presence-not-truthiness rule when it lands.
65
+ for (const key of ["SKYRAMP_UI_USERNAME", "SKYRAMP_UI_PASSWORD"]) {
66
+ const val = hostEnv[key];
67
+ if (val !== undefined) {
68
+ env.push(`${key}=${val}`);
69
+ }
70
+ }
47
71
  if (hostEnv.SKYRAMP_DEBUG) {
48
72
  env.push(`SKYRAMP_DEBUG=${hostEnv.SKYRAMP_DEBUG}`);
49
73
  }
@@ -275,7 +275,6 @@ function startPortForward(kubeconfig, namespace, target, remotePort) {
275
275
  async function queryProtocolSamples(baseUrl, protocol, pageSize, destination) {
276
276
  const collected = [];
277
277
  let offset = 0;
278
- // eslint-disable-next-line no-constant-condition
279
278
  while (true) {
280
279
  const params = new URLSearchParams({
281
280
  protocol,
@@ -591,9 +591,18 @@ export function registerSubmitReportTool(server) {
591
591
  // without it, the cheapest compliant move is a token no-op edit to the
592
592
  // mis-claimed file, which pollutes the delivered branch and leaves the report
593
593
  // crediting the wrong file (observed in the Ghost rc.pom-3 tester run, D-04).
594
+ // The two claim kinds have DIFFERENT remedies and must say so. newTestsCreated
595
+ // is a tool input, so it can be corrected here. A testMaintenance row is built
596
+ // from stateData.maintenanceVerdicts and cannot be — saying so stops the agent
597
+ // permuting testMaintenanceDetails, which is what it did for 15 rounds in
598
+ // SKYR-4129. Do NOT offer re-running skyramp_actions to restate the verdict:
599
+ // this branch only fires when the edit is genuinely absent, so rewriting the
600
+ // record to match that would be the tamper path, not the fix.
594
601
  const changedList = changedFiles.slice(0, 20).map((f) => ` - ${f}`).join("\n");
595
602
  errorResult = toolError(`${unbacked.length} report claim(s) are not backed by any change in the working tree — ` +
596
- `Testbot will not report file work that hasn't actually been made. If the work WAS done but in a different file, correct the claim's fileName/description to the file you actually edited (see the changed files below) — do NOT make a token edit to the claimed file just to satisfy this check. Otherwise make the edit, or remove the claim. Then resubmit.\n` +
603
+ `Testbot will not report file work that hasn't actually been made. Do NOT make a token edit to the claimed file just to satisfy this check.\n` +
604
+ `For a newTestsCreated claim: create the file, correct the claim's fileName to the file you actually created (see the changed files below), or remove the claim.\n` +
605
+ `For a testMaintenance claim: make the edit on the named file. That row is derived from the maintenance triage, not from this tool's input, so it cannot be corrected by changing what you pass here.\n` +
597
606
  `Unbacked claims:\n${unbacked.map((u) => ` - ${u}`).join("\n")}\n` +
598
607
  `Files with actual working-tree changes:\n${changedList || " (none)"}`);
599
608
  return errorResult;
@@ -265,14 +265,20 @@ export function registerActionsTool(server) {
265
265
  });
266
266
  return;
267
267
  }
268
- // Resolve and validate pomFile with the same path-traversal guard as testFilePath.
268
+ // Resolve and validate pomFile with the same path-traversal guard as testFilePath,
269
+ // minus that guard's `=== repoRoot` allowance: the repo root is a directory, never
270
+ // a page object. Admitting it would make it the edit target, and every consumer
271
+ // that resolves it relative to repoRoot gets the empty string — which the report's
272
+ // working-tree check reads as "outside this checkout, exempt", waving through an
273
+ // UPDATE that changed nothing. Dropping it here means pomFile falls back to the
274
+ // spec (`pomFile ?? testFilePath`) for both the edit and the check.
269
275
  let resolvedPomFile;
270
276
  if (rec.pomFile) {
271
277
  const rawPom = path.isAbsolute(rec.pomFile)
272
278
  ? rec.pomFile
273
279
  : path.resolve(repoRoot ?? "", rec.pomFile);
274
280
  resolvedPomFile = path.resolve(rawPom);
275
- if (repoRoot && !resolvedPomFile.startsWith(repoRoot + path.sep) && resolvedPomFile !== repoRoot) {
281
+ if (repoRoot && !resolvedPomFile.startsWith(repoRoot + path.sep)) {
276
282
  logger.warning(`Skipping pomFile outside repo root: ${rec.pomFile}`);
277
283
  resolvedPomFile = undefined;
278
284
  }
@@ -315,6 +321,10 @@ export function registerActionsTool(server) {
315
321
  ignoredTestFiles,
316
322
  maintenanceVerdicts: recommendations.map((r) => ({
317
323
  testFilePath: r.testFilePath,
324
+ // Persisted because the edit lands here, not in testFilePath. Dropping it
325
+ // left skyramp_submit_report unable to tell a POM-backed UPDATE from a
326
+ // fabricated one, so it rejected real maintenance work (SKYR-4129).
327
+ pomFile: r.pomFile,
318
328
  action: r.action,
319
329
  rationale: r.rationale,
320
330
  testType: r.testType,
@@ -34,12 +34,16 @@ export interface MaintenanceActionCore {
34
34
  testType: TestType;
35
35
  endpoint: string;
36
36
  rationale: string;
37
+ /** When selectors are abstracted into a page object, pomFile is the POM path to edit.
38
+ * testFilePath remains the spec (for catalog lookup and report); pomFile is what gets
39
+ * patched. Part of the core shape because the EXECUTED file (the spec, which carries the
40
+ * execution records) and the EDITED file (the POM) are structurally different, and every
41
+ * downstream stage that reasons about the edit needs both — notably the report-time
42
+ * working-tree check, which otherwise calls a POM-backed UPDATE unbacked (SKYR-4129). */
43
+ pomFile?: string;
37
44
  }
38
45
  /** Normalized internal recommendation built from LLM-supplied args.recommendations. */
39
46
  export interface DriftRecommendation extends MaintenanceActionCore {
40
- /** When selectors are abstracted into a page object, pomFile is the POM path to edit.
41
- * testFilePath remains the spec (for catalog lookup and report); pomFile is what gets patched. */
42
- pomFile?: string;
43
47
  priority: RecommendationPriority;
44
48
  estimatedWork: EstimatedWork;
45
49
  updateInstructions: string;
@@ -0,0 +1,35 @@
1
+ import type { Service } from "../workspace/workspace.js";
2
+ /** One related repository to analyze as shared context in multi-repo mode. */
3
+ export interface RelatedRepository {
4
+ repositoryPath: string;
5
+ baseBranch?: string;
6
+ /** `owner/repo` slug, resolved by the action from the checkout's git remote.
7
+ * Used verbatim as the `repository` argument/attribution for this repo so the
8
+ * agent never has to infer it. */
9
+ repo: string;
10
+ }
11
+ /**
12
+ * Options accepted by getTestbotPrompt (src/prompts/testbot/testbot-prompts.ts).
13
+ * Shared here — rather than declared inline in the prompt module — so testbot.git
14
+ * can import this exact type instead of hand-maintaining its own mirror.
15
+ */
16
+ export interface TestbotPromptOptions {
17
+ prTitle: string;
18
+ prDescription: string;
19
+ summaryOutputFile: string;
20
+ repositoryPath: string;
21
+ baseBranch?: string;
22
+ maxRecommendations?: number;
23
+ maxGenerate?: number;
24
+ /** Reserved — accepted for API compat but not yet wired into the prompt. */
25
+ maxCritical?: number;
26
+ prNumber?: number;
27
+ userPrompt?: string;
28
+ services?: Service[];
29
+ uiCredentials?: string;
30
+ testsRepoDir?: string;
31
+ relatedRepositories?: RelatedRepository[];
32
+ primaryRepo?: string;
33
+ planOnly?: boolean;
34
+ language?: string;
35
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -4,3 +4,4 @@ export { TestType, HttpMethod } from "./TestTypes.js";
4
4
  export type { TestbotReport } from "./TestbotReport.js";
5
5
  export { ReuseDeclinedBy, ReuseVerificationOutcome } from "./ReuseOutcome.js";
6
6
  export type { ReuseOutcome, ReuseSkippedEntry } from "./ReuseOutcome.js";
7
+ export type { RelatedRepository, TestbotPromptOptions, } from "./TestbotPromptOptions.js";
@@ -74,7 +74,6 @@ export function extractChangedSelectors(fileDiff) {
74
74
  // `data-testid="x"`, `data-testid={'x'}`, `data-testid={`x`}`. The closing delimiter
75
75
  // deliberately rejects interpolated template literals (`data-testid={`members-${id}`}`),
76
76
  // which would otherwise leak a partial prefix (`members-`) into the selector set.
77
- // eslint-disable-next-line no-useless-escape
78
77
  const ATTR = /\bdata-(?:test-?id|test|cy|qa)\b\s*=\s*\{?\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/g;
79
78
  const GET_BY_TEST_ID = /getByTestId\(\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/g;
80
79
  const KEBAB = /[a-z][a-z0-9]*(?:-{1,2}[a-z0-9]+)+/g;