@rettangoli/vt 0.0.14 → 1.0.0-rc12
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/README.md +164 -64
- package/package.json +9 -2
- package/src/capture/capture-scheduler.js +206 -0
- package/src/capture/playwright-runner.js +405 -0
- package/src/capture/result-collector.js +234 -0
- package/src/capture/screenshot-naming.js +13 -0
- package/src/capture/spec-loader.js +119 -0
- package/src/capture/worker-plan.js +66 -0
- package/src/cli/generate-options.js +94 -0
- package/src/cli/generate.js +118 -28
- package/src/cli/index.js +3 -1
- package/src/cli/report-options.js +43 -0
- package/src/cli/report.js +88 -151
- package/src/cli/screenshot.js +8 -0
- package/src/cli/service-runtime.js +116 -0
- package/src/cli/templates/default.html +5 -3
- package/src/cli/templates/index.html +9 -7
- package/src/cli/templates/report.html +8 -6
- package/src/common.js +124 -185
- package/src/createSteps.js +810 -44
- package/src/report/report-model.js +76 -0
- package/src/report/report-render.js +22 -0
- package/src/section-page-key.js +14 -0
- package/src/selector-filter.js +140 -0
- package/src/step-commands.js +37 -0
- package/src/validation.js +636 -0
- package/src/viewport.js +99 -0
- package/docker/Dockerfile +0 -21
- package/docker/build-and-push.sh +0 -16
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { appendViewportToBaseName, resolveViewports } from "../viewport.js";
|
|
2
|
+
|
|
3
|
+
const removeExtension = (filePath) => filePath.replace(/\.[^/.]+$/, "");
|
|
4
|
+
|
|
5
|
+
const toHtmlPath = (filePath) => {
|
|
6
|
+
if (filePath.endsWith(".html")) {
|
|
7
|
+
return filePath;
|
|
8
|
+
}
|
|
9
|
+
return `${removeExtension(filePath)}.html`;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function normalizePathForUrl(filePath) {
|
|
13
|
+
return filePath.replace(/\\/g, "/");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toAbsoluteUrl(rawUrl, serverUrl) {
|
|
17
|
+
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) {
|
|
18
|
+
return rawUrl;
|
|
19
|
+
}
|
|
20
|
+
return new URL(rawUrl, serverUrl).href;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveWaitStrategy(frontMatter, defaults) {
|
|
24
|
+
if (frontMatter.waitStrategy) {
|
|
25
|
+
return frontMatter.waitStrategy;
|
|
26
|
+
}
|
|
27
|
+
if (frontMatter.waitEvent) {
|
|
28
|
+
return "event";
|
|
29
|
+
}
|
|
30
|
+
if (frontMatter.waitSelector) {
|
|
31
|
+
return "selector";
|
|
32
|
+
}
|
|
33
|
+
if (defaults.waitStrategy) {
|
|
34
|
+
return defaults.waitStrategy;
|
|
35
|
+
}
|
|
36
|
+
if (defaults.waitEvent) {
|
|
37
|
+
return "event";
|
|
38
|
+
}
|
|
39
|
+
if (defaults.waitSelector) {
|
|
40
|
+
return "selector";
|
|
41
|
+
}
|
|
42
|
+
return "networkidle";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function estimateTaskCost(steps, resolvedWaitStrategy) {
|
|
46
|
+
const strategyBase = {
|
|
47
|
+
networkidle: 30,
|
|
48
|
+
load: 10,
|
|
49
|
+
event: 20,
|
|
50
|
+
selector: 15,
|
|
51
|
+
};
|
|
52
|
+
const stepCost = Array.isArray(steps) ? steps.length * 25 : 0;
|
|
53
|
+
return 100 + stepCost + (strategyBase[resolvedWaitStrategy] ?? 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createCaptureTasks(generatedFiles, options) {
|
|
57
|
+
const {
|
|
58
|
+
serverUrl,
|
|
59
|
+
configUrl,
|
|
60
|
+
waitEvent,
|
|
61
|
+
waitSelector,
|
|
62
|
+
waitStrategy,
|
|
63
|
+
viewport,
|
|
64
|
+
} = options;
|
|
65
|
+
|
|
66
|
+
const tasks = [];
|
|
67
|
+
let taskIndex = 0;
|
|
68
|
+
|
|
69
|
+
generatedFiles.forEach((file, fileIndex) => {
|
|
70
|
+
const frontMatter = file.frontMatter || {};
|
|
71
|
+
const normalizedPath = normalizePathForUrl(file.path);
|
|
72
|
+
const constructedUrl = toHtmlPath(`${serverUrl}/candidate/${normalizedPath}`);
|
|
73
|
+
const resolvedConfigUrl = configUrl ? toAbsoluteUrl(configUrl, serverUrl) : null;
|
|
74
|
+
const urlResolutionBase = resolvedConfigUrl || serverUrl;
|
|
75
|
+
const rawUrl = frontMatter.url ?? resolvedConfigUrl ?? constructedUrl;
|
|
76
|
+
const url = toAbsoluteUrl(rawUrl, urlResolutionBase);
|
|
77
|
+
const resolvedWaitEvent = frontMatter.waitEvent ?? waitEvent;
|
|
78
|
+
const resolvedWaitSelector = frontMatter.waitSelector ?? waitSelector;
|
|
79
|
+
|
|
80
|
+
const resolvedWaitStrategy = resolveWaitStrategy(frontMatter, {
|
|
81
|
+
waitEvent,
|
|
82
|
+
waitSelector,
|
|
83
|
+
waitStrategy,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const resolvedViewports = resolveViewports(frontMatter.viewport, viewport);
|
|
87
|
+
resolvedViewports.forEach((resolvedViewport) => {
|
|
88
|
+
const viewportId = resolvedViewport.id;
|
|
89
|
+
const task = {
|
|
90
|
+
id: `${fileIndex}:${file.path}:${viewportId ?? "default"}`,
|
|
91
|
+
index: taskIndex,
|
|
92
|
+
path: file.path,
|
|
93
|
+
url,
|
|
94
|
+
baseName: appendViewportToBaseName(removeExtension(file.path), viewportId),
|
|
95
|
+
frontMatter,
|
|
96
|
+
steps: frontMatter.steps || [],
|
|
97
|
+
waitStrategy: resolvedWaitStrategy,
|
|
98
|
+
estimatedCost: estimateTaskCost(frontMatter.steps || [], resolvedWaitStrategy),
|
|
99
|
+
viewport: {
|
|
100
|
+
id: viewportId,
|
|
101
|
+
width: resolvedViewport.width,
|
|
102
|
+
height: resolvedViewport.height,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (resolvedWaitEvent !== undefined && resolvedWaitEvent !== null) {
|
|
107
|
+
task.waitEvent = resolvedWaitEvent;
|
|
108
|
+
}
|
|
109
|
+
if (resolvedWaitSelector !== undefined && resolvedWaitSelector !== null) {
|
|
110
|
+
task.waitSelector = resolvedWaitSelector;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
tasks.push(task);
|
|
114
|
+
taskIndex += 1;
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return tasks;
|
|
119
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
|
|
3
|
+
const GIGABYTE = 1024 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
function normalizeFinite(value, fallback) {
|
|
6
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
return fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeCpuCount(cpuCount) {
|
|
13
|
+
const normalized = Math.floor(normalizeFinite(cpuCount, 1));
|
|
14
|
+
return Math.max(1, normalized);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeMemoryGb(totalMemoryGb) {
|
|
18
|
+
const normalized = normalizeFinite(totalMemoryGb, 1);
|
|
19
|
+
return Math.max(0.1, normalized);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveWorkerPlan(requestedWorkers, system = {}) {
|
|
23
|
+
if (requestedWorkers !== undefined && requestedWorkers !== null) {
|
|
24
|
+
if (!Number.isInteger(requestedWorkers) || requestedWorkers < 1) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`workerCount must be an integer >= 1, got ${requestedWorkers}.`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const cpuCount = normalizeCpuCount(
|
|
32
|
+
system.cpuCount ?? os.cpus()?.length ?? 1,
|
|
33
|
+
);
|
|
34
|
+
const cpuBound = Math.max(1, cpuCount - 1);
|
|
35
|
+
|
|
36
|
+
const totalMemoryGb = normalizeMemoryGb(
|
|
37
|
+
system.totalMemoryGb ?? (os.totalmem() / GIGABYTE),
|
|
38
|
+
);
|
|
39
|
+
const memoryBound = Math.max(1, Math.floor(totalMemoryGb / 1.5));
|
|
40
|
+
const autoWorkers = Math.max(1, Math.min(cpuBound, memoryBound, 16));
|
|
41
|
+
|
|
42
|
+
const baseAdaptivePolicy = {
|
|
43
|
+
cpuCount,
|
|
44
|
+
cpuBound,
|
|
45
|
+
totalMemoryGb: Number(totalMemoryGb.toFixed(2)),
|
|
46
|
+
memoryBound,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (requestedWorkers !== undefined && requestedWorkers !== null) {
|
|
50
|
+
return {
|
|
51
|
+
workerCount: requestedWorkers,
|
|
52
|
+
adaptivePolicy: {
|
|
53
|
+
mode: "manual",
|
|
54
|
+
...baseAdaptivePolicy,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
workerCount: autoWorkers,
|
|
61
|
+
adaptivePolicy: {
|
|
62
|
+
mode: "auto",
|
|
63
|
+
...baseAdaptivePolicy,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { validateFiniteNumber } from "../validation.js";
|
|
3
|
+
import { normalizeSelectors } from "../selector-filter.js";
|
|
4
|
+
|
|
5
|
+
export function resolveGenerateOptions(options = {}, configData = {}) {
|
|
6
|
+
const {
|
|
7
|
+
skipScreenshots: cliSkipScreenshots,
|
|
8
|
+
captureScreenshots,
|
|
9
|
+
vtPath: cliVtPath,
|
|
10
|
+
port: cliPort,
|
|
11
|
+
concurrency: cliConcurrency,
|
|
12
|
+
timeout: cliTimeout,
|
|
13
|
+
waitEvent: cliWaitEvent,
|
|
14
|
+
folder: cliFolder,
|
|
15
|
+
group: cliGroup,
|
|
16
|
+
item: cliItem,
|
|
17
|
+
headless: cliHeadless,
|
|
18
|
+
url: cliUrl,
|
|
19
|
+
} = options;
|
|
20
|
+
|
|
21
|
+
const waitEvent = cliWaitEvent ?? configData.waitEvent;
|
|
22
|
+
const timeout = cliTimeout ?? configData.timeout ?? 30000;
|
|
23
|
+
const selectors = normalizeSelectors({
|
|
24
|
+
folder: cliFolder,
|
|
25
|
+
group: cliGroup,
|
|
26
|
+
item: cliItem,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let resolvedSkipScreenshots;
|
|
30
|
+
if (captureScreenshots === true) {
|
|
31
|
+
resolvedSkipScreenshots = false;
|
|
32
|
+
} else if (captureScreenshots === false) {
|
|
33
|
+
resolvedSkipScreenshots = true;
|
|
34
|
+
} else if (cliSkipScreenshots === true) {
|
|
35
|
+
resolvedSkipScreenshots = true;
|
|
36
|
+
} else {
|
|
37
|
+
resolvedSkipScreenshots = configData.skipScreenshots ?? false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const resolvedOptions = {
|
|
41
|
+
vtPath: cliVtPath ?? configData.path ?? "./vt",
|
|
42
|
+
skipScreenshots: resolvedSkipScreenshots,
|
|
43
|
+
port: cliPort ?? configData.port ?? 3001,
|
|
44
|
+
waitEvent,
|
|
45
|
+
headless: cliHeadless ?? true,
|
|
46
|
+
configUrl: cliUrl ?? configData.url,
|
|
47
|
+
...(configData.viewport !== undefined ? { viewport: configData.viewport } : {}),
|
|
48
|
+
...(configData.service?.start ? { serviceStart: configData.service.start } : {}),
|
|
49
|
+
selectors,
|
|
50
|
+
|
|
51
|
+
// Internal capture defaults (not user-configurable).
|
|
52
|
+
screenshotWaitTime: 0,
|
|
53
|
+
waitStrategy: waitEvent ? "event" : "load",
|
|
54
|
+
workerCount: cliConcurrency ?? configData.concurrency ?? undefined, // adaptive worker planning
|
|
55
|
+
isolationMode: "fast",
|
|
56
|
+
navigationTimeout: timeout,
|
|
57
|
+
readyTimeout: timeout,
|
|
58
|
+
screenshotTimeout: timeout,
|
|
59
|
+
maxRetries: 1,
|
|
60
|
+
recycleEvery: 25,
|
|
61
|
+
metricsPath: join(".rettangoli", "vt", "metrics.json"),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (typeof resolvedOptions.vtPath !== "string" || resolvedOptions.vtPath.trim().length === 0) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Invalid vtPath: expected a non-empty string, got "${resolvedOptions.vtPath}".`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (typeof resolvedOptions.skipScreenshots !== "boolean") {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Invalid skipScreenshots: expected a boolean, got ${typeof resolvedOptions.skipScreenshots}.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
validateFiniteNumber(resolvedOptions.port, "port", { integer: true, min: 1, max: 65535 });
|
|
75
|
+
if (resolvedOptions.workerCount !== undefined && resolvedOptions.workerCount !== null) {
|
|
76
|
+
validateFiniteNumber(resolvedOptions.workerCount, "concurrency", { integer: true, min: 1 });
|
|
77
|
+
}
|
|
78
|
+
validateFiniteNumber(resolvedOptions.navigationTimeout, "timeout", { integer: true, min: 1 });
|
|
79
|
+
if (typeof resolvedOptions.headless !== "boolean") {
|
|
80
|
+
throw new Error(`Invalid headless: expected a boolean, got ${typeof resolvedOptions.headless}.`);
|
|
81
|
+
}
|
|
82
|
+
if (resolvedOptions.waitEvent !== undefined && resolvedOptions.waitEvent !== null) {
|
|
83
|
+
if (typeof resolvedOptions.waitEvent !== "string" || resolvedOptions.waitEvent.trim().length === 0) {
|
|
84
|
+
throw new Error(`Invalid waitEvent: expected a non-empty string, got ${typeof resolvedOptions.waitEvent}.`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (resolvedOptions.configUrl !== undefined && resolvedOptions.configUrl !== null) {
|
|
88
|
+
if (typeof resolvedOptions.configUrl !== "string" || resolvedOptions.configUrl.trim().length === 0) {
|
|
89
|
+
throw new Error(`Invalid url: expected a non-empty string, got ${typeof resolvedOptions.configUrl}.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return resolvedOptions;
|
|
94
|
+
}
|
package/src/cli/generate.js
CHANGED
|
@@ -8,38 +8,98 @@ import {
|
|
|
8
8
|
generateOverview,
|
|
9
9
|
readYaml,
|
|
10
10
|
} from "../common.js";
|
|
11
|
+
import { validateVtConfig } from "../validation.js";
|
|
12
|
+
import { resolveGenerateOptions } from "./generate-options.js";
|
|
13
|
+
import {
|
|
14
|
+
filterGeneratedFilesBySelectors,
|
|
15
|
+
hasSelectors,
|
|
16
|
+
} from "../selector-filter.js";
|
|
17
|
+
import {
|
|
18
|
+
startManagedService,
|
|
19
|
+
stopManagedService,
|
|
20
|
+
waitForServiceReady,
|
|
21
|
+
} from "./service-runtime.js";
|
|
11
22
|
|
|
12
23
|
const libraryTemplatesPath = new URL("./templates", import.meta.url).pathname;
|
|
13
24
|
const libraryStaticPath = new URL("./static", import.meta.url).pathname;
|
|
14
25
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
export function buildCaptureOptions({
|
|
27
|
+
filesToScreenshot,
|
|
28
|
+
port,
|
|
29
|
+
candidatePath,
|
|
30
|
+
resolvedOptions,
|
|
31
|
+
}) {
|
|
19
32
|
const {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
workerCount,
|
|
34
|
+
screenshotWaitTime,
|
|
35
|
+
configUrl,
|
|
36
|
+
waitEvent,
|
|
37
|
+
waitSelector,
|
|
38
|
+
waitStrategy,
|
|
39
|
+
navigationTimeout,
|
|
40
|
+
readyTimeout,
|
|
41
|
+
screenshotTimeout,
|
|
42
|
+
maxRetries,
|
|
43
|
+
recycleEvery,
|
|
44
|
+
isolationMode,
|
|
45
|
+
metricsPath,
|
|
46
|
+
headless,
|
|
47
|
+
viewport,
|
|
48
|
+
} = resolvedOptions;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
generatedFiles: filesToScreenshot,
|
|
52
|
+
serverUrl: `http://localhost:${port}`,
|
|
53
|
+
screenshotsDir: candidatePath,
|
|
54
|
+
workerCount,
|
|
55
|
+
screenshotWaitTime,
|
|
56
|
+
configUrl,
|
|
24
57
|
waitEvent,
|
|
25
|
-
|
|
26
|
-
|
|
58
|
+
waitSelector,
|
|
59
|
+
waitStrategy,
|
|
60
|
+
navigationTimeout,
|
|
61
|
+
readyTimeout,
|
|
62
|
+
screenshotTimeout,
|
|
63
|
+
maxRetries,
|
|
64
|
+
recycleEvery,
|
|
65
|
+
isolationMode,
|
|
66
|
+
metricsPath,
|
|
67
|
+
headless,
|
|
68
|
+
viewport,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
27
71
|
|
|
28
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Main function that orchestrates the entire process
|
|
74
|
+
*/
|
|
75
|
+
async function main(options = {}) {
|
|
29
76
|
const mainConfigPath = "rettangoli.config.yaml";
|
|
30
77
|
const siteOutputPath = join(".rettangoli", "vt", "_site");
|
|
31
|
-
const candidatePath = join(siteOutputPath, "candidate");
|
|
32
78
|
|
|
33
|
-
|
|
34
|
-
let configData = {};
|
|
79
|
+
let mainConfig;
|
|
35
80
|
try {
|
|
36
|
-
|
|
37
|
-
configData = mainConfig.vt || {};
|
|
81
|
+
mainConfig = await readYaml(mainConfigPath);
|
|
38
82
|
} catch (error) {
|
|
39
|
-
|
|
83
|
+
throw new Error(`Unable to read "${mainConfigPath}": ${error.message}`, { cause: error });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const vtConfig = mainConfig?.vt;
|
|
87
|
+
if (!vtConfig) {
|
|
88
|
+
throw new Error(`Invalid "${mainConfigPath}": missing required "vt" section.`);
|
|
40
89
|
}
|
|
41
90
|
|
|
42
|
-
const
|
|
91
|
+
const configData = validateVtConfig(vtConfig, mainConfigPath);
|
|
92
|
+
const resolvedOptions = resolveGenerateOptions(options, configData);
|
|
93
|
+
const {
|
|
94
|
+
vtPath,
|
|
95
|
+
skipScreenshots,
|
|
96
|
+
port,
|
|
97
|
+
configUrl,
|
|
98
|
+
serviceStart,
|
|
99
|
+
} = resolvedOptions;
|
|
100
|
+
|
|
101
|
+
const specsPath = join(vtPath, "specs");
|
|
102
|
+
const candidatePath = join(siteOutputPath, "candidate");
|
|
43
103
|
|
|
44
104
|
// Clear candidate directory
|
|
45
105
|
await rm(candidatePath, { recursive: true, force: true });
|
|
@@ -76,6 +136,16 @@ async function main(options) {
|
|
|
76
136
|
templateConfig,
|
|
77
137
|
);
|
|
78
138
|
|
|
139
|
+
const scopedFiles = filterGeneratedFilesBySelectors(
|
|
140
|
+
generatedFiles,
|
|
141
|
+
resolvedOptions.selectors,
|
|
142
|
+
configData.sections,
|
|
143
|
+
);
|
|
144
|
+
if (hasSelectors(resolvedOptions.selectors)) {
|
|
145
|
+
const excludedCount = generatedFiles.length - scopedFiles.length;
|
|
146
|
+
console.log(`Selector scope: ${scopedFiles.length} file(s) selected, ${excludedCount} excluded.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
79
149
|
// Generate overview page (includes all files, skipped or not)
|
|
80
150
|
generateOverview(
|
|
81
151
|
generatedFiles,
|
|
@@ -87,31 +157,51 @@ async function main(options) {
|
|
|
87
157
|
// Take screenshots (only for non-skipped files)
|
|
88
158
|
if (!skipScreenshots) {
|
|
89
159
|
// Filter out files with skipScreenshot: true in frontmatter
|
|
90
|
-
const filesToScreenshot =
|
|
160
|
+
const filesToScreenshot = scopedFiles.filter(
|
|
91
161
|
(file) => !file.frontMatter?.skipScreenshot
|
|
92
162
|
);
|
|
93
163
|
|
|
94
|
-
const skippedCount =
|
|
164
|
+
const skippedCount = scopedFiles.length - filesToScreenshot.length;
|
|
95
165
|
if (skippedCount > 0) {
|
|
96
166
|
console.log(`Skipping screenshots for ${skippedCount} files`);
|
|
97
167
|
}
|
|
168
|
+
if (filesToScreenshot.length === 0) {
|
|
169
|
+
console.log("No files selected for screenshot capture. Skipping Playwright run.");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let server = null;
|
|
174
|
+
let managedService = null;
|
|
98
175
|
|
|
99
|
-
const server = configUrl ? null : startWebServer(siteOutputPath, vtPath, port);
|
|
100
176
|
try {
|
|
177
|
+
if (serviceStart) {
|
|
178
|
+
if (!configUrl) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
"vt.service.start requires vt.url (or --url) so VT can wait for readiness and capture against that URL.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
managedService = startManagedService({ command: serviceStart });
|
|
184
|
+
await waitForServiceReady({ url: configUrl, handle: managedService });
|
|
185
|
+
} else if (!configUrl) {
|
|
186
|
+
server = await startWebServer(siteOutputPath, vtPath, port);
|
|
187
|
+
}
|
|
188
|
+
|
|
101
189
|
await takeScreenshots(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
waitEvent,
|
|
190
|
+
buildCaptureOptions({
|
|
191
|
+
filesToScreenshot,
|
|
192
|
+
port,
|
|
193
|
+
candidatePath,
|
|
194
|
+
resolvedOptions,
|
|
195
|
+
}),
|
|
109
196
|
);
|
|
110
197
|
} finally {
|
|
111
198
|
if (server) {
|
|
112
199
|
server.close();
|
|
113
200
|
console.log("Server stopped");
|
|
114
201
|
}
|
|
202
|
+
if (managedService) {
|
|
203
|
+
await stopManagedService(managedService);
|
|
204
|
+
}
|
|
115
205
|
}
|
|
116
206
|
}
|
|
117
207
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { validateFiniteNumber } from "../validation.js";
|
|
2
|
+
import { normalizeSelectors } from "../selector-filter.js";
|
|
3
|
+
|
|
4
|
+
const COMPARE_METHODS = ["pixelmatch", "md5"];
|
|
5
|
+
|
|
6
|
+
export function resolveReportOptions(options = {}, configData = {}) {
|
|
7
|
+
const {
|
|
8
|
+
vtPath: cliVtPath,
|
|
9
|
+
compareMethod: cliCompareMethod,
|
|
10
|
+
colorThreshold: cliColorThreshold,
|
|
11
|
+
diffThreshold: cliDiffThreshold,
|
|
12
|
+
folder: cliFolder,
|
|
13
|
+
group: cliGroup,
|
|
14
|
+
item: cliItem,
|
|
15
|
+
} = options;
|
|
16
|
+
|
|
17
|
+
const selectors = normalizeSelectors({
|
|
18
|
+
folder: cliFolder,
|
|
19
|
+
group: cliGroup,
|
|
20
|
+
item: cliItem,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const resolvedOptions = {
|
|
24
|
+
vtPath: cliVtPath ?? configData.path ?? "./vt",
|
|
25
|
+
compareMethod: cliCompareMethod ?? configData.compareMethod ?? "pixelmatch",
|
|
26
|
+
colorThreshold: cliColorThreshold ?? configData.colorThreshold ?? 0.1,
|
|
27
|
+
diffThreshold: cliDiffThreshold ?? configData.diffThreshold ?? 0.3,
|
|
28
|
+
selectors,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
if (typeof resolvedOptions.vtPath !== "string" || resolvedOptions.vtPath.trim().length === 0) {
|
|
32
|
+
throw new Error(`Invalid vtPath: expected a non-empty string, got "${resolvedOptions.vtPath}".`);
|
|
33
|
+
}
|
|
34
|
+
if (!COMPARE_METHODS.includes(resolvedOptions.compareMethod)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Invalid compareMethod "${resolvedOptions.compareMethod}". Expected "pixelmatch" or "md5".`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
validateFiniteNumber(resolvedOptions.colorThreshold, "colorThreshold", { min: 0, max: 1 });
|
|
40
|
+
validateFiniteNumber(resolvedOptions.diffThreshold, "diffThreshold", { min: 0, max: 100 });
|
|
41
|
+
|
|
42
|
+
return resolvedOptions;
|
|
43
|
+
}
|