@skyramp/mcp 0.2.7 → 0.2.150-rc.mntnc
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.
- package/build/index.js +5 -2
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -5
- package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.d.ts +2 -0
- package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +114 -0
- package/build/prompts/sut-setup/modes/dockerComposePrompt.d.ts +2 -0
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +292 -0
- package/build/prompts/sut-setup/shared.d.ts +39 -0
- package/build/prompts/sut-setup/shared.js +132 -0
- package/build/prompts/test-maintenance/driftAnalysisSections.js +9 -3
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -1
- package/build/prompts/testbot/testbot-prompts.d.ts +1 -1
- package/build/prompts/testbot/testbot-prompts.js +23 -16
- package/build/prompts/testbot/testbot-prompts.test.js +0 -44
- package/build/resources/sutSetupResource.d.ts +2 -0
- package/build/resources/sutSetupResource.js +45 -0
- package/build/resources/testbotResource.js +1 -1
- package/build/services/TestDiscoveryService.d.ts +2 -2
- package/build/services/TestDiscoveryService.js +22 -12
- package/build/services/TestExecutionService.d.ts +9 -0
- package/build/services/TestExecutionService.js +158 -26
- package/build/services/TestExecutionService.test.js +306 -0
- package/build/tools/executeSkyrampTestTool.js +17 -4
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +1 -1
- package/build/tools/submitReportTool.js +81 -35
- package/build/tools/submitReportTool.test.js +264 -50
- package/build/tools/test-management/actionsTool.js +10 -0
- package/build/tools/test-management/analyzeChangesTool.d.ts +0 -1
- package/build/tools/test-management/analyzeChangesTool.js +84 -41
- package/build/tools/test-management/analyzeChangesTool.test.js +4 -34
- package/build/tools/test-management/analyzeTestHealthTool.js +6 -3
- package/build/types/TestAnalysis.d.ts +2 -1
- package/build/types/TestExecution.d.ts +12 -2
- package/build/types/TestExecution.js +11 -1
- package/build/types/TestbotReport.d.ts +68 -0
- package/build/types/TestbotReport.js +1 -0
- package/build/types/index.d.ts +4 -0
- package/build/types/index.js +3 -0
- package/build/utils/AnalysisStateManager.js +10 -1
- package/build/utils/routeParsers.d.ts +0 -10
- package/build/utils/routeParsers.js +31 -38
- package/build/utils/scenarioDrafting.d.ts +1 -1
- package/build/utils/scenarioDrafting.js +23 -1
- package/build/utils/utils.d.ts +7 -1
- package/build/utils/utils.js +10 -0
- package/node_modules/playwright/LICENSE +202 -0
- package/node_modules/playwright/NOTICE +5 -0
- package/node_modules/playwright/README.md +168 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +13 -9
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -3
- package/node_modules/playwright/node_modules/playwright-core/LICENSE +202 -0
- package/node_modules/playwright/node_modules/playwright-core/NOTICE +5 -0
- package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +23 -126
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-B0JOjboO.js → codeMirrorModule-B5kqh2EV.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-1anWeyDf.js → defaultSettingsView-CZ9npQ3N.js} +79 -79
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.Cc5029a3.js → index.BCnxj-_b.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.Wo5yvvVh.js → uiMode.1Ym0Ivn8.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +9 -2
- package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
- package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/.bin/node-which +0 -52
- package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/is-docker +0 -5
- package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/mime +0 -46
- package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/yaml +0 -11
- 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
|
-
|
|
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.
|
|
283
|
-
const failed = results.filter((r) =>
|
|
284
|
-
const crashed = results.filter((r) => r.
|
|
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
|
|
429
|
-
//
|
|
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:
|
|
436
|
-
Target:
|
|
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
|
-
//
|
|
640
|
-
const
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
+
});
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { stripVTControlCharacters } from "util";
|
|
3
3
|
import { TestExecutionService } from "../services/TestExecutionService.js";
|
|
4
4
|
import { AnalyticsService } from "../services/AnalyticsService.js";
|
|
5
|
+
import { TestExecutionStatus } from "../types/TestExecution.js";
|
|
5
6
|
import { getWorkspaceBaseUrl } from "../utils/workspaceAuth.js";
|
|
6
7
|
import { ProgrammingLanguage, TestType } from "../types/TestTypes.js";
|
|
7
8
|
import { StateManager } from "../utils/AnalysisStateManager.js";
|
|
@@ -66,7 +67,11 @@ For detailed documentation visit: https://www.skyramp.dev/docs/quickstart`,
|
|
|
66
67
|
stateFile: z
|
|
67
68
|
.string()
|
|
68
69
|
.optional()
|
|
69
|
-
.describe("
|
|
70
|
+
.describe("Path to state file from skyramp_analyze_changes. Always pass when available — results are written back so skyramp_submit_report can override before/afterStatus with ground-truth pass/fail."),
|
|
71
|
+
phase: z
|
|
72
|
+
.enum(["before", "after"])
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("Execution phase for maintained tests: 'before' captures pre-edit baseline; 'after' (default) records post-edit result."),
|
|
70
75
|
},
|
|
71
76
|
_meta: {
|
|
72
77
|
keywords: ["run test", "execute test"],
|
|
@@ -147,7 +152,12 @@ For detailed documentation visit: https://www.skyramp.dev/docs/quickstart`,
|
|
|
147
152
|
if (stateData && stateData.existingTests) {
|
|
148
153
|
const testIndex = stateData.existingTests.findIndex((t) => t.testFile === params.testFile);
|
|
149
154
|
if (testIndex >= 0) {
|
|
150
|
-
|
|
155
|
+
if (params.phase === "before") {
|
|
156
|
+
stateData.existingTests[testIndex].executionBefore = result;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
stateData.existingTests[testIndex].executionAfter = result;
|
|
160
|
+
}
|
|
151
161
|
await stateManager.writeData(stateData);
|
|
152
162
|
logger.info(`Updated stateFile with execution results for ${params.testFile}`);
|
|
153
163
|
}
|
|
@@ -163,7 +173,7 @@ For detailed documentation visit: https://www.skyramp.dev/docs/quickstart`,
|
|
|
163
173
|
}
|
|
164
174
|
// Progress is already reported by TestExecutionService
|
|
165
175
|
// Only report final status if not already at 100%
|
|
166
|
-
if (
|
|
176
|
+
if (result.status !== TestExecutionStatus.Pass) {
|
|
167
177
|
errorResult = {
|
|
168
178
|
content: [
|
|
169
179
|
{
|
|
@@ -177,11 +187,14 @@ For detailed documentation visit: https://www.skyramp.dev/docs/quickstart`,
|
|
|
177
187
|
return errorResult;
|
|
178
188
|
}
|
|
179
189
|
// Success - progress already reported by TestExecutionService
|
|
190
|
+
const videoInfo = result.videoPath
|
|
191
|
+
? `\n\nVideo recording: ${result.videoPath}`
|
|
192
|
+
: "";
|
|
180
193
|
return {
|
|
181
194
|
content: [
|
|
182
195
|
{
|
|
183
196
|
type: "text",
|
|
184
|
-
text: `Test execution result: ${stripVTControlCharacters(result.output || "")}`,
|
|
197
|
+
text: `Test execution result: ${stripVTControlCharacters(result.output || "")}${videoInfo}`,
|
|
185
198
|
},
|
|
186
199
|
],
|
|
187
200
|
};
|