@iqual/playwright-vrt 0.1.0
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 +208 -0
- package/package.json +45 -0
- package/playwright.config.ts +54 -0
- package/src/cli.ts +339 -0
- package/src/collect.ts +171 -0
- package/src/config.ts +119 -0
- package/src/index.ts +2 -0
- package/src/runner.ts +232 -0
- package/src/workspace.ts +64 -0
- package/tests/vrt.css +13 -0
- package/tests/vrt.spec.ts +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# @iqual/playwright-vrt
|
|
2
|
+
|
|
3
|
+
**Standalone Visual Regression Testing CLI tool using Playwright**
|
|
4
|
+
|
|
5
|
+
Zero-installation visual regression testing that runs directly in CI/CD. Test visual differences across multiple projects with a single config file.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
## Why Use This?
|
|
10
|
+
|
|
11
|
+
- **No per-project setup** - Just run `bunx @iqual/playwright-vrt run --test <url>` or `--config <file>`
|
|
12
|
+
- **Smart defaults** - Works without a config file
|
|
13
|
+
- **Flexible** - Use CLI args for quick tests, config files for complex setups
|
|
14
|
+
- **CI/CD optimized** - Caches baselines, auto-detects config changes
|
|
15
|
+
|
|
16
|
+
**Option 1: Quick test with URL only**:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"referenceUrl": "https://production.example.com",
|
|
21
|
+
"testUrl": "https://staging.example.com",
|
|
22
|
+
"maxUrls": 25,
|
|
23
|
+
"exclude": ["**/admin/**"]
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Run with config file (includes both URLs)
|
|
29
|
+
bunx @iqual/playwright-vrt run --config ./playwright-vrt.config.json
|
|
30
|
+
|
|
31
|
+
# Or override test URL from config
|
|
32
|
+
bunx @iqual/playwright-vrt run \
|
|
33
|
+
--test https://preview-branch.staging.com \
|
|
34
|
+
--config ./playwright-vrt.config.json
|
|
35
|
+
|
|
36
|
+
# Or compare production vs staging
|
|
37
|
+
bunx @iqual/playwright-vrt run \
|
|
38
|
+
--reference https://production.example.com \
|
|
39
|
+
--test https://staging.example.com
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# First run: create baseline from test URL
|
|
44
|
+
bunx @iqual/playwright-vrt run --test https://staging.example.com --update-baseline
|
|
45
|
+
|
|
46
|
+
# Subsequent runs: compare against cached baseline
|
|
47
|
+
bunx @iqual/playwright-vrt run --test https://staging.example.com
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Option 2: Use config file** (`playwright-vrt.config.json`):
|
|
51
|
+
|
|
52
|
+
That's it! The tool will:
|
|
53
|
+
- Auto-discover URLs from your sitemap (or use defaults)
|
|
54
|
+
- Create baseline screenshots from reference URL (or test URL on first run)
|
|
55
|
+
- Compare against test environment
|
|
56
|
+
- Generate visual diff report in `playwright-report/`
|
|
57
|
+
|
|
58
|
+
## Features
|
|
59
|
+
|
|
60
|
+
- ✅ **Zero installation** - Run with `bunx`, no setup needed
|
|
61
|
+
- ✅ **Auto URL discovery** - Sitemap parsing + crawler fallback
|
|
62
|
+
- ✅ **Smart caching** - Reuses URLs and baselines, avoids hitting production
|
|
63
|
+
- ✅ **Multi-viewport** - Test desktop, mobile, tablet simultaneously
|
|
64
|
+
- ✅ **Standard reports** - Playwright HTML reports with visual diffs
|
|
65
|
+
- ✅ **CI/CD ready** - Built for GitHub Actions, GitLab CI, etc.
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Full config example (includes URLs + advanced settings):
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"referenceUrl": "https://production.com",
|
|
74
|
+
"testUrl": "https://staging.com",
|
|
75
|
+
"sitemapPath": "/sitemap.xml",
|
|
76
|
+
"maxUrls": 25,
|
|
77
|
+
"exclude": ["**/admin/**", "**/user/**"],
|
|
78
|
+
"include": ["**"],
|
|
79
|
+
"viewports": [
|
|
80
|
+
{ "name": "desktop", "width": 1920, "height": 1080 },
|
|
81
|
+
{ "name": "mobile", "width": 375, "height": 667 }
|
|
82
|
+
],
|
|
83
|
+
"threshold": {
|
|
84
|
+
"maxDiffPixels": 100,
|
|
85
|
+
"maxDiffPixelRatio": 0.01
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## CLI Options
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bunx @iqual/playwright-vrt run \
|
|
94
|
+
--test <url> # Test URL (required if no config)
|
|
95
|
+
--config <path> # Config file (required if no --test)
|
|
96
|
+
--reference <url> # Reference URL (optional, for comparison)
|
|
97
|
+
--output <dir> # Output directory
|
|
98
|
+
--max-urls <number> # Limit URLs to test
|
|
99
|
+
--project <name> # Test specific viewport only
|
|
100
|
+
--verbose # Detailed logging
|
|
101
|
+
--update-baseline # Force regenerate URLs and baseline snapshots
|
|
102
|
+
--clean # Remove all cached data
|
|
103
|
+
|
|
104
|
+
Note: If no --reference is provided and no baseline exists, you must use --update-baseline
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Caching & Performance
|
|
108
|
+
|
|
109
|
+
## Baseline Behavior
|
|
110
|
+
|
|
111
|
+
The tool requires a **reference URL** or an **existing baseline** to run:
|
|
112
|
+
|
|
113
|
+
**With explicit reference URL:**
|
|
114
|
+
- Provided via `--reference` flag or `referenceUrl` in config
|
|
115
|
+
- Baseline is created/updated from the reference system
|
|
116
|
+
- Test URL is compared against this baseline
|
|
117
|
+
|
|
118
|
+
**Without explicit reference URL:**
|
|
119
|
+
- **First run**: You must use `--update-baseline` to create initial baseline from test URL
|
|
120
|
+
- **Subsequent runs**: Compares test URL against cached baseline
|
|
121
|
+
- Fails if no baseline exists and `--update-baseline` not set
|
|
122
|
+
|
|
123
|
+
This prevents accidentally creating baselines from the wrong environment.
|
|
124
|
+
|
|
125
|
+
The tool intelligently caches URLs and baseline snapshots based on your **configuration**:
|
|
126
|
+
|
|
127
|
+
**First run:**
|
|
128
|
+
|
|
129
|
+
- Collects URLs from sitemap/crawler
|
|
130
|
+
- Creates baseline snapshots from `referenceUrl`
|
|
131
|
+
- Stores cache validation hashes
|
|
132
|
+
- Tests against `testUrl`
|
|
133
|
+
|
|
134
|
+
**Subsequent runs:**
|
|
135
|
+
|
|
136
|
+
- Validates cache by checking config and test file hashes
|
|
137
|
+
- If cache is valid: reuses URLs and baseline snapshots
|
|
138
|
+
- If cache is invalid: automatically regenerates (config/test changed)
|
|
139
|
+
- Only tests against `testUrl` (reference system not touched when cache is valid!)
|
|
140
|
+
|
|
141
|
+
**Automatic cache invalidation:**
|
|
142
|
+
|
|
143
|
+
The cache is automatically invalidated and regenerated when:
|
|
144
|
+
|
|
145
|
+
- Configuration changes (URLs, viewports, filters, thresholds, etc.)
|
|
146
|
+
- Test file (`vrt.spec.ts`) changes in the package
|
|
147
|
+
|
|
148
|
+
The hash is based on the **final merged config object**, not the config file itself.
|
|
149
|
+
|
|
150
|
+
**Force update:**
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
bunx @iqual/playwright-vrt run --config config.json --update-baseline
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
This regenerates both URLs and baseline snapshots from the reference system regardless of cache validity.
|
|
157
|
+
|
|
158
|
+
**Directory structure:**
|
|
159
|
+
|
|
160
|
+
- `playwright-snapshots/` - Cached URLs + baseline screenshots (cache this in CI!)
|
|
161
|
+
- `playwright-report/` - HTML test report + results
|
|
162
|
+
- `playwright-tmp/` - Temporary test artifacts (auto-cleared)
|
|
163
|
+
|
|
164
|
+
## GitHub Actions
|
|
165
|
+
|
|
166
|
+
```yaml
|
|
167
|
+
- uses: oven-sh/setup-bun@v1
|
|
168
|
+
|
|
169
|
+
# Cache baseline snapshots to avoid hitting production on every run
|
|
170
|
+
- uses: actions/cache@v4
|
|
171
|
+
with:
|
|
172
|
+
path: playwright-snapshots
|
|
173
|
+
key: vrt-baseline-${{ hashFiles('playwright-vrt.config.json') }}
|
|
174
|
+
restore-keys: vrt-baseline-
|
|
175
|
+
|
|
176
|
+
- run: |
|
|
177
|
+
bunx @iqual/playwright-vrt run \
|
|
178
|
+
--reference https://production.com \
|
|
179
|
+
--test https://preview-${{ github.event.pull_request.number }}.staging.com
|
|
180
|
+
|
|
181
|
+
- uses: actions/upload-artifact@v4
|
|
182
|
+
if: always()
|
|
183
|
+
with:
|
|
184
|
+
name: vrt-report
|
|
185
|
+
path: playwright-report/
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## How It Works
|
|
189
|
+
|
|
190
|
+
1. **Collect URLs** - Parse sitemap or crawl site (cached after first run)
|
|
191
|
+
2. **Filter & limit** - Apply include/exclude patterns, limit to maxUrls
|
|
192
|
+
3. **Create baseline** - Screenshot all URLs from `referenceUrl` (cached after first run)
|
|
193
|
+
4. **Run tests** - Screenshot all URLs from `testUrl` and compare
|
|
194
|
+
5. **Generate report** - Create Playwright HTML report with diffs
|
|
195
|
+
|
|
196
|
+
All URLs and baseline snapshots are cached in `playwright-snapshots/` to minimize load on your production system.
|
|
197
|
+
|
|
198
|
+
## Exit Codes
|
|
199
|
+
|
|
200
|
+
- `0` - All tests passed
|
|
201
|
+
- `1` - Visual differences detected
|
|
202
|
+
- `2` - Configuration or runtime error
|
|
203
|
+
|
|
204
|
+
## Requirements
|
|
205
|
+
|
|
206
|
+
- [Bun](https://bun.sh) runtime (for `bunx` command)
|
|
207
|
+
- Or npm/yarn to install globally
|
|
208
|
+
- Playwright `bunx playwright install chromium`
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iqual/playwright-vrt",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Standalone Visual Regression Testing CLI tool using Playwright",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"playwright-vrt": "./src/cli.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"tests/",
|
|
12
|
+
"playwright.config.ts",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "bun run src/cli.ts",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"prepublishOnly": "bun run typecheck",
|
|
19
|
+
"test": "bun run src/cli.ts --help"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"visual-regression",
|
|
23
|
+
"playwright",
|
|
24
|
+
"testing",
|
|
25
|
+
"vrt",
|
|
26
|
+
"screenshot",
|
|
27
|
+
"visual-testing",
|
|
28
|
+
"regression-testing"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@playwright/test": "^1.40.0",
|
|
32
|
+
"micromatch": "^4.0.5",
|
|
33
|
+
"playwright": "^1.40.0",
|
|
34
|
+
"sitemapper": "^3.2.6"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/bun": "latest",
|
|
38
|
+
"@types/micromatch": "^4.0.6",
|
|
39
|
+
"@types/node": "^20.10.0",
|
|
40
|
+
"typescript": "^5.3.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"typescript": "^5.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { defineConfig } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
// This config is shipped with playwright-vrt package
|
|
4
|
+
// User's config is loaded via environment variables
|
|
5
|
+
|
|
6
|
+
const vrtConfig = process.env.VRT_CONFIG
|
|
7
|
+
? JSON.parse(process.env.VRT_CONFIG)
|
|
8
|
+
: { viewports: [{ name: 'desktop', width: 1920, height: 1080 }] };
|
|
9
|
+
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
testDir: './tests',
|
|
12
|
+
fullyParallel: true,
|
|
13
|
+
retries: process.env.CI ? 2 : 1,
|
|
14
|
+
workers: 2,
|
|
15
|
+
timeout: 60000,
|
|
16
|
+
|
|
17
|
+
// Store snapshots in playwright-snapshots/ for easy caching
|
|
18
|
+
snapshotDir: './playwright-snapshots',
|
|
19
|
+
|
|
20
|
+
// Store temporary test artifacts in playwright-tmp/
|
|
21
|
+
outputDir: './playwright-tmp',
|
|
22
|
+
|
|
23
|
+
reporter: [
|
|
24
|
+
['html', {
|
|
25
|
+
outputFolder: process.env.OUTPUT_DIR || 'playwright-report',
|
|
26
|
+
open: 'never',
|
|
27
|
+
noSnippets: true,
|
|
28
|
+
}],
|
|
29
|
+
['json', {
|
|
30
|
+
outputFile: process.env.OUTPUT_DIR
|
|
31
|
+
? `${process.env.OUTPUT_DIR}/results.json`
|
|
32
|
+
: 'playwright-report/results.json'
|
|
33
|
+
}],
|
|
34
|
+
['list'],
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
use: {
|
|
38
|
+
baseURL: process.env.BASE_URL,
|
|
39
|
+
trace: 'retain-on-failure',
|
|
40
|
+
screenshot: 'on',
|
|
41
|
+
launchOptions: {
|
|
42
|
+
slowMo: 100,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Create a project for each viewport
|
|
47
|
+
projects: vrtConfig.viewports.map((vp: any) => ({
|
|
48
|
+
name: vp.name,
|
|
49
|
+
use: {
|
|
50
|
+
viewport: { width: vp.width, height: vp.height },
|
|
51
|
+
deviceScaleFactor: 1,
|
|
52
|
+
},
|
|
53
|
+
})),
|
|
54
|
+
});
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import { loadConfig, validateConfig, type CLIOptions } from './config';
|
|
6
|
+
import { collectURLs } from './collect';
|
|
7
|
+
import { runVisualTests, printResults, hasExistingSnapshots } from './runner';
|
|
8
|
+
import { createHash } from 'crypto';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compute SHA-256 hash of a file
|
|
12
|
+
*/
|
|
13
|
+
function computeFileHash(filePath: string): string {
|
|
14
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
15
|
+
return createHash('sha256').update(content).digest('hex');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute SHA-256 hash of a config object
|
|
20
|
+
*/
|
|
21
|
+
function computeConfigHash(config: any): string {
|
|
22
|
+
// Create a stable JSON representation (sorted keys)
|
|
23
|
+
const configStr = JSON.stringify(config, Object.keys(config).sort());
|
|
24
|
+
return createHash('sha256').update(configStr).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if cache is valid by comparing stored hashes
|
|
29
|
+
*/
|
|
30
|
+
function isCacheValid(snapshotDir: string, config: any): boolean {
|
|
31
|
+
const hashFile = path.join(snapshotDir, '.cache-hash.json');
|
|
32
|
+
|
|
33
|
+
if (!fs.existsSync(hashFile)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const stored = JSON.parse(fs.readFileSync(hashFile, 'utf-8'));
|
|
39
|
+
const packageDir = path.join(__dirname, '..');
|
|
40
|
+
const testFilePath = path.join(packageDir, 'tests', 'vrt.spec.ts');
|
|
41
|
+
|
|
42
|
+
const currentHashes = {
|
|
43
|
+
config: computeConfigHash(config),
|
|
44
|
+
testFile: fs.existsSync(testFilePath) ? computeFileHash(testFilePath) : '',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Log cache timestamp
|
|
48
|
+
console.log(` Cache timestamp: ${stored.timestamp}`);
|
|
49
|
+
|
|
50
|
+
return stored.config === currentHashes.config &&
|
|
51
|
+
stored.testFile === currentHashes.testFile;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Save current config and test file hashes to cache
|
|
59
|
+
*/
|
|
60
|
+
function saveCacheHashes(snapshotDir: string, config: any): void {
|
|
61
|
+
const packageDir = path.join(__dirname, '..');
|
|
62
|
+
const testFilePath = path.join(packageDir, 'tests', 'vrt.spec.ts');
|
|
63
|
+
|
|
64
|
+
const hashes = {
|
|
65
|
+
config: computeConfigHash(config),
|
|
66
|
+
testFile: fs.existsSync(testFilePath) ? computeFileHash(testFilePath) : '',
|
|
67
|
+
timestamp: new Date().toISOString(),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const hashFile = path.join(snapshotDir, '.cache-hash.json');
|
|
71
|
+
fs.writeFileSync(hashFile, JSON.stringify(hashes, null, 2), 'utf-8');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function main() {
|
|
75
|
+
const args = parseArgs();
|
|
76
|
+
|
|
77
|
+
// Either --config or --test is required
|
|
78
|
+
if (!args.config && !args.test) {
|
|
79
|
+
console.error('Error: Either --config or --test is required');
|
|
80
|
+
printUsage();
|
|
81
|
+
process.exit(2);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// Load and validate configuration
|
|
86
|
+
let config;
|
|
87
|
+
let configPath: string | undefined;
|
|
88
|
+
|
|
89
|
+
if (args.config) {
|
|
90
|
+
// Load from config file
|
|
91
|
+
configPath = path.resolve(args.config);
|
|
92
|
+
config = await loadConfig(configPath);
|
|
93
|
+
} else {
|
|
94
|
+
// Use defaults
|
|
95
|
+
const { DEFAULT_CONFIG } = await import('./config');
|
|
96
|
+
config = { ...DEFAULT_CONFIG } as any;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Override with CLI args
|
|
100
|
+
if (args.test) config.testUrl = args.test;
|
|
101
|
+
if (args.reference) config.referenceUrl = args.reference;
|
|
102
|
+
|
|
103
|
+
let hasExplicitReference = true;
|
|
104
|
+
|
|
105
|
+
// If no reference URL set, default to test URL
|
|
106
|
+
if (!config.referenceUrl && config.testUrl) {
|
|
107
|
+
config.referenceUrl = config.testUrl;
|
|
108
|
+
hasExplicitReference = false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (args.maxUrls) config.maxUrls = args.maxUrls;
|
|
112
|
+
|
|
113
|
+
validateConfig(config);
|
|
114
|
+
|
|
115
|
+
console.log('🚀 Starting Visual Regression Testing');
|
|
116
|
+
console.log(` Reference: ${config.referenceUrl}`);
|
|
117
|
+
console.log(` Test: ${config.testUrl}`);
|
|
118
|
+
|
|
119
|
+
// Create snapshot directory for URLs and snapshots
|
|
120
|
+
const snapshotDir = path.resolve('playwright-snapshots');
|
|
121
|
+
const outputDir = path.resolve(args.output || 'playwright-report');
|
|
122
|
+
|
|
123
|
+
if (!fs.existsSync(snapshotDir)) {
|
|
124
|
+
fs.mkdirSync(snapshotDir, { recursive: true });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (args.verbose) {
|
|
128
|
+
console.log(`📁 Snapshots: ${snapshotDir}`);
|
|
129
|
+
console.log(`📁 Output: ${outputDir}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check if URLs already exist (unless --update-baseline)
|
|
133
|
+
const urlsPath = path.join(snapshotDir, 'urls.json');
|
|
134
|
+
let urls: string[] = [];
|
|
135
|
+
|
|
136
|
+
// Check cache validity based on config object and test file hashes
|
|
137
|
+
const cacheValid = isCacheValid(snapshotDir, config);
|
|
138
|
+
const shouldRegenerate = args.updateBaseline || !cacheValid;
|
|
139
|
+
|
|
140
|
+
if (!hasExplicitReference && !cacheValid && !args.updateBaseline) {
|
|
141
|
+
console.error('\n❌ Error: No baseline snapshots found and no reference URL provided.');
|
|
142
|
+
console.error(' Either:');
|
|
143
|
+
console.error(' 1. Provide --reference <url> to create baseline from a reference system');
|
|
144
|
+
console.error(' 2. Use --update-baseline to create baseline from test URL');
|
|
145
|
+
console.error(' 3. Add referenceUrl to your config file\n');
|
|
146
|
+
process.exit(2);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (fs.existsSync(urlsPath) && !shouldRegenerate) {
|
|
150
|
+
// Load existing URLs
|
|
151
|
+
console.log('\n📋 Using cached URLs from previous run');
|
|
152
|
+
urls = JSON.parse(fs.readFileSync(urlsPath, 'utf-8'));
|
|
153
|
+
console.log(`✓ Loaded ${urls.length} URLs from cache`);
|
|
154
|
+
|
|
155
|
+
if (args.verbose) {
|
|
156
|
+
console.log(' (Use --update-baseline to regenerate URLs)');
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
// Collect URLs from sitemap/crawler
|
|
160
|
+
if (args.updateBaseline) {
|
|
161
|
+
console.log('\n🔄 Updating baseline (regenerating URLs)...');
|
|
162
|
+
} else if (!cacheValid && fs.existsSync(urlsPath)) {
|
|
163
|
+
console.log('\n🔄 Config or test file changed, regenerating URLs...');
|
|
164
|
+
} else {
|
|
165
|
+
console.log('\n🔍 Collecting URLs (first run)...');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(` Source: ${config.referenceUrl}${config.sitemapPath || '/sitemap.xml'}`);
|
|
169
|
+
const urlResult = await collectURLs(config);
|
|
170
|
+
|
|
171
|
+
console.log(`✓ Found ${urlResult.total} URLs, filtered to ${urlResult.filtered}, using top ${urlResult.urls.length}`);
|
|
172
|
+
console.log(` Source: ${urlResult.source}`);
|
|
173
|
+
|
|
174
|
+
urls = urlResult.urls;
|
|
175
|
+
|
|
176
|
+
// Save URLs to snapshot directory (co-located with snapshots for easy caching)
|
|
177
|
+
fs.writeFileSync(urlsPath, JSON.stringify(urls, null, 2), 'utf-8');
|
|
178
|
+
|
|
179
|
+
// Save cache hashes based on final config object
|
|
180
|
+
saveCacheHashes(snapshotDir, config);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (urls.length === 0) {
|
|
184
|
+
throw new Error('No URLs found to test');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (args.verbose) {
|
|
188
|
+
console.log('\n📝 URLs to test:');
|
|
189
|
+
urls.forEach((url, i) => console.log(` ${i + 1}. ${url}`));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Run visual regression tests using shipped Playwright config and tests
|
|
193
|
+
const results = await runVisualTests({
|
|
194
|
+
config,
|
|
195
|
+
outputDir,
|
|
196
|
+
verbose: args.verbose,
|
|
197
|
+
project: args.project,
|
|
198
|
+
updateBaseline: shouldRegenerate,
|
|
199
|
+
hasExplicitReference,
|
|
200
|
+
headed: args.headed,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Print results
|
|
204
|
+
printResults(results, config);
|
|
205
|
+
|
|
206
|
+
// Report location
|
|
207
|
+
const reportPath = path.join(outputDir, 'index.html');
|
|
208
|
+
console.log(`\n📊 Report: ${reportPath}`);
|
|
209
|
+
|
|
210
|
+
if (args.verbose) {
|
|
211
|
+
console.log(`📁 Snapshots: ${snapshotDir}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Exit with appropriate code
|
|
215
|
+
process.exit(results.failed > 0 ? 1 : 0);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
console.error('\n❌ Error:', error instanceof Error ? error.message : error);
|
|
218
|
+
if (args.verbose && error instanceof Error) {
|
|
219
|
+
console.error(error.stack);
|
|
220
|
+
}
|
|
221
|
+
process.exit(2);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function parseArgs(): CLIOptions {
|
|
226
|
+
const args: CLIOptions = {
|
|
227
|
+
config: '',
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
231
|
+
const arg = process.argv[i];
|
|
232
|
+
const next = process.argv[i + 1];
|
|
233
|
+
|
|
234
|
+
switch (arg) {
|
|
235
|
+
case '--reference':
|
|
236
|
+
args.reference = next;
|
|
237
|
+
i++;
|
|
238
|
+
break;
|
|
239
|
+
case '--test':
|
|
240
|
+
args.test = next;
|
|
241
|
+
i++;
|
|
242
|
+
break;
|
|
243
|
+
case '--config':
|
|
244
|
+
args.config = next;
|
|
245
|
+
i++;
|
|
246
|
+
break;
|
|
247
|
+
case '--output':
|
|
248
|
+
args.output = next;
|
|
249
|
+
i++;
|
|
250
|
+
break;
|
|
251
|
+
case '--max-urls':
|
|
252
|
+
args.maxUrls = parseInt(next, 10);
|
|
253
|
+
i++;
|
|
254
|
+
break;
|
|
255
|
+
case '--project':
|
|
256
|
+
args.project = next;
|
|
257
|
+
i++;
|
|
258
|
+
break;
|
|
259
|
+
case '--verbose':
|
|
260
|
+
args.verbose = true;
|
|
261
|
+
break;
|
|
262
|
+
case '--headed':
|
|
263
|
+
args.headed = true;
|
|
264
|
+
break;
|
|
265
|
+
case '--update-baseline':
|
|
266
|
+
args.updateBaseline = true;
|
|
267
|
+
break;
|
|
268
|
+
case '--clean':
|
|
269
|
+
// Clean snapshots and reports
|
|
270
|
+
console.log('🗑️ Cleaning...');
|
|
271
|
+
['playwright-snapshots', 'playwright-report', 'playwright-tmp'].forEach(dir => {
|
|
272
|
+
const fullPath = path.resolve(dir);
|
|
273
|
+
if (fs.existsSync(fullPath)) {
|
|
274
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
275
|
+
console.log(` Removed: ${dir}/`);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
console.log('✓ Clean complete');
|
|
279
|
+
process.exit(0);
|
|
280
|
+
break;
|
|
281
|
+
case '--help':
|
|
282
|
+
case '-h':
|
|
283
|
+
printUsage();
|
|
284
|
+
process.exit(0);
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return args;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function printUsage(): void {
|
|
293
|
+
console.log(`
|
|
294
|
+
Usage: playwright-vrt run [options]
|
|
295
|
+
|
|
296
|
+
Required (one of):
|
|
297
|
+
--test <url> Test URL
|
|
298
|
+
--config <path> Path to config file with testUrl/referenceUrl
|
|
299
|
+
|
|
300
|
+
Optional:
|
|
301
|
+
--reference <url> Reference URL (defaults to --test URL or config)
|
|
302
|
+
--output <dir> Output directory (default: ./playwright-report)
|
|
303
|
+
--max-urls <number> Override config maxUrls
|
|
304
|
+
--project <name> Playwright project to run (default: all)
|
|
305
|
+
--verbose Detailed logging
|
|
306
|
+
--headed Run browser in headed mode (visible)
|
|
307
|
+
--update-baseline Force regenerate URLs and baseline snapshots
|
|
308
|
+
--clean Clean playwright-snapshots/ and playwright-report/
|
|
309
|
+
--help, -h Show this help message
|
|
310
|
+
|
|
311
|
+
Examples:
|
|
312
|
+
# Minimal - compare staging against itself (first run creates baseline)
|
|
313
|
+
bunx playwright-vrt run --test https://staging.example.com
|
|
314
|
+
|
|
315
|
+
# Compare staging against production
|
|
316
|
+
bunx playwright-vrt run \\
|
|
317
|
+
--reference https://production.com \\
|
|
318
|
+
--test https://staging.com
|
|
319
|
+
|
|
320
|
+
# With config file only (contains testUrl and referenceUrl)
|
|
321
|
+
bunx playwright-vrt run --config ./playwright-vrt.config.json
|
|
322
|
+
|
|
323
|
+
# With config file + URL override
|
|
324
|
+
bunx playwright-vrt run \\
|
|
325
|
+
--test https://preview-123.staging.com \\
|
|
326
|
+
--config ./playwright-vrt.config.json
|
|
327
|
+
|
|
328
|
+
Directories:
|
|
329
|
+
playwright-snapshots/ Baseline snapshots and URLs (cache this!)
|
|
330
|
+
playwright-report/ HTML test report
|
|
331
|
+
playwright-tmp/ Temporary test artifacts (cleared on each run)
|
|
332
|
+
|
|
333
|
+
Clean with: playwright-vrt run --clean
|
|
334
|
+
Or manually: rm -rf playwright-snapshots playwright-report playwright-tmp
|
|
335
|
+
`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Run the CLI
|
|
339
|
+
main();
|
package/src/collect.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import Sitemapper from 'sitemapper';
|
|
4
|
+
import micromatch from 'micromatch';
|
|
5
|
+
import { chromium } from 'playwright';
|
|
6
|
+
import type { VRTConfig } from './config';
|
|
7
|
+
|
|
8
|
+
export interface URLCollectionResult {
|
|
9
|
+
urls: string[];
|
|
10
|
+
source: 'sitemap' | 'crawl';
|
|
11
|
+
total: number;
|
|
12
|
+
filtered: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function collectURLs(config: VRTConfig): Promise<URLCollectionResult> {
|
|
16
|
+
let urls: string[] = [];
|
|
17
|
+
let source: 'sitemap' | 'crawl' = 'sitemap';
|
|
18
|
+
|
|
19
|
+
// Try sitemap first
|
|
20
|
+
try {
|
|
21
|
+
urls = await collectFromSitemap(config.referenceUrl, config.sitemapPath || '/sitemap.xml');
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.warn('⚠️ Sitemap fetch failed, falling back to crawler');
|
|
24
|
+
// Fallback to crawling
|
|
25
|
+
urls = await crawlWebsite(config.referenceUrl, config.crawlOptions);
|
|
26
|
+
source = 'crawl';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const totalUrls = urls.length;
|
|
30
|
+
|
|
31
|
+
// Filter URLs
|
|
32
|
+
urls = filterURLs(urls, config.referenceUrl, config.include || ['*'], config.exclude || []);
|
|
33
|
+
|
|
34
|
+
// Remove duplicate URLs but keep order
|
|
35
|
+
urls = Array.from(new Set(urls));
|
|
36
|
+
|
|
37
|
+
// Limit to maxUrls
|
|
38
|
+
const maxUrls = config.maxUrls || 25;
|
|
39
|
+
const filteredCount = urls.length;
|
|
40
|
+
urls = urls.slice(0, maxUrls);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
urls,
|
|
44
|
+
source,
|
|
45
|
+
total: totalUrls,
|
|
46
|
+
filtered: filteredCount,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function collectFromSitemap(baseUrl: string, sitemapPath: string): Promise<string[]> {
|
|
51
|
+
const sitemapUrl = new URL(sitemapPath, baseUrl).toString();
|
|
52
|
+
|
|
53
|
+
const sitemap = new Sitemapper({
|
|
54
|
+
url: sitemapUrl,
|
|
55
|
+
timeout: 15000,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const { sites } = await sitemap.fetch();
|
|
59
|
+
|
|
60
|
+
if (!sites || sites.length === 0) {
|
|
61
|
+
throw new Error('No URLs found in sitemap');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return sites;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function crawlWebsite(
|
|
68
|
+
baseUrl: string,
|
|
69
|
+
options?: VRTConfig['crawlOptions']
|
|
70
|
+
): Promise<string[]> {
|
|
71
|
+
const urls = new Set<string>();
|
|
72
|
+
|
|
73
|
+
console.log(' Using crawler (homepage links only)...');
|
|
74
|
+
|
|
75
|
+
const browser = await chromium.launch({ headless: true });
|
|
76
|
+
const page = await browser.newPage();
|
|
77
|
+
|
|
78
|
+
const baseUrlObj = new URL(baseUrl);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// Visit the homepage
|
|
82
|
+
await page.goto(baseUrl, {
|
|
83
|
+
waitUntil: 'networkidle',
|
|
84
|
+
timeout: 30000
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Add the homepage itself, by adding the current page URL
|
|
88
|
+
urls.add(page.url());
|
|
89
|
+
|
|
90
|
+
// Extract all links from the page
|
|
91
|
+
const links = await page.evaluate(() => {
|
|
92
|
+
// @ts-ignore - runs in browser context
|
|
93
|
+
const anchors = Array.from(document.querySelectorAll('a[href]'));
|
|
94
|
+
// @ts-ignore - runs in browser context
|
|
95
|
+
return anchors.map(a => (a as HTMLAnchorElement).href);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Filter links to same domain and add to set
|
|
99
|
+
for (const link of links) {
|
|
100
|
+
try {
|
|
101
|
+
const linkUrl = new URL(link);
|
|
102
|
+
|
|
103
|
+
// Only include links from the same domain
|
|
104
|
+
if (linkUrl.hostname === baseUrlObj.hostname) {
|
|
105
|
+
// Normalize URL (remove hash)
|
|
106
|
+
linkUrl.hash = '';
|
|
107
|
+
const normalizedUrl = linkUrl.toString();
|
|
108
|
+
|
|
109
|
+
if (options?.removeTrailingSlash) {
|
|
110
|
+
// Remove trailing slash for consistency (except for root)
|
|
111
|
+
const cleanUrl = normalizedUrl.endsWith('/') && normalizedUrl !== baseUrl + '/'
|
|
112
|
+
? normalizedUrl.slice(0, -1)
|
|
113
|
+
: normalizedUrl;
|
|
114
|
+
urls.add(cleanUrl);
|
|
115
|
+
} else {
|
|
116
|
+
urls.add(normalizedUrl);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Skip invalid URLs
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(` Found ${urls.size} URLs from homepage`);
|
|
125
|
+
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(' Crawler error:', error instanceof Error ? error.message : error);
|
|
128
|
+
// At minimum, return the base URL
|
|
129
|
+
urls.add(baseUrl);
|
|
130
|
+
} finally {
|
|
131
|
+
await browser.close();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return Array.from(urls);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function filterURLs(urls: string[], baseUrl: string, include: string[], exclude: string[]): string[] {
|
|
138
|
+
return urls.filter((url) => {
|
|
139
|
+
// Convert URL to path for pattern matching
|
|
140
|
+
const urlObj = new URL(url);
|
|
141
|
+
const path = urlObj.pathname + urlObj.search;
|
|
142
|
+
|
|
143
|
+
// Filter URLs that don't match the reference URL domain
|
|
144
|
+
const baseObj = new URL(baseUrl);
|
|
145
|
+
if (urlObj.hostname !== baseObj.hostname) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Also match against full URL for more flexibility
|
|
150
|
+
const fullUrl = url;
|
|
151
|
+
|
|
152
|
+
// Check if excluded
|
|
153
|
+
if (exclude.length > 0) {
|
|
154
|
+
if (micromatch.isMatch(path, exclude, {bash: true})) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Check if included
|
|
160
|
+
if (include.length > 0) {
|
|
161
|
+
return micromatch.isMatch(path, include, {bash: true});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return true;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function saveURLs(urls: string[], filepath: string): void {
|
|
169
|
+
const content = JSON.stringify(urls, null, 2);
|
|
170
|
+
Bun.write(filepath, content);
|
|
171
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
export interface VRTConfig {
|
|
4
|
+
referenceUrl: string;
|
|
5
|
+
testUrl: string;
|
|
6
|
+
sitemapPath?: string;
|
|
7
|
+
maxUrls?: number;
|
|
8
|
+
exclude?: string[];
|
|
9
|
+
include?: string[];
|
|
10
|
+
crawlOptions?: {
|
|
11
|
+
maxDepth?: number;
|
|
12
|
+
removeTrailingSlash?: boolean;
|
|
13
|
+
};
|
|
14
|
+
viewports?: Array<{
|
|
15
|
+
name: string;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
}>;
|
|
19
|
+
threshold?: {
|
|
20
|
+
maxDiffPixels?: number;
|
|
21
|
+
maxDiffPixelRatio?: number;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CLIOptions {
|
|
26
|
+
reference?: string;
|
|
27
|
+
test?: string;
|
|
28
|
+
config: string;
|
|
29
|
+
output?: string;
|
|
30
|
+
maxUrls?: number;
|
|
31
|
+
project?: string;
|
|
32
|
+
verbose?: boolean;
|
|
33
|
+
identifier?: string;
|
|
34
|
+
updateBaseline?: boolean;
|
|
35
|
+
headed?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_CONFIG: Partial<VRTConfig> = {
|
|
39
|
+
sitemapPath: '/sitemap.xml',
|
|
40
|
+
maxUrls: 25,
|
|
41
|
+
exclude: [],
|
|
42
|
+
include: ['*'],
|
|
43
|
+
crawlOptions: {
|
|
44
|
+
maxDepth: 1,
|
|
45
|
+
removeTrailingSlash: true,
|
|
46
|
+
},
|
|
47
|
+
viewports: [
|
|
48
|
+
{ name: 'desktop', width: 1920, height: 1080 },
|
|
49
|
+
{ name: 'mobile', width: 375, height: 667 },
|
|
50
|
+
],
|
|
51
|
+
threshold: {
|
|
52
|
+
maxDiffPixels: 100,
|
|
53
|
+
maxDiffPixelRatio: 0.01,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export async function loadConfig(configPath: string): Promise<VRTConfig> {
|
|
58
|
+
try {
|
|
59
|
+
const file = Bun.file(configPath);
|
|
60
|
+
const config = await file.json();
|
|
61
|
+
|
|
62
|
+
// Merge with defaults
|
|
63
|
+
return {
|
|
64
|
+
...DEFAULT_CONFIG,
|
|
65
|
+
...config,
|
|
66
|
+
crawlOptions: {
|
|
67
|
+
...DEFAULT_CONFIG.crawlOptions,
|
|
68
|
+
...config.crawlOptions,
|
|
69
|
+
},
|
|
70
|
+
viewports: config.viewports || DEFAULT_CONFIG.viewports,
|
|
71
|
+
threshold: {
|
|
72
|
+
...DEFAULT_CONFIG.threshold,
|
|
73
|
+
...config.threshold,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
78
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function validateConfig(config: VRTConfig): void {
|
|
85
|
+
// Validate required URLs
|
|
86
|
+
if (!config.testUrl) {
|
|
87
|
+
throw new Error('testUrl is required');
|
|
88
|
+
}
|
|
89
|
+
if (!config.referenceUrl) {
|
|
90
|
+
throw new Error('referenceUrl is required');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Validate URL format
|
|
94
|
+
try {
|
|
95
|
+
new URL(config.referenceUrl);
|
|
96
|
+
new URL(config.testUrl);
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error('Invalid URL format in referenceUrl or testUrl');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Validate viewports
|
|
102
|
+
if (!config.viewports || config.viewports.length === 0) {
|
|
103
|
+
throw new Error('At least one viewport must be defined');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const vp of config.viewports) {
|
|
107
|
+
if (!vp.name || vp.width <= 0 || vp.height <= 0) {
|
|
108
|
+
throw new Error(`Invalid viewport configuration: ${JSON.stringify(vp)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Validate threshold
|
|
113
|
+
if (config.threshold) {
|
|
114
|
+
if (config.threshold.maxDiffPixelRatio &&
|
|
115
|
+
(config.threshold.maxDiffPixelRatio < 0 || config.threshold.maxDiffPixelRatio > 1)) {
|
|
116
|
+
throw new Error('maxDiffPixelRatio must be between 0 and 1');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/index.ts
ADDED
package/src/runner.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import type { VRTConfig } from './config';
|
|
7
|
+
|
|
8
|
+
export interface TestResults {
|
|
9
|
+
passed: number;
|
|
10
|
+
failed: number;
|
|
11
|
+
total: number;
|
|
12
|
+
exitCode: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RunnerOptions {
|
|
16
|
+
config: VRTConfig;
|
|
17
|
+
outputDir: string;
|
|
18
|
+
verbose?: boolean;
|
|
19
|
+
project?: string;
|
|
20
|
+
updateBaseline?: boolean;
|
|
21
|
+
hasExplicitReference?: boolean;
|
|
22
|
+
headed?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if baseline snapshots already exist and are valid
|
|
27
|
+
*/
|
|
28
|
+
export function hasExistingSnapshots(snapshotDir: string): boolean {
|
|
29
|
+
if (!fs.existsSync(snapshotDir)) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Recursively check for any .png files
|
|
34
|
+
function hasSnapshotFiles(dir: string): boolean {
|
|
35
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
36
|
+
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const fullPath = path.join(dir, entry.name);
|
|
39
|
+
|
|
40
|
+
if (entry.isDirectory()) {
|
|
41
|
+
if (hasSnapshotFiles(fullPath)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
} else if (entry.name.endsWith('.png')) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return hasSnapshotFiles(snapshotDir);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run Playwright tests using the shipped config and test files
|
|
57
|
+
* Much simpler than the old approach - just exec playwright
|
|
58
|
+
*/
|
|
59
|
+
export async function runVisualTests(options: RunnerOptions): Promise<TestResults> {
|
|
60
|
+
const { config, outputDir, verbose, project, updateBaseline, hasExplicitReference, headed } = options;
|
|
61
|
+
|
|
62
|
+
// Find the playwright-vrt package directory
|
|
63
|
+
const packageDir = path.join(__dirname, '..');
|
|
64
|
+
const playwrightConfigPath = path.join(packageDir, 'playwright.config.ts');
|
|
65
|
+
const snapshotDir = path.join(process.cwd(), 'playwright-snapshots');
|
|
66
|
+
|
|
67
|
+
// Check if baseline snapshots already exist (look for any .png files in snapshots)
|
|
68
|
+
const hasBaseline = !updateBaseline && hasExistingSnapshots(snapshotDir);
|
|
69
|
+
|
|
70
|
+
if (hasBaseline) {
|
|
71
|
+
console.log('\n📸 Using existing baseline snapshots');
|
|
72
|
+
if (verbose) {
|
|
73
|
+
console.log(' (Use --update-baseline to regenerate from reference URL)');
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
if (updateBaseline) {
|
|
77
|
+
console.log('\n🔄 Updating baseline snapshots...');
|
|
78
|
+
} else {
|
|
79
|
+
console.log('\n📸 Creating baseline snapshots (first run)...');
|
|
80
|
+
}
|
|
81
|
+
console.log(` Source: ${config.referenceUrl}`);
|
|
82
|
+
|
|
83
|
+
// Step 1: Create baseline screenshots
|
|
84
|
+
await runPlaywright({
|
|
85
|
+
configPath: playwrightConfigPath,
|
|
86
|
+
baseURL: config.referenceUrl,
|
|
87
|
+
vrtConfig: config,
|
|
88
|
+
outputDir,
|
|
89
|
+
updateSnapshots: true,
|
|
90
|
+
verbose: true,
|
|
91
|
+
project,
|
|
92
|
+
headed,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
console.log('✓ Baseline created');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`\n🧪 Testing ${config.testUrl}`);
|
|
99
|
+
|
|
100
|
+
// Step 2: Run tests against test URL
|
|
101
|
+
const exitCode = await runPlaywright({
|
|
102
|
+
configPath: playwrightConfigPath,
|
|
103
|
+
baseURL: config.testUrl,
|
|
104
|
+
vrtConfig: config,
|
|
105
|
+
outputDir,
|
|
106
|
+
updateSnapshots: false,
|
|
107
|
+
verbose: true,
|
|
108
|
+
project,
|
|
109
|
+
headed,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Parse results
|
|
113
|
+
const results = await parseResults(outputDir);
|
|
114
|
+
results.exitCode = exitCode;
|
|
115
|
+
|
|
116
|
+
return results;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface PlaywrightRunOptions {
|
|
120
|
+
configPath: string;
|
|
121
|
+
baseURL: string;
|
|
122
|
+
vrtConfig: VRTConfig;
|
|
123
|
+
outputDir: string;
|
|
124
|
+
updateSnapshots: boolean;
|
|
125
|
+
verbose?: boolean;
|
|
126
|
+
project?: string;
|
|
127
|
+
headed?: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function runPlaywright(options: PlaywrightRunOptions): Promise<number> {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const args = ['playwright', 'test', '--config', options.configPath];
|
|
133
|
+
|
|
134
|
+
if (options.updateSnapshots) {
|
|
135
|
+
args.push('--update-snapshots');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (options.project) {
|
|
139
|
+
args.push('--project', options.project);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (options.headed) {
|
|
143
|
+
args.push('--headed');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const env = {
|
|
147
|
+
...process.env,
|
|
148
|
+
BASE_URL: options.baseURL,
|
|
149
|
+
VRT_CONFIG: JSON.stringify(options.vrtConfig),
|
|
150
|
+
OUTPUT_DIR: options.outputDir,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const proc = spawn('bunx', args, {
|
|
154
|
+
env,
|
|
155
|
+
stdio: options.verbose ? 'inherit' : 'pipe',
|
|
156
|
+
shell: true,
|
|
157
|
+
cwd: process.cwd(),
|
|
158
|
+
}); let stdout = '';
|
|
159
|
+
let stderr = '';
|
|
160
|
+
|
|
161
|
+
if (!options.verbose) {
|
|
162
|
+
proc.stdout?.on('data', (data) => {
|
|
163
|
+
stdout += data.toString();
|
|
164
|
+
});
|
|
165
|
+
proc.stderr?.on('data', (data) => {
|
|
166
|
+
stderr += data.toString();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
proc.on('close', (code) => {
|
|
171
|
+
const exitCode = code || 0;
|
|
172
|
+
|
|
173
|
+
// For baseline creation (update-snapshots), always succeed
|
|
174
|
+
if (options.updateSnapshots) {
|
|
175
|
+
resolve(0);
|
|
176
|
+
} else {
|
|
177
|
+
// For actual tests, return the exit code
|
|
178
|
+
resolve(exitCode);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
proc.on('error', (error) => {
|
|
183
|
+
reject(new Error(`Failed to run Playwright: ${error.message}`));
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function parseResults(outputDir: string): Promise<TestResults> {
|
|
189
|
+
const resultsPath = path.join(outputDir, 'results.json');
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const file = Bun.file(resultsPath);
|
|
193
|
+
const results = await file.json();
|
|
194
|
+
|
|
195
|
+
let passed = 0;
|
|
196
|
+
let failed = 0;
|
|
197
|
+
let total = 0;
|
|
198
|
+
|
|
199
|
+
// Parse Playwright JSON results
|
|
200
|
+
if (results.suites) {
|
|
201
|
+
for (const suite of results.suites) {
|
|
202
|
+
if (suite.specs) {
|
|
203
|
+
for (const spec of suite.specs) {
|
|
204
|
+
total++;
|
|
205
|
+
if (spec.ok) {
|
|
206
|
+
passed++;
|
|
207
|
+
} else {
|
|
208
|
+
failed++;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { passed, failed, total, exitCode: 0 };
|
|
216
|
+
} catch {
|
|
217
|
+
return { passed: 0, failed: 0, total: 0, exitCode: 1 };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function printResults(results: TestResults, config: VRTConfig): void {
|
|
222
|
+
console.log('\n📊 Test Results:');
|
|
223
|
+
console.log(` Total: ${results.total}`);
|
|
224
|
+
console.log(` Passed: ${results.passed}`);
|
|
225
|
+
console.log(` Failed: ${results.failed}`);
|
|
226
|
+
|
|
227
|
+
if (results.failed > 0) {
|
|
228
|
+
console.log(`\n❌ ${results.failed} visual difference(s) detected`);
|
|
229
|
+
} else if (results.total > 0) {
|
|
230
|
+
console.log('\n✅ All visual tests passed');
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Workspace manages the .playwright-vrt/ directory
|
|
8
|
+
* This is persistent between runs for debugging and caching
|
|
9
|
+
*/
|
|
10
|
+
export class Workspace {
|
|
11
|
+
private readonly dir: string;
|
|
12
|
+
|
|
13
|
+
constructor(baseDir?: string) {
|
|
14
|
+
// Use .playwright-vrt in current directory (or specified base)
|
|
15
|
+
this.dir = path.join(baseDir || process.cwd(), '.playwright-vrt');
|
|
16
|
+
|
|
17
|
+
// Create workspace directory
|
|
18
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getPath(file: string = ''): string {
|
|
22
|
+
return file ? path.join(this.dir, file) : this.dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
writeFile(filename: string, content: string): void {
|
|
26
|
+
const filePath = this.getPath(filename);
|
|
27
|
+
const dir = path.dirname(filePath);
|
|
28
|
+
|
|
29
|
+
if (!fs.existsSync(dir)) {
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
readFile(filename: string): string {
|
|
37
|
+
const filePath = this.getPath(filename);
|
|
38
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
exists(filename: string): boolean {
|
|
42
|
+
return fs.existsSync(this.getPath(filename));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
writeJSON(filename: string, data: any): void {
|
|
46
|
+
this.writeFile(filename, JSON.stringify(data, null, 2));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
readJSON(filename: string): any {
|
|
50
|
+
return JSON.parse(this.readFile(filename));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Manual cleanup - workspace is persistent by default
|
|
54
|
+
clean(): void {
|
|
55
|
+
if (fs.existsSync(this.dir)) {
|
|
56
|
+
fs.rmSync(this.dir, { recursive: true, force: true });
|
|
57
|
+
console.log(`🗑️ Cleaned workspace: ${this.dir}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
info(): void {
|
|
62
|
+
console.log(`📁 Workspace: ${this.dir}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/tests/vrt.css
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
*, *::before, *::after {
|
|
2
|
+
animation-duration: 0s !important;
|
|
3
|
+
animation-delay: 0s !important;
|
|
4
|
+
transition-duration: 0s !important;
|
|
5
|
+
transition-delay: 0s !important;
|
|
6
|
+
scroll-behavior: auto !important;
|
|
7
|
+
}
|
|
8
|
+
#drupal-live-announce {
|
|
9
|
+
display: none !important;
|
|
10
|
+
}
|
|
11
|
+
#cookiebanner, #usercentrics-root, #CybotCookiebotDialog, #klaro, .cc-compliance {
|
|
12
|
+
display: none !important;
|
|
13
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
// Load URLs from playwright-snapshots/ (shared with snapshots for easy caching)
|
|
6
|
+
const urlsPath = join(process.cwd(), 'playwright-snapshots', 'urls.json');
|
|
7
|
+
|
|
8
|
+
if (!existsSync(urlsPath)) {
|
|
9
|
+
throw new Error(`URLs file not found at ${urlsPath}. Did you run URL collection?`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const urls: string[] = JSON.parse(readFileSync(urlsPath, 'utf-8'));
|
|
13
|
+
|
|
14
|
+
// Load config for threshold settings
|
|
15
|
+
const vrtConfig = process.env.VRT_CONFIG
|
|
16
|
+
? JSON.parse(process.env.VRT_CONFIG)
|
|
17
|
+
: {};
|
|
18
|
+
|
|
19
|
+
const threshold = vrtConfig.threshold || {
|
|
20
|
+
maxDiffPixels: 100,
|
|
21
|
+
maxDiffPixelRatio: 0.01,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Create a test for each URL
|
|
25
|
+
for (const url of urls) {
|
|
26
|
+
test(`VRT: ${url}`, async ({ page }) => {
|
|
27
|
+
// Navigate to the URL
|
|
28
|
+
const pageUrl = new URL(url);
|
|
29
|
+
const fullPath = pageUrl.pathname + pageUrl.search;
|
|
30
|
+
await page.goto(fullPath, {
|
|
31
|
+
waitUntil: 'networkidle',
|
|
32
|
+
timeout: 30000
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Wait for fonts to load
|
|
36
|
+
await page.evaluate(() => document.fonts.ready);
|
|
37
|
+
|
|
38
|
+
// Wait for animations to settle
|
|
39
|
+
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve)));
|
|
40
|
+
|
|
41
|
+
// Additional stability wait for lazy-loaded content
|
|
42
|
+
await page.waitForTimeout(250);
|
|
43
|
+
|
|
44
|
+
// Take full page screenshot and compare
|
|
45
|
+
await expect(page).toHaveScreenshot({
|
|
46
|
+
fullPage: true,
|
|
47
|
+
maxDiffPixels: threshold.maxDiffPixels,
|
|
48
|
+
maxDiffPixelRatio: threshold.maxDiffPixelRatio,
|
|
49
|
+
animations: 'disabled',
|
|
50
|
+
stylePath: join(process.cwd(), 'tests', 'vrt.css')
|
|
51
|
+
}, { timeout: 10000 });
|
|
52
|
+
});
|
|
53
|
+
}
|