@rettangoli/vt 1.0.0-rc2 → 1.0.0-rc4

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 CHANGED
@@ -5,12 +5,18 @@ Visual regression testing for Rettangoli specs using Playwright screenshots.
5
5
  ## Commands
6
6
 
7
7
  - `rtgl vt generate`
8
+ - `rtgl vt screenshot`
8
9
  - `rtgl vt report`
9
10
  - `rtgl vt accept`
10
11
 
11
- ## Public Generate Options
12
+ Behavior split:
13
+
14
+ - `generate` builds candidate HTML only (no Playwright capture)
15
+ - `screenshot` runs generate flow and captures candidate screenshots
16
+ - `report` compares existing artifacts only (does not run generate/screenshot)
17
+
18
+ ## Public Screenshot Options
12
19
 
13
- - `--skip-screenshots`
14
20
  - `--headed`
15
21
  - `--concurrency <number>`
16
22
  - `--timeout <ms>`
@@ -30,7 +36,7 @@ Visual regression testing for Rettangoli specs using Playwright screenshots.
30
36
 
31
37
  ## Scoped Runs
32
38
 
33
- Use selectors to run only part of VT in both `generate` and `report`:
39
+ Use selectors to run only part of VT in both `screenshot` and `report`:
34
40
 
35
41
  - `folder`: matches specs by folder prefix under `vt/specs` (example: `components/forms`)
36
42
  - `group`: matches section page key from `vt.sections` (`title` for flat sections, `items[].title` for grouped sections)
@@ -45,17 +51,17 @@ Examples:
45
51
 
46
52
  ```bash
47
53
  # Only specs under a folder
48
- rtgl vt generate --folder components/forms
54
+ rtgl vt screenshot --folder components/forms
49
55
 
50
56
  # Only one section/group key from vt.sections
51
- rtgl vt generate --group components_basic
57
+ rtgl vt screenshot --group components_basic
52
58
 
53
59
  # Only one spec item (extension optional)
54
- rtgl vt generate --item components/forms/login
55
- rtgl vt generate --item components/forms/login.html
60
+ rtgl vt screenshot --item components/forms/login
61
+ rtgl vt screenshot --item components/forms/login.html
56
62
 
57
63
  # Combine selectors (union)
58
- rtgl vt generate --group components_basic --item pages/home
64
+ rtgl vt screenshot --group components_basic --item pages/home
59
65
 
60
66
  # Same selectors for report
61
67
  rtgl vt report --folder components/forms
@@ -73,7 +79,9 @@ Everything else in capture is internal and intentionally not user-configurable.
73
79
  vt:
74
80
  path: ./vt
75
81
  port: 3001
76
- skipScreenshots: false
82
+ url: http://127.0.0.1:4173
83
+ service:
84
+ start: bun run preview
77
85
  concurrency: 4
78
86
  timeout: 30000
79
87
  waitEvent: vt:ready
@@ -89,6 +97,8 @@ vt:
89
97
  Notes:
90
98
 
91
99
  - `vt.sections` is required.
100
+ - `vt.service` is optional. When set, VT starts the command before capture, waits for `vt.url`, then stops it after capture.
101
+ - when `vt.service` is omitted and `vt.url` is set, VT expects that URL to already be running.
92
102
  - Section page keys (`title` for flat sections and group `items[].title`) allow only letters, numbers, `-`, `_`.
93
103
  - `vt.viewport` supports object or array; each viewport requires `id`, `width`, `height`.
94
104
  - `vt.capture` is internal and must be omitted.
@@ -113,6 +123,8 @@ Supported frontmatter keys per spec file:
113
123
  Step action reference:
114
124
 
115
125
  - `docs/step-actions.md`
126
+ - canonical format is structured action objects (`- action: ...`), with legacy string/block forms still supported.
127
+ - `assert` supports `js` deep-equal checks for object/array values.
116
128
 
117
129
  Screenshot naming:
118
130
 
@@ -125,17 +137,22 @@ Screenshot naming:
125
137
  A pre-built Docker image with `rtgl` and Playwright browsers is available:
126
138
 
127
139
  ```bash
128
- docker pull han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc2
140
+ docker pull han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc6
129
141
  ```
130
142
 
131
143
  Run commands against a local project:
132
144
 
133
145
  ```bash
134
- docker run --rm -v "$(pwd):/workspace" -w /workspace han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc2 rtgl vt generate
135
- docker run --rm -v "$(pwd):/workspace" -w /workspace han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc2 rtgl vt report
136
- docker run --rm -v "$(pwd):/workspace" -w /workspace han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc2 rtgl vt accept
146
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc6 rtgl vt screenshot
147
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc6 rtgl vt report
148
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc6 rtgl vt accept
137
149
  ```
138
150
 
151
+ Note:
152
+
153
+ - Image default working directory is `/workspace`.
154
+ - Use `-w /workspace/<subdir>` only when running commands from a subfolder within the mounted project.
155
+
139
156
  Supports `linux/amd64` and `linux/arm64`.
140
157
 
141
158
  ## Development
@@ -146,6 +163,14 @@ Run unit tests:
146
163
  bun test
147
164
  ```
148
165
 
166
+ Default unit run behavior:
167
+
168
+ - `bun test` skips the real-browser smoke tests in `spec/e2e-smoke.spec.js` unless `VT_E2E=1`.
169
+ - Skipped smoke tests are:
170
+ - `runs generate, accept, and report with real screenshots`
171
+ - `supports waitEvent readiness with real browser screenshots`
172
+ - `supports managed service lifecycle with vt.service.start and vt.url`
173
+
149
174
  Run real-browser smoke:
150
175
 
151
176
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/vt",
3
- "version": "1.0.0-rc2",
3
+ "version": "1.0.0-rc4",
4
4
  "description": "Rettangoli Visual Testing",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -70,8 +70,10 @@ export function createCaptureTasks(generatedFiles, options) {
70
70
  const frontMatter = file.frontMatter || {};
71
71
  const normalizedPath = normalizePathForUrl(file.path);
72
72
  const constructedUrl = toHtmlPath(`${serverUrl}/candidate/${normalizedPath}`);
73
- const rawUrl = frontMatter.url ?? configUrl ?? constructedUrl;
74
- const url = toAbsoluteUrl(rawUrl, serverUrl);
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);
75
77
  const resolvedWaitEvent = frontMatter.waitEvent ?? waitEvent;
76
78
  const resolvedWaitSelector = frontMatter.waitSelector ?? waitSelector;
77
79
 
@@ -5,6 +5,7 @@ import { normalizeSelectors } from "../selector-filter.js";
5
5
  export function resolveGenerateOptions(options = {}, configData = {}) {
6
6
  const {
7
7
  skipScreenshots: cliSkipScreenshots,
8
+ captureScreenshots,
8
9
  vtPath: cliVtPath,
9
10
  port: cliPort,
10
11
  concurrency: cliConcurrency,
@@ -25,14 +26,26 @@ export function resolveGenerateOptions(options = {}, configData = {}) {
25
26
  item: cliItem,
26
27
  });
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
+
28
40
  const resolvedOptions = {
29
41
  vtPath: cliVtPath ?? configData.path ?? "./vt",
30
- skipScreenshots: cliSkipScreenshots ? true : (configData.skipScreenshots ?? false),
42
+ skipScreenshots: resolvedSkipScreenshots,
31
43
  port: cliPort ?? configData.port ?? 3001,
32
44
  waitEvent,
33
45
  headless: cliHeadless ?? true,
34
46
  configUrl: cliUrl ?? configData.url,
35
47
  ...(configData.viewport !== undefined ? { viewport: configData.viewport } : {}),
48
+ ...(configData.service?.start ? { serviceStart: configData.service.start } : {}),
36
49
  selectors,
37
50
 
38
51
  // Internal capture defaults (not user-configurable).
@@ -14,6 +14,11 @@ import {
14
14
  filterGeneratedFilesBySelectors,
15
15
  hasSelectors,
16
16
  } from "../selector-filter.js";
17
+ import {
18
+ startManagedService,
19
+ stopManagedService,
20
+ waitForServiceReady,
21
+ } from "./service-runtime.js";
17
22
 
18
23
  const libraryTemplatesPath = new URL("./templates", import.meta.url).pathname;
19
24
  const libraryStaticPath = new URL("./static", import.meta.url).pathname;
@@ -90,6 +95,7 @@ async function main(options = {}) {
90
95
  skipScreenshots,
91
96
  port,
92
97
  configUrl,
98
+ serviceStart,
93
99
  } = resolvedOptions;
94
100
 
95
101
  const specsPath = join(vtPath, "specs");
@@ -164,8 +170,22 @@ async function main(options = {}) {
164
170
  return;
165
171
  }
166
172
 
167
- const server = configUrl ? null : await startWebServer(siteOutputPath, vtPath, port);
173
+ let server = null;
174
+ let managedService = null;
175
+
168
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
+
169
189
  await takeScreenshots(
170
190
  buildCaptureOptions({
171
191
  filesToScreenshot,
@@ -179,6 +199,9 @@ async function main(options = {}) {
179
199
  server.close();
180
200
  console.log("Server stopped");
181
201
  }
202
+ if (managedService) {
203
+ await stopManagedService(managedService);
204
+ }
182
205
  }
183
206
  }
184
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
+ }
package/src/cli/report.js CHANGED
@@ -159,7 +159,7 @@ async function main(options = {}) {
159
159
 
160
160
  try {
161
161
  if (!fs.existsSync(candidateDir)) {
162
- throw new Error(`Candidate screenshots directory not found: "${candidateDir}". Run "rtgl vt generate" first.`);
162
+ throw new Error(`Candidate screenshots directory not found: "${candidateDir}". Run "rtgl vt screenshot" first.`);
163
163
  }
164
164
 
165
165
  const candidateFiles = getAllFiles(candidateDir).filter((file) => file.endsWith(".webp"));
@@ -0,0 +1,8 @@
1
+ import generate from "./generate.js";
2
+
3
+ export default async function screenshot(options = {}) {
4
+ await generate({
5
+ ...options,
6
+ captureScreenshots: true,
7
+ });
8
+ }
@@ -0,0 +1,116 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ const READY_TIMEOUT_MS = 120000;
4
+ const READY_INTERVAL_MS = 500;
5
+ const STOP_TIMEOUT_MS = 10000;
6
+
7
+ function sleep(ms) {
8
+ return new Promise((resolve) => setTimeout(resolve, ms));
9
+ }
10
+
11
+ function formatExitMessage(result) {
12
+ if (!result) return "unknown exit state";
13
+ if (result.code !== null && result.code !== undefined) {
14
+ return `code ${result.code}`;
15
+ }
16
+ if (result.signal) {
17
+ return `signal ${result.signal}`;
18
+ }
19
+ return "unknown exit state";
20
+ }
21
+
22
+ function sendSignal(handle, signal) {
23
+ const { child, useProcessGroup } = handle;
24
+ const pid = child.pid;
25
+ if (!pid) {
26
+ return;
27
+ }
28
+
29
+ try {
30
+ if (useProcessGroup) {
31
+ process.kill(-pid, signal);
32
+ return;
33
+ }
34
+ child.kill(signal);
35
+ } catch (error) {
36
+ if (error?.code !== "ESRCH") {
37
+ throw error;
38
+ }
39
+ }
40
+ }
41
+
42
+ export function startManagedService({ command }) {
43
+ const useProcessGroup = process.platform !== "win32";
44
+ const child = spawn(command, {
45
+ cwd: process.cwd(),
46
+ stdio: "inherit",
47
+ shell: true,
48
+ env: process.env,
49
+ detached: useProcessGroup,
50
+ });
51
+
52
+ const exitPromise = new Promise((resolve, reject) => {
53
+ child.once("error", reject);
54
+ child.once("exit", (code, signal) => {
55
+ resolve({ code, signal });
56
+ });
57
+ });
58
+
59
+ console.log(`Starting managed service: ${command}`);
60
+ return { child, exitPromise, useProcessGroup };
61
+ }
62
+
63
+ export async function waitForServiceReady({ url, handle }) {
64
+ const startMs = Date.now();
65
+
66
+ while (Date.now() - startMs < READY_TIMEOUT_MS) {
67
+ if (handle.child.exitCode !== null) {
68
+ const result = await handle.exitPromise.catch(() => null);
69
+ throw new Error(
70
+ `Managed service exited before becoming ready (${formatExitMessage(result)}).`,
71
+ );
72
+ }
73
+
74
+ try {
75
+ const response = await fetch(url);
76
+ console.log(`Managed service ready: ${url} (status ${response.status})`);
77
+ return;
78
+ } catch {
79
+ // Keep polling until timeout.
80
+ }
81
+
82
+ await sleep(READY_INTERVAL_MS);
83
+ }
84
+
85
+ throw new Error(
86
+ `Timed out waiting for managed service at ${url} after ${READY_TIMEOUT_MS}ms.`,
87
+ );
88
+ }
89
+
90
+ export async function stopManagedService(handle) {
91
+ if (!handle) return;
92
+
93
+ const { child, exitPromise } = handle;
94
+ if (child.exitCode !== null) {
95
+ return;
96
+ }
97
+
98
+ console.log("Stopping managed service (SIGTERM)...");
99
+ sendSignal(handle, "SIGTERM");
100
+
101
+ const exitedGracefully = await Promise.race([
102
+ exitPromise.then(() => true).catch(() => true),
103
+ sleep(STOP_TIMEOUT_MS).then(() => false),
104
+ ]);
105
+
106
+ if (exitedGracefully) {
107
+ return;
108
+ }
109
+
110
+ if (child.exitCode === null) {
111
+ console.warn("Managed service did not exit after SIGTERM, sending SIGKILL...");
112
+ sendSignal(handle, "SIGKILL");
113
+ }
114
+
115
+ await exitPromise.catch(() => null);
116
+ }
package/src/common.js CHANGED
@@ -248,7 +248,7 @@ function getContentType(filePath) {
248
248
  }
249
249
 
250
250
  function toSectionPageKey(sectionLike) {
251
- return sectionLike.title;
251
+ return String(sectionLike.title || "").toLowerCase();
252
252
  }
253
253
 
254
254
  /**