@rettangoli/vt 0.0.14 → 1.0.0-rc2
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 +135 -64
- package/package.json +9 -2
- package/src/capture/capture-scheduler.js +206 -0
- package/src/capture/playwright-runner.js +403 -0
- package/src/capture/result-collector.js +234 -0
- package/src/capture/screenshot-naming.js +13 -0
- package/src/capture/spec-loader.js +117 -0
- package/src/capture/worker-plan.js +66 -0
- package/src/cli/generate-options.js +81 -0
- package/src/cli/generate.js +95 -28
- package/src/cli/report-options.js +43 -0
- package/src/cli/report.js +88 -151
- package/src/cli/templates/index.html +2 -2
- package/src/cli/templates/report.html +4 -4
- package/src/common.js +123 -185
- package/src/createSteps.js +358 -28
- package/src/report/report-model.js +76 -0
- package/src/report/report-render.js +22 -0
- package/src/selector-filter.js +139 -0
- package/src/step-commands.js +33 -0
- package/src/validation.js +304 -0
- package/src/viewport.js +99 -0
- package/docker/Dockerfile +0 -21
- package/docker/build-and-push.sh +0 -16
|
@@ -0,0 +1,117 @@
|
|
|
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 rawUrl = frontMatter.url ?? configUrl ?? constructedUrl;
|
|
74
|
+
const url = toAbsoluteUrl(rawUrl, serverUrl);
|
|
75
|
+
const resolvedWaitEvent = frontMatter.waitEvent ?? waitEvent;
|
|
76
|
+
const resolvedWaitSelector = frontMatter.waitSelector ?? waitSelector;
|
|
77
|
+
|
|
78
|
+
const resolvedWaitStrategy = resolveWaitStrategy(frontMatter, {
|
|
79
|
+
waitEvent,
|
|
80
|
+
waitSelector,
|
|
81
|
+
waitStrategy,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const resolvedViewports = resolveViewports(frontMatter.viewport, viewport);
|
|
85
|
+
resolvedViewports.forEach((resolvedViewport) => {
|
|
86
|
+
const viewportId = resolvedViewport.id;
|
|
87
|
+
const task = {
|
|
88
|
+
id: `${fileIndex}:${file.path}:${viewportId ?? "default"}`,
|
|
89
|
+
index: taskIndex,
|
|
90
|
+
path: file.path,
|
|
91
|
+
url,
|
|
92
|
+
baseName: appendViewportToBaseName(removeExtension(file.path), viewportId),
|
|
93
|
+
frontMatter,
|
|
94
|
+
steps: frontMatter.steps || [],
|
|
95
|
+
waitStrategy: resolvedWaitStrategy,
|
|
96
|
+
estimatedCost: estimateTaskCost(frontMatter.steps || [], resolvedWaitStrategy),
|
|
97
|
+
viewport: {
|
|
98
|
+
id: viewportId,
|
|
99
|
+
width: resolvedViewport.width,
|
|
100
|
+
height: resolvedViewport.height,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (resolvedWaitEvent !== undefined && resolvedWaitEvent !== null) {
|
|
105
|
+
task.waitEvent = resolvedWaitEvent;
|
|
106
|
+
}
|
|
107
|
+
if (resolvedWaitSelector !== undefined && resolvedWaitSelector !== null) {
|
|
108
|
+
task.waitSelector = resolvedWaitSelector;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
tasks.push(task);
|
|
112
|
+
taskIndex += 1;
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return tasks;
|
|
117
|
+
}
|
|
@@ -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,81 @@
|
|
|
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
|
+
vtPath: cliVtPath,
|
|
9
|
+
port: cliPort,
|
|
10
|
+
concurrency: cliConcurrency,
|
|
11
|
+
timeout: cliTimeout,
|
|
12
|
+
waitEvent: cliWaitEvent,
|
|
13
|
+
folder: cliFolder,
|
|
14
|
+
group: cliGroup,
|
|
15
|
+
item: cliItem,
|
|
16
|
+
headless: cliHeadless,
|
|
17
|
+
url: cliUrl,
|
|
18
|
+
} = options;
|
|
19
|
+
|
|
20
|
+
const waitEvent = cliWaitEvent ?? configData.waitEvent;
|
|
21
|
+
const timeout = cliTimeout ?? configData.timeout ?? 30000;
|
|
22
|
+
const selectors = normalizeSelectors({
|
|
23
|
+
folder: cliFolder,
|
|
24
|
+
group: cliGroup,
|
|
25
|
+
item: cliItem,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const resolvedOptions = {
|
|
29
|
+
vtPath: cliVtPath ?? configData.path ?? "./vt",
|
|
30
|
+
skipScreenshots: cliSkipScreenshots ? true : (configData.skipScreenshots ?? false),
|
|
31
|
+
port: cliPort ?? configData.port ?? 3001,
|
|
32
|
+
waitEvent,
|
|
33
|
+
headless: cliHeadless ?? true,
|
|
34
|
+
configUrl: cliUrl ?? configData.url,
|
|
35
|
+
...(configData.viewport !== undefined ? { viewport: configData.viewport } : {}),
|
|
36
|
+
selectors,
|
|
37
|
+
|
|
38
|
+
// Internal capture defaults (not user-configurable).
|
|
39
|
+
screenshotWaitTime: 0,
|
|
40
|
+
waitStrategy: waitEvent ? "event" : "load",
|
|
41
|
+
workerCount: cliConcurrency ?? configData.concurrency ?? undefined, // adaptive worker planning
|
|
42
|
+
isolationMode: "fast",
|
|
43
|
+
navigationTimeout: timeout,
|
|
44
|
+
readyTimeout: timeout,
|
|
45
|
+
screenshotTimeout: timeout,
|
|
46
|
+
maxRetries: 1,
|
|
47
|
+
recycleEvery: 25,
|
|
48
|
+
metricsPath: join(".rettangoli", "vt", "metrics.json"),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (typeof resolvedOptions.vtPath !== "string" || resolvedOptions.vtPath.trim().length === 0) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Invalid vtPath: expected a non-empty string, got "${resolvedOptions.vtPath}".`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (typeof resolvedOptions.skipScreenshots !== "boolean") {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Invalid skipScreenshots: expected a boolean, got ${typeof resolvedOptions.skipScreenshots}.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
validateFiniteNumber(resolvedOptions.port, "port", { integer: true, min: 1, max: 65535 });
|
|
62
|
+
if (resolvedOptions.workerCount !== undefined && resolvedOptions.workerCount !== null) {
|
|
63
|
+
validateFiniteNumber(resolvedOptions.workerCount, "concurrency", { integer: true, min: 1 });
|
|
64
|
+
}
|
|
65
|
+
validateFiniteNumber(resolvedOptions.navigationTimeout, "timeout", { integer: true, min: 1 });
|
|
66
|
+
if (typeof resolvedOptions.headless !== "boolean") {
|
|
67
|
+
throw new Error(`Invalid headless: expected a boolean, got ${typeof resolvedOptions.headless}.`);
|
|
68
|
+
}
|
|
69
|
+
if (resolvedOptions.waitEvent !== undefined && resolvedOptions.waitEvent !== null) {
|
|
70
|
+
if (typeof resolvedOptions.waitEvent !== "string" || resolvedOptions.waitEvent.trim().length === 0) {
|
|
71
|
+
throw new Error(`Invalid waitEvent: expected a non-empty string, got ${typeof resolvedOptions.waitEvent}.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (resolvedOptions.configUrl !== undefined && resolvedOptions.configUrl !== null) {
|
|
75
|
+
if (typeof resolvedOptions.configUrl !== "string" || resolvedOptions.configUrl.trim().length === 0) {
|
|
76
|
+
throw new Error(`Invalid url: expected a non-empty string, got ${typeof resolvedOptions.configUrl}.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return resolvedOptions;
|
|
81
|
+
}
|
package/src/cli/generate.js
CHANGED
|
@@ -8,38 +8,92 @@ 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";
|
|
11
17
|
|
|
12
18
|
const libraryTemplatesPath = new URL("./templates", import.meta.url).pathname;
|
|
13
19
|
const libraryStaticPath = new URL("./static", import.meta.url).pathname;
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
export function buildCaptureOptions({
|
|
22
|
+
filesToScreenshot,
|
|
23
|
+
port,
|
|
24
|
+
candidatePath,
|
|
25
|
+
resolvedOptions,
|
|
26
|
+
}) {
|
|
19
27
|
const {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
workerCount,
|
|
29
|
+
screenshotWaitTime,
|
|
30
|
+
configUrl,
|
|
31
|
+
waitEvent,
|
|
32
|
+
waitSelector,
|
|
33
|
+
waitStrategy,
|
|
34
|
+
navigationTimeout,
|
|
35
|
+
readyTimeout,
|
|
36
|
+
screenshotTimeout,
|
|
37
|
+
maxRetries,
|
|
38
|
+
recycleEvery,
|
|
39
|
+
isolationMode,
|
|
40
|
+
metricsPath,
|
|
41
|
+
headless,
|
|
42
|
+
viewport,
|
|
43
|
+
} = resolvedOptions;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
generatedFiles: filesToScreenshot,
|
|
47
|
+
serverUrl: `http://localhost:${port}`,
|
|
48
|
+
screenshotsDir: candidatePath,
|
|
49
|
+
workerCount,
|
|
50
|
+
screenshotWaitTime,
|
|
51
|
+
configUrl,
|
|
24
52
|
waitEvent,
|
|
25
|
-
|
|
26
|
-
|
|
53
|
+
waitSelector,
|
|
54
|
+
waitStrategy,
|
|
55
|
+
navigationTimeout,
|
|
56
|
+
readyTimeout,
|
|
57
|
+
screenshotTimeout,
|
|
58
|
+
maxRetries,
|
|
59
|
+
recycleEvery,
|
|
60
|
+
isolationMode,
|
|
61
|
+
metricsPath,
|
|
62
|
+
headless,
|
|
63
|
+
viewport,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
27
66
|
|
|
28
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Main function that orchestrates the entire process
|
|
69
|
+
*/
|
|
70
|
+
async function main(options = {}) {
|
|
29
71
|
const mainConfigPath = "rettangoli.config.yaml";
|
|
30
72
|
const siteOutputPath = join(".rettangoli", "vt", "_site");
|
|
31
|
-
const candidatePath = join(siteOutputPath, "candidate");
|
|
32
73
|
|
|
33
|
-
|
|
34
|
-
let configData = {};
|
|
74
|
+
let mainConfig;
|
|
35
75
|
try {
|
|
36
|
-
|
|
37
|
-
configData = mainConfig.vt || {};
|
|
76
|
+
mainConfig = await readYaml(mainConfigPath);
|
|
38
77
|
} catch (error) {
|
|
39
|
-
|
|
78
|
+
throw new Error(`Unable to read "${mainConfigPath}": ${error.message}`, { cause: error });
|
|
40
79
|
}
|
|
41
80
|
|
|
42
|
-
const
|
|
81
|
+
const vtConfig = mainConfig?.vt;
|
|
82
|
+
if (!vtConfig) {
|
|
83
|
+
throw new Error(`Invalid "${mainConfigPath}": missing required "vt" section.`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const configData = validateVtConfig(vtConfig, mainConfigPath);
|
|
87
|
+
const resolvedOptions = resolveGenerateOptions(options, configData);
|
|
88
|
+
const {
|
|
89
|
+
vtPath,
|
|
90
|
+
skipScreenshots,
|
|
91
|
+
port,
|
|
92
|
+
configUrl,
|
|
93
|
+
} = resolvedOptions;
|
|
94
|
+
|
|
95
|
+
const specsPath = join(vtPath, "specs");
|
|
96
|
+
const candidatePath = join(siteOutputPath, "candidate");
|
|
43
97
|
|
|
44
98
|
// Clear candidate directory
|
|
45
99
|
await rm(candidatePath, { recursive: true, force: true });
|
|
@@ -76,6 +130,16 @@ async function main(options) {
|
|
|
76
130
|
templateConfig,
|
|
77
131
|
);
|
|
78
132
|
|
|
133
|
+
const scopedFiles = filterGeneratedFilesBySelectors(
|
|
134
|
+
generatedFiles,
|
|
135
|
+
resolvedOptions.selectors,
|
|
136
|
+
configData.sections,
|
|
137
|
+
);
|
|
138
|
+
if (hasSelectors(resolvedOptions.selectors)) {
|
|
139
|
+
const excludedCount = generatedFiles.length - scopedFiles.length;
|
|
140
|
+
console.log(`Selector scope: ${scopedFiles.length} file(s) selected, ${excludedCount} excluded.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
79
143
|
// Generate overview page (includes all files, skipped or not)
|
|
80
144
|
generateOverview(
|
|
81
145
|
generatedFiles,
|
|
@@ -87,25 +151,28 @@ async function main(options) {
|
|
|
87
151
|
// Take screenshots (only for non-skipped files)
|
|
88
152
|
if (!skipScreenshots) {
|
|
89
153
|
// Filter out files with skipScreenshot: true in frontmatter
|
|
90
|
-
const filesToScreenshot =
|
|
154
|
+
const filesToScreenshot = scopedFiles.filter(
|
|
91
155
|
(file) => !file.frontMatter?.skipScreenshot
|
|
92
156
|
);
|
|
93
157
|
|
|
94
|
-
const skippedCount =
|
|
158
|
+
const skippedCount = scopedFiles.length - filesToScreenshot.length;
|
|
95
159
|
if (skippedCount > 0) {
|
|
96
160
|
console.log(`Skipping screenshots for ${skippedCount} files`);
|
|
97
161
|
}
|
|
162
|
+
if (filesToScreenshot.length === 0) {
|
|
163
|
+
console.log("No files selected for screenshot capture. Skipping Playwright run.");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
98
166
|
|
|
99
|
-
const server = configUrl ? null : startWebServer(siteOutputPath, vtPath, port);
|
|
167
|
+
const server = configUrl ? null : await startWebServer(siteOutputPath, vtPath, port);
|
|
100
168
|
try {
|
|
101
169
|
await takeScreenshots(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
waitEvent,
|
|
170
|
+
buildCaptureOptions({
|
|
171
|
+
filesToScreenshot,
|
|
172
|
+
port,
|
|
173
|
+
candidatePath,
|
|
174
|
+
resolvedOptions,
|
|
175
|
+
}),
|
|
109
176
|
);
|
|
110
177
|
} finally {
|
|
111
178
|
if (server) {
|
|
@@ -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
|
+
}
|