@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
@@ -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;
@@ -9,6 +9,8 @@ import { logger } from "../utils/logger.js";
9
9
  import { TestExecutionStatus, } from "../types/TestExecution.js";
10
10
  import { TestType } from "../types/TestTypes.js";
11
11
  import { buildContainerEnv } from "./containerEnv.js";
12
+ import { getWorkspaceScopedQueryParams } from "../utils/workspaceAuth.js";
13
+ import { extractEnvVarNames } from "../workspace/queryParamResolution.js";
12
14
  import { EXECUTOR_DOCKER_IMAGE } from "../utils/versions.js";
13
15
  import { walkDir } from "../utils/fileWalk.js";
14
16
  export { EXECUTOR_DOCKER_IMAGE };
@@ -54,8 +56,11 @@ export const PLAYWRIGHT_CONFIG_FILES = [
54
56
  "playwright.config.js",
55
57
  "playwright.config.mjs",
56
58
  ];
57
- // Directories to skip mounting entirely (cannot bind-mount /dev/null to a directory)
58
- export const EXCLUDED_MOUNT_ITEMS = [
59
+ // Directories the recursive shadow scans must never descend into. node_modules
60
+ // IS mounted (see mountRepoTree) — but walking it would shadow every package's
61
+ // own package.json with empty JSON via MOUNT_NULL_ITEMS, breaking resolution
62
+ // for the whole tree.
63
+ export const MOUNT_SCAN_SKIP_DIRS = [
59
64
  "node_modules",
60
65
  ];
61
66
  // Reserved container subdir under /home/user where a cross-repo test repository
@@ -354,7 +359,7 @@ export function detectSessionFiles(testFilePath) {
354
359
  */
355
360
  export function findExcludedPaths(dir, excludedItems) {
356
361
  const results = [];
357
- const shouldSkipDir = (entry) => excludedItems.includes(entry.name) || EXCLUDED_MOUNT_ITEMS.includes(entry.name);
362
+ const shouldSkipDir = (entry) => excludedItems.includes(entry.name) || MOUNT_SCAN_SKIP_DIRS.includes(entry.name);
358
363
  for (const [entry, fullPath] of walkDir(dir, { shouldSkipDir })) {
359
364
  // Only shadow files — mounting /dev/null to a directory target causes Docker errors
360
365
  if (excludedItems.includes(entry.name)) {
@@ -364,18 +369,23 @@ export function findExcludedPaths(dir, excludedItems) {
364
369
  return results;
365
370
  }
366
371
  /**
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.
372
+ * Find every `node_modules` directory under `dir`, including the top-level
373
+ * `<dir>/node_modules`. Never descends into a `node_modules`. Used to shadow
374
+ * SHADOWED_NESTED_PACKAGES inside each one: a repo-local `@playwright/test`
375
+ * becomes a second physical copy and trips Playwright's "Requiring
376
+ * @playwright/test second time" guard.
377
+ *
378
+ * The top-level one is deliberately NOT special-cased (SKYR-4140). When
379
+ * `workspacePath` is itself an npm package root — the standard `tests/<suite>/`
380
+ * layout — its node_modules IS the only one, so skipping it left the shadow
381
+ * machinery with nothing to act on and no customer dependency resolvable.
382
+ *
373
383
  * Skips `.git` (nothing importable lives there) and does not follow symlinks —
374
384
  * by design (a symlinked nested node_modules either dangles in-container,
375
385
  * falling through to NODE_PATH's global copy, or its real-path target is
376
386
  * walked directly).
377
387
  */
378
- export function findNestedNodeModules(dir) {
388
+ export function findNodeModulesDirs(dir) {
379
389
  const results = [];
380
390
  const walk = (current) => {
381
391
  let entries;
@@ -392,8 +402,7 @@ export function findNestedNodeModules(dir) {
392
402
  continue;
393
403
  const full = path.join(current, entry.name);
394
404
  if (entry.name === "node_modules") {
395
- if (current !== dir)
396
- results.push(full); // skip the top-level node_modules
405
+ results.push(full);
397
406
  continue; // never descend into a node_modules
398
407
  }
399
408
  walk(full);
@@ -593,17 +602,16 @@ export class TestExecutionService {
593
602
  hostConfig.Mounts.push(mount);
594
603
  };
595
604
  // Mount one repo tree (the workspace, or a cross-repo test repository) into
596
- // the container, skipping EXCLUDED_MOUNT_ITEMS completely.
605
+ // the container.
597
606
  //
598
607
  // Each top-level entry is bind-mounted at the canonical container path and —
599
608
  // when mirrorToHost is set — at its host-absolute path too. The dual mount
600
609
  // lets the test resolve any absolute reference the codegen happens to embed
601
610
  // (storageState, fixture paths, snapshots) — including the host workspace
602
611
  // 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
612
+ // `__dirname` — without needing source-code detection. MOUNT_NULL_ITEMS
613
+ // shadows (package.json empty JSON, etc.), node_modules package shadows,
614
+ // and PLAYWRIGHT_CONFIG_FILES shadows (minimal config) are applied at both
607
615
  // targets too so the protections survive regardless of which path the test
608
616
  // resolves to.
609
617
  //
@@ -611,7 +619,7 @@ export class TestExecutionService {
611
619
  // later mounts over earlier ones, so the top-level entry mounts must be
612
620
  // pushed BEFORE the shadow mounts that override paths inside them.
613
621
  //
614
- // The three recursive scans (findExcludedPaths × 2 + findNestedNodeModules)
622
+ // The three recursive scans (findExcludedPaths × 2 + findNodeModulesDirs)
615
623
  // are deliberately kept as separate walks rather than fused into one pass:
616
624
  // each skips node_modules/.git so the cost is small, and separate walks keep
617
625
  // each shadow category's semantics independently testable.
@@ -623,11 +631,13 @@ export class TestExecutionService {
623
631
  // otherwise the entry mount would win the dedup race and the repo's own
624
632
  // config (which may import dotenv, or simply isn't the minimal/generated
625
633
  // one) would leak into the container.
634
+ //
635
+ // node_modules is NOT filtered out (SKYR-4140): it carries the customer's
636
+ // test-support deps, and the per-package shadows below neutralise the only
637
+ // copies that must not load from the repo.
626
638
  const entriesToMount = fs
627
639
  .readdirSync(hostRoot)
628
- .filter((file) => !EXCLUDED_MOUNT_ITEMS.includes(file) &&
629
- !MOUNT_NULL_ITEMS.includes(file) &&
630
- !PLAYWRIGHT_CONFIG_FILES.includes(file));
640
+ .filter((file) => !MOUNT_NULL_ITEMS.includes(file) && !PLAYWRIGHT_CONFIG_FILES.includes(file));
631
641
  for (const file of entriesToMount) {
632
642
  const source = path.join(hostRoot, file);
633
643
  pushMount({ Type: "bind", Target: path.join(containerRoot, file), Source: source });
@@ -644,10 +654,9 @@ export class TestExecutionService {
644
654
  pushMount({ Type: "bind", Source: source, Target: absolutePath });
645
655
  }
646
656
  }
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)) {
657
+ // Shadow SHADOWED_NESTED_PACKAGES inside every mounted node_modules the
658
+ // repo's own top-level one included (see findNodeModulesDirs).
659
+ for (const nested of findNodeModulesDirs(hostRoot)) {
651
660
  for (const pkg of SHADOWED_NESTED_PACKAGES) {
652
661
  const pkgPath = path.join(nested, pkg);
653
662
  if (!fs.existsSync(pkgPath))
@@ -711,6 +720,22 @@ export class TestExecutionService {
711
720
  if (crossRepoTestRoot) {
712
721
  mountRepoTree(crossRepoTestRoot, path.join(containerMountPath, TEST_REPO_CONTAINER_SUBDIR), true);
713
722
  }
723
+ // runner.sh invokes `npx playwright test`, and npx resolves binaries from
724
+ // <cwd>/node_modules/.bin before PATH — cwd is always containerMountPath. Now
725
+ // that node_modules is mounted (SKYR-4140), a repo-local .bin/playwright would
726
+ // run instead of the image's global CLI. The per-package shadows above do not
727
+ // cover it under a pnpm layout, where .bin/playwright symlinks into .pnpm/
728
+ // rather than into node_modules/playwright. Shadow this one .bin — the only
729
+ // one npx can reach — so binary resolution falls through to PATH as before.
730
+ // Pushed after mountRepoTree so it layers over the node_modules entry mount.
731
+ if (fs.existsSync(path.join(workspacePath, "node_modules", ".bin"))) {
732
+ pushMount({
733
+ Type: "bind",
734
+ Source: EMPTY_DIR_PATH,
735
+ Target: path.join(containerMountPath, "node_modules", ".bin"),
736
+ ReadOnly: true,
737
+ });
738
+ }
714
739
  // Mount video output directory for browser tests (writable)
715
740
  if (browserTest && videoHostDir) {
716
741
  pushMount({
@@ -794,7 +819,29 @@ export class TestExecutionService {
794
819
  mountedPaths.add(saveStorageTarget);
795
820
  }
796
821
  }
797
- const env = buildContainerEnv(options, saveStorageTargetPath);
822
+ // Forward the environment variables this workspace's query params declare
823
+ // via `env.VAR` (SKYR-4127). The references resolve where the test runs, so
824
+ // without this they resolve to nothing inside the container and the query
825
+ // param is dropped from the request silently.
826
+ //
827
+ // getWorkspaceScopedQueryParams already returns undefined on a read or
828
+ // parse failure, so those degrade quietly to the pre-existing behavior.
829
+ // This guard covers only an unexpected throw, and must not fail an
830
+ // otherwise valid execution.
831
+ let passthroughNames = [];
832
+ try {
833
+ const scoped = await getWorkspaceScopedQueryParams(workspacePath);
834
+ if (scoped) {
835
+ passthroughNames = extractEnvVarNames(scoped);
836
+ if (passthroughNames.length > 0) {
837
+ logger.debug(`Forwarding workspace-declared environment variables to the executor: ${passthroughNames.join(", ")}`);
838
+ }
839
+ }
840
+ }
841
+ catch (err) {
842
+ logger.warning(`Could not resolve workspace environment passthrough: ${err instanceof Error ? err.message : String(err)}`);
843
+ }
844
+ const env = buildContainerEnv(options, saveStorageTargetPath, process.env, passthroughNames);
798
845
  // Capture output
799
846
  let output = "";
800
847
  class DockerStream extends Writable {
@@ -2,7 +2,8 @@ import path from "path";
2
2
  import fs from "fs";
3
3
  import { SkyrampClient } from "@skyramp/skyramp";
4
4
  import { analyzeOpenAPIWithGivenEndpoint } from "../utils/analyze-openapi.js";
5
- import { isAuthorizationHeaderName, KNOWN_AUTH_HEADERS, resolveAuthFromWorkspace, getWorkspaceSkipTLSVerify, getWorkspaceDefaultQueryParams, mergeQueryParamsString, } from "../utils/workspaceAuth.js";
5
+ import { isAuthorizationHeaderName, KNOWN_AUTH_HEADERS, resolveAuthFromWorkspace, getWorkspaceSkipTLSVerify, getWorkspaceScopedQueryParams, mergeQueryParamsString, } from "../utils/workspaceAuth.js";
6
+ import { extractPathname, resolveQueryParamsForPath, contributesNothing } from "../workspace/queryParamResolution.js";
6
7
  import { getPathParameterValidationError, OUTPUT_DIR_FIELD_NAME, PATH_PARAMS_FIELD_NAME, QUERY_PARAMS_FIELD_NAME, FORM_PARAMS_FIELD_NAME, validateParams, validatePath, validateRequestData, } from "../utils/utils.js";
7
8
  import { getEntryPoint } from "../utils/telemetry.js";
8
9
  import { getLanguageSteps } from "../utils/language-helper.js";
@@ -364,17 +365,31 @@ The generated test file remains unchanged and ready to use as-is.
364
365
  logger.warning("Could not resolve skipTLSVerify from workspace config");
365
366
  }
366
367
  }
367
- // Workspace-declared default query params (SKYR-4050): api.defaultQueryParams
368
- // are attached to every generated request for the service. Existing
369
- // queryParams entries (explicit caller values) always win on a key collision.
368
+ // Workspace-declared query params (SKYR-4050, path-scoped in SKYR-4127):
369
+ // api.defaultQueryParams is the base map; api.queryParamOverrides layers
370
+ // the most-specific matching pathPattern on top. Existing queryParams
371
+ // entries (explicit caller values) always win on a key collision.
370
372
  if (!SKIP_DEFAULT_QUERY_PARAMS_TEST_TYPES.has(this.getTestType())) {
371
373
  try {
372
374
  const repoPath = generateOptions.outputDir || process.cwd();
373
- const defaults = await getWorkspaceDefaultQueryParams(repoPath);
374
- if (defaults) {
375
- generateOptions.queryParams = mergeQueryParamsString(generateOptions.queryParams, defaults);
376
- logger.info("Merged workspace defaultQueryParams into queryParams", {
377
- keys: Object.keys(defaults),
375
+ const scoped = await getWorkspaceScopedQueryParams(repoPath);
376
+ // Skip the merge entirely when the resolved config contributes
377
+ // nothing otherwise an empty/null defaultQueryParams declaration
378
+ // with no overrides would set queryParams to "" where the old code
379
+ // left the field untouched (Finding 4, SKYR-4127 final review).
380
+ if (scoped && !contributesNothing(scoped)) {
381
+ const pathname = extractPathname(generateOptions.uri);
382
+ if (!pathname && scoped.overrides.length > 0) {
383
+ logger.warning("queryParamOverrides configured but this generation call has no single endpoint path; " +
384
+ "applying api.defaultQueryParams only", { patterns: scoped.overrides.map((o) => o.pathPattern) });
385
+ }
386
+ const effective = pathname
387
+ ? resolveQueryParamsForPath(scoped, pathname)
388
+ : { ...scoped.base };
389
+ generateOptions.queryParams = mergeQueryParamsString(generateOptions.queryParams, effective);
390
+ logger.info("Merged workspace query params into queryParams", {
391
+ keys: Object.keys(effective),
392
+ pathname,
378
393
  });
379
394
  }
380
395
  }
@@ -8,7 +8,18 @@ import { TestExecutionOptions } from "../types/TestExecution.js";
8
8
  * host.docker.internal is mapped via ExtraHosts in TestExecutionService.
9
9
  */
10
10
  export declare function rewriteLocalhostForDocker(url: string): string;
11
+ /** Why a name is refused, or null when it is allowed. The two reasons need
12
+ * different messages: a reserved name is already provided by the executor, so
13
+ * the declaration is only redundant, while a credential name is refused. */
14
+ export declare function passthroughDenyReason(name: string): "reserved" | "credential" | null;
15
+ /** True when a name is refused regardless of who declared it. */
16
+ export declare function isDeniedPassthroughName(name: string): boolean;
17
+ /** A name is interpolated directly into a Docker `Env` entry, which is a plain
18
+ * `NAME=value` string. A name holding `=`, whitespace or a newline produces a
19
+ * malformed entry and can inject a second variable. workspace.yml supplies
20
+ * these names, so `env.FOO=BAR` would yield the name `FOO=BAR`. */
21
+ export declare function isValidEnvVarName(name: string): boolean;
11
22
  /**
12
23
  * Build the environment variable array for the Docker executor container.
13
24
  */
14
- export declare function buildContainerEnv(options: Pick<TestExecutionOptions, "token" | "language" | "useHostNetwork">, saveStoragePath?: string, hostEnv?: Record<string, string | undefined>): string[];
25
+ export declare function buildContainerEnv(options: Pick<TestExecutionOptions, "token" | "language" | "useHostNetwork">, saveStoragePath?: string, hostEnv?: Record<string, string | undefined>, passthroughNames?: string[]): string[];
@@ -1,3 +1,4 @@
1
+ import { logger } from "../utils/logger.js";
1
2
  /**
2
3
  * Rewrite localhost / 127.0.0.1 URLs to host.docker.internal so the Docker
3
4
  * executor container can reach services running on the host machine.
@@ -9,10 +10,67 @@
9
10
  export function rewriteLocalhostForDocker(url) {
10
11
  return url.replace(/^(https?:\/\/)(localhost|127\.0\.0\.1)([:/?#]|$)/, (_, scheme, _host, rest) => `${scheme}host.docker.internal${rest}`);
11
12
  }
13
+ /** Names a workspace declaration may never forward. Matching is
14
+ * case-insensitive: `process.env` is case-insensitive on Windows, so a
15
+ * lower-case declaration there returns the real value and would otherwise
16
+ * pass the checks below.
17
+ *
18
+ * Two distinct concerns share one list. The first is CI identity: testbot puts
19
+ * the workflow's GitHub token in the MCP process env so the agent's `gh` calls
20
+ * authenticate, and a declaration naming it would push it into the container
21
+ * and from there into a query string — where the SUT's own access log records
22
+ * it. The second is internal signals the executor sets for itself, which a
23
+ * declaration must not be able to spoof or override.
24
+ *
25
+ * Deliberately NOT a blanket `*_TOKEN`: a token for the customer's own SUT is
26
+ * exactly the kind of value an RBAC query param carries, and refusing it would
27
+ * break the feature this list exists to protect. */
28
+ // Names the executor sets for itself. API_KEY carries the Skyramp LLM key and
29
+ // is forwarded unconditionally by the built-in block below, so a declaration
30
+ // adds nothing. UI Smart LLM features keep working either way.
31
+ const PASSTHROUGH_RESERVED_EXACT = new Set([
32
+ "API_KEY",
33
+ "PYTEST_ADDOPTS",
34
+ "PLAYWRIGHT_SAVE_STORAGE_PATH",
35
+ ]);
36
+ const PASSTHROUGH_RESERVED_PREFIXES = ["SKYRAMP_"];
37
+ // CI identity and secret-shaped names. Testbot puts the workflow GitHub token
38
+ // in the MCP process env, and a declaration would send it to the container and
39
+ // into a query string, where the SUT access log records it.
40
+ const PASSTHROUGH_CREDENTIAL_EXACT = new Set(["GITHUB_TOKEN", "GH_TOKEN"]);
41
+ const PASSTHROUGH_CREDENTIAL_PREFIXES = ["ACTIONS_", "AWS_"];
42
+ const PASSTHROUGH_CREDENTIAL_SUBSTRINGS = ["_SECRET", "_PRIVATE_KEY"];
43
+ /** Why a name is refused, or null when it is allowed. The two reasons need
44
+ * different messages: a reserved name is already provided by the executor, so
45
+ * the declaration is only redundant, while a credential name is refused. */
46
+ export function passthroughDenyReason(name) {
47
+ const upper = name.toUpperCase();
48
+ if (PASSTHROUGH_CREDENTIAL_EXACT.has(upper) ||
49
+ PASSTHROUGH_CREDENTIAL_PREFIXES.some((p) => upper.startsWith(p)) ||
50
+ PASSTHROUGH_CREDENTIAL_SUBSTRINGS.some((s) => upper.includes(s))) {
51
+ return "credential";
52
+ }
53
+ if (PASSTHROUGH_RESERVED_EXACT.has(upper) ||
54
+ PASSTHROUGH_RESERVED_PREFIXES.some((p) => upper.startsWith(p))) {
55
+ return "reserved";
56
+ }
57
+ return null;
58
+ }
59
+ /** True when a name is refused regardless of who declared it. */
60
+ export function isDeniedPassthroughName(name) {
61
+ return passthroughDenyReason(name) !== null;
62
+ }
63
+ /** A name is interpolated directly into a Docker `Env` entry, which is a plain
64
+ * `NAME=value` string. A name holding `=`, whitespace or a newline produces a
65
+ * malformed entry and can inject a second variable. workspace.yml supplies
66
+ * these names, so `env.FOO=BAR` would yield the name `FOO=BAR`. */
67
+ export function isValidEnvVarName(name) {
68
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
69
+ }
12
70
  /**
13
71
  * Build the environment variable array for the Docker executor container.
14
72
  */
15
- export function buildContainerEnv(options, saveStoragePath, hostEnv = process.env) {
73
+ export function buildContainerEnv(options, saveStoragePath, hostEnv = process.env, passthroughNames = []) {
16
74
  const env = [
17
75
  // Omit entirely when empty so os.getenv() returns None in the test —
18
76
  // unauthenticated endpoints won't send an empty auth header (E7).
@@ -44,6 +102,65 @@ export function buildContainerEnv(options, saveStoragePath, hostEnv = process.en
44
102
  env.push(`${key}=${shouldRewrite ? rewriteLocalhostForDocker(val) : val}`);
45
103
  }
46
104
  }
105
+ // Credentials that generated UI tests read from the environment instead of
106
+ // carrying as literals (SKYR-3862). Without this the container never sees
107
+ // them and every generated login fails its guard.
108
+ //
109
+ // Forwarded on presence, not truthiness: the generated guard deliberately
110
+ // distinguishes an unset variable (a misconfiguration, fails fast) from one
111
+ // set to "" (valid on forms that accept a blank password). A truthy check —
112
+ // as used for the URL vars above — would drop the empty case and collapse it
113
+ // into the failure case.
114
+ //
115
+ // TODO(SKYR-4110): only ONE credential pair is forwarded, because testbot
116
+ // exports only the first credential from its `uiCredentials` input. That input
117
+ // was designed to carry several, so the agent can pick per test case by
118
+ // authorization role — so a multi-role suite can get a credential its test did
119
+ // not expect, surfacing as a false 403. The fix is a keyed form
120
+ // (SKYRAMP_UI_PASSWORD_<KEY>), which needs no new forwarding logic here: the
121
+ // loop above already forwards SKYRAMP_TEST_SERVICE_URL_* by prefix, so the
122
+ // same approach applies. Keep the presence-not-truthiness rule when it lands.
123
+ for (const key of ["SKYRAMP_UI_USERNAME", "SKYRAMP_UI_PASSWORD"]) {
124
+ const val = hostEnv[key];
125
+ if (val !== undefined) {
126
+ env.push(`${key}=${val}`);
127
+ }
128
+ }
129
+ // Environment variables the workspace's query params declare via `env.VAR`
130
+ // (SKYR-4127). These references resolve where the test RUNS — inside this
131
+ // container — so a name that does not reach it resolves to nothing, and an
132
+ // unresolved query param is dropped from the request silently, yielding a
133
+ // passing test that exercised the wrong identity.
134
+ //
135
+ // The list comes from what workspace.yml declared, never from enumerating the
136
+ // ambient environment: nothing is forwarded that the workspace did not name.
137
+ // Forwarded on presence, not truthiness, for the same reason as the
138
+ // credentials above — an empty value is a legitimate declaration.
139
+ const seen = new Set();
140
+ for (const name of passthroughNames) {
141
+ if (seen.has(name))
142
+ continue;
143
+ seen.add(name);
144
+ if (!isValidEnvVarName(name)) {
145
+ logger.warning(`Ignoring ${JSON.stringify(name)} declared in workspace.yml: it is not a valid environment variable name.`);
146
+ continue;
147
+ }
148
+ const denyReason = passthroughDenyReason(name);
149
+ if (denyReason === "reserved") {
150
+ logger.warning(`Ignoring the workspace.yml declaration of ${name}: the executor sets this variable itself. ` +
151
+ `Its value still reaches the test.`);
152
+ continue;
153
+ }
154
+ if (denyReason === "credential") {
155
+ logger.warning(`Refusing to forward ${name} to the test executor: the name is credential-shaped. ` +
156
+ `Declare a variable that carries only the value the request needs.`);
157
+ continue;
158
+ }
159
+ const val = hostEnv[name];
160
+ if (val !== undefined) {
161
+ env.push(`${name}=${val}`);
162
+ }
163
+ }
47
164
  if (hostEnv.SKYRAMP_DEBUG) {
48
165
  env.push(`SKYRAMP_DEBUG=${hostEnv.SKYRAMP_DEBUG}`);
49
166
  }
@@ -10,4 +10,13 @@ export type ContractExecutionMode = (typeof CONTRACT_EXECUTION_MODES)[number];
10
10
  */
11
11
  export declare function resolveEffectiveToken(unauthenticated?: boolean, paramToken?: string, envToken?: string): string;
12
12
  export declare function shouldInjectSkyrampBaseUrl(testType: TestType, contractMode?: ContractExecutionMode): boolean;
13
+ /**
14
+ * Append the recorded video path to execution output.
15
+ *
16
+ * Applied to the failure return as well as the success one (SKYR-4156): a video is
17
+ * most useful for a test that just failed, and surfacing it only on success meant a
18
+ * failing UI test reported no recording at all unless Playwright happened to print
19
+ * its own attachment line.
20
+ */
21
+ export declare function withVideoInfo(output: string, videoPath?: string): string;
13
22
  export declare function registerExecuteSkyrampTestTool(server: McpServer): void;
@@ -11,6 +11,7 @@ import { StateManager, getTestsRepoDir } from "../utils/AnalysisStateManager.js"
11
11
  import { TestSource } from "../types/TestAnalysis.js";
12
12
  import { logger } from "../utils/logger.js";
13
13
  import { toolError } from "../utils/utils.js";
14
+ import { recordExecutionVideo } from "./execution-video-state.js";
14
15
  const TOOL_NAME = "skyramp_execute_test";
15
16
  export const CONTRACT_EXECUTION_MODES = ["provider", "consumer"];
16
17
  /**
@@ -27,6 +28,17 @@ export function resolveEffectiveToken(unauthenticated, paramToken, envToken) {
27
28
  export function shouldInjectSkyrampBaseUrl(testType, contractMode) {
28
29
  return testType !== TestType.CONTRACT || contractMode !== "consumer";
29
30
  }
31
+ /**
32
+ * Append the recorded video path to execution output.
33
+ *
34
+ * Applied to the failure return as well as the success one (SKYR-4156): a video is
35
+ * most useful for a test that just failed, and surfacing it only on success meant a
36
+ * failing UI test reported no recording at all unless Playwright happened to print
37
+ * its own attachment line.
38
+ */
39
+ export function withVideoInfo(output, videoPath) {
40
+ return videoPath ? `${output}\n\nVideo recording: ${videoPath}` : output;
41
+ }
30
42
  export function registerExecuteSkyrampTestTool(server) {
31
43
  server.registerTool(TOOL_NAME, {
32
44
  description: `Execute a Skyramp-generated test in isolated containerized environments for reliable, deterministic testing. Call this once a test file exists on disk (from a skyramp_*_test_generation tool). First-time execution may take longer while Docker images download — this is expected, not a failure.`,
@@ -224,23 +236,25 @@ export function registerExecuteSkyrampTestTool(server) {
224
236
  // Don't fail the tool call if stateFile update fails
225
237
  }
226
238
  }
239
+ // Record the recording for the report before returning, so it happens on the
240
+ // failure path too (SKYR-4156). skyramp_submit_report reads these records to
241
+ // populate testResults[].videoPath — testbot uploads only the video
242
+ // directories the report references, so an unrecorded video is never seen.
243
+ await recordExecutionVideo(result, params.stateFile);
227
244
  // Progress is already reported by TestExecutionService
228
245
  // Only report final status if not already at 100%
229
246
  if (result.status !== TestExecutionStatus.Pass) {
230
- errorResult = toolError(`Test execution failed: ${stripVTControlCharacters(result.output || "")}
247
+ errorResult = toolError(withVideoInfo(`Test execution failed: ${stripVTControlCharacters(result.output || "")}
231
248
 
232
- If this failed with a 401 status code, ask the user for the authentication token (or where to read it from) and retry — leave the generated test file unchanged.`);
249
+ If this failed with a 401 status code, ask the user for the authentication token (or where to read it from) and retry — leave the generated test file unchanged.`, result.videoPath));
233
250
  return errorResult;
234
251
  }
235
252
  // Success - progress already reported by TestExecutionService
236
- const videoInfo = result.videoPath
237
- ? `\n\nVideo recording: ${result.videoPath}`
238
- : "";
239
253
  return {
240
254
  content: [
241
255
  {
242
256
  type: "text",
243
- text: `Test execution result: ${stripVTControlCharacters(result.output || "")}${videoInfo}`,
257
+ text: withVideoInfo(`Test execution result: ${stripVTControlCharacters(result.output || "")}`, result.videoPath),
244
258
  },
245
259
  ],
246
260
  };
@@ -0,0 +1,21 @@
1
+ import type { TestExecutionResult } from "../types/TestExecution.js";
2
+ /**
3
+ * Persistence for the server-derived video recording of a browser test execution,
4
+ * keyed by test-file basename (what `newTestsCreated[].fileName` carries, so
5
+ * `skyramp_submit_report` can match without path normalization).
6
+ *
7
+ * Why this exists (SKYR-4156): testbot uploads only the `.skyramp/videos`
8
+ * directories the submitted report actually references, so a recording nobody
9
+ * references is a recording nobody ever sees. The path is known only inside
10
+ * `skyramp_execute_test`, and asking the agent to carry it across tool calls left
11
+ * the signal prose-mediated — it decayed to zero referenced videos, and every
12
+ * recording of the run was discarded. Recording it here makes the reference
13
+ * server-derived, the same line already drawn for POM reuse and maintenance
14
+ * before/after status.
15
+ *
16
+ * Every write is best-effort. Video observability must never fail an execution
17
+ * that otherwise succeeded, so a missing/unreadable/unwritable state file degrades
18
+ * to "no video link in the report" — the same outcome as running outside a Testbot
19
+ * run, where there is no run state to record into.
20
+ */
21
+ export declare function recordExecutionVideo(result: TestExecutionResult, explicitStateFile?: string): Promise<void>;
@@ -0,0 +1,51 @@
1
+ import * as path from "path";
2
+ import { logger } from "../utils/logger.js";
3
+ import { resolveRunStatePath, StateManager, } from "../utils/AnalysisStateManager.js";
4
+ /**
5
+ * Persistence for the server-derived video recording of a browser test execution,
6
+ * keyed by test-file basename (what `newTestsCreated[].fileName` carries, so
7
+ * `skyramp_submit_report` can match without path normalization).
8
+ *
9
+ * Why this exists (SKYR-4156): testbot uploads only the `.skyramp/videos`
10
+ * directories the submitted report actually references, so a recording nobody
11
+ * references is a recording nobody ever sees. The path is known only inside
12
+ * `skyramp_execute_test`, and asking the agent to carry it across tool calls left
13
+ * the signal prose-mediated — it decayed to zero referenced videos, and every
14
+ * recording of the run was discarded. Recording it here makes the reference
15
+ * server-derived, the same line already drawn for POM reuse and maintenance
16
+ * before/after status.
17
+ *
18
+ * Every write is best-effort. Video observability must never fail an execution
19
+ * that otherwise succeeded, so a missing/unreadable/unwritable state file degrades
20
+ * to "no video link in the report" — the same outcome as running outside a Testbot
21
+ * run, where there is no run state to record into.
22
+ */
23
+ export async function recordExecutionVideo(result, explicitStateFile) {
24
+ if (!result.videoPath)
25
+ return;
26
+ const stateFile = resolveRunStatePath(explicitStateFile);
27
+ if (!stateFile)
28
+ return;
29
+ try {
30
+ const manager = StateManager.fromStatePath(stateFile);
31
+ const data = await manager.readData();
32
+ if (!data)
33
+ return;
34
+ // Last write wins: a retried execution leaves a video directory per attempt,
35
+ // and the report must reference the attempt whose status it reports.
36
+ await manager.writeData({
37
+ ...data,
38
+ executionVideos: {
39
+ ...data.executionVideos,
40
+ [path.basename(result.testFile)]: {
41
+ videoPath: result.videoPath,
42
+ status: result.status,
43
+ executedAt: result.executedAt,
44
+ },
45
+ },
46
+ });
47
+ }
48
+ catch (err) {
49
+ logger.warning("Could not record the test video — the report will omit its video link", { testFile: result.testFile, error: String(err) });
50
+ }
51
+ }