snapdrift 0.2.1 → 0.4.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/CHANGELOG.md +37 -1
- package/actions/comment/action.yml +22 -3
- package/actions/pr-diff/action.yml +16 -3
- package/docs/contracts.md +126 -1
- package/lib/capture-routes.mjs +2 -368
- package/lib/cli.mjs +167 -34
- package/lib/compare-results.mjs +21 -642
- package/lib/drift-summary.mjs +3 -154
- package/lib/init-from-action.mjs +244 -0
- package/lib/migrate-baselines.mjs +199 -0
- package/lib/pr-comment.mjs +2 -147
- package/lib/provider.mjs +106 -0
- package/lib/report.mjs +17 -246
- package/lib/snap-provider.mjs +734 -0
- package/lib/snapdrift-config.mjs +22 -284
- package/lib/stage-artifacts.mjs +2 -123
- package/package.json +16 -4
- package/types/visual-diff-types.d.ts +65 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.4.0 - 2026-05-26
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **VisualProvider interface** — new `VisualProvider` interface (`capture`, `diff`, `publishBaseline`, `fetchLatestBaseline`, `buildCommentBody`) with `LocalProvider` and `SnapProvider` implementations. Config `provider` field (`"local"` | `"snap"`) selects the backend; defaults to `"local"` for zero-config backward compatibility.
|
|
8
|
+
- **SnapProvider** — calls Snap's `/v1/visual/*` API for capture, diff, and baseline management. Retries on 5xx with exponential backoff (3 attempts, 30 s cap). Honors `snap.onUnavailable` (`"fail"` | `"warn-and-skip"` | `"fallback-local"`). 4xx errors never retry or fall back.
|
|
9
|
+
- **Snap config** — new `snap` config block (`apiUrl`, `apiKeyEnv`/`apiKey`, `projectId`, `onUnavailable`) in `snapdrift.json`. Exactly one of `apiKeyEnv` or `apiKey` is required when `provider: "snap"`.
|
|
10
|
+
- **PR comment unification** — both providers now produce identical PR comment markdown via a shared `buildCommentBody(summary, meta?)` method. `SnapProvider` appends a "View in dashboard →" link when `dashboardUrl` is present; `LocalProvider` omits it. Comment steps route through the provider instead of calling `buildReportCommentBody` directly, with a defensive fallback to `LocalProvider` if config loading fails.
|
|
11
|
+
- **`migrate-baselines` command** — one-shot CLI command (`snapdrift migrate-baselines --to snap` / `--to local`) to upload local baselines to Snap or export Snap baselines back to a local directory. Supports `--accept-cross-engine` for engine-mismatch scenarios.
|
|
12
|
+
- **`init --from-snap-action` codemod** — translates a Snap `github-action` workflow YAML into `snapdrift.json` with `provider: "snap"`. Emits structured warnings for features with no direct equivalent (jpeg format, baseline tags, single-shot artifact upload).
|
|
13
|
+
|
|
14
|
+
### Infrastructure
|
|
15
|
+
|
|
16
|
+
- **`@snapdrift/manifest` v1.1.0** — added `ProviderCommentMeta`, `dashboardUrl` on `VisualDiffSummary`, `buildCommentBody` on `VisualProvider`, `SnapConfig` re-export.
|
|
17
|
+
- **`@snapdrift/adapter-report-md` v1.1.0** — `buildReportCommentBody` now accepts `dashboardUrl` in meta; renders "View in dashboard →" link when present and valid.
|
|
18
|
+
- **Action contract tests** — `snapdrift-actions-contract` test suite now asserts that comment and pr-diff steps include `createProvider` and `buildCommentBody` calls.
|
|
19
|
+
- **Provider unification tests** — new `buildCommentBody — provider unification` test block verifying identical markdown output across providers, dashboard link inclusion/exclusion, and URL validation.
|
|
20
|
+
|
|
21
|
+
## 0.3.0 - 2026-05-26
|
|
22
|
+
|
|
23
|
+
### Infrastructure
|
|
24
|
+
|
|
25
|
+
- **Package extraction (Phase 1a)** — extracted `@snapdrift/manifest` (schema, validation, indexing, viewport presets) and `@snapdrift/compare-core` (pure pixel comparison, ignore-region masking) as standalone npm packages with zero runtime deps beyond `pngjs`.
|
|
26
|
+
- **Package extraction (Phase 1b)** — extracted `@snapdrift/adapter-fs` (all filesystem I/O: capture, compare, config, staging, drift report generation) and `@snapdrift/adapter-report-md` (pure markdown/HTML report generators, zero runtime deps) as standalone npm packages.
|
|
27
|
+
- **Lib/ shim refactor** — all `lib/*.mjs` modules are now thin re-export shims that delegate to packages. `lib/report.mjs` wires the default filesystem `imageReader` for HTML reports. Backward-compatible — all export surfaces preserved.
|
|
28
|
+
- **`generateDiffImage` ignore regions** — `@snapdrift/compare-core` now overlays ignored pixel regions with semi-transparent gray instead of skipping them.
|
|
29
|
+
- **Publish workflow** — `.github/workflows/publish.yml` now publishes all four `@snapdrift/*` workspace packages in dependency order before the root `snapdrift` package.
|
|
30
|
+
|
|
31
|
+
### Performance
|
|
32
|
+
|
|
33
|
+
- **Faster screenshot capture** — navigation now waits for `load` instead of `networkidle`, removing a guaranteed ≥500 ms floor per route. A 20-route run saves ~10 s in navigation waits alone.
|
|
34
|
+
- **Concurrent route capture** — routes within the same viewport are now captured in parallel (up to `SNAPDRIFT_CAPTURE_CONCURRENCY` at a time, default 5) rather than sequentially, giving a further 3–5× throughput improvement on multi-route suites.
|
|
35
|
+
- **Deterministic animation handling** — screenshots are taken with `animations: 'disabled'` so Playwright finishes/cancels CSS animations before capture, replacing reliance on the settle delay for animation timing.
|
|
36
|
+
|
|
37
|
+
### Features
|
|
38
|
+
|
|
39
|
+
- **`SNAPDRIFT_CAPTURE_CONCURRENCY` env var** — controls the maximum number of routes captured concurrently within a viewport context (positive integer, default `5`). Set to `1` to restore serial-per-viewport behaviour for apps with shared session or auth state.
|
|
4
40
|
|
|
5
41
|
## 0.2.1 - 2026-04-13
|
|
6
42
|
|
|
@@ -20,6 +20,10 @@ inputs:
|
|
|
20
20
|
description: Artifact label to include in the comment.
|
|
21
21
|
required: false
|
|
22
22
|
default: ''
|
|
23
|
+
repo-config-path:
|
|
24
|
+
description: Path to the SnapDrift config file (needed for provider-aware comment rendering).
|
|
25
|
+
required: false
|
|
26
|
+
default: .github/snapdrift.json
|
|
23
27
|
max-changed-rows:
|
|
24
28
|
description: Maximum number of changed-route rows shown in the PR comment before truncation.
|
|
25
29
|
required: false
|
|
@@ -42,6 +46,7 @@ runs:
|
|
|
42
46
|
VISUAL_DIFF_RUN_URL: ${{ inputs.run-url }}
|
|
43
47
|
VISUAL_DIFF_ARTIFACT_NAME: ${{ inputs.artifact-name }}
|
|
44
48
|
INPUT_PR_NUMBER: ${{ inputs.pr-number }}
|
|
49
|
+
REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
|
|
45
50
|
MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
|
|
46
51
|
MAX_ERROR_ROWS: ${{ inputs.max-error-rows }}
|
|
47
52
|
with:
|
|
@@ -63,15 +68,29 @@ runs:
|
|
|
63
68
|
return;
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
const
|
|
67
|
-
const
|
|
71
|
+
const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
|
|
72
|
+
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
73
|
+
const prCommentModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/pr-comment.mjs');
|
|
74
|
+
const { createProvider } = await import(pathToFileURL(providerModulePath));
|
|
75
|
+
const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
|
|
76
|
+
const { PR_COMMENT_MARKERS } = await import(pathToFileURL(prCommentModulePath));
|
|
77
|
+
|
|
78
|
+
// Provider-loading block duplicated in actions/pr-diff/action.yml — keep in sync.
|
|
79
|
+
let provider;
|
|
80
|
+
try {
|
|
81
|
+
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
82
|
+
provider = createProvider(config.provider || 'local', config);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
core.warning(`Could not load SnapDrift config; falling back to LocalProvider. ${err.message}`);
|
|
85
|
+
provider = createProvider('local');
|
|
86
|
+
}
|
|
68
87
|
|
|
69
88
|
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
|
|
70
89
|
const runUrl = process.env.VISUAL_DIFF_RUN_URL || `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
71
90
|
|
|
72
91
|
const maxChangedRows = Number(process.env.MAX_CHANGED_ROWS) || 20;
|
|
73
92
|
const maxErrorRows = Number(process.env.MAX_ERROR_ROWS) || 10;
|
|
74
|
-
const body =
|
|
93
|
+
const body = provider.buildCommentBody(summary, {
|
|
75
94
|
artifactName: process.env.VISUAL_DIFF_ARTIFACT_NAME || '',
|
|
76
95
|
runUrl,
|
|
77
96
|
maxChangedRows,
|
|
@@ -498,6 +498,7 @@ runs:
|
|
|
498
498
|
SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
|
|
499
499
|
ARTIFACT_NAME: ${{ steps.context.outputs.artifact_name }}
|
|
500
500
|
RUN_URL: ${{ steps.context.outputs.run_url }}
|
|
501
|
+
REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
|
|
501
502
|
MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
|
|
502
503
|
MAX_ERROR_ROWS: ${{ inputs.max-error-rows }}
|
|
503
504
|
with:
|
|
@@ -531,13 +532,25 @@ runs:
|
|
|
531
532
|
return;
|
|
532
533
|
}
|
|
533
534
|
|
|
534
|
-
const
|
|
535
|
-
const
|
|
535
|
+
const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
|
|
536
|
+
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
537
|
+
const { createProvider } = await import(pathToFileURL(providerModulePath));
|
|
538
|
+
const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
|
|
539
|
+
|
|
540
|
+
// Provider-loading block duplicated in actions/comment/action.yml — keep in sync.
|
|
541
|
+
let provider;
|
|
542
|
+
try {
|
|
543
|
+
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
544
|
+
provider = createProvider(config.provider || 'local', config);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
core.warning(`Could not load SnapDrift config; falling back to LocalProvider. ${err.message}`);
|
|
547
|
+
provider = createProvider('local');
|
|
548
|
+
}
|
|
536
549
|
|
|
537
550
|
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
|
|
538
551
|
const maxChangedRows = Number(process.env.MAX_CHANGED_ROWS) || 20;
|
|
539
552
|
const maxErrorRows = Number(process.env.MAX_ERROR_ROWS) || 10;
|
|
540
|
-
const body =
|
|
553
|
+
const body = provider.buildCommentBody(summary, {
|
|
541
554
|
artifactName: process.env.ARTIFACT_NAME || '',
|
|
542
555
|
runUrl: process.env.RUN_URL || '',
|
|
543
556
|
maxChangedRows,
|
package/docs/contracts.md
CHANGED
|
@@ -34,6 +34,12 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
|
|
|
34
34
|
|:------|:-----|:------------|
|
|
35
35
|
| `selection.sharedPrefixes` | `string[]` | Prefixes that force the full route set |
|
|
36
36
|
| `selection.sharedExact` | `string[]` | Exact files that force the full route set |
|
|
37
|
+
| `provider` | `string` | `"local"` (default) or `"snap"` for hosted backend |
|
|
38
|
+
| `snap.apiUrl` | `string` | Snap API base URL (default: `https://snap.i2dev.com`) |
|
|
39
|
+
| `snap.apiKeyEnv` | `string` | Env var name for API key (mutually exclusive with `snap.apiKey`) |
|
|
40
|
+
| `snap.apiKey` | `string` | Inline API key with `${VAR}` interpolation (mutually exclusive with `snap.apiKeyEnv`) |
|
|
41
|
+
| `snap.projectId` | `string` | Snap project ID or `"auto"` (default: `"auto"`, derives from `GITHUB_REPOSITORY`) |
|
|
42
|
+
| `snap.onUnavailable` | `string` | Behavior when Snap is unreachable: `"fail"` (default), `"warn-and-skip"`, or `"fallback-local"` |
|
|
37
43
|
|
|
38
44
|
### Example
|
|
39
45
|
|
|
@@ -129,6 +135,12 @@ The pull request drift bundle contains:
|
|
|
129
135
|
}
|
|
130
136
|
```
|
|
131
137
|
|
|
138
|
+
### Optional summary fields
|
|
139
|
+
|
|
140
|
+
| Field | Type | Description |
|
|
141
|
+
|:------|:-----|:------------|
|
|
142
|
+
| `dashboardUrl` | `string?` | Snap dashboard URL for the run (set by `SnapProvider`; omitted by `LocalProvider`) |
|
|
143
|
+
|
|
132
144
|
### Status values
|
|
133
145
|
|
|
134
146
|
| Status | Meaning |
|
|
@@ -180,9 +192,11 @@ Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
|
|
|
180
192
|
|
|
181
193
|
| Setting | Value |
|
|
182
194
|
|:--------|:------|
|
|
183
|
-
| Navigation wait | `
|
|
195
|
+
| Navigation wait | `load` |
|
|
184
196
|
| Navigation timeout | 30000ms |
|
|
185
197
|
| Settle delay | 300ms |
|
|
198
|
+
| Screenshot animations | `disabled` (Playwright finishes/cancels CSS animations before capture) |
|
|
199
|
+
| Capture concurrency | 5 routes per viewport (overridable via `SNAPDRIFT_CAPTURE_CONCURRENCY`)|
|
|
186
200
|
|
|
187
201
|
## Local CLI directory layout
|
|
188
202
|
|
|
@@ -206,6 +220,47 @@ When using the `snapdrift` CLI, outputs are written to `.snapdrift/` by default:
|
|
|
206
220
|
|
|
207
221
|
All three directories can be overridden with `--baseline-dir`, `--current-dir`, and `--diff-dir`. See the [Local CLI guide](local-cli.md) for details.
|
|
208
222
|
|
|
223
|
+
## Migration commands
|
|
224
|
+
|
|
225
|
+
### migrate-baselines
|
|
226
|
+
|
|
227
|
+
Migrate baselines between local storage and Snap.
|
|
228
|
+
|
|
229
|
+
**Upload local baselines to Snap:**
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
snapdrift migrate-baselines --to snap [--config <path>] [--baseline-dir <dir>]
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
- Reads local manifest + baseline images from the baseline directory.
|
|
236
|
+
- Uploads as the initial accepted baseline on Snap via `POST /v1/visual/projects/:id/baselines`.
|
|
237
|
+
- Idempotent: skips upload if a baseline already exists with the matching commit SHA.
|
|
238
|
+
- Requires snapdrift.json with `provider: "snap"` (or snap config).
|
|
239
|
+
|
|
240
|
+
**Download Snap baselines to local:**
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
snapdrift migrate-baselines --to local --from snap [--accept-cross-engine] [--config <path>] [--baseline-dir <dir>]
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- Downloads baselines from Snap's export endpoint.
|
|
247
|
+
- Writes to the local baseline directory.
|
|
248
|
+
- Without `--accept-cross-engine`: hard-errors if the exported baselines were captured by a different engine.
|
|
249
|
+
- With `--accept-cross-engine`: overrides the engine name to `snapdrift-local` in the imported manifest (visual differences may occur).
|
|
250
|
+
|
|
251
|
+
### init
|
|
252
|
+
|
|
253
|
+
**Translate a Snap action workflow to snapdrift.json:**
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
snapdrift init --from-snap-action <workflow-yaml-path>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
- Reads an existing Snap `github-action/` workflow YAML.
|
|
260
|
+
- Translates Snap action inputs into `.github/snapdrift.json`.
|
|
261
|
+
- Emits `.github/MIGRATION_NOTES.md` listing warnings and deferred decisions.
|
|
262
|
+
- Idempotent: refuses to overwrite an existing `snapdrift.json`.
|
|
263
|
+
|
|
209
264
|
## Primary entrypoints
|
|
210
265
|
|
|
211
266
|
- `actions/baseline`
|
|
@@ -232,3 +287,73 @@ These are for custom orchestration only. Wrapper actions set them automatically.
|
|
|
232
287
|
| `SNAPDRIFT_BASELINE_ARTIFACT_NAME` | `compare-results.mjs` | Baseline artifact label to embed in the report |
|
|
233
288
|
| `SNAPDRIFT_BASELINE_SOURCE_SHA` | `compare-results.mjs` | Baseline source SHA to embed in the report |
|
|
234
289
|
| `SNAPDRIFT_ENFORCE_OUTCOME` | `compare-results.mjs` | Set to `0` to disable enforcement in direct CLI usage |
|
|
290
|
+
| `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture-routes.mjs` | Max concurrent route captures per viewport context (positive integer, default `5`). Set to `1` to restore serial behaviour for apps with shared session/auth state. |
|
|
291
|
+
|
|
292
|
+
## PR comment markdown shape
|
|
293
|
+
|
|
294
|
+
The canonical PR comment is produced by `provider.buildCommentBody(summary, meta)` in `@snapdrift/adapter-report-md`. Both `LocalProvider` and `SnapProvider` emit byte-identical markdown for the same summary — the only provider-specific difference is the optional **"View in dashboard →"** link appended by `SnapProvider`.
|
|
295
|
+
|
|
296
|
+
Snap's server-side notification posting should render the same template from the run summary JSON so that reviewers see a consistent format regardless of which provider produced the run.
|
|
297
|
+
|
|
298
|
+
### Structure
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
<!-- snapdrift-report -->
|
|
302
|
+
## {status-icon} SnapDrift Report — {status-label}
|
|
303
|
+
|
|
304
|
+
| Signal | Count |
|
|
305
|
+
|:-------|------:|
|
|
306
|
+
| Drift signals | N |
|
|
307
|
+
| Missing in baseline | N |
|
|
308
|
+
| Missing in current capture | N |
|
|
309
|
+
| Dimension shifts | N |
|
|
310
|
+
|
|
311
|
+
> **Note:** {message} ← optional, when summary.message is set
|
|
312
|
+
|
|
313
|
+
<details><summary>Error details</summary> ← collapsible, only when errors exist
|
|
314
|
+
| Route | Viewport | Error |
|
|
315
|
+
|:------|:---------|:------|
|
|
316
|
+
| route-id | viewport | error message |
|
|
317
|
+
*...and N more* — [View full report →]({runUrl}) ← when errors exceed maxErrorRows
|
|
318
|
+
</details>
|
|
319
|
+
|
|
320
|
+
<details><summary>Drift signals</summary> ← collapsible, only when changed routes exist
|
|
321
|
+
| Route | Viewport | Mismatch |
|
|
322
|
+
|:------|:---------|:---------|
|
|
323
|
+
| route-id | viewport | X.XX% |
|
|
324
|
+
*...and N more* — [View full report →]({runUrl}) ← when routes exceed maxChangedRows
|
|
325
|
+
</details>
|
|
326
|
+
|
|
327
|
+
<details open><summary>Dimension shifts — comparison skipped</summary> ← auto-expanded, only when dimension shifts exist
|
|
328
|
+
> SnapDrift detected a dimension shift …
|
|
329
|
+
| Route | Viewport | Baseline | Current |
|
|
330
|
+
|:------|:---------|:---------|:--------|
|
|
331
|
+
| route-id | viewport | WxH | WxH |
|
|
332
|
+
</details>
|
|
333
|
+
|
|
334
|
+
<sub>SnapDrift · artifact `name` · baseline `name` · sha `abc1234` · [View run](url) · [View in dashboard →](url)</sub>
|
|
335
|
+
↑ LocalProvider omits "View in dashboard"
|
|
336
|
+
|
|
337
|
+
<div align="right"><sub>Powered by <a href="…">SnapDrift</a></sub></div>
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Meta parameters
|
|
341
|
+
|
|
342
|
+
| Field | Type | Description |
|
|
343
|
+
|:------|:-----|:------------|
|
|
344
|
+
| `artifactName` | `string?` | PR diff artifact label |
|
|
345
|
+
| `runUrl` | `string?` | GitHub Actions run URL (adds `[View run]` link) |
|
|
346
|
+
| `dashboardUrl` | `string?` | Snap dashboard URL (adds `[View in dashboard →]` link; SnapProvider only) |
|
|
347
|
+
| `maxChangedRows` | `number` | Max drift-signal rows before truncation (default 20) |
|
|
348
|
+
| `maxErrorRows` | `number` | Max error rows before truncation (default 10) |
|
|
349
|
+
|
|
350
|
+
### Update-in-place semantics
|
|
351
|
+
|
|
352
|
+
Comments are identified by the `<!-- snapdrift-report -->` HTML marker. On re-run, the action finds the most recent matching comment and updates it in place. Duplicate markers from earlier runs are deleted.
|
|
353
|
+
|
|
354
|
+
### Provider contract
|
|
355
|
+
|
|
356
|
+
The `VisualProvider` interface requires `buildCommentBody(summary, meta?)`:
|
|
357
|
+
|
|
358
|
+
- **`LocalProvider`** — delegates to `buildReportCommentBody` without a `dashboardUrl`.
|
|
359
|
+
- **`SnapProvider`** — constructs `dashboardUrl` from `{apiUrl}/projects/{projectId}/runs/{lastRunId}` and passes it through.
|
package/lib/capture-routes.mjs
CHANGED
|
@@ -1,370 +1,4 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
// Re-exports from packages — this module is a thin shim.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { pathToFileURL } from 'node:url';
|
|
6
|
-
import { chromium } from 'playwright';
|
|
7
|
-
import pngjs from 'pngjs';
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
loadSnapdriftConfig,
|
|
11
|
-
readFirstDefinedEnv,
|
|
12
|
-
resolveFromWorkingDirectory,
|
|
13
|
-
selectConfiguredRoutes,
|
|
14
|
-
splitCommaList,
|
|
15
|
-
SNAPDRIFT_VIEWPORT_PRESETS,
|
|
16
|
-
SNAPDRIFT_NAVIGATION_TIMEOUT_MS,
|
|
17
|
-
SNAPDRIFT_SETTLE_DELAY_MS
|
|
18
|
-
} from './snapdrift-config.mjs';
|
|
19
|
-
|
|
20
|
-
const { PNG } = pngjs;
|
|
21
|
-
|
|
22
|
-
/** @typedef {import('../types/visual-diff-types').VisualBaselineResults} BaselineResults */
|
|
23
|
-
/** @typedef {import('../types/visual-diff-types').VisualBaselineRouteResult} BaselineRouteResult */
|
|
24
|
-
/** @typedef {import('../types/visual-diff-types').VisualRegressionRouteConfig} SnapdriftRouteConfig */
|
|
25
|
-
/** @typedef {import('../types/visual-diff-types').VisualScreenshotManifest} ScreenshotManifest */
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* @param {string} targetPath
|
|
29
|
-
* @returns {Promise<void>}
|
|
30
|
-
*/
|
|
31
|
-
async function ensureParentDirectory(targetPath) {
|
|
32
|
-
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* @param {string} targetPath
|
|
37
|
-
* @returns {Promise<void>}
|
|
38
|
-
*/
|
|
39
|
-
async function ensureDirectory(targetPath) {
|
|
40
|
-
await fs.mkdir(targetPath, { recursive: true });
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Strips characters that could cause path traversal or produce invalid filenames.
|
|
45
|
-
* @param {string} id
|
|
46
|
-
* @returns {string}
|
|
47
|
-
*/
|
|
48
|
-
function sanitizeRouteId(id) {
|
|
49
|
-
return id
|
|
50
|
-
.replace(/\.\./g, '_')
|
|
51
|
-
.replace(/[/\\]/g, '_')
|
|
52
|
-
// eslint-disable-next-line no-control-regex
|
|
53
|
-
.replace(/[\x00-\x1f\x7f]/g, '');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** @typedef {import('../types/visual-diff-types').VisualViewport} VisualViewport */
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Returns a stable string key for a viewport value (preset name or custom dimensions).
|
|
60
|
-
* @param {VisualViewport} viewport
|
|
61
|
-
* @returns {string}
|
|
62
|
-
*/
|
|
63
|
-
function viewportKey(viewport) {
|
|
64
|
-
return typeof viewport === 'string' ? viewport : `custom:${viewport.width}x${viewport.height}`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Returns a human-readable label for a viewport.
|
|
69
|
-
* @param {VisualViewport} viewport
|
|
70
|
-
* @returns {string}
|
|
71
|
-
*/
|
|
72
|
-
function viewportLabel(viewport) {
|
|
73
|
-
return typeof viewport === 'string' ? viewport : `${viewport.width}x${viewport.height}`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Returns Playwright context options for a viewport.
|
|
78
|
-
* @param {VisualViewport} viewport
|
|
79
|
-
* @returns {import('playwright').BrowserContextOptions}
|
|
80
|
-
*/
|
|
81
|
-
function viewportContextOptions(viewport) {
|
|
82
|
-
if (typeof viewport === 'string') {
|
|
83
|
-
const preset = SNAPDRIFT_VIEWPORT_PRESETS[viewport];
|
|
84
|
-
return {
|
|
85
|
-
viewport: { width: preset.width, height: preset.height },
|
|
86
|
-
deviceScaleFactor: preset.deviceScaleFactor,
|
|
87
|
-
isMobile: preset.isMobile,
|
|
88
|
-
hasTouch: preset.hasTouch
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
return {
|
|
92
|
-
viewport: { width: viewport.width, height: viewport.height },
|
|
93
|
-
deviceScaleFactor: 1,
|
|
94
|
-
isMobile: false,
|
|
95
|
-
hasTouch: false
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* @param {import('playwright').Browser} browser
|
|
101
|
-
* @returns {Promise<Map<'desktop' | 'mobile', import('playwright').BrowserContext>>}
|
|
102
|
-
*/
|
|
103
|
-
async function createViewportContexts(browser) {
|
|
104
|
-
return new Map([
|
|
105
|
-
['desktop', await browser.newContext({
|
|
106
|
-
viewport: { width: SNAPDRIFT_VIEWPORT_PRESETS.desktop.width, height: SNAPDRIFT_VIEWPORT_PRESETS.desktop.height },
|
|
107
|
-
deviceScaleFactor: SNAPDRIFT_VIEWPORT_PRESETS.desktop.deviceScaleFactor,
|
|
108
|
-
isMobile: SNAPDRIFT_VIEWPORT_PRESETS.desktop.isMobile,
|
|
109
|
-
hasTouch: SNAPDRIFT_VIEWPORT_PRESETS.desktop.hasTouch
|
|
110
|
-
})],
|
|
111
|
-
['mobile', await browser.newContext({
|
|
112
|
-
viewport: { width: SNAPDRIFT_VIEWPORT_PRESETS.mobile.width, height: SNAPDRIFT_VIEWPORT_PRESETS.mobile.height },
|
|
113
|
-
deviceScaleFactor: SNAPDRIFT_VIEWPORT_PRESETS.mobile.deviceScaleFactor,
|
|
114
|
-
isMobile: SNAPDRIFT_VIEWPORT_PRESETS.mobile.isMobile,
|
|
115
|
-
hasTouch: SNAPDRIFT_VIEWPORT_PRESETS.mobile.hasTouch
|
|
116
|
-
})]
|
|
117
|
-
]);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* @param {import('playwright').BrowserContext} context
|
|
122
|
-
* @param {SnapdriftRouteConfig} route
|
|
123
|
-
* @param {string} baseUrl
|
|
124
|
-
* @param {string} screenshotsRoot
|
|
125
|
-
* @returns {Promise<BaselineRouteResult & { manifestEntry?: ScreenshotManifest['screenshots'][number] }>}
|
|
126
|
-
*/
|
|
127
|
-
async function captureRoute(context, route, baseUrl, screenshotsRoot) {
|
|
128
|
-
const startedAt = Date.now();
|
|
129
|
-
|
|
130
|
-
const page = await context.newPage();
|
|
131
|
-
try {
|
|
132
|
-
const targetUrl = new URL(route.path, baseUrl).toString();
|
|
133
|
-
await page.goto(targetUrl, {
|
|
134
|
-
waitUntil: 'networkidle',
|
|
135
|
-
timeout: route.navigationTimeout ?? SNAPDRIFT_NAVIGATION_TIMEOUT_MS
|
|
136
|
-
});
|
|
137
|
-
await page.waitForTimeout(SNAPDRIFT_SETTLE_DELAY_MS);
|
|
138
|
-
|
|
139
|
-
const imagePath = path.join('screenshots', `${sanitizeRouteId(route.id)}.png`);
|
|
140
|
-
const absoluteImagePath = path.join(screenshotsRoot, imagePath);
|
|
141
|
-
await ensureParentDirectory(absoluteImagePath);
|
|
142
|
-
const screenshotBuffer = await page.screenshot({
|
|
143
|
-
path: absoluteImagePath,
|
|
144
|
-
fullPage: true
|
|
145
|
-
});
|
|
146
|
-
const screenshot = PNG.sync.read(screenshotBuffer);
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
id: route.id,
|
|
150
|
-
path: route.path,
|
|
151
|
-
viewport: route.viewport,
|
|
152
|
-
status: 'passed',
|
|
153
|
-
durationMs: Date.now() - startedAt,
|
|
154
|
-
imagePath,
|
|
155
|
-
width: screenshot.width,
|
|
156
|
-
height: screenshot.height,
|
|
157
|
-
manifestEntry: {
|
|
158
|
-
id: route.id,
|
|
159
|
-
path: route.path,
|
|
160
|
-
viewport: route.viewport,
|
|
161
|
-
imagePath,
|
|
162
|
-
width: screenshot.width,
|
|
163
|
-
height: screenshot.height
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
} catch (error) {
|
|
167
|
-
return {
|
|
168
|
-
id: route.id,
|
|
169
|
-
path: route.path,
|
|
170
|
-
viewport: route.viewport,
|
|
171
|
-
status: 'failed',
|
|
172
|
-
durationMs: Date.now() - startedAt,
|
|
173
|
-
error: error instanceof Error ? error.message : String(error)
|
|
174
|
-
};
|
|
175
|
-
} finally {
|
|
176
|
-
await page.close();
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const CAPTURE_MAX_RETRIES = 1;
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* @param {import('playwright').BrowserContext} context
|
|
184
|
-
* @param {SnapdriftRouteConfig} route
|
|
185
|
-
* @param {string} baseUrl
|
|
186
|
-
* @param {string} screenshotsRoot
|
|
187
|
-
* @returns {Promise<BaselineRouteResult & { manifestEntry?: ScreenshotManifest['screenshots'][number] }>}
|
|
188
|
-
*/
|
|
189
|
-
async function captureRouteWithRetry(context, route, baseUrl, screenshotsRoot) {
|
|
190
|
-
let result = await captureRoute(context, route, baseUrl, screenshotsRoot);
|
|
191
|
-
for (let attempt = 1; attempt <= CAPTURE_MAX_RETRIES && result.status !== 'passed'; attempt++) {
|
|
192
|
-
console.log(`[SnapDrift] Retrying route ${route.id} (attempt ${attempt + 1}/${CAPTURE_MAX_RETRIES + 1})...`);
|
|
193
|
-
result = await captureRoute(context, route, baseUrl, screenshotsRoot);
|
|
194
|
-
}
|
|
195
|
-
return result;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Captures all routes for a single viewport context sequentially, logging progress.
|
|
200
|
-
*
|
|
201
|
-
* @param {import('playwright').BrowserContext} context
|
|
202
|
-
* @param {Array<{ route: SnapdriftRouteConfig, originalIndex: number }>} entries
|
|
203
|
-
* @param {string} baseUrl
|
|
204
|
-
* @param {string} screenshotsRoot
|
|
205
|
-
* @param {number} totalRoutes
|
|
206
|
-
* @param {Array<BaselineRouteResult & { manifestEntry?: ScreenshotManifest['screenshots'][number] }>} out
|
|
207
|
-
* @returns {Promise<void>}
|
|
208
|
-
*/
|
|
209
|
-
async function captureViewportRoutes(context, entries, baseUrl, screenshotsRoot, totalRoutes, out) {
|
|
210
|
-
for (const { route, originalIndex } of entries) {
|
|
211
|
-
console.log(`[SnapDrift] Capturing route ${originalIndex + 1}/${totalRoutes}: ${route.id} (${viewportLabel(route.viewport)})`);
|
|
212
|
-
out[originalIndex] = await captureRouteWithRetry(context, route, baseUrl, screenshotsRoot);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* @param {{
|
|
218
|
-
* configPath?: string,
|
|
219
|
-
* routeIds?: Iterable<string>,
|
|
220
|
-
* outDir?: string
|
|
221
|
-
* }} [options]
|
|
222
|
-
* @returns {Promise<{ resultsPath: string, manifestPath: string, screenshotsRoot: string, selectedRouteIds: string[] }>}
|
|
223
|
-
*/
|
|
224
|
-
export async function runBaselineCapture(options = {}) {
|
|
225
|
-
const requestedRouteIds = [...(
|
|
226
|
-
options.routeIds || splitCommaList(readFirstDefinedEnv(['SNAPDRIFT_ROUTE_IDS']))
|
|
227
|
-
)];
|
|
228
|
-
const { config, configPath } = await loadSnapdriftConfig(options.configPath);
|
|
229
|
-
const { routes, selectedRouteIds } = selectConfiguredRoutes(config, requestedRouteIds);
|
|
230
|
-
|
|
231
|
-
// When outDir is provided (e.g. by the local CLI), store all outputs flat inside that directory
|
|
232
|
-
// using just the basename of each configured path. Otherwise, use the config-resolved paths.
|
|
233
|
-
const localOutDir = options.outDir ? path.resolve(options.outDir) : null;
|
|
234
|
-
const resultsPath = localOutDir
|
|
235
|
-
? path.join(localOutDir, path.basename(config.resultsFile))
|
|
236
|
-
: resolveFromWorkingDirectory(config, config.resultsFile);
|
|
237
|
-
const manifestPath = localOutDir
|
|
238
|
-
? path.join(localOutDir, path.basename(config.manifestFile))
|
|
239
|
-
: resolveFromWorkingDirectory(config, config.manifestFile);
|
|
240
|
-
const screenshotsRoot = localOutDir || resolveFromWorkingDirectory(config, config.screenshotsRoot);
|
|
241
|
-
|
|
242
|
-
// Screenshots are written to a `screenshots/` subdirectory inside screenshotsRoot,
|
|
243
|
-
// i.e. the actual PNG files land at `{screenshotsRoot}/screenshots/{id}.png`.
|
|
244
|
-
await Promise.all([
|
|
245
|
-
ensureParentDirectory(resultsPath),
|
|
246
|
-
ensureParentDirectory(manifestPath),
|
|
247
|
-
ensureDirectory(path.join(screenshotsRoot, 'screenshots'))
|
|
248
|
-
]);
|
|
249
|
-
|
|
250
|
-
/** @type {BaselineResults} */
|
|
251
|
-
const results = {
|
|
252
|
-
startedAt: new Date().toISOString(),
|
|
253
|
-
baseUrl: config.baseUrl,
|
|
254
|
-
suite: 'snapdrift-capture',
|
|
255
|
-
configPath: path.relative(path.resolve('.'), configPath),
|
|
256
|
-
manifestPath: path.relative(path.resolve('.'), manifestPath),
|
|
257
|
-
screenshotsRoot: path.relative(path.resolve('.'), screenshotsRoot),
|
|
258
|
-
routes: []
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
/** @type {ScreenshotManifest} */
|
|
262
|
-
const manifest = {
|
|
263
|
-
generatedAt: new Date().toISOString(),
|
|
264
|
-
baseUrl: config.baseUrl,
|
|
265
|
-
screenshots: []
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
const browser = await chromium.launch({ headless: true });
|
|
269
|
-
// Pre-create contexts for the two standard presets; custom viewports are created on demand below.
|
|
270
|
-
const presetContexts = await createViewportContexts(browser);
|
|
271
|
-
/** @type {Map<string, import('playwright').BrowserContext>} */
|
|
272
|
-
const allContexts = new Map(presetContexts);
|
|
273
|
-
let failures = 0;
|
|
274
|
-
|
|
275
|
-
try {
|
|
276
|
-
// Group routes by viewport key, preserving each route's original index for result ordering.
|
|
277
|
-
/** @type {Map<string, Array<{ route: SnapdriftRouteConfig, originalIndex: number }>>} */
|
|
278
|
-
const byViewport = new Map();
|
|
279
|
-
for (const [i, route] of routes.entries()) {
|
|
280
|
-
const key = viewportKey(route.viewport);
|
|
281
|
-
const existing = byViewport.get(key);
|
|
282
|
-
if (existing) {
|
|
283
|
-
existing.push({ route, originalIndex: i });
|
|
284
|
-
} else {
|
|
285
|
-
byViewport.set(key, [{ route, originalIndex: i }]);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Create browser contexts for any custom viewports not covered by presets.
|
|
290
|
-
for (const [key, entries] of byViewport) {
|
|
291
|
-
if (!allContexts.has(key)) {
|
|
292
|
-
try {
|
|
293
|
-
allContexts.set(key, await browser.newContext(viewportContextOptions(entries[0].route.viewport)));
|
|
294
|
-
} catch (err) {
|
|
295
|
-
const message = `Failed to create browser context: ${err instanceof Error ? err.message : String(err)}`;
|
|
296
|
-
for (const { route } of entries) {
|
|
297
|
-
results.routes.push({ id: route.id, path: route.path, viewport: route.viewport, status: 'failed', durationMs: 0, error: message });
|
|
298
|
-
failures += 1;
|
|
299
|
-
}
|
|
300
|
-
throw err;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Pre-allocate results array; captureViewportRoutes fills slots by originalIndex.
|
|
306
|
-
/** @type {Array<BaselineRouteResult & { manifestEntry?: ScreenshotManifest['screenshots'][number] }>} */
|
|
307
|
-
const captureResults = new Array(routes.length);
|
|
308
|
-
|
|
309
|
-
// Run each viewport group concurrently; routes within a group stay sequential.
|
|
310
|
-
await Promise.all([...byViewport.entries()].map(([key, entries]) => {
|
|
311
|
-
const context = allContexts.get(key);
|
|
312
|
-
if (!context) {
|
|
313
|
-
throw new Error(`No browser context for viewport: ${key}`);
|
|
314
|
-
}
|
|
315
|
-
return captureViewportRoutes(context, entries, config.baseUrl, screenshotsRoot, routes.length, captureResults);
|
|
316
|
-
}));
|
|
317
|
-
|
|
318
|
-
// Merge results in original route order.
|
|
319
|
-
for (const capture of captureResults) {
|
|
320
|
-
results.routes.push({
|
|
321
|
-
id: capture.id,
|
|
322
|
-
path: capture.path,
|
|
323
|
-
viewport: capture.viewport,
|
|
324
|
-
status: capture.status,
|
|
325
|
-
durationMs: capture.durationMs,
|
|
326
|
-
imagePath: capture.imagePath,
|
|
327
|
-
width: capture.width,
|
|
328
|
-
height: capture.height,
|
|
329
|
-
error: capture.error
|
|
330
|
-
});
|
|
331
|
-
if (capture.manifestEntry) {
|
|
332
|
-
manifest.screenshots.push(capture.manifestEntry);
|
|
333
|
-
}
|
|
334
|
-
if (capture.status !== 'passed') {
|
|
335
|
-
failures += 1;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
} finally {
|
|
339
|
-
await Promise.all([...allContexts.values()].map((context) => context.close()));
|
|
340
|
-
await browser.close();
|
|
341
|
-
results.finishedAt = new Date().toISOString();
|
|
342
|
-
results.passed = failures === 0;
|
|
343
|
-
manifest.generatedAt = new Date().toISOString();
|
|
344
|
-
|
|
345
|
-
await Promise.all([
|
|
346
|
-
fs.writeFile(resultsPath, JSON.stringify(results, null, 2)),
|
|
347
|
-
fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2))
|
|
348
|
-
]);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (failures > 0) {
|
|
352
|
-
throw new Error(`SnapDrift capture failed for ${failures} route(s).`);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
return {
|
|
356
|
-
resultsPath,
|
|
357
|
-
manifestPath,
|
|
358
|
-
screenshotsRoot,
|
|
359
|
-
selectedRouteIds
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const isDirectRun = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
|
364
|
-
|
|
365
|
-
if (isDirectRun) {
|
|
366
|
-
runBaselineCapture().catch((error) => {
|
|
367
|
-
console.error(error);
|
|
368
|
-
process.exitCode = 1;
|
|
369
|
-
});
|
|
370
|
-
}
|
|
4
|
+
export { runBaselineCapture } from '@snapdrift/adapter-fs';
|