@skyramp/mcp 0.3.2 → 0.3.4

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 (181) 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/playwright/registerPlaywrightTools.js +42 -1
  5. package/build/prompts/enhance-assertions/sharedAssertionRules.js +19 -0
  6. package/build/prompts/pom-aware-code-reuse.js +17 -8
  7. package/build/prompts/test-maintenance/actionsInstructions.js +2 -2
  8. package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -4
  9. package/build/prompts/test-recommendation/recommendationSections.d.ts +1 -1
  10. package/build/prompts/test-recommendation/recommendationSections.js +5 -5
  11. package/build/prompts/test-recommendation/test-recommendation-prompt.js +13 -7
  12. package/build/prompts/testbot/testbot-prompts.d.ts +2 -12
  13. package/build/prompts/testbot/testbot-prompts.js +28 -16
  14. package/build/recommendation/discriminators.d.ts +7 -1
  15. package/build/recommendation/discriminators.js +16 -3
  16. package/build/resources/testbotResource.js +20 -4
  17. package/build/services/ScenarioGenerationService.js +5 -2
  18. package/build/services/TestExecutionService.d.ts +13 -8
  19. package/build/services/TestExecutionService.js +73 -26
  20. package/build/services/TestGenerationService.js +24 -9
  21. package/build/services/containerEnv.d.ts +12 -1
  22. package/build/services/containerEnv.js +118 -1
  23. package/build/tools/executeSkyrampTestTool.d.ts +9 -0
  24. package/build/tools/executeSkyrampTestTool.js +20 -6
  25. package/build/tools/execution-video-state.d.ts +21 -0
  26. package/build/tools/execution-video-state.js +51 -0
  27. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +31 -11
  28. package/build/tools/generate-tests/planGuard.d.ts +5 -5
  29. package/build/tools/generate-tests/planGuard.js +5 -17
  30. package/build/tools/queryProxyMocksTool.js +0 -1
  31. package/build/tools/submitReportTool.d.ts +83 -10
  32. package/build/tools/submitReportTool.js +179 -29
  33. package/build/tools/test-management/actionsTool.js +52 -41
  34. package/build/tools/test-management/analyzeChangesTool.d.ts +11 -0
  35. package/build/tools/test-management/analyzeChangesTool.js +37 -33
  36. package/build/tools/test-management/analyzeTestHealthTool.js +3 -3
  37. package/build/tools/test-management/registerTestPlanTool.js +113 -31
  38. package/build/types/TestAnalysis.d.ts +7 -3
  39. package/build/types/TestExecution.d.ts +14 -0
  40. package/build/types/TestTypes.js +3 -2
  41. package/build/types/TestbotPromptOptions.d.ts +34 -0
  42. package/build/types/TestbotPromptOptions.js +1 -0
  43. package/build/types/TestbotReport.d.ts +10 -0
  44. package/build/types/TestbotReport.js +10 -1
  45. package/build/types/index.d.ts +2 -0
  46. package/build/types/index.js +1 -0
  47. package/build/utils/AnalysisStateManager.d.ts +36 -2
  48. package/build/utils/AnalysisStateManager.js +34 -13
  49. package/build/utils/frontendSelectors.js +0 -1
  50. package/build/utils/gitStaging.d.ts +5 -0
  51. package/build/utils/gitStaging.js +1 -1
  52. package/build/utils/pom-catalog.d.ts +23 -0
  53. package/build/utils/pom-catalog.js +30 -0
  54. package/build/utils/pom-scope/pom-files.d.ts +14 -0
  55. package/build/utils/pom-scope/pom-files.js +32 -6
  56. package/build/utils/pom-scope/testIdDiscovery.d.ts +40 -0
  57. package/build/utils/pom-scope/testIdDiscovery.js +104 -0
  58. package/build/utils/reportVerification.d.ts +7 -2
  59. package/build/utils/reportVerification.js +9 -3
  60. package/build/utils/scenarioDrafting.js +7 -1
  61. package/build/utils/skyrampMdContent.d.ts +1 -1
  62. package/build/utils/skyrampMdContent.js +1 -1
  63. package/build/utils/urlPath.d.ts +37 -0
  64. package/build/utils/urlPath.js +55 -0
  65. package/build/utils/utils.d.ts +45 -0
  66. package/build/utils/utils.js +50 -0
  67. package/build/utils/versions.d.ts +3 -3
  68. package/build/utils/versions.js +1 -1
  69. package/build/utils/workspaceAuth.d.ts +15 -15
  70. package/build/utils/workspaceAuth.js +32 -17
  71. package/build/workspace/queryParamResolution.d.ts +93 -0
  72. package/build/workspace/queryParamResolution.js +201 -0
  73. package/build/workspace/workspace.d.ts +104 -0
  74. package/build/workspace/workspace.js +24 -0
  75. package/node_modules/playwright/ThirdPartyNotices.txt +319 -266
  76. package/node_modules/playwright/lib/dom-analyzer/blueprint.js +154 -27
  77. package/node_modules/playwright/lib/dom-analyzer/crawler.js +2 -2
  78. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +1 -1
  79. package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +6 -2
  80. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +3 -2
  81. package/node_modules/playwright/lib/mcp/skyramp/resultCode.js +4 -3
  82. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +15 -3
  83. package/node_modules/playwright/lib/mcp/skyramp/specImport.js +781 -0
  84. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +14 -3
  85. package/node_modules/playwright/lib/mcp/test/resultCode.test.js +2 -1
  86. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +1 -1
  87. package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +30 -0
  88. package/node_modules/playwright/lib/transform/babelBundleImpl.js +200 -199
  89. package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +3 -3
  90. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/.package-lock.json +3 -3
  91. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/adapter/aws-lambda/handler.js +16 -25
  92. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/adapter/bun/websocket.js +3 -1
  93. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/adapter/lambda-edge/handler.js +20 -4
  94. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/adapter/aws-lambda/handler.js +16 -25
  95. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/adapter/bun/websocket.js +3 -1
  96. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/adapter/lambda-edge/handler.js +20 -4
  97. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/client/client.js +10 -1
  98. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/client/utils.js +1 -1
  99. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/helper/css/common.js +3 -1
  100. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/helper/css/index.js +9 -1
  101. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/jsx/base.js +8 -14
  102. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/jsx/components.js +41 -21
  103. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/jsx/context.js +131 -5
  104. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/jsx/streaming.js +9 -7
  105. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/bearer-auth/index.js +1 -1
  106. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/cache/index.js +1 -1
  107. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/compress/index.js +2 -1
  108. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/cors/index.js +2 -5
  109. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/etag/index.js +2 -1
  110. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/language/language.js +10 -32
  111. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/method-override/index.js +5 -3
  112. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/serve-static/index.js +2 -2
  113. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/middleware/timing/timing.js +3 -1
  114. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/router/trie-router/node.js +9 -0
  115. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/utils/body.js +12 -4
  116. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/utils/buffer.js +2 -1
  117. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/utils/ipaddr.js +6 -1
  118. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/cjs/validator/validator.js +3 -3
  119. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/client/client.js +10 -1
  120. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/client/utils.js +1 -1
  121. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/helper/css/common.js +3 -1
  122. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/helper/css/index.js +9 -1
  123. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/jsx/base.js +15 -15
  124. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/jsx/components.js +42 -22
  125. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/jsx/context.js +129 -5
  126. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/jsx/streaming.js +10 -8
  127. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/bearer-auth/index.js +1 -1
  128. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/cache/index.js +1 -1
  129. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/compress/index.js +2 -1
  130. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/cors/index.js +2 -5
  131. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/etag/index.js +2 -1
  132. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/language/language.js +10 -32
  133. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/method-override/index.js +5 -3
  134. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/serve-static/index.js +2 -2
  135. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/middleware/timing/timing.js +3 -1
  136. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/router/trie-router/node.js +9 -0
  137. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/adapter/aws-lambda/handler.d.ts +1 -1
  138. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/adapter/lambda-edge/handler.d.ts +1 -1
  139. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/helper/websocket/index.d.ts +1 -1
  140. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/jsx/base.d.ts +1 -3
  141. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/jsx/context.d.ts +39 -0
  142. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/middleware/context-storage/index.d.ts +2 -2
  143. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/middleware/language/language.d.ts +18 -0
  144. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/utils/body.d.ts +1 -1
  145. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/types/utils/types.d.ts +1 -1
  146. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/utils/body.js +12 -4
  147. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/utils/buffer.js +2 -1
  148. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/utils/ipaddr.js +6 -1
  149. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/validator/validator.js +3 -3
  150. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/package.json +29 -22
  151. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/package-lock.json +3 -3
  152. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  153. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +5 -1
  154. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/specReader.js +781 -0
  155. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderApp.js +25 -6
  156. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/replayEngine.js +3 -1
  157. package/node_modules/playwright/node_modules/playwright-core/lib/utils.js +2 -0
  158. package/node_modules/playwright/node_modules/playwright-core/lib/vite/htmlReport/index.html +253 -27
  159. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-D0BjbCb7.js → codeMirrorModule-DtudTj_v.js} +1 -1
  160. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BpDwp16L.js +422 -0
  161. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  162. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-Bzd72-bG.js → codeMirrorModule-FNMuBzX1.js} +1 -1
  163. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Co9upU5h.js +1035 -0
  164. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.DXNIQ_dx.js +2 -0
  165. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  166. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CIKB3XSv.js +5 -0
  167. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  168. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  169. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  170. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +4 -0
  171. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/specReader.ts +1028 -0
  172. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderApp.ts +31 -8
  173. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/replayEngine.ts +1 -0
  174. package/node_modules/playwright/node_modules/playwright-core/src/utils.ts +1 -0
  175. package/node_modules/playwright/package.json +1 -1
  176. package/package.json +10 -6
  177. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/hono/dist/tsconfig.build.tsbuildinfo +0 -1
  178. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-lvTRGFx-.js +0 -193
  179. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-DzxTioTK.js +0 -809
  180. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.BGc30U3S.js +0 -2
  181. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.IaDrb29A.js +0 -5
@@ -6,6 +6,7 @@ import * as path from "path";
6
6
  import yaml from "js-yaml";
7
7
  import { simpleGit } from "simple-git";
8
8
  import { logger } from "../../utils/logger.js";
9
+ import { dualChannelResult } from "../../utils/utils.js";
9
10
  import { parseWorkspaceAuthType, getDefaultAuthHeader, WorkspaceAuthType, readWorkspaceConfigRaw } from "../../utils/workspaceAuth.js";
10
11
  import { AnalyticsService } from "../../services/AnalyticsService.js";
11
12
  import { makeProgressReporter } from "../../utils/progress.js";
@@ -367,6 +368,14 @@ These components have no DOM presence in the running app: no route, page, or oth
367
368
  - Log an \`issuesFound\` entry (high severity) naming the unintegrated file(s) above and stating that the changed code is unreachable in the running app.
368
369
  - Backend/other work in this PR is unaffected — proceed with it normally.`;
369
370
  }
371
+ /**
372
+ * Assemble the tool result. See `dualChannelResult` for why the payload is
373
+ * carried on both result channels.
374
+ */
375
+ export function buildAnalyzeChangesResult(parts) {
376
+ const executionPlan = `\`\`\`json\n${parts.structuredSummary}\n\`\`\`\n\n## UI Blueprint Capture — do this BEFORE writing UI recommendation reasoning\n${parts.uiInstructions}\n\n${parts.outputText}\n\n---\n\n## Pre-built Test Catalog — Fill in placeholders from source code, then display verbatim\n⚠️ Do NOT reformat, rename sections, or generate a new catalog. Replace \`<…from source>\` values, then show this output exactly as-is, grouped by test type.\n\n${parts.recommendationPrompt}`;
377
+ return dualChannelResult({ executionPlan });
378
+ }
370
379
  export function registerAnalyzeChangesTool(server) {
371
380
  server.registerTool(TOOL_NAME, {
372
381
  annotations: {
@@ -376,8 +385,14 @@ export function registerAnalyzeChangesTool(server) {
376
385
  openWorldHint: true, // may fetch PR comments from GitHub
377
386
  },
378
387
  description: `Scan repository API endpoints and discover existing tests — first step of the unified Test Health Analysis Flow. Returns a stateFile path and ranked test recommendations. Pass stateFile to skyramp_analyze_test_health and skyramp_actions.`,
379
- // TODO: Replace description-embedded output format with outputSchema structural
380
- // output schema reduces token usage vs natural language in description.
388
+ // SKYR-4182: the payload is returned via structuredContent, which requires a
389
+ // declared outputSchema. This also keeps the result out of the single-line
390
+ // `.json` spill shape that Read cannot paginate — see buildAnalyzeChangesResult.
391
+ outputSchema: {
392
+ executionPlan: z
393
+ .string()
394
+ .describe("Analysis summary, UI blueprint capture instructions, and the ranked Execution Plan. Read and follow this field."),
395
+ },
381
396
  inputSchema: analyzeChangesInputSchema,
382
397
  }, async (params, extra) => {
383
398
  let errorResult;
@@ -419,12 +434,9 @@ export function registerAnalyzeChangesTool(server) {
419
434
  logger.info("All user-changed files are non-application — skipping analysis", {
420
435
  changedFiles: filesToCheck,
421
436
  });
422
- return {
423
- content: [{
424
- type: "text",
425
- text: `All ${filesToCheck.length} changed file(s) are non-application (CI/CD, docs, lock files, config). No test analysis needed for this diff.\n\nChanged files: ${filesToCheck.join(", ")}`,
426
- }],
427
- };
437
+ return dualChannelResult({
438
+ executionPlan: `All ${filesToCheck.length} changed file(s) are non-application (CI/CD, docs, lock files, config). No test analysis needed for this diff.\n\nChanged files: ${filesToCheck.join(", ")}`,
439
+ });
428
440
  }
429
441
  }
430
442
  // ── Step 2: Scan endpoints ──
@@ -569,22 +581,17 @@ export function registerAnalyzeChangesTool(server) {
569
581
  logger.info("GraphQL-only diff detected — REST testing not supported", {
570
582
  changedFiles: diffData.changedFiles,
571
583
  });
572
- return {
573
- content: [{
574
- type: "text",
575
- text: [
576
- "**GraphQL-only diff detected.**",
577
- "",
578
- "The changed files appear to be GraphQL schema, artifact, or endpoint implementation files.",
579
- "Skyramp currently supports REST API testing only GraphQL introspection,",
580
- "query validation, and type-name grounding are not yet supported.",
581
- "",
582
- "No test recommendations can be generated for this diff.",
583
- "",
584
- `Changed files: ${diffData.changedFiles.join(", ")}`,
585
- ].join("\n"),
586
- }],
587
- };
584
+ return dualChannelResult({ executionPlan: [
585
+ "**GraphQL-only diff detected.**",
586
+ "",
587
+ "The changed files appear to be GraphQL schema, artifact, or endpoint implementation files.",
588
+ "Skyramp currently supports REST API testing only GraphQL introspection,",
589
+ "query validation, and type-name grounding are not yet supported.",
590
+ "",
591
+ "No test recommendations can be generated for this diff.",
592
+ "",
593
+ `Changed files: ${diffData.changedFiles.join(", ")}`,
594
+ ].join("\n") });
588
595
  }
589
596
  }
590
597
  await sendProgress(50, 100, "Discovering existing tests...");
@@ -1601,15 +1608,12 @@ export function registerAnalyzeChangesTool(server) {
1601
1608
  unmatchedFiles: classifiedEndpoints?.unmatchedFiles,
1602
1609
  nextTool: "skyramp_analyze_test_health",
1603
1610
  });
1604
- return {
1605
- content: [
1606
- {
1607
- type: "text",
1608
- text: `\`\`\`json\n${structuredSummary}\n\`\`\`\n\n## UI Blueprint Capture — do this BEFORE writing UI recommendation reasoning\n${uiInstructions}\n\n${outputText}\n\n---\n\n## Pre-built Test Catalog — Fill in placeholders from source code, then display verbatim\n⚠️ Do NOT reformat, rename sections, or generate a new catalog. Replace \`<…from source>\` values, then show this output exactly as-is, grouped by test type.\n\n${recommendationPrompt}`,
1609
- },
1610
- ],
1611
- isError: false,
1612
- };
1611
+ return buildAnalyzeChangesResult({
1612
+ structuredSummary,
1613
+ uiInstructions,
1614
+ outputText,
1615
+ recommendationPrompt,
1616
+ });
1613
1617
  }
1614
1618
  catch (error) {
1615
1619
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -62,7 +62,7 @@ export function registerAnalyzeTestHealthTool(server) {
62
62
  inputSchema: {
63
63
  stateFile: z
64
64
  .string()
65
- .describe("Path to the analyze-changes-state.json file returned by skyramp_analyze_changes (the stateFile field in its output). Do NOT pass summaryOutputFile or testbot-result.txt here."),
65
+ .describe("Path to the analyze-changes-state.json file returned by skyramp_analyze_changes (the stateFile field in its output). Do NOT pass testbot-result.txt here."),
66
66
  blueprintCaptured: z
67
67
  .boolean()
68
68
  .optional()
@@ -88,10 +88,10 @@ export function registerAnalyzeTestHealthTool(server) {
88
88
  return toolError(`State file is empty or invalid: ${args.stateFile}${args.repository ? ` (no section for repository ${args.repository})` : ""}. Call skyramp_analyze_changes first to generate a valid state file.`);
89
89
  }
90
90
  if (!repositoryPath || typeof repositoryPath !== "string") {
91
- return toolError(`repositoryPath not found in state file metadata. The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not summaryOutputFile or testbot-result.txt. Re-run skyramp_analyze_changes to regenerate it.`);
91
+ return toolError(`repositoryPath not found in state file metadata. The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not testbot-result.txt. Re-run skyramp_analyze_changes to regenerate it.`);
92
92
  }
93
93
  if (!Array.isArray(stateData.existingTests)) {
94
- return toolError(`stateFile does not contain test discovery data (existingTests missing). The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not summaryOutputFile or testbot-result.txt.`);
94
+ return toolError(`stateFile does not contain test discovery data (existingTests missing). The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not testbot-result.txt.`);
95
95
  }
96
96
  // External tests: UPDATE only — REGENERATE/DELETE are report-only (enforced in
97
97
  // skyramp_actions). Already scoped to relevant ones by discovery.
@@ -128,38 +128,57 @@ function toDraftedScenario(input) {
128
128
  testType: input.testType,
129
129
  };
130
130
  }
131
- /** Build the agent-submitted candidates, running discriminator verification
132
- * against the persisted diff text. Failed claims demote (never reject). */
133
- function buildAgentCandidates(candidates, diffText) {
134
- const demotions = [];
135
- const built = candidates.map((input) => {
131
+ /** Build the agent-submitted candidates. Discriminator claims are verified
132
+ * later, by {@link applyDiscriminatorClaims}, once the merge has settled which
133
+ * scenario each name resolves to. */
134
+ function buildAgentCandidates(candidates) {
135
+ return candidates.map((input) => {
136
136
  const scenario = toDraftedScenario(input);
137
- const candidateId = computeCandidateId(scenario);
138
- let verifiedDiscriminator;
139
- if (input.discriminator) {
140
- const claim = input.discriminator;
141
- const result = validateDiscriminator(scenario, claim, diffText);
142
- if (result.verified) {
143
- verifiedDiscriminator = claim.kind;
144
- }
145
- else {
146
- demotions.push({
147
- candidateId,
148
- reason: result.reason ?? `${claim.kind} discriminator claim could not be verified.`,
149
- });
150
- }
151
- }
152
- const candidate = {
137
+ return {
153
138
  scenario,
154
139
  priority: derivePriorityTier(scenario),
155
140
  novelty: Novelty.NEW,
156
141
  source: CandidateSource.AGENT,
157
- candidateId,
158
- ...(verifiedDiscriminator ? { verifiedDiscriminator } : {}),
142
+ candidateId: computeCandidateId(scenario),
159
143
  };
160
- return candidate;
161
144
  });
162
- return { candidates: built, demotions };
145
+ }
146
+ /**
147
+ * Verify each declared discriminator against the candidate that actually won
148
+ * the merge, and mark it. Failed claims demote (never reject).
149
+ *
150
+ * Runs AFTER the merge, not while building the agent's candidates: a server
151
+ * pre-seeded candidate wins the name, so verifying the agent's copy first
152
+ * attaches the result to a candidate that is about to be discarded — silently
153
+ * stripping the verification. Verification is against the WINNER's
154
+ * steps, so a claim that only held for the agent's rewrite is correctly refused
155
+ * rather than carried across.
156
+ *
157
+ * The marker is cleared before the current claim is applied. Each call replaces
158
+ * the agent's previous submission (see the `candidates` schema), and
159
+ * recoverServerCandidates restores whatever the LAST plan recorded — so keeping
160
+ * it would let a boost earned by an earlier registration outlive the claim that
161
+ * earned it, on a submission that no longer makes that claim or whose new claim
162
+ * fails.
163
+ */
164
+ function applyDiscriminatorClaims(candidates, claimsByKey, diffText, reposWithoutDiff = []) {
165
+ const demotions = [];
166
+ const marked = candidates.map((candidate) => {
167
+ const { verifiedDiscriminator: _stale, ...unmarked } = candidate;
168
+ const claim = claimsByKey.get(scenarioMergeKey(candidate.scenario.scenarioName) || candidate.candidateId);
169
+ if (!claim)
170
+ return unmarked;
171
+ const result = validateDiscriminator(candidate.scenario, claim, diffText, reposWithoutDiff);
172
+ if (result.verified) {
173
+ return { ...unmarked, verifiedDiscriminator: claim.kind };
174
+ }
175
+ demotions.push({
176
+ candidateId: candidate.candidateId,
177
+ reason: result.reason ?? `${claim.kind} discriminator claim could not be verified.`,
178
+ });
179
+ return unmarked;
180
+ });
181
+ return { candidates: marked, demotions };
163
182
  }
164
183
  /** Recover full Candidates for pre-seeded ("server") plan items by matching
165
184
  * candidateId back to the analyzed scenarios (repositoryAnalysis.scenarios) —
@@ -190,15 +209,49 @@ function recoverServerCandidates(priorPlan, allScenarios) {
190
209
  }
191
210
  return recovered;
192
211
  }
193
- function resolveBudgetContext(stateData) {
212
+ /** Whether a repo section's persisted diff classification contains any new,
213
+ * modified, or removed endpoint — the same signal `computeScoredCandidates`
214
+ * derives `hasApiChanges` from at analyze time. */
215
+ function sectionHasApiChanges(data) {
216
+ const diff = data?.repositoryAnalysis?.diff;
217
+ return ((diff?.newEndpoints?.length ?? 0) > 0 ||
218
+ (diff?.modifiedEndpoints?.length ?? 0) > 0 ||
219
+ (diff?.removedEndpoints?.length ?? 0) > 0);
220
+ }
221
+ function resolveBudgetContext(stateData, fullState) {
194
222
  const pbc = stateData.planBudgetContext;
195
- return {
223
+ const base = {
196
224
  maxGenerate: pbc?.maxGenerate ?? MAX_TESTS_TO_GENERATE,
197
225
  maxTotal: pbc?.maxTotal ?? MAX_RECOMMENDATIONS,
198
226
  isUIOnlyPR: pbc?.isUIOnlyPR ?? false,
199
227
  hasFrontendChanges: pbc?.hasFrontendChanges ?? false,
200
228
  externalCoverage: new Set(pbc?.externalCoverageKeys ?? []),
201
229
  };
230
+ const relatedSections = Object.values(fullState?.relatedRepos ?? {}).map((section) => section.data);
231
+ if (relatedSections.length === 0)
232
+ return base;
233
+ // SKYR-4204: isUIOnlyPR/hasFrontendChanges are per-SECTION facts — each
234
+ // analyze call computes them from its own repo's diff. The approved plan is
235
+ // one run-wide decision, so a frontend-only PRIMARY diff must not put the
236
+ // budgeter on its all-UI branch (backendGenerateCount() = 0) while a related
237
+ // repo carries endpoint changes; that pinned GENERATE to UI placeholders and
238
+ // left every API candidate in ADDITIONAL regardless of maxGenerate. Derive
239
+ // the flags across ALL sections. A related repo checked out on its default
240
+ // branch classifies no endpoint changes, so it leaves a UI-only run UI-only.
241
+ // Budget numbers come from the root (primary) section: the testbot prompt
242
+ // passes topN/maxGenerate only to the primary analyze call, so related
243
+ // sections carry defaults.
244
+ const sections = [fullState ?? undefined, ...relatedSections];
245
+ const hasFrontendChanges = sections.some((s) => s?.planBudgetContext?.hasFrontendChanges ?? false);
246
+ const hasApiChanges = sections.some(sectionHasApiChanges);
247
+ const rootPbc = fullState?.planBudgetContext;
248
+ return {
249
+ maxGenerate: rootPbc?.maxGenerate ?? base.maxGenerate,
250
+ maxTotal: rootPbc?.maxTotal ?? base.maxTotal,
251
+ isUIOnlyPR: hasFrontendChanges && !hasApiChanges,
252
+ hasFrontendChanges,
253
+ externalCoverage: base.externalCoverage,
254
+ };
202
255
  }
203
256
  function describeGenerationCall(item) {
204
257
  switch (item.testType) {
@@ -316,9 +369,27 @@ export function registerRegisterTestPlanTool(server) {
316
369
  errorResult = toolError(`State file is empty or invalid: ${params.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
317
370
  return errorResult;
318
371
  }
319
- const diffText = stateData.diffText ?? "";
372
+ // SKYR-4203: one register call carries candidates for EVERY repo of the
373
+ // run, but `stateData` is a single repo's section — and the candidate
374
+ // schema has no per-candidate repository, so a related repo's anchors
375
+ // were unverifiable by construction. Verify anchors (and rank diff-hunk
376
+ // proximity) against the union of ALL sections' diffs: an anchor
377
+ // grounded in a related repo's change is as real as one in the
378
+ // primary's. Sections with no diff (a related repo checked out on its
379
+ // default branch) are collected so an unmatched anchor reads as
380
+ // uncheckable rather than ungrounded.
381
+ const fullState = await stateManager.readFullState();
382
+ const diffSections = [
383
+ { repo: fullState?.metadata?.repository ?? "the primary repository", diff: fullState?.diffText },
384
+ ...Object.entries(fullState?.relatedRepos ?? {}).map(([repo, section]) => ({
385
+ repo,
386
+ diff: section.data?.diffText,
387
+ })),
388
+ ];
389
+ const diffText = diffSections.map((s) => s.diff).filter(Boolean).join("\n");
390
+ const reposWithoutDiff = diffSections.filter((s) => !s.diff).map((s) => s.repo);
320
391
  const allScenarios = stateData.repositoryAnalysis?.scenarios ?? [];
321
- const { candidates: agentCandidates, demotions } = buildAgentCandidates(params.candidates ?? [], diffText);
392
+ const agentCandidates = buildAgentCandidates(params.candidates ?? []);
322
393
  const serverCandidates = recoverServerCandidates(stateData.approvedPlan, allScenarios);
323
394
  // Merge by scenario-name identity, NOT the full content-hashed candidateId:
324
395
  // the server (analyze_changes) and the agent frequently draft their own
@@ -341,7 +412,18 @@ export function registerRegisterTestPlanTool(server) {
341
412
  merged.set(mergeKey(candidate), candidate);
342
413
  for (const candidate of serverCandidates)
343
414
  merged.set(mergeKey(candidate), candidate);
344
- const allCandidates = [...merged.values()];
415
+ // The agent declares a claim per scenario NAME, so key the claims the
416
+ // same way the merge does and verify against whichever copy survived.
417
+ const claimsByKey = new Map();
418
+ // Keyed through mergeKey, not scenarioMergeKey alone: a name that
419
+ // slugifies to nothing (e.g. an all-non-ASCII name) would be stored
420
+ // under "" while the lookup falls back to the candidateId, so the claim
421
+ // would be neither verified nor demoted.
422
+ (params.candidates ?? []).forEach((input, i) => {
423
+ if (input.discriminator)
424
+ claimsByKey.set(mergeKey(agentCandidates[i]), input.discriminator);
425
+ });
426
+ const { candidates: allCandidates, demotions } = applyDiscriminatorClaims([...merged.values()], claimsByKey, diffText, reposWithoutDiff);
345
427
  // An empty union would persist an authoritative plan with an empty
346
428
  // GENERATE list, which the generation gate then enforces — bricking
347
429
  // the run until re-registration. Reject instead so the agent can
@@ -351,7 +433,7 @@ export function registerRegisterTestPlanTool(server) {
351
433
  "Submit your complete candidate list (every test you would generate or recommend) in `candidates` — an empty plan would block all generation.");
352
434
  return errorResult;
353
435
  }
354
- const budgetContext = resolveBudgetContext(stateData);
436
+ const budgetContext = resolveBudgetContext(stateData, fullState);
355
437
  const result = selectPlan(allCandidates, { ...budgetContext, demotions, diffText });
356
438
  const approvedPlan = {
357
439
  planId: crypto.randomUUID(),
@@ -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;
@@ -26,6 +26,20 @@ export interface TestExecutionResult extends TestExecutionData {
26
26
  testFile: string;
27
27
  videoPath?: string;
28
28
  }
29
+ /**
30
+ * One browser execution's recorded video, as persisted in run state by
31
+ * skyramp_execute_test and read back by skyramp_submit_report (SKYR-4156).
32
+ *
33
+ * `status`/`executedAt` are not rendered in the report — they identify WHICH
34
+ * execution the referenced recording belongs to, which matters because a retried
35
+ * test leaves a video directory behind for every attempt.
36
+ */
37
+ export interface VideoRecord {
38
+ /** Host path to the recorded video.webm. */
39
+ videoPath: string;
40
+ status: TestExecutionStatus;
41
+ executedAt: string;
42
+ }
29
43
  export interface BatchExecutionResult {
30
44
  totalTests: number;
31
45
  passed: number;
@@ -216,8 +216,9 @@ export const baseTestSchema = {
216
216
  .string()
217
217
  .default("")
218
218
  .describe("MUST be string of comma separated values like 'id=1,name=John' for URL query parameters. "
219
- + "Workspace-configured api.defaultQueryParams (if set) are merged in automatically "
220
- + "no need to repeat them here. An explicit value for the same key here overrides the workspace default."),
219
+ + "Workspace-configured api.defaultQueryParams (if set) are merged in automatically, with any "
220
+ + "api.queryParamOverrides entry whose pathPattern matches this endpoint layered on top "
221
+ + "no need to repeat them here. An explicit value for the same key here overrides both."),
221
222
  formParams: z
222
223
  .string()
223
224
  .default("")
@@ -0,0 +1,34 @@
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
+ repositoryPath: string;
20
+ baseBranch?: string;
21
+ maxRecommendations?: number;
22
+ maxGenerate?: number;
23
+ /** Reserved — accepted for API compat but not yet wired into the prompt. */
24
+ maxCritical?: number;
25
+ prNumber?: number;
26
+ userPrompt?: string;
27
+ services?: Service[];
28
+ uiCredentials?: string;
29
+ testsRepoDir?: string;
30
+ relatedRepositories?: RelatedRepository[];
31
+ primaryRepo?: string;
32
+ planOnly?: boolean;
33
+ language?: string;
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -2,6 +2,15 @@ import type { DriftAction } from "./TestAnalysis.js";
2
2
  import type { TestExecutionStatus } from "./TestExecution.js";
3
3
  import type { HttpMethod, TestType } from "./TestTypes.js";
4
4
  import type { ReuseOutcome } from "./ReuseOutcome.js";
5
+ /** Classification of an issuesFound entry. Bug is a product/code defect; the
6
+ * other values are tooling noise that the Testbot report renders in its
7
+ * Configuration Errors section instead of Issues Found. */
8
+ export declare enum IssueFoundCategory {
9
+ Bug = "bug",
10
+ Lint = "lint",
11
+ Type = "type",
12
+ Config = "config"
13
+ }
5
14
  /**
6
15
  * Shape of the JSON report written by skyramp_submit_report and read by testbot
7
16
  * for rendering as Markdown. All fields mirror the corresponding Zod schemas in
@@ -69,6 +78,7 @@ export interface TestbotReport {
69
78
  issuesFound: {
70
79
  description: string;
71
80
  severity?: "critical" | "high" | "medium" | "low";
81
+ category: IssueFoundCategory;
72
82
  }[];
73
83
  nextSteps: string[];
74
84
  commitMessage: string;
@@ -1 +1,10 @@
1
- export {};
1
+ /** Classification of an issuesFound entry. Bug is a product/code defect; the
2
+ * other values are tooling noise that the Testbot report renders in its
3
+ * Configuration Errors section instead of Issues Found. */
4
+ export var IssueFoundCategory;
5
+ (function (IssueFoundCategory) {
6
+ IssueFoundCategory["Bug"] = "bug";
7
+ IssueFoundCategory["Lint"] = "lint";
8
+ IssueFoundCategory["Type"] = "type";
9
+ IssueFoundCategory["Config"] = "config";
10
+ })(IssueFoundCategory || (IssueFoundCategory = {}));
@@ -2,5 +2,7 @@ export { TestExecutionStatus } from "./TestExecution.js";
2
2
  export { DriftAction } from "./TestAnalysis.js";
3
3
  export { TestType, HttpMethod } from "./TestTypes.js";
4
4
  export type { TestbotReport } from "./TestbotReport.js";
5
+ export { IssueFoundCategory } from "./TestbotReport.js";
5
6
  export { ReuseDeclinedBy, ReuseVerificationOutcome } from "./ReuseOutcome.js";
6
7
  export type { ReuseOutcome, ReuseSkippedEntry } from "./ReuseOutcome.js";
8
+ export type { RelatedRepository, TestbotPromptOptions, } from "./TestbotPromptOptions.js";
@@ -1,4 +1,5 @@
1
1
  export { TestExecutionStatus } from "./TestExecution.js";
2
2
  export { DriftAction } from "./TestAnalysis.js";
3
3
  export { TestType, HttpMethod } from "./TestTypes.js";
4
+ export { IssueFoundCategory } from "./TestbotReport.js";
4
5
  export { ReuseDeclinedBy, ReuseVerificationOutcome } from "./ReuseOutcome.js";
@@ -7,13 +7,33 @@ import type { CandidateUiPage } from "./uiPageEnumerator.js";
7
7
  import type { FrontendFileIntegration } from "../types/FrontendIntegration.js";
8
8
  import type { ApprovedPlanItem } from "../types/Recommendation.js";
9
9
  import type { ExternalTestRunRecord } from "../types/ExternalTestExecution.js";
10
+ import type { VideoRecord } from "../types/TestExecution.js";
10
11
  export type { CandidateUiPage } from "./uiPageEnumerator.js";
11
12
  export declare function setTestsRepoDir(dir: string | undefined): void;
12
13
  export declare function getTestsRepoDir(): string | undefined;
13
- /** Filename of the run-scoped analysis state file under `<RUNNER_TEMP>/skyramp/`.
14
+ /** Filename of the run-scoped analysis state file under `runArtifactDir()`.
14
15
  * Single-sourced so the constructor that WRITES there and `resolveRunStatePath`
15
16
  * which LOOKS there cannot drift apart. */
16
17
  export declare const RUN_STATE_FILE_NAME = "analyze-changes-state.json";
18
+ /**
19
+ * Directory holding this run's Skyramp artifacts — the state file, and the report written
20
+ * beside it (SKYR-4147) — or undefined outside a run.
21
+ *
22
+ * `RUNNER_TEMP` is a fresh directory per CI job, and the caller and this server can each
23
+ * work it out from the environment on their own. So neither has to tell the other where
24
+ * the run's files live, and the path never passes through the model.
25
+ *
26
+ * The GitHub Action is the only caller customers run, and GitHub sets `RUNNER_TEMP` for
27
+ * it; the eval harness sets it itself. Nothing customers run is on another CI system, so
28
+ * the GitHub-specific name is only a naming problem — it does not break anything today.
29
+ * If a caller on another CI system is added, rename this then, or let it read that
30
+ * system's variable. Do not add a second variable for a caller that does not exist.
31
+ *
32
+ * Kept in one place for the same reason `RUN_STATE_FILE_NAME` is: the directory used to be
33
+ * written out at three call sites, so only the filename was safe from the three copies
34
+ * drifting apart. testbot keeps its matching copy in one place too, in `skyrampTempDir()`.
35
+ */
36
+ export declare function runArtifactDir(): string | undefined;
17
37
  export declare function setActiveRunStatePath(stateFilePath: string): void;
18
38
  export declare function getActiveRunStatePath(): string | undefined;
19
39
  /** Reset the run anchor — for tests, and as a safety hook between runs. */
@@ -25,7 +45,7 @@ export declare function clearActiveRunStatePath(): void;
25
45
  * `setActiveRunStatePath` is only called by skyramp_analyze_changes on multi-repo
26
46
  * runs (the ones carrying a `repository` param), but a single-repo Testbot run's
27
47
  * path is still deterministic in CI: the StateManager constructor above roots
28
- * "analysis" state under `$RUNNER_TEMP/skyramp/`. Falling back to that covers
48
+ * "analysis" state under `runArtifactDir()`. Falling back to that covers
29
49
  * single-repo runs without a new anchor mechanism. Standalone/IDE usage has no
30
50
  * RUNNER_TEMP and resolves to undefined — correct, since there is no run state.
31
51
  *
@@ -166,6 +186,20 @@ export interface UnifiedAnalysisState {
166
186
  * which computes every value in-process; skyramp_submit_report merges it into
167
187
  * the report's `reuse` field. Never supplied by the LLM. */
168
188
  reuseOutcomes?: Record<string, ReuseRecord>;
189
+ /**
190
+ * SKYR-4156. Recorded video per executed browser test, keyed by test-file
191
+ * BASENAME (the same key `reuseOutcomes` uses, so matching needs no path
192
+ * normalization). Written in-process by skyramp_execute_test — the only place
193
+ * the path is known — and merged into the report's `testResults[].videoPath` by
194
+ * skyramp_submit_report. Never supplied by the LLM.
195
+ *
196
+ * Deliberately separate from `existingTests[].executionBefore/After`: those only
197
+ * exist for tests skyramp_analyze_changes discovered, so a NEWLY generated spec
198
+ * has nowhere to record into — which is exactly how one run recorded three videos
199
+ * and referenced none of them. Last write wins, so a retried execution replaces
200
+ * the earlier attempt's now-stale directory.
201
+ */
202
+ executionVideos?: Record<string, VideoRecord>;
169
203
  }
170
204
  /**
171
205
  * State file metadata
@@ -35,10 +35,32 @@ export function getTestsRepoDir() {
35
35
  * Single-repo / standalone calls (no `repository`) never set or read this.
36
36
  */
37
37
  let _activeRunStatePath;
38
- /** Filename of the run-scoped analysis state file under `<RUNNER_TEMP>/skyramp/`.
38
+ /** Filename of the run-scoped analysis state file under `runArtifactDir()`.
39
39
  * Single-sourced so the constructor that WRITES there and `resolveRunStatePath`
40
40
  * which LOOKS there cannot drift apart. */
41
41
  export const RUN_STATE_FILE_NAME = "analyze-changes-state.json";
42
+ /**
43
+ * Directory holding this run's Skyramp artifacts — the state file, and the report written
44
+ * beside it (SKYR-4147) — or undefined outside a run.
45
+ *
46
+ * `RUNNER_TEMP` is a fresh directory per CI job, and the caller and this server can each
47
+ * work it out from the environment on their own. So neither has to tell the other where
48
+ * the run's files live, and the path never passes through the model.
49
+ *
50
+ * The GitHub Action is the only caller customers run, and GitHub sets `RUNNER_TEMP` for
51
+ * it; the eval harness sets it itself. Nothing customers run is on another CI system, so
52
+ * the GitHub-specific name is only a naming problem — it does not break anything today.
53
+ * If a caller on another CI system is added, rename this then, or let it read that
54
+ * system's variable. Do not add a second variable for a caller that does not exist.
55
+ *
56
+ * Kept in one place for the same reason `RUN_STATE_FILE_NAME` is: the directory used to be
57
+ * written out at three call sites, so only the filename was safe from the three copies
58
+ * drifting apart. testbot keeps its matching copy in one place too, in `skyrampTempDir()`.
59
+ */
60
+ export function runArtifactDir() {
61
+ const runnerTemp = process.env.RUNNER_TEMP?.trim();
62
+ return runnerTemp ? path.join(runnerTemp, "skyramp") : undefined;
63
+ }
42
64
  export function setActiveRunStatePath(stateFilePath) {
43
65
  _activeRunStatePath = stateFilePath;
44
66
  }
@@ -56,7 +78,7 @@ export function clearActiveRunStatePath() {
56
78
  * `setActiveRunStatePath` is only called by skyramp_analyze_changes on multi-repo
57
79
  * runs (the ones carrying a `repository` param), but a single-repo Testbot run's
58
80
  * path is still deterministic in CI: the StateManager constructor above roots
59
- * "analysis" state under `$RUNNER_TEMP/skyramp/`. Falling back to that covers
81
+ * "analysis" state under `runArtifactDir()`. Falling back to that covers
60
82
  * single-repo runs without a new anchor mechanism. Standalone/IDE usage has no
61
83
  * RUNNER_TEMP and resolves to undefined — correct, since there is no run state.
62
84
  *
@@ -70,8 +92,9 @@ export function resolveRunStatePath(explicitPath) {
70
92
  const anchored = getActiveRunStatePath();
71
93
  if (anchored)
72
94
  return anchored;
73
- if (process.env.RUNNER_TEMP) {
74
- return path.join(process.env.RUNNER_TEMP, "skyramp", RUN_STATE_FILE_NAME);
95
+ const runDir = runArtifactDir();
96
+ if (runDir) {
97
+ return path.join(runDir, RUN_STATE_FILE_NAME);
75
98
  }
76
99
  return undefined;
77
100
  }
@@ -169,17 +192,15 @@ export class StateManager {
169
192
  constructor(stateType = "analysis", sessionId, stateDir, stateFilePath) {
170
193
  this.stateType = stateType;
171
194
  this.sessionId = sessionId || crypto.randomUUID();
195
+ const runDir = stateType === "analysis" ? runArtifactDir() : undefined;
172
196
  if (stateFilePath) {
173
197
  this.stateFile = stateFilePath;
174
198
  }
175
- else if (stateType === "analysis" && process.env.RUNNER_TEMP) {
176
- // In CI (testbot action), RUNNER_TEMP is a job-isolated dir that both the
177
- // action and this tool can independently derive — no LLM input needed.
178
- // TODO: interim step remove this branch when stateFile and summaryOutputFile are merged
179
- // (see getTestbotPrompt TODO). RUNNER_TEMP is GitHub Actions-specific; other CI systems
180
- // (Jenkins, GitLab, Buildkite) must set it explicitly. Once merged, path flows through
181
- // summaryOutputFile which is already testbot-controlled and CI-agnostic.
182
- this.stateFile = path.join(process.env.RUNNER_TEMP, "skyramp", RUN_STATE_FILE_NAME);
199
+ else if (runDir) {
200
+ // In CI, the run directory is fresh per job and the caller and this tool each work
201
+ // it out from the environment — no LLM input needed. It is also where
202
+ // skyramp_submit_report writes the report (SKYR-4147).
203
+ this.stateFile = path.join(runDir, RUN_STATE_FILE_NAME);
183
204
  }
184
205
  else {
185
206
  const baseDir = stateDir || os.tmpdir();
@@ -435,7 +456,7 @@ export class StateManager {
435
456
  * @returns Number of files deleted
436
457
  */
437
458
  static async cleanupOldFiles(maxAgeHours = 24, stateDir, stateTypes) {
438
- const baseDir = stateDir || (process.env.RUNNER_TEMP ? path.join(process.env.RUNNER_TEMP, "skyramp") : os.tmpdir());
459
+ const baseDir = stateDir || runArtifactDir() || os.tmpdir();
439
460
  const files = await fs.promises.readdir(baseDir).catch(() => []);
440
461
  const statePrefixes = stateTypes
441
462
  ? stateTypes.map((t) => STATE_FILE_PREFIXES[t])
@@ -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;