@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.
@@ -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
+ }
@@ -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
- * Main function that orchestrates the entire process
17
- */
18
- async function main(options) {
21
+ export function buildCaptureOptions({
22
+ filesToScreenshot,
23
+ port,
24
+ candidatePath,
25
+ resolvedOptions,
26
+ }) {
19
27
  const {
20
- skipScreenshots = false,
21
- vtPath = "./vt",
22
- screenshotWaitTime = 0,
23
- port = 3001,
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
- concurrency = 12,
26
- } = options;
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
- const specsPath = join(vtPath, "specs");
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
- // Read VT config from main rettangoli.config.yaml
34
- let configData = {};
74
+ let mainConfig;
35
75
  try {
36
- const mainConfig = await readYaml(mainConfigPath);
37
- configData = mainConfig.vt || {};
76
+ mainConfig = await readYaml(mainConfigPath);
38
77
  } catch (error) {
39
- console.log("Main config file not found, using defaults");
78
+ throw new Error(`Unable to read "${mainConfigPath}": ${error.message}`, { cause: error });
40
79
  }
41
80
 
42
- const configUrl = configData.url;
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 = generatedFiles.filter(
154
+ const filesToScreenshot = scopedFiles.filter(
91
155
  (file) => !file.frontMatter?.skipScreenshot
92
156
  );
93
157
 
94
- const skippedCount = generatedFiles.length - filesToScreenshot.length;
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
- filesToScreenshot,
103
- `http://localhost:${port}`,
104
- candidatePath,
105
- concurrency,
106
- screenshotWaitTime,
107
- configUrl,
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
+ }