@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.
package/README.md CHANGED
@@ -1,98 +1,198 @@
1
-
2
1
  # Rettangoli Visual Testing
3
2
 
4
- A visual testing framework for UI components using Playwright and screenshot comparison. Perfect for regression testing and ensuring UI consistency across changes.
3
+ Visual regression testing for Rettangoli specs using Playwright screenshots.
5
4
 
6
- **In production**, this package is typically used through the `rtgl` CLI tool. For development and testing of this package itself, you should call the local CLI directly.
5
+ ## Commands
7
6
 
8
- ## Features
7
+ - `rtgl vt generate`
8
+ - `rtgl vt screenshot`
9
+ - `rtgl vt report`
10
+ - `rtgl vt accept`
9
11
 
10
- - **Screenshot Generation** - Automatically generate screenshots from HTML specifications
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
12
+ Behavior split:
16
13
 
17
- ## Development
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)
18
17
 
19
- ### Prerequisites
18
+ ## Public Screenshot Options
20
19
 
21
- - Node.js 18+ or Bun
22
- - Playwright browsers (automatically installed)
20
+ - `--headed`
21
+ - `--concurrency <number>`
22
+ - `--timeout <ms>`
23
+ - `--wait-event <name>`
24
+ - `--folder <path>` (repeatable)
25
+ - `--group <section-key>` (repeatable)
26
+ - `--item <spec-path>` (repeatable)
23
27
 
24
- ### Setup
28
+ ## Public Report Options
25
29
 
26
- 1. **Install dependencies**:
27
- ```bash
28
- bun install
29
- ```
30
+ - `--compare-method <method>`
31
+ - `--color-threshold <number>`
32
+ - `--diff-threshold <number>`
33
+ - `--folder <path>` (repeatable)
34
+ - `--group <section-key>` (repeatable)
35
+ - `--item <spec-path>` (repeatable)
36
+
37
+ ## Scoped Runs
38
+
39
+ Use selectors to run only part of VT in both `screenshot` and `report`:
40
+
41
+ - `folder`: matches specs by folder prefix under `vt/specs` (example: `components/forms`)
42
+ - `group`: matches derived section page key from `vt.sections` titles (`kebab-case(title)`)
43
+ - `item`: matches a single spec path relative to `vt/specs` (with or without extension)
44
+
45
+ Selector rules:
46
+
47
+ - selectors are unioned (OR); any matched item is included
48
+ - if no selector is provided, all items are included
49
+
50
+ Examples:
30
51
 
31
- 2. **Install Playwright browsers** (if not already installed):
32
52
  ```bash
33
- npx playwright install
53
+ # Only specs under a folder
54
+ rtgl vt screenshot --folder components/forms
55
+
56
+ # Only one section/group key from vt.sections
57
+ rtgl vt screenshot --group components-basic
58
+
59
+ # Only one spec item (extension optional)
60
+ rtgl vt screenshot --item components/forms/login
61
+ rtgl vt screenshot --item components/forms/login.html
62
+
63
+ # Combine selectors (union)
64
+ rtgl vt screenshot --group components-basic --item pages/home
65
+
66
+ # Same selectors for report
67
+ rtgl vt report --folder components/forms
68
+ rtgl vt report --group components-basic
69
+ rtgl vt report --item components/forms/login
34
70
  ```
35
71
 
36
- ### Project Structure
72
+ Everything else in capture is internal and intentionally not user-configurable.
73
+
74
+ ## Config
37
75
 
76
+ `rettangoli.config.yaml`:
77
+
78
+ ```yaml
79
+ vt:
80
+ path: ./vt
81
+ port: 3001
82
+ url: http://127.0.0.1:4173
83
+ service:
84
+ start: bun run preview
85
+ concurrency: 4
86
+ timeout: 30000
87
+ waitEvent: vt:ready
88
+ viewport:
89
+ id: desktop
90
+ width: 1280
91
+ height: 720
92
+ sections:
93
+ - title: Components Basic
94
+ files: components
38
95
  ```
39
- src/
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)
96
+
97
+ Notes:
98
+
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.
102
+ - Section page keys are derived as `kebab-case(title)` for flat sections and group `items[].title`.
103
+ - Derived section page keys must be unique case-insensitively.
104
+ - `vt.viewport` supports object or array; each viewport requires `id`, `width`, `height`.
105
+ - `vt.capture` is internal and must be omitted.
106
+ - Viewport contract details: `docs/viewport-contract.md`.
107
+
108
+ ## Spec Frontmatter
109
+
110
+ Supported frontmatter keys per spec file:
111
+
112
+ - `title`
113
+ - `description`
114
+ - `template`
115
+ - `url`
116
+ - `waitEvent`
117
+ - `waitSelector`
118
+ - `waitStrategy` (`networkidle` | `load` | `event` | `selector`)
119
+ - `viewport` (object or array of viewport objects)
120
+ - `skipScreenshot`
121
+ - `skipInitialScreenshot`
122
+ - `specs`
123
+ - `steps`
124
+
125
+ Step action reference:
126
+
127
+ - `docs/step-actions.md`
128
+ - canonical format is structured action objects (`- action: ...`); legacy one-line string steps are not supported.
129
+ - `assert` supports `js` deep-equal checks for object/array values.
130
+
131
+ Screenshot naming:
132
+
133
+ - By default, VT takes an immediate first screenshot before running `steps`.
134
+ - Set `skipInitialScreenshot: true` in frontmatter to skip that immediate first screenshot.
135
+ - First captured screenshot is `-01`.
136
+ - Then `-02`, `-03`, up to `-99`.
137
+ - When viewport id is configured, filenames include `--<viewportId>` before ordinal (for example `pages/home--mobile-01.webp`).
138
+
139
+ ## Docker
140
+
141
+ A pre-built Docker image with `rtgl` and Playwright browsers is available:
142
+
143
+ ```bash
144
+ docker pull han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc12
48
145
  ```
49
146
 
50
- ### Core Functionality
147
+ Run commands against a local project:
51
148
 
52
- The visual testing framework provides three main commands:
149
+ ```bash
150
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc12 rtgl vt screenshot
151
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc12 rtgl vt report
152
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc12 rtgl vt accept
153
+ ```
53
154
 
54
- #### 1. Generate (`vt generate`)
55
- - Reads HTML specifications from `vt/specs/` directory
56
- - Generates screenshots using Playwright
57
- - Saves candidate screenshots for comparison
58
- - Creates a static site for viewing results
155
+ Note:
59
156
 
60
- #### 2. Report (`vt report`)
61
- - Compares candidate screenshots with reference screenshots
62
- - Generates visual diff reports highlighting changes
63
- - Creates an HTML report with before/after comparisons
64
- - Uses `looks-same` library for pixel-perfect comparison
157
+ - Image default working directory is `/workspace`.
158
+ - Use `-w /workspace/<subdir>` only when running commands from a subfolder within the mounted project.
65
159
 
66
- #### 3. Accept (`vt accept`)
67
- - Accepts candidate screenshots as new reference images
68
- - Updates the golden/reference screenshot directory
69
- - Used when visual changes are intentional
160
+ Supports `linux/amd64` and `linux/arm64`.
70
161
 
71
- ### Configuration
162
+ ## Development
72
163
 
73
- The framework reads configuration from `rettangoli.config.yaml`:
164
+ Run unit tests:
74
165
 
75
- ```yaml
76
- vt:
77
- port: 3001
78
- screenshotWaitTime: 500
79
- skipScreenshots: false
166
+ ```bash
167
+ bun test
80
168
  ```
81
169
 
82
- ### Testing Your Changes
170
+ Default unit run behavior:
171
+
172
+ - `bun test` skips the real-browser smoke tests in `spec/e2e-smoke.spec.js` unless `VT_E2E=1`.
173
+ - Skipped smoke tests are:
174
+ - `runs generate, accept, and report with real screenshots`
175
+ - `supports waitEvent readiness with real browser screenshots`
176
+ - `supports managed service lifecycle with vt.service.start and vt.url`
83
177
 
84
- **Note**: This package doesn't include example files in its directory. For testing during development, use examples from other packages (like `rettangoli-ui`) and call the CLI directly:
178
+ Run real-browser smoke:
85
179
 
86
180
  ```bash
87
- # Call the local CLI directly for development
88
- node ../rettangoli-cli/cli.js vt generate
89
- node ../rettangoli-cli/cli.js vt report
90
- node ../rettangoli-cli/cli.js vt accept
181
+ VT_E2E=1 bun test spec/e2e-smoke.spec.js
91
182
  ```
92
183
 
93
- **Production usage** (when rtgl is installed globally):
184
+ Run Docker E2E tests (requires Docker daemon running):
185
+
186
+ ```bash
187
+ # Full pipeline: build test image → run all E2E scenarios
188
+ bun run test:e2e:full
189
+
190
+ # Scenarios only (skip image build, assumes image already exists)
191
+ bun run test:e2e
192
+ ```
193
+
194
+ Optional benchmark fixture:
195
+
94
196
  ```bash
95
- rtgl vt generate
96
- rtgl vt report
97
- rtgl vt accept
197
+ bun run bench:capture
98
198
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/vt",
3
- "version": "0.0.14",
3
+ "version": "1.0.0-rc12",
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": "echo \"Error: no test specified\" && exit 1"
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
+ }