@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 +38 -13
- package/package.json +1 -1
- package/src/capture/spec-loader.js +4 -2
- package/src/cli/generate-options.js +14 -1
- package/src/cli/generate.js +24 -1
- package/src/cli/index.js +3 -1
- package/src/cli/report.js +1 -1
- package/src/cli/screenshot.js +8 -0
- package/src/cli/service-runtime.js +116 -0
- package/src/common.js +1 -1
- package/src/createSteps.js +530 -85
- package/src/step-commands.js +5 -1
- package/src/validation.js +351 -7
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
|
-
|
|
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 `
|
|
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
|
|
54
|
+
rtgl vt screenshot --folder components/forms
|
|
49
55
|
|
|
50
56
|
# Only one section/group key from vt.sections
|
|
51
|
-
rtgl vt
|
|
57
|
+
rtgl vt screenshot --group components_basic
|
|
52
58
|
|
|
53
59
|
# Only one spec item (extension optional)
|
|
54
|
-
rtgl vt
|
|
55
|
-
rtgl vt
|
|
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
|
|
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
|
-
|
|
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-
|
|
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"
|
|
135
|
-
docker run --rm -v "$(pwd):/workspace"
|
|
136
|
-
docker run --rm -v "$(pwd):/workspace"
|
|
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
|
@@ -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
|
|
74
|
-
const
|
|
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:
|
|
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).
|
package/src/cli/generate.js
CHANGED
|
@@ -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
|
-
|
|
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
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
|
|
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,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
|
+
}
|