@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.
@@ -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
+ }
@@ -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
- * Main function that orchestrates the entire process
17
- */
18
- async function main(options) {
26
+ export function buildCaptureOptions({
27
+ filesToScreenshot,
28
+ port,
29
+ candidatePath,
30
+ resolvedOptions,
31
+ }) {
19
32
  const {
20
- skipScreenshots = false,
21
- vtPath = "./vt",
22
- screenshotWaitTime = 0,
23
- port = 3001,
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
- concurrency = 12,
26
- } = options;
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
- const specsPath = join(vtPath, "specs");
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
- // Read VT config from main rettangoli.config.yaml
34
- let configData = {};
79
+ let mainConfig;
35
80
  try {
36
- const mainConfig = await readYaml(mainConfigPath);
37
- configData = mainConfig.vt || {};
81
+ mainConfig = await readYaml(mainConfigPath);
38
82
  } catch (error) {
39
- console.log("Main config file not found, using defaults");
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 configUrl = configData.url;
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 = generatedFiles.filter(
160
+ const filesToScreenshot = scopedFiles.filter(
91
161
  (file) => !file.frontMatter?.skipScreenshot
92
162
  );
93
163
 
94
- const skippedCount = generatedFiles.length - filesToScreenshot.length;
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
- filesToScreenshot,
103
- `http://localhost:${port}`,
104
- candidatePath,
105
- concurrency,
106
- screenshotWaitTime,
107
- configUrl,
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
@@ -1,9 +1,11 @@
1
1
  import generate from './generate.js';
2
+ import screenshot from './screenshot.js';
2
3
  import report from './report.js';
3
4
  import accept from './accept.js';
4
5
 
5
6
  export {
6
7
  generate,
8
+ screenshot,
7
9
  report,
8
10
  accept,
9
- }
11
+ }
@@ -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
+ }