@rettangoli/vt 0.0.13 → 1.0.0-rc1
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 +107 -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 +99 -0
- package/src/capture/worker-plan.js +66 -0
- package/src/cli/accept.js +39 -49
- package/src/cli/generate-options.js +80 -0
- package/src/cli/generate.js +93 -25
- package/src/cli/report-options.js +43 -0
- package/src/cli/report.js +94 -137
- package/src/cli/templates/index.html +2 -2
- package/src/cli/templates/report.html +4 -4
- package/src/common.js +124 -122
- package/src/createSteps.js +2 -5
- package/src/report/report-model.js +76 -0
- package/src/report/report-render.js +22 -0
- package/src/selector-filter.js +137 -0
- package/src/validation.js +300 -0
package/README.md
CHANGED
|
@@ -1,98 +1,141 @@
|
|
|
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
|
|
49
|
+
|
|
50
|
+
# Only one section/group key from vt.sections
|
|
51
|
+
rtgl vt generate --group components_basic
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
- Saves candidate screenshots for comparison
|
|
58
|
-
- Creates a static site for viewing results
|
|
53
|
+
# Only one spec item (extension optional)
|
|
54
|
+
rtgl vt generate --item components/forms/login
|
|
55
|
+
rtgl vt generate --item components/forms/login.html
|
|
59
56
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
```
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
- Accepts candidate screenshots as new reference images
|
|
68
|
-
- Updates the golden/reference screenshot directory
|
|
69
|
-
- Used when visual changes are intentional
|
|
66
|
+
Everything else in capture is internal and intentionally not user-configurable.
|
|
70
67
|
|
|
71
|
-
|
|
68
|
+
## Config
|
|
72
69
|
|
|
73
|
-
|
|
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
|
+
sections:
|
|
81
|
+
- title: components_basic
|
|
82
|
+
files: components
|
|
80
83
|
```
|
|
81
84
|
|
|
82
|
-
|
|
85
|
+
Notes:
|
|
86
|
+
|
|
87
|
+
- `vt.sections` is required.
|
|
88
|
+
- Section page keys (`title` for flat sections and group `items[].title`) allow only letters, numbers, `-`, `_`.
|
|
89
|
+
- `vt.capture` is internal and must be omitted.
|
|
90
|
+
|
|
91
|
+
## Spec Frontmatter
|
|
92
|
+
|
|
93
|
+
Supported frontmatter keys per spec file:
|
|
94
|
+
|
|
95
|
+
- `title`
|
|
96
|
+
- `description`
|
|
97
|
+
- `template`
|
|
98
|
+
- `url`
|
|
99
|
+
- `waitEvent`
|
|
100
|
+
- `waitSelector`
|
|
101
|
+
- `waitStrategy` (`networkidle` | `load` | `event` | `selector`)
|
|
102
|
+
- `skipScreenshot`
|
|
103
|
+
- `specs`
|
|
104
|
+
- `steps`
|
|
83
105
|
|
|
84
|
-
|
|
106
|
+
Screenshot naming:
|
|
107
|
+
|
|
108
|
+
- First screenshot is `-01`.
|
|
109
|
+
- Then `-02`, `-03`, up to `-99`.
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
Run tests:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
bun test
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Run real-browser smoke:
|
|
85
120
|
|
|
86
121
|
```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
|
|
122
|
+
VT_E2E=1 bun test spec/e2e-smoke.spec.js
|
|
91
123
|
```
|
|
92
124
|
|
|
93
|
-
|
|
125
|
+
Run Docker E2E tests (requires Docker daemon running):
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
# Full pipeline: build image → verify → run all Docker E2E tests
|
|
129
|
+
bun run test:docker:full
|
|
130
|
+
|
|
131
|
+
# Tests only (skip image build, assumes image already exists)
|
|
132
|
+
bun run test:docker
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The Docker E2E suite builds a local `rtgl-local-test:latest` image, then runs generate/report/accept in temp directories inside containers. Tests validate WebP screenshot headers, report.json schema, metrics.json schema, HTML content, directory structure, accept file copies, multi-spec fixtures, multi-screenshot ordinals, and pixelmatch diff detection.
|
|
136
|
+
|
|
137
|
+
Optional benchmark fixture:
|
|
138
|
+
|
|
94
139
|
```bash
|
|
95
|
-
|
|
96
|
-
rtgl vt report
|
|
97
|
-
rtgl vt accept
|
|
140
|
+
bun run bench:capture
|
|
98
141
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rettangoli/vt",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "1.0.0-rc1",
|
|
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
|
+
}
|