@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 CHANGED
@@ -1,98 +1,141 @@
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
49
+
50
+ # Only one section/group key from vt.sections
51
+ rtgl vt generate --group components_basic
53
52
 
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
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
- #### 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
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
- #### 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
66
+ Everything else in capture is internal and intentionally not user-configurable.
70
67
 
71
- ### Configuration
68
+ ## Config
72
69
 
73
- The framework reads configuration from `rettangoli.config.yaml`:
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
- ### Testing Your Changes
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
- **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:
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
- # 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
122
+ VT_E2E=1 bun test spec/e2e-smoke.spec.js
91
123
  ```
92
124
 
93
- **Production usage** (when rtgl is installed globally):
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
- rtgl vt generate
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.13",
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": "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
+ }