@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
package/README.md
CHANGED
|
@@ -1,98 +1,169 @@
|
|
|
1
|
-
|
|
2
1
|
# Rettangoli Visual Testing
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
Visual regression testing for Rettangoli specs using Playwright screenshots.
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
## Commands
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
- `rtgl vt generate`
|
|
8
|
+
- `rtgl vt report`
|
|
9
|
+
- `rtgl vt accept`
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
- **Visual Comparison** - Compare screenshots to detect visual changes
|
|
12
|
-
- **Test Reports** - Generate detailed reports with diff highlights
|
|
13
|
-
- **Playwright Integration** - Uses Playwright for reliable cross-browser testing
|
|
14
|
-
- **Template System** - Liquid templates for flexible HTML generation
|
|
15
|
-
- **Configuration** - YAML-based configuration for easy customization
|
|
11
|
+
## Public Generate Options
|
|
16
12
|
|
|
17
|
-
|
|
13
|
+
- `--skip-screenshots`
|
|
14
|
+
- `--headed`
|
|
15
|
+
- `--concurrency <number>`
|
|
16
|
+
- `--timeout <ms>`
|
|
17
|
+
- `--wait-event <name>`
|
|
18
|
+
- `--folder <path>` (repeatable)
|
|
19
|
+
- `--group <section-key>` (repeatable)
|
|
20
|
+
- `--item <spec-path>` (repeatable)
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
## Public Report Options
|
|
20
23
|
|
|
21
|
-
-
|
|
22
|
-
-
|
|
24
|
+
- `--compare-method <method>`
|
|
25
|
+
- `--color-threshold <number>`
|
|
26
|
+
- `--diff-threshold <number>`
|
|
27
|
+
- `--folder <path>` (repeatable)
|
|
28
|
+
- `--group <section-key>` (repeatable)
|
|
29
|
+
- `--item <spec-path>` (repeatable)
|
|
23
30
|
|
|
24
|
-
|
|
31
|
+
## Scoped Runs
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
bun install
|
|
29
|
-
```
|
|
33
|
+
Use selectors to run only part of VT in both `generate` and `report`:
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
```
|
|
35
|
+
- `folder`: matches specs by folder prefix under `vt/specs` (example: `components/forms`)
|
|
36
|
+
- `group`: matches section page key from `vt.sections` (`title` for flat sections, `items[].title` for grouped sections)
|
|
37
|
+
- `item`: matches a single spec path relative to `vt/specs` (with or without extension)
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
Selector rules:
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
├── cli/
|
|
41
|
-
│ ├── generate.js # Generate screenshots from specifications
|
|
42
|
-
│ ├── report.js # Generate visual comparison reports
|
|
43
|
-
│ ├── accept.js # Accept screenshot changes as new reference
|
|
44
|
-
│ ├── templates/ # HTML templates for reports
|
|
45
|
-
│ └── static/ # Static assets (CSS, etc.)
|
|
46
|
-
├── common.js # Shared utilities and functions
|
|
47
|
-
└── index.js # Main export (empty - CLI focused)
|
|
48
|
-
```
|
|
41
|
+
- selectors are unioned (OR); any matched item is included
|
|
42
|
+
- if no selector is provided, all items are included
|
|
49
43
|
|
|
50
|
-
|
|
44
|
+
Examples:
|
|
51
45
|
|
|
52
|
-
|
|
46
|
+
```bash
|
|
47
|
+
# Only specs under a folder
|
|
48
|
+
rtgl vt generate --folder components/forms
|
|
53
49
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
- Generates screenshots using Playwright
|
|
57
|
-
- Saves candidate screenshots for comparison
|
|
58
|
-
- Creates a static site for viewing results
|
|
50
|
+
# Only one section/group key from vt.sections
|
|
51
|
+
rtgl vt generate --group components_basic
|
|
59
52
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
- Creates an HTML report with before/after comparisons
|
|
64
|
-
- Uses `looks-same` library for pixel-perfect comparison
|
|
53
|
+
# Only one spec item (extension optional)
|
|
54
|
+
rtgl vt generate --item components/forms/login
|
|
55
|
+
rtgl vt generate --item components/forms/login.html
|
|
65
56
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
57
|
+
# Combine selectors (union)
|
|
58
|
+
rtgl vt generate --group components_basic --item pages/home
|
|
59
|
+
|
|
60
|
+
# Same selectors for report
|
|
61
|
+
rtgl vt report --folder components/forms
|
|
62
|
+
rtgl vt report --group components_basic
|
|
63
|
+
rtgl vt report --item components/forms/login
|
|
64
|
+
```
|
|
70
65
|
|
|
71
|
-
|
|
66
|
+
Everything else in capture is internal and intentionally not user-configurable.
|
|
72
67
|
|
|
73
|
-
|
|
68
|
+
## Config
|
|
69
|
+
|
|
70
|
+
`rettangoli.config.yaml`:
|
|
74
71
|
|
|
75
72
|
```yaml
|
|
76
73
|
vt:
|
|
74
|
+
path: ./vt
|
|
77
75
|
port: 3001
|
|
78
|
-
screenshotWaitTime: 500
|
|
79
76
|
skipScreenshots: false
|
|
77
|
+
concurrency: 4
|
|
78
|
+
timeout: 30000
|
|
79
|
+
waitEvent: vt:ready
|
|
80
|
+
viewport:
|
|
81
|
+
id: desktop
|
|
82
|
+
width: 1280
|
|
83
|
+
height: 720
|
|
84
|
+
sections:
|
|
85
|
+
- title: components_basic
|
|
86
|
+
files: components
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Notes:
|
|
90
|
+
|
|
91
|
+
- `vt.sections` is required.
|
|
92
|
+
- Section page keys (`title` for flat sections and group `items[].title`) allow only letters, numbers, `-`, `_`.
|
|
93
|
+
- `vt.viewport` supports object or array; each viewport requires `id`, `width`, `height`.
|
|
94
|
+
- `vt.capture` is internal and must be omitted.
|
|
95
|
+
- Viewport contract details: `docs/viewport-contract.md`.
|
|
96
|
+
|
|
97
|
+
## Spec Frontmatter
|
|
98
|
+
|
|
99
|
+
Supported frontmatter keys per spec file:
|
|
100
|
+
|
|
101
|
+
- `title`
|
|
102
|
+
- `description`
|
|
103
|
+
- `template`
|
|
104
|
+
- `url`
|
|
105
|
+
- `waitEvent`
|
|
106
|
+
- `waitSelector`
|
|
107
|
+
- `waitStrategy` (`networkidle` | `load` | `event` | `selector`)
|
|
108
|
+
- `viewport` (object or array of viewport objects)
|
|
109
|
+
- `skipScreenshot`
|
|
110
|
+
- `specs`
|
|
111
|
+
- `steps`
|
|
112
|
+
|
|
113
|
+
Step action reference:
|
|
114
|
+
|
|
115
|
+
- `docs/step-actions.md`
|
|
116
|
+
|
|
117
|
+
Screenshot naming:
|
|
118
|
+
|
|
119
|
+
- First screenshot is `-01`.
|
|
120
|
+
- Then `-02`, `-03`, up to `-99`.
|
|
121
|
+
- When viewport id is configured, filenames include `--<viewportId>` before ordinal (for example `pages/home--mobile-01.webp`).
|
|
122
|
+
|
|
123
|
+
## Docker
|
|
124
|
+
|
|
125
|
+
A pre-built Docker image with `rtgl` and Playwright browsers is available:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
docker pull han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc2
|
|
80
129
|
```
|
|
81
130
|
|
|
82
|
-
|
|
131
|
+
Run commands against a local project:
|
|
132
|
+
|
|
133
|
+
```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
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Supports `linux/amd64` and `linux/arm64`.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
83
142
|
|
|
84
|
-
|
|
143
|
+
Run unit tests:
|
|
85
144
|
|
|
86
145
|
```bash
|
|
87
|
-
|
|
88
|
-
node ../rettangoli-cli/cli.js vt generate
|
|
89
|
-
node ../rettangoli-cli/cli.js vt report
|
|
90
|
-
node ../rettangoli-cli/cli.js vt accept
|
|
146
|
+
bun test
|
|
91
147
|
```
|
|
92
148
|
|
|
93
|
-
|
|
149
|
+
Run real-browser smoke:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
VT_E2E=1 bun test spec/e2e-smoke.spec.js
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Run Docker E2E tests (requires Docker daemon running):
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
# Full pipeline: build test image → run all E2E scenarios
|
|
159
|
+
bun run test:e2e:full
|
|
160
|
+
|
|
161
|
+
# Scenarios only (skip image build, assumes image already exists)
|
|
162
|
+
bun run test:e2e
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Optional benchmark fixture:
|
|
166
|
+
|
|
94
167
|
```bash
|
|
95
|
-
|
|
96
|
-
rtgl vt report
|
|
97
|
-
rtgl vt accept
|
|
168
|
+
bun run bench:capture
|
|
98
169
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rettangoli/vt",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "1.0.0-rc2",
|
|
4
4
|
"description": "Rettangoli Visual Testing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
"./cli": "./src/cli/index.js"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
|
-
"test": "
|
|
12
|
+
"test": "vitest run --reporter=verbose",
|
|
13
|
+
"test:e2e": "node e2e/run.js",
|
|
14
|
+
"test:e2e:full": "bash scripts/docker-e2e-test.sh",
|
|
15
|
+
"bench:capture": "node ./scripts/benchmark-capture.js"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
18
|
"commander": "^13.1.0",
|
|
@@ -19,5 +22,9 @@
|
|
|
19
22
|
"playwright": "1.57.0",
|
|
20
23
|
"sharp": "^0.34.5",
|
|
21
24
|
"shiki": "^3.3.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"puty": "^0.1.1",
|
|
28
|
+
"vitest": "^4.0.15"
|
|
22
29
|
}
|
|
23
30
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
import { PlaywrightRunner } from "./playwright-runner.js";
|
|
3
|
+
import { ResultCollector } from "./result-collector.js";
|
|
4
|
+
import { resolveWorkerPlan } from "./worker-plan.js";
|
|
5
|
+
|
|
6
|
+
function nowMs() {
|
|
7
|
+
return performance.now();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function createTaskQueues(tasks) {
|
|
11
|
+
const freshQueue = tasks
|
|
12
|
+
.map((task, sourceOrder) => ({
|
|
13
|
+
task,
|
|
14
|
+
sourceOrder,
|
|
15
|
+
attempt: 1,
|
|
16
|
+
queueType: "fresh",
|
|
17
|
+
enqueuedAtMs: nowMs(),
|
|
18
|
+
}))
|
|
19
|
+
.sort((left, right) => {
|
|
20
|
+
const leftCost = left.task.estimatedCost ?? 0;
|
|
21
|
+
const rightCost = right.task.estimatedCost ?? 0;
|
|
22
|
+
if (leftCost !== rightCost) {
|
|
23
|
+
return leftCost - rightCost;
|
|
24
|
+
}
|
|
25
|
+
return right.sourceOrder - left.sourceOrder;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const retryQueue = [];
|
|
29
|
+
let dispatchCount = 0;
|
|
30
|
+
const fairRetryInterval = 4; // 3 fresh dispatches then 1 retry when available
|
|
31
|
+
|
|
32
|
+
const getNextTask = () => {
|
|
33
|
+
const shouldDispatchRetry = retryQueue.length > 0
|
|
34
|
+
&& (freshQueue.length === 0 || (dispatchCount % fairRetryInterval) === (fairRetryInterval - 1));
|
|
35
|
+
|
|
36
|
+
if (shouldDispatchRetry) {
|
|
37
|
+
dispatchCount += 1;
|
|
38
|
+
return retryQueue.shift();
|
|
39
|
+
}
|
|
40
|
+
if (freshQueue.length > 0) {
|
|
41
|
+
dispatchCount += 1;
|
|
42
|
+
return freshQueue.pop();
|
|
43
|
+
}
|
|
44
|
+
if (retryQueue.length > 0) {
|
|
45
|
+
dispatchCount += 1;
|
|
46
|
+
return retryQueue.shift();
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const enqueueRetry = (item) => {
|
|
52
|
+
retryQueue.push({
|
|
53
|
+
task: item.task,
|
|
54
|
+
attempt: item.attempt + 1,
|
|
55
|
+
queueType: "retry",
|
|
56
|
+
enqueuedAtMs: nowMs(),
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
getNextTask,
|
|
62
|
+
enqueueRetry,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function runCaptureScheduler(options) {
|
|
67
|
+
const {
|
|
68
|
+
tasks,
|
|
69
|
+
screenshotsDir,
|
|
70
|
+
workerCount: requestedWorkers,
|
|
71
|
+
isolationMode,
|
|
72
|
+
screenshotWaitTime,
|
|
73
|
+
waitEvent,
|
|
74
|
+
waitSelector,
|
|
75
|
+
waitStrategy,
|
|
76
|
+
navigationTimeout,
|
|
77
|
+
readyTimeout,
|
|
78
|
+
screenshotTimeout,
|
|
79
|
+
maxRetries,
|
|
80
|
+
recycleEvery,
|
|
81
|
+
metricsPath,
|
|
82
|
+
headless,
|
|
83
|
+
} = options;
|
|
84
|
+
|
|
85
|
+
const { workerCount, adaptivePolicy } = resolveWorkerPlan(requestedWorkers);
|
|
86
|
+
console.log(
|
|
87
|
+
`Capture scheduler: mode=${adaptivePolicy.mode}, workers=${workerCount}, isolation=${isolationMode}`,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const collector = new ResultCollector({
|
|
91
|
+
totalTasks: tasks.length,
|
|
92
|
+
metricsPath,
|
|
93
|
+
workerCount,
|
|
94
|
+
isolationMode,
|
|
95
|
+
maxRetries,
|
|
96
|
+
adaptivePolicy,
|
|
97
|
+
schedulingPolicy: {
|
|
98
|
+
type: "duration-aware-fair-retry",
|
|
99
|
+
freshBeforeRetry: 3,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!tasks.length) {
|
|
104
|
+
const { summary } = collector.finalize();
|
|
105
|
+
return {
|
|
106
|
+
summary,
|
|
107
|
+
failures: [],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const queue = createTaskQueues(tasks);
|
|
112
|
+
const getNextTask = queue.getNextTask;
|
|
113
|
+
|
|
114
|
+
const browser = await chromium.launch({ headless });
|
|
115
|
+
const runners = [];
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
for (let index = 0; index < workerCount; index += 1) {
|
|
119
|
+
const runner = new PlaywrightRunner({
|
|
120
|
+
workerId: index + 1,
|
|
121
|
+
browser,
|
|
122
|
+
screenshotsDir,
|
|
123
|
+
isolationMode,
|
|
124
|
+
screenshotWaitTime,
|
|
125
|
+
waitEvent,
|
|
126
|
+
waitSelector,
|
|
127
|
+
waitStrategy,
|
|
128
|
+
navigationTimeout,
|
|
129
|
+
readyTimeout,
|
|
130
|
+
screenshotTimeout,
|
|
131
|
+
});
|
|
132
|
+
await runner.initialize();
|
|
133
|
+
runners.push(runner);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const workerLoops = runners.map(async (runner) => {
|
|
137
|
+
let processedSinceRecycle = 0;
|
|
138
|
+
|
|
139
|
+
while (true) {
|
|
140
|
+
const item = getNextTask();
|
|
141
|
+
if (!item) {
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const queueWaitMs = Math.max(0, nowMs() - item.enqueuedAtMs);
|
|
146
|
+
const attemptStartMs = nowMs();
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const result = await runner.runTask(item.task, item.attempt);
|
|
150
|
+
const attemptMs = nowMs() - attemptStartMs;
|
|
151
|
+
collector.recordSuccess(item.task, result, {
|
|
152
|
+
workerId: runner.workerId,
|
|
153
|
+
queueType: item.queueType,
|
|
154
|
+
queueWaitMs,
|
|
155
|
+
attemptMs,
|
|
156
|
+
});
|
|
157
|
+
processedSinceRecycle += 1;
|
|
158
|
+
|
|
159
|
+
if (
|
|
160
|
+
isolationMode === "fast"
|
|
161
|
+
&& recycleEvery > 0
|
|
162
|
+
&& processedSinceRecycle >= recycleEvery
|
|
163
|
+
) {
|
|
164
|
+
await runner.recycleSharedContext();
|
|
165
|
+
collector.recordRecycle(runner.workerId, `recycleEvery=${recycleEvery}`);
|
|
166
|
+
processedSinceRecycle = 0;
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const attemptMs = nowMs() - attemptStartMs;
|
|
170
|
+
if (item.attempt <= maxRetries) {
|
|
171
|
+
collector.recordRetry(item.task, item.attempt, error.message, {
|
|
172
|
+
workerId: runner.workerId,
|
|
173
|
+
queueType: item.queueType,
|
|
174
|
+
queueWaitMs,
|
|
175
|
+
attemptMs,
|
|
176
|
+
});
|
|
177
|
+
queue.enqueueRetry(item);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
collector.recordFailure(item.task, item.attempt, error.message, {
|
|
182
|
+
workerId: runner.workerId,
|
|
183
|
+
queueType: item.queueType,
|
|
184
|
+
queueWaitMs,
|
|
185
|
+
attemptMs,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await Promise.all(workerLoops);
|
|
192
|
+
} finally {
|
|
193
|
+
await Promise.all(
|
|
194
|
+
runners.map(async (runner) => {
|
|
195
|
+
await runner.dispose();
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
await browser.close();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const { summary, failures } = collector.finalize();
|
|
202
|
+
return {
|
|
203
|
+
summary,
|
|
204
|
+
failures,
|
|
205
|
+
};
|
|
206
|
+
}
|