@skyramp/mcp 0.2.7 → 0.2.8-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/build/index.js +5 -2
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -5
  3. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.d.ts +2 -0
  4. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +114 -0
  5. package/build/prompts/sut-setup/modes/dockerComposePrompt.d.ts +2 -0
  6. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +292 -0
  7. package/build/prompts/sut-setup/shared.d.ts +39 -0
  8. package/build/prompts/sut-setup/shared.js +132 -0
  9. package/build/prompts/test-maintenance/driftAnalysisSections.js +9 -3
  10. package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -1
  11. package/build/prompts/test-recommendation/diffExecutionPlan.js +76 -2
  12. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +91 -0
  13. package/build/prompts/testbot/testbot-prompts.d.ts +16 -1
  14. package/build/prompts/testbot/testbot-prompts.js +113 -22
  15. package/build/prompts/testbot/testbot-prompts.test.js +95 -45
  16. package/build/resources/sutSetupResource.d.ts +2 -0
  17. package/build/resources/sutSetupResource.js +45 -0
  18. package/build/resources/testbotResource.js +2 -2
  19. package/build/services/TestDiscoveryService.d.ts +2 -2
  20. package/build/services/TestDiscoveryService.js +22 -12
  21. package/build/services/TestExecutionService.d.ts +10 -1
  22. package/build/services/TestExecutionService.js +158 -26
  23. package/build/services/TestExecutionService.test.js +306 -0
  24. package/build/tools/executeSkyrampTestTool.js +26 -7
  25. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +1 -1
  26. package/build/tools/submitReportTool.d.ts +10 -0
  27. package/build/tools/submitReportTool.js +106 -39
  28. package/build/tools/submitReportTool.test.js +296 -50
  29. package/build/tools/test-management/actionsTool.js +21 -7
  30. package/build/tools/test-management/analyzeChangesTool.d.ts +1 -1
  31. package/build/tools/test-management/analyzeChangesTool.js +106 -41
  32. package/build/tools/test-management/analyzeChangesTool.test.js +21 -41
  33. package/build/tools/test-management/analyzeTestHealthTool.js +16 -9
  34. package/build/tools/workspace/initializeWorkspaceTool.js +39 -22
  35. package/build/tools/workspace/serviceUpsert.d.ts +12 -0
  36. package/build/tools/workspace/serviceUpsert.js +23 -0
  37. package/build/tools/workspace/serviceUpsert.test.d.ts +1 -0
  38. package/build/tools/workspace/serviceUpsert.test.js +50 -0
  39. package/build/types/TestAnalysis.d.ts +2 -1
  40. package/build/types/TestExecution.d.ts +12 -2
  41. package/build/types/TestExecution.js +11 -1
  42. package/build/types/TestbotReport.d.ts +68 -0
  43. package/build/types/TestbotReport.js +1 -0
  44. package/build/types/index.d.ts +4 -0
  45. package/build/types/index.js +3 -0
  46. package/build/utils/AnalysisStateManager.d.ts +61 -1
  47. package/build/utils/AnalysisStateManager.js +144 -3
  48. package/build/utils/AnalysisStateManager.test.js +98 -0
  49. package/build/utils/docker.test.js +1 -1
  50. package/build/utils/routeParsers.d.ts +0 -10
  51. package/build/utils/routeParsers.js +31 -38
  52. package/build/utils/scenarioDrafting.d.ts +1 -1
  53. package/build/utils/scenarioDrafting.js +23 -1
  54. package/build/utils/utils.d.ts +7 -1
  55. package/build/utils/utils.js +10 -0
  56. package/build/utils/versions.d.ts +3 -3
  57. package/build/utils/versions.js +1 -1
  58. package/build/workspace/workspace.d.ts +10 -10
  59. package/build/workspace/workspace.js +7 -7
  60. package/build/workspace/workspace.test.js +6 -6
  61. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +31 -13
  62. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +88 -15
  63. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -3
  64. package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +23 -126
  65. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  66. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderApp.js +79 -19
  67. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/aliasRemapper.js +84 -0
  68. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/replayEngine.js +15 -4
  69. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-CPZKmjFR.js → codeMirrorModule-D0BjbCb7.js} +1 -1
  70. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-lvTRGFx-.js +193 -0
  71. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  72. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-B0JOjboO.js → codeMirrorModule-Cqp3cwEJ.js} +1 -1
  73. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-1anWeyDf.js → defaultSettingsView-CYf9adZh.js} +79 -79
  74. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.Cc5029a3.js → index.B_7ywgmr.js} +1 -1
  75. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  76. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  77. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.Wo5yvvVh.js → uiMode.u4_8VnCV.js} +1 -1
  78. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  79. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  80. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderApp.ts +129 -43
  81. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/aliasRemapper.ts +119 -0
  82. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/replayEngine.ts +33 -10
  83. package/node_modules/playwright/package.json +1 -1
  84. package/package.json +10 -3
  85. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/.bin/node-which +0 -52
  86. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/is-docker +0 -5
  87. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/mime +0 -46
  88. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/yaml +0 -11
  89. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-CsgPR2wy.js +0 -193
  90. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.CP3Y8Zsb.js +0 -2
@@ -2,9 +2,12 @@ import Docker from "dockerode";
2
2
  import path from "path";
3
3
  import fs from "fs";
4
4
  import os from "os";
5
+ import crypto from "crypto";
5
6
  import { Writable } from "stream";
6
7
  import { stripVTControlCharacters } from "util";
7
8
  import { logger } from "../utils/logger.js";
9
+ import { TestExecutionStatus, } from "../types/TestExecution.js";
10
+ import { TestType } from "../types/TestTypes.js";
8
11
  import { buildContainerEnv } from "./containerEnv.js";
9
12
  import { SKYRAMP_IMAGE_VERSION } from "../utils/versions.js";
10
13
  const DEFAULT_TIMEOUT = 300000; // 5 minutes
@@ -47,6 +50,109 @@ export const MOUNT_NULL_ITEMS = [
47
50
  "__init__.py",
48
51
  "conftest.py",
49
52
  ];
53
+ /**
54
+ * Check if a test type requires browser execution (UI/E2E)
55
+ */
56
+ export function isBrowserTest(testType) {
57
+ return testType === TestType.UI || testType === TestType.E2E;
58
+ }
59
+ /**
60
+ * Generate a deterministic unique subdirectory name for video output
61
+ * based on the test file path
62
+ */
63
+ export function getVideoSubdir(testFile) {
64
+ const basename = path.basename(testFile, path.extname(testFile)).replace(/[^A-Za-z0-9._-]/g, "-");
65
+ const hash = crypto.createHash("sha256").update(testFile).digest("hex").slice(0, 8);
66
+ return `${basename}-${hash}`;
67
+ }
68
+ /**
69
+ * For UI/E2E tests, create a per-execution video output directory under the
70
+ * workspace and generate the language-specific runner config that enables
71
+ * recording (Playwright config for TS/JS, pytest.ini for Python). The returned
72
+ * paths are mounted into the executor container by the caller.
73
+ *
74
+ * No-op (returns disabled defaults) for non-browser test types.
75
+ */
76
+ function setupVideoCapture(options, workspacePath, containerMountPath) {
77
+ const browserTest = isBrowserTest(options.testType);
78
+ const setup = {
79
+ browserTest,
80
+ videoHostDir: "",
81
+ videoContainerDir: "",
82
+ playwrightConfigPath: MINIMAL_PLAYWRIGHT_CONFIG_PATH,
83
+ pytestConfigPath: "",
84
+ };
85
+ if (!browserTest)
86
+ return setup;
87
+ // Per-execution video output directory on host (cleared so a retry never
88
+ // reports a stale recording from a prior run).
89
+ const videoSubdir = getVideoSubdir(options.testFile);
90
+ setup.videoHostDir = path.join(workspacePath, ".skyramp", "videos", videoSubdir);
91
+ setup.videoContainerDir = path.join(containerMountPath, ".skyramp", "videos", videoSubdir);
92
+ if (fs.existsSync(setup.videoHostDir)) {
93
+ fs.rmSync(setup.videoHostDir, { recursive: true, force: true });
94
+ }
95
+ fs.mkdirSync(setup.videoHostDir, { recursive: true });
96
+ // TypeScript/JavaScript: generate a per-execution Playwright config with video.
97
+ if (options.language === "typescript" || options.language === "javascript") {
98
+ const configContent = `import { defineConfig } from "@playwright/test";
99
+ export default defineConfig({
100
+ testDir: ".",
101
+ timeout: 30000,
102
+ retries: 0,
103
+ use: { video: "on" },
104
+ outputDir: "${setup.videoContainerDir}"
105
+ });
106
+ `;
107
+ setup.playwrightConfigPath = path.join(os.tmpdir(), `skyramp-playwright-${videoSubdir}.config.ts`);
108
+ fs.writeFileSync(setup.playwrightConfigPath, configContent);
109
+ }
110
+ // Python: generate a pytest.ini with the video flags.
111
+ if (options.language === "python") {
112
+ const pytestContent = `[pytest]
113
+ addopts = --video on --output ${setup.videoContainerDir}
114
+ `;
115
+ setup.pytestConfigPath = path.join(os.tmpdir(), `skyramp-pytest-${videoSubdir}.ini`);
116
+ fs.writeFileSync(setup.pytestConfigPath, pytestContent);
117
+ }
118
+ return setup;
119
+ }
120
+ /**
121
+ * Recursively scan a per-execution video output directory for the recorded
122
+ * `video.webm`. Returns its host path, or undefined if none was produced.
123
+ * Best-effort: scan errors are logged and treated as "no video".
124
+ */
125
+ function collectVideoPath(videoHostDir) {
126
+ if (!videoHostDir)
127
+ return undefined;
128
+ const findVideo = (dir) => {
129
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
130
+ const fullPath = path.join(dir, entry.name);
131
+ if (entry.isFile() && entry.name === "video.webm")
132
+ return fullPath;
133
+ if (entry.isDirectory()) {
134
+ const found = findVideo(fullPath);
135
+ if (found)
136
+ return found;
137
+ }
138
+ }
139
+ return undefined;
140
+ };
141
+ try {
142
+ const videoPath = findVideo(videoHostDir);
143
+ if (videoPath) {
144
+ logger.info(`Video recording found: ${videoPath}`);
145
+ }
146
+ else {
147
+ logger.warning(`No video.webm found in ${videoHostDir}`);
148
+ }
149
+ return videoPath;
150
+ }
151
+ catch (err) {
152
+ logger.error(`Failed to scan for video: ${err.message}`);
153
+ return undefined;
154
+ }
155
+ }
50
156
  /**
51
157
  * Find the start index of a comment in a line, ignoring comment delimiters inside strings
52
158
  * Returns -1 if no comment is found outside of strings
@@ -267,21 +373,20 @@ export class TestExecutionService {
267
373
  // If execution fails completely, return error result
268
374
  return {
269
375
  testFile: options.testFile,
270
- passed: false,
376
+ status: TestExecutionStatus.Error,
271
377
  executedAt: new Date().toISOString(),
272
378
  duration: 0,
273
379
  errors: [error.message],
274
380
  warnings: [],
275
- crashed: true,
276
381
  };
277
382
  }));
278
383
  const batchResults = await Promise.all(batchPromises);
279
384
  results.push(...batchResults);
280
385
  }
281
386
  const totalDuration = Date.now() - startTime;
282
- const passed = results.filter((r) => r.passed).length;
283
- const failed = results.filter((r) => !r.passed && !r.crashed).length;
284
- const crashed = results.filter((r) => r.crashed).length;
387
+ const passed = results.filter((r) => r.status === TestExecutionStatus.Pass).length;
388
+ const failed = results.filter((r) => r.status === TestExecutionStatus.Fail).length;
389
+ const crashed = results.filter((r) => r.status === TestExecutionStatus.Error).length;
285
390
  logger.info(`Batch execution complete: ${passed} passed, ${failed} failed, ${crashed} crashed`);
286
391
  return {
287
392
  totalTests: testOptions.length,
@@ -356,6 +461,8 @@ export class TestExecutionService {
356
461
  // Calculate relative test file path
357
462
  let testFilePath = path.relative(workspacePath, options.testFile);
358
463
  testFilePath = path.resolve(containerMountPath, testFilePath);
464
+ // Video recording setup for UI/E2E browser tests (no-op for API tests).
465
+ const { browserTest, videoHostDir, videoContainerDir, playwrightConfigPath, pytestConfigPath } = setupVideoCapture(options, workspacePath, containerMountPath);
359
466
  // Prepare Docker command
360
467
  const command = [
361
468
  "/root/runner.sh",
@@ -399,6 +506,28 @@ export class TestExecutionService {
399
506
  mountedPaths.add(mount.Target);
400
507
  hostConfig.Mounts.push(mount);
401
508
  };
509
+ // For browser tests, mount generated video-enabled configs at canonical container
510
+ // paths BEFORE the nullPaths loop so they WIN over any /dev/null shadows and work
511
+ // even if the repo has no config file. The pushMount dedup ensures later shadows
512
+ // for the same target are dropped.
513
+ if (browserTest) {
514
+ if (options.language === "typescript" || options.language === "javascript") {
515
+ // Mount generated Playwright config at canonical container path
516
+ pushMount({
517
+ Type: "bind",
518
+ Source: playwrightConfigPath,
519
+ Target: path.join(containerMountPath, "playwright.config.ts"),
520
+ });
521
+ }
522
+ if (options.language === "python" && pytestConfigPath) {
523
+ // Mount generated pytest.ini at canonical container path
524
+ pushMount({
525
+ Type: "bind",
526
+ Source: pytestConfigPath,
527
+ Target: path.join(containerMountPath, "pytest.ini"),
528
+ });
529
+ }
530
+ }
402
531
  const mirrorAtHostPath = workspacePath !== containerMountPath;
403
532
  for (const file of filesToMount) {
404
533
  const source = path.join(workspacePath, file);
@@ -425,23 +554,27 @@ export class TestExecutionService {
425
554
  pushMount({ Type: "bind", Source: source, Target: absolutePath });
426
555
  }
427
556
  }
428
- // Mount Playwright config files with minimal config (shadows repo configs that may
429
- // import dotenv or other dependencies not available in the executor container)
557
+ // Mount Playwright config files with the per-execution config (minimal or generated).
558
+ // Shadows ANY repo playwright.config.{ts,js,mjs} recursively so repo configs that
559
+ // import dotenv/etc. don't crash the executor. Applies to ALL tests — including
560
+ // non-browser TypeScript tests (contract/integration/fuzz/smoke/load) which per
561
+ // runner.sh also run via `npx playwright test <file>` and read playwright.config.ts.
430
562
  const playwrightConfigPaths = findExcludedPaths(workspacePath, PLAYWRIGHT_CONFIG_FILES);
431
563
  for (const absolutePath of playwrightConfigPaths) {
432
564
  const rel = path.relative(workspacePath, absolutePath);
565
+ pushMount({ Type: "bind", Source: playwrightConfigPath, Target: path.join(containerMountPath, rel) });
566
+ if (mirrorAtHostPath) {
567
+ pushMount({ Type: "bind", Source: playwrightConfigPath, Target: absolutePath });
568
+ }
569
+ }
570
+ // Mount video output directory for browser tests (writable)
571
+ if (browserTest && videoHostDir) {
433
572
  pushMount({
434
573
  Type: "bind",
435
- Source: MINIMAL_PLAYWRIGHT_CONFIG_PATH,
436
- Target: path.join(containerMountPath, rel),
574
+ Source: videoHostDir,
575
+ Target: videoContainerDir,
576
+ ReadOnly: false,
437
577
  });
438
- if (mirrorAtHostPath) {
439
- pushMount({
440
- Type: "bind",
441
- Source: MINIMAL_PLAYWRIGHT_CONFIG_PATH,
442
- Target: absolutePath,
443
- });
444
- }
445
578
  }
446
579
  // Detect and mount session files referenced outside the workspace
447
580
  // (anything inside the workspace is already covered by the dual mount
@@ -636,28 +769,28 @@ export class TestExecutionService {
636
769
  // Parse errors and warnings from output
637
770
  const errors = this.parseErrors(cleanOutput);
638
771
  const warnings = this.parseWarnings(cleanOutput);
639
- // Check if test crashed
640
- const crashed = statusCode !== 0 && statusCode !== 1;
772
+ // Collect the recorded video (browser tests only).
773
+ const videoPath = browserTest ? collectVideoPath(videoHostDir) : undefined;
641
774
  const result = {
642
775
  testFile: options.testFile,
643
- passed: statusCode === 0,
776
+ status: statusCode === 0 ? TestExecutionStatus.Pass
777
+ : statusCode !== 1 ? TestExecutionStatus.Error
778
+ : TestExecutionStatus.Fail,
644
779
  executedAt,
645
780
  duration,
646
781
  errors,
647
782
  warnings,
648
- crashed,
649
783
  output: cleanOutput,
650
784
  exitCode: statusCode,
785
+ videoPath,
651
786
  };
652
787
  // Report completion
653
788
  await onProgress?.({
654
789
  phase: "processing",
655
- message: result.passed
656
- ? "Test passed successfully"
657
- : "Test execution completed with failures",
790
+ message: `Test execution completed: ${result.status}`,
658
791
  percent: 100,
659
792
  });
660
- logger.debug(`Test ${result.passed ? "passed" : "failed"}: ${options.testFile}`);
793
+ logger.debug(`Test ${result.status}: ${options.testFile}`);
661
794
  return result;
662
795
  }
663
796
  catch (error) {
@@ -666,12 +799,11 @@ export class TestExecutionService {
666
799
  logger.error(`Test execution error: ${error.message}`);
667
800
  return {
668
801
  testFile: options.testFile,
669
- passed: false,
802
+ status: TestExecutionStatus.Error,
670
803
  executedAt,
671
804
  duration,
672
805
  errors: [error.message],
673
806
  warnings: [],
674
- crashed: true,
675
807
  output: cleanOutput,
676
808
  };
677
809
  }
@@ -21,6 +21,9 @@ const mockReaddirSync = jest.fn().mockImplementation((_path, options) => {
21
21
  return ["test_file.py"];
22
22
  });
23
23
  const mockReadFileSync = jest.fn().mockReturnValue("");
24
+ const mockWriteFileSync = jest.fn();
25
+ const mockMkdirSync = jest.fn();
26
+ const mockRmSync = jest.fn();
24
27
  jest.unstable_mockModule("fs", () => ({
25
28
  ...actualFs,
26
29
  default: {
@@ -29,12 +32,18 @@ jest.unstable_mockModule("fs", () => ({
29
32
  existsSync: mockExistsSync,
30
33
  readdirSync: mockReaddirSync,
31
34
  readFileSync: mockReadFileSync,
35
+ writeFileSync: mockWriteFileSync,
36
+ mkdirSync: mockMkdirSync,
37
+ rmSync: mockRmSync,
32
38
  constants: actualFs.default.constants,
33
39
  },
34
40
  accessSync: mockAccessSync,
35
41
  existsSync: mockExistsSync,
36
42
  readdirSync: mockReaddirSync,
37
43
  readFileSync: mockReadFileSync,
44
+ writeFileSync: mockWriteFileSync,
45
+ mkdirSync: mockMkdirSync,
46
+ rmSync: mockRmSync,
38
47
  }));
39
48
  // Mock logger
40
49
  jest.unstable_mockModule("../utils/logger.js", () => ({
@@ -197,6 +206,81 @@ test('x', () => {});
197
206
  expect(detectSessionFiles("/ws/spec.ts")).toEqual([]);
198
207
  });
199
208
  });
209
+ describe("isBrowserTest", () => {
210
+ let isBrowserTest;
211
+ beforeAll(async () => {
212
+ const mod = await import("./TestExecutionService.js");
213
+ isBrowserTest = mod.isBrowserTest;
214
+ });
215
+ it("returns true for UI test type", () => {
216
+ expect(isBrowserTest("ui")).toBe(true);
217
+ });
218
+ it("returns true for E2E test type", () => {
219
+ expect(isBrowserTest("e2e")).toBe(true);
220
+ });
221
+ it("returns false for contract test type", () => {
222
+ expect(isBrowserTest("contract")).toBe(false);
223
+ });
224
+ it("returns false for integration test type", () => {
225
+ expect(isBrowserTest("integration")).toBe(false);
226
+ });
227
+ it("returns false for fuzz test type", () => {
228
+ expect(isBrowserTest("fuzz")).toBe(false);
229
+ });
230
+ it("returns false for smoke test type", () => {
231
+ expect(isBrowserTest("smoke")).toBe(false);
232
+ });
233
+ it("returns false for load test type", () => {
234
+ expect(isBrowserTest("load")).toBe(false);
235
+ });
236
+ it("returns false for mock test type", () => {
237
+ expect(isBrowserTest("mock")).toBe(false);
238
+ });
239
+ });
240
+ describe("getVideoSubdir", () => {
241
+ let getVideoSubdir;
242
+ beforeAll(async () => {
243
+ const mod = await import("./TestExecutionService.js");
244
+ getVideoSubdir = mod.getVideoSubdir;
245
+ });
246
+ it("generates deterministic subdirectory name with basename and hash", () => {
247
+ const testFile = "/workspace/tests/login.spec.ts";
248
+ const result = getVideoSubdir(testFile);
249
+ // Should be "basename-8hexchars" format
250
+ // Note: path.extname only strips the final extension (.ts), so .spec remains in basename
251
+ expect(result).toMatch(/^login\.spec-[0-9a-f]{8}$/);
252
+ });
253
+ it("strips file extension from basename", () => {
254
+ const result = getVideoSubdir("/workspace/checkout.spec.ts");
255
+ // path.extname('.spec.ts') returns '.ts', so 'checkout.spec' is the basename
256
+ expect(result).toMatch(/^checkout\.spec-[0-9a-f]{8}$/);
257
+ expect(result).toContain(".spec");
258
+ });
259
+ it("produces different hashes for different paths", () => {
260
+ const path1 = "/workspace/tests/login.spec.ts";
261
+ const path2 = "/workspace/tests/checkout.spec.ts";
262
+ const result1 = getVideoSubdir(path1);
263
+ const result2 = getVideoSubdir(path2);
264
+ expect(result1).not.toBe(result2);
265
+ // Different hashes
266
+ expect(result1.split("-")[1]).not.toBe(result2.split("-")[1]);
267
+ });
268
+ it("produces same hash for the same path (deterministic)", () => {
269
+ const testFile = "/workspace/tests/products.spec.ts";
270
+ const result1 = getVideoSubdir(testFile);
271
+ const result2 = getVideoSubdir(testFile);
272
+ expect(result1).toBe(result2);
273
+ });
274
+ it("handles paths with no extension", () => {
275
+ const result = getVideoSubdir("/workspace/test_file");
276
+ expect(result).toMatch(/^test_file-[0-9a-f]{8}$/);
277
+ });
278
+ it("handles paths with multiple dots", () => {
279
+ const result = getVideoSubdir("/workspace/test.spec.e2e.ts");
280
+ // Should strip only the last extension (.ts)
281
+ expect(result).toMatch(/^test\.spec\.e2e-[0-9a-f]{8}$/);
282
+ });
283
+ });
200
284
  describe("TestExecutionService.executeTest - Docker env forwarding", () => {
201
285
  // Import after mocks are set up
202
286
  let TestExecutionService;
@@ -324,3 +408,225 @@ describe("TestExecutionService.executeTest - Docker env forwarding", () => {
324
408
  }
325
409
  });
326
410
  });
411
+ describe("TestExecutionService.executeTest - Video capture for browser tests", () => {
412
+ let TestExecutionService;
413
+ let getVideoSubdir;
414
+ let EXECUTOR_DOCKER_IMAGE;
415
+ beforeAll(async () => {
416
+ const mod = await import("./TestExecutionService.js");
417
+ TestExecutionService = mod.TestExecutionService;
418
+ getVideoSubdir = mod.getVideoSubdir;
419
+ EXECUTOR_DOCKER_IMAGE = mod.EXECUTOR_DOCKER_IMAGE;
420
+ });
421
+ beforeEach(() => {
422
+ jest.clearAllMocks();
423
+ mockListImages.mockResolvedValue([{ RepoTags: [EXECUTOR_DOCKER_IMAGE] }]);
424
+ mockExistsSync.mockReturnValue(true);
425
+ mockReaddirSync.mockImplementation((_path, options) => {
426
+ if (options?.withFileTypes) {
427
+ return [
428
+ { name: "test.spec.ts", isFile: () => true, isDirectory: () => false },
429
+ { name: "test.py", isFile: () => true, isDirectory: () => false },
430
+ ];
431
+ }
432
+ return ["test.spec.ts", "test.py"];
433
+ });
434
+ // Reset writeFileSync mock to default behavior
435
+ mockWriteFileSync.mockImplementation(() => { });
436
+ });
437
+ it("mounts generated Playwright config at /home/user/playwright.config.ts for TypeScript UI test", async () => {
438
+ const mockContainer = { remove: jest.fn().mockResolvedValue(undefined) };
439
+ mockRun.mockResolvedValue([{ StatusCode: 0 }, mockContainer]);
440
+ const service = new TestExecutionService();
441
+ await service.executeTest({
442
+ testFile: "/workspace/login.spec.ts",
443
+ workspacePath: "/workspace",
444
+ language: "typescript",
445
+ testType: "ui",
446
+ });
447
+ const dockerOptions = mockRun.mock.calls[0][3];
448
+ const playwrightConfigMount = dockerOptions.HostConfig.Mounts.find((m) => m.Target === "/home/user/playwright.config.ts");
449
+ expect(playwrightConfigMount).toBeDefined();
450
+ expect(playwrightConfigMount.Type).toBe("bind");
451
+ // Source should be a temp file containing the generated config
452
+ expect(playwrightConfigMount.Source).toContain("skyramp-playwright-");
453
+ expect(playwrightConfigMount.Source).toContain(".config.ts");
454
+ });
455
+ it("generated Playwright config for TypeScript contains video:on and outputDir", async () => {
456
+ const mockContainer = { remove: jest.fn().mockResolvedValue(undefined) };
457
+ mockRun.mockResolvedValue([{ StatusCode: 0 }, mockContainer]);
458
+ let capturedConfigContent = "";
459
+ mockWriteFileSync.mockImplementation((filePath, content) => {
460
+ if (filePath.includes("skyramp-playwright-") && filePath.endsWith(".config.ts")) {
461
+ capturedConfigContent = content;
462
+ }
463
+ });
464
+ const service = new TestExecutionService();
465
+ await service.executeTest({
466
+ testFile: "/workspace/checkout.spec.ts",
467
+ workspacePath: "/workspace",
468
+ language: "typescript",
469
+ testType: "e2e",
470
+ });
471
+ expect(capturedConfigContent).toContain('video: "on"');
472
+ expect(capturedConfigContent).toContain("outputDir:");
473
+ expect(capturedConfigContent).toContain(".skyramp/videos/");
474
+ });
475
+ it("mounts generated pytest.ini at /home/user/pytest.ini for Python UI test", async () => {
476
+ const mockContainer = { remove: jest.fn().mockResolvedValue(undefined) };
477
+ mockRun.mockResolvedValue([{ StatusCode: 0 }, mockContainer]);
478
+ const service = new TestExecutionService();
479
+ await service.executeTest({
480
+ testFile: "/workspace/test_login.py",
481
+ workspacePath: "/workspace",
482
+ language: "python",
483
+ testType: "ui",
484
+ });
485
+ const dockerOptions = mockRun.mock.calls[0][3];
486
+ const pytestConfigMount = dockerOptions.HostConfig.Mounts.find((m) => m.Target === "/home/user/pytest.ini");
487
+ expect(pytestConfigMount).toBeDefined();
488
+ expect(pytestConfigMount.Type).toBe("bind");
489
+ expect(pytestConfigMount.Source).toContain("skyramp-pytest-");
490
+ expect(pytestConfigMount.Source).toContain(".ini");
491
+ });
492
+ it("generated pytest.ini for Python contains --video on and --output", async () => {
493
+ const mockContainer = { remove: jest.fn().mockResolvedValue(undefined) };
494
+ mockRun.mockResolvedValue([{ StatusCode: 0 }, mockContainer]);
495
+ let capturedPytestContent = "";
496
+ mockWriteFileSync.mockImplementation((filePath, content) => {
497
+ if (filePath.includes("skyramp-pytest-") && filePath.endsWith(".ini")) {
498
+ capturedPytestContent = content;
499
+ }
500
+ });
501
+ const service = new TestExecutionService();
502
+ await service.executeTest({
503
+ testFile: "/workspace/test_checkout.py",
504
+ workspacePath: "/workspace",
505
+ language: "python",
506
+ testType: "e2e",
507
+ });
508
+ expect(capturedPytestContent).toContain("--video on");
509
+ expect(capturedPytestContent).toContain("--output");
510
+ expect(capturedPytestContent).toContain(".skyramp/videos/");
511
+ });
512
+ it("mounts video output directory as writable for UI test", async () => {
513
+ const mockContainer = { remove: jest.fn().mockResolvedValue(undefined) };
514
+ mockRun.mockResolvedValue([{ StatusCode: 0 }, mockContainer]);
515
+ const service = new TestExecutionService();
516
+ await service.executeTest({
517
+ testFile: "/workspace/tests/login.spec.ts",
518
+ workspacePath: "/workspace",
519
+ language: "typescript",
520
+ testType: "ui",
521
+ });
522
+ const dockerOptions = mockRun.mock.calls[0][3];
523
+ const videoSubdir = getVideoSubdir("/workspace/tests/login.spec.ts");
524
+ const videoMount = dockerOptions.HostConfig.Mounts.find((m) => m.Target === `/home/user/.skyramp/videos/${videoSubdir}`);
525
+ expect(videoMount).toBeDefined();
526
+ expect(videoMount.Type).toBe("bind");
527
+ expect(videoMount.ReadOnly).toBe(false);
528
+ expect(videoMount.Source).toContain(".skyramp/videos/");
529
+ expect(videoMount.Source).toContain(videoSubdir);
530
+ });
531
+ it("does not mount video directory for non-browser tests", async () => {
532
+ const mockContainer = { remove: jest.fn().mockResolvedValue(undefined) };
533
+ mockRun.mockResolvedValue([{ StatusCode: 0 }, mockContainer]);
534
+ const service = new TestExecutionService();
535
+ await service.executeTest({
536
+ testFile: "/workspace/contract_test.py",
537
+ workspacePath: "/workspace",
538
+ language: "python",
539
+ testType: "contract",
540
+ });
541
+ const dockerOptions = mockRun.mock.calls[0][3];
542
+ const videoMounts = dockerOptions.HostConfig.Mounts.filter((m) => m.Target.includes(".skyramp/videos"));
543
+ expect(videoMounts).toHaveLength(0);
544
+ });
545
+ it("uses minimal static Playwright config for non-browser TypeScript tests", async () => {
546
+ const mockContainer = { remove: jest.fn().mockResolvedValue(undefined) };
547
+ mockRun.mockResolvedValue([{ StatusCode: 0 }, mockContainer]);
548
+ let usedGeneratedConfig = false;
549
+ mockWriteFileSync.mockImplementation((filePath, content) => {
550
+ if (filePath.includes("skyramp-playwright-") && content.includes('video: "on"')) {
551
+ usedGeneratedConfig = true;
552
+ }
553
+ });
554
+ const service = new TestExecutionService();
555
+ await service.executeTest({
556
+ testFile: "/workspace/contract_test.spec.ts",
557
+ workspacePath: "/workspace",
558
+ language: "typescript",
559
+ testType: "contract",
560
+ });
561
+ // Should NOT generate a video-enabled config for contract test
562
+ expect(usedGeneratedConfig).toBe(false);
563
+ });
564
+ it("populates videoPath when video.webm exists after execution", async () => {
565
+ const mockContainer = { remove: jest.fn().mockResolvedValue(undefined) };
566
+ mockRun.mockResolvedValue([{ StatusCode: 0 }, mockContainer]);
567
+ const videoSubdir = getVideoSubdir("/workspace/test.spec.ts");
568
+ mockReaddirSync.mockImplementation((dir, options) => {
569
+ // Mock video directory scan
570
+ if (dir.includes(".skyramp/videos") && dir.includes(videoSubdir)) {
571
+ if (options?.withFileTypes) {
572
+ return [{ name: "video.webm", isFile: () => true, isDirectory: () => false }];
573
+ }
574
+ return ["video.webm"];
575
+ }
576
+ // Default workspace files
577
+ if (options?.withFileTypes) {
578
+ return [{ name: "test.spec.ts", isFile: () => true, isDirectory: () => false }];
579
+ }
580
+ return ["test.spec.ts"];
581
+ });
582
+ const service = new TestExecutionService();
583
+ const result = await service.executeTest({
584
+ testFile: "/workspace/test.spec.ts",
585
+ workspacePath: "/workspace",
586
+ language: "typescript",
587
+ testType: "ui",
588
+ });
589
+ expect(result.videoPath).toBeDefined();
590
+ expect(result.videoPath).toContain(".skyramp/videos/");
591
+ expect(result.videoPath).toContain("video.webm");
592
+ });
593
+ it("leaves videoPath undefined when no video.webm found", async () => {
594
+ const mockContainer = { remove: jest.fn().mockResolvedValue(undefined) };
595
+ mockRun.mockResolvedValue([{ StatusCode: 0 }, mockContainer]);
596
+ const videoSubdir = getVideoSubdir("/workspace/test.spec.ts");
597
+ mockReaddirSync.mockImplementation((dir, options) => {
598
+ // Mock empty video directory
599
+ if (dir.includes(".skyramp/videos") && dir.includes(videoSubdir)) {
600
+ if (options?.withFileTypes) {
601
+ return [];
602
+ }
603
+ return [];
604
+ }
605
+ // Default workspace files
606
+ if (options?.withFileTypes) {
607
+ return [{ name: "test.spec.ts", isFile: () => true, isDirectory: () => false }];
608
+ }
609
+ return ["test.spec.ts"];
610
+ });
611
+ const service = new TestExecutionService();
612
+ const result = await service.executeTest({
613
+ testFile: "/workspace/test.spec.ts",
614
+ workspacePath: "/workspace",
615
+ language: "typescript",
616
+ testType: "ui",
617
+ });
618
+ expect(result.videoPath).toBeUndefined();
619
+ });
620
+ it("leaves videoPath undefined for non-browser tests", async () => {
621
+ const mockContainer = { remove: jest.fn().mockResolvedValue(undefined) };
622
+ mockRun.mockResolvedValue([{ StatusCode: 0 }, mockContainer]);
623
+ const service = new TestExecutionService();
624
+ const result = await service.executeTest({
625
+ testFile: "/workspace/integration_test.py",
626
+ workspacePath: "/workspace",
627
+ language: "python",
628
+ testType: "integration",
629
+ });
630
+ expect(result.videoPath).toBeUndefined();
631
+ });
632
+ });