@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 CHANGED
@@ -1,98 +1,169 @@
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 report`
9
+ - `rtgl vt accept`
9
10
 
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
11
+ ## Public Generate Options
16
12
 
17
- ## Development
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
- ### Prerequisites
22
+ ## Public Report Options
20
23
 
21
- - Node.js 18+ or Bun
22
- - Playwright browsers (automatically installed)
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
- ### Setup
31
+ ## Scoped Runs
25
32
 
26
- 1. **Install dependencies**:
27
- ```bash
28
- bun install
29
- ```
33
+ Use selectors to run only part of VT in both `generate` and `report`:
30
34
 
31
- 2. **Install Playwright browsers** (if not already installed):
32
- ```bash
33
- npx playwright install
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
- ### Project Structure
39
+ Selector rules:
37
40
 
38
- ```
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)
48
- ```
41
+ - selectors are unioned (OR); any matched item is included
42
+ - if no selector is provided, all items are included
49
43
 
50
- ### Core Functionality
44
+ Examples:
51
45
 
52
- The visual testing framework provides three main commands:
46
+ ```bash
47
+ # Only specs under a folder
48
+ rtgl vt generate --folder components/forms
53
49
 
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
50
+ # Only one section/group key from vt.sections
51
+ rtgl vt generate --group components_basic
59
52
 
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
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
- #### 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
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
- ### Configuration
66
+ Everything else in capture is internal and intentionally not user-configurable.
72
67
 
73
- The framework reads configuration from `rettangoli.config.yaml`:
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
- ### Testing Your Changes
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
- **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:
143
+ Run unit tests:
85
144
 
86
145
  ```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
146
+ bun test
91
147
  ```
92
148
 
93
- **Production usage** (when rtgl is installed globally):
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
- rtgl vt generate
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.14",
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": "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
+ }