snapdrift 0.8.2 → 0.10.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 +74 -0
- package/README.md +8 -3
- package/action.yml +10 -2
- package/actions/comment/action.yml +6 -0
- package/actions/compare/action.yml +6 -0
- package/actions/pr-diff/action.yml +102 -55
- package/actions/resolve-baseline/action.yml +36 -50
- package/actions/scope/action.yml +24 -1
- package/actions/stage/action.yml +7 -1
- package/bin/snapdrift.mjs +6 -3
- package/docs/contracts.md +125 -9
- package/docs/integration-guide.md +61 -12
- package/docs/local-cli.md +15 -1
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +1 -1
- package/lib/cli.mjs +140 -52
- package/lib/init-from-action.mjs +24 -13
- package/lib/outage-policy.mjs +35 -2
- package/lib/provider.mjs +39 -12
- package/lib/report.mjs +3 -2
- package/lib/resolve-baseline-artifact.mjs +147 -0
- package/lib/resolve-baseline-paths.mjs +40 -0
- package/lib/snap-provider.mjs +1164 -481
- package/package.json +15 -11
- package/types/visual-diff-types.d.ts +30 -1
package/actions/scope/action.yml
CHANGED
|
@@ -105,9 +105,32 @@ runs:
|
|
|
105
105
|
per_page: 100
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
if (!Array.isArray(files)) {
|
|
109
|
+
throw new Error('Malformed GitHub changed-file response: expected an array of file records.');
|
|
110
|
+
}
|
|
111
|
+
for (const [index, file] of files.entries()) {
|
|
112
|
+
if (!file || typeof file !== 'object' || typeof file.filename !== 'string' || file.filename.length === 0) {
|
|
113
|
+
throw new Error(`Malformed GitHub changed-file response: file record ${index} must include a non-empty string filename.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (files.length >= 3000) {
|
|
117
|
+
core.warning('GitHub returned the maximum 3000 changed-file records; the list may be truncated, so running all configured captures.');
|
|
118
|
+
setOutputs({
|
|
119
|
+
shouldRun: true,
|
|
120
|
+
reason: 'changed_files_truncated',
|
|
121
|
+
selectedRouteIds: allRouteIds
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const changedFiles = [...new Set(files.flatMap((file) => [
|
|
126
|
+
file.filename,
|
|
127
|
+
...(file.status === 'renamed' && typeof file.previous_filename === 'string' && file.previous_filename
|
|
128
|
+
? [file.previous_filename]
|
|
129
|
+
: [])
|
|
130
|
+
]))];
|
|
108
131
|
const scope = selectRoutesForChangedFiles(
|
|
109
132
|
config,
|
|
110
|
-
|
|
133
|
+
changedFiles
|
|
111
134
|
);
|
|
112
135
|
setOutputs(scope);
|
|
113
136
|
} catch (error) {
|
package/actions/stage/action.yml
CHANGED
|
@@ -57,6 +57,10 @@ inputs:
|
|
|
57
57
|
description: Current screenshots dir for diff artifact staging.
|
|
58
58
|
required: false
|
|
59
59
|
default: ''
|
|
60
|
+
diff-images-dir:
|
|
61
|
+
description: Local diff PNG directory for diff artifact staging.
|
|
62
|
+
required: false
|
|
63
|
+
default: ''
|
|
60
64
|
|
|
61
65
|
outputs:
|
|
62
66
|
bundle-dir:
|
|
@@ -91,6 +95,7 @@ runs:
|
|
|
91
95
|
CURRENT_MANIFEST_PATH: ${{ inputs.current-manifest-path }}
|
|
92
96
|
BASELINE_SCREENSHOTS_DIR: ${{ inputs.baseline-screenshots-dir }}
|
|
93
97
|
CURRENT_SCREENSHOTS_DIR: ${{ inputs.current-screenshots-dir }}
|
|
98
|
+
DIFF_IMAGES_DIR: ${{ inputs.diff-images-dir }}
|
|
94
99
|
run: |
|
|
95
100
|
node --input-type=module <<'EOF'
|
|
96
101
|
import fs from 'node:fs/promises';
|
|
@@ -113,7 +118,8 @@ runs:
|
|
|
113
118
|
baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
|
|
114
119
|
currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
|
|
115
120
|
baselineScreenshotsDir: process.env.BASELINE_SCREENSHOTS_DIR || undefined,
|
|
116
|
-
currentScreenshotsDir: process.env.CURRENT_SCREENSHOTS_DIR || undefined
|
|
121
|
+
currentScreenshotsDir: process.env.CURRENT_SCREENSHOTS_DIR || undefined,
|
|
122
|
+
diffImagesDir: process.env.DIFF_IMAGES_DIR || undefined
|
|
117
123
|
});
|
|
118
124
|
|
|
119
125
|
await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
|
package/bin/snapdrift.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @ts-check
|
|
3
3
|
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
5
|
|
|
6
6
|
// Resolve cli.mjs relative to this file so the bin entry works regardless of
|
|
7
|
-
// where the consumer installs the package.
|
|
8
|
-
|
|
7
|
+
// where the consumer installs the package. Use fileURLToPath(new URL(...)) rather
|
|
8
|
+
// than URL.pathname so paths containing spaces or Windows drive letters survive
|
|
9
|
+
// the conversion (URL.pathname percent-encodes them, then pathToFileURL re-encodes
|
|
10
|
+
// the result, producing a path that does not exist).
|
|
11
|
+
const cliPath = fileURLToPath(new URL('../lib/cli.mjs', import.meta.url));
|
|
9
12
|
const { main } = await import(pathToFileURL(cliPath).href);
|
|
10
13
|
|
|
11
14
|
main(process.argv).catch((error) => {
|
package/docs/contracts.md
CHANGED
|
@@ -22,7 +22,7 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
|
|
|
22
22
|
|
|
23
23
|
| Field | Type | Description |
|
|
24
24
|
|:------|:-----|:------------|
|
|
25
|
-
| `id` | `string` | Unique route identifier across runs |
|
|
25
|
+
| `id` | `string` | Unique route identifier across runs. Its sanitized screenshot filename must also be unique across the full configured route set. |
|
|
26
26
|
| `path` | `string` | URL path appended to `baseUrl` |
|
|
27
27
|
| `viewport` | `string` or `object` | Preset name (`"desktop"`, `"mobile"`) or a custom object `{ "width": number, "height": number }` |
|
|
28
28
|
| `changePaths` | `string[]` | Optional prefixes used for changed-file scoping |
|
|
@@ -40,9 +40,28 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
|
|
|
40
40
|
| `snap.apiKey` | `string` | Inline API key with `${VAR}` interpolation (mutually exclusive with `snap.apiKeyEnv`) |
|
|
41
41
|
| `snap.projectId` | `string` | Snap project ID or `"auto"` (default: `"auto"`, derives from `GITHUB_REPOSITORY`) |
|
|
42
42
|
| `snap.onUnavailable` | `string` | Behavior when Snap is unreachable: `"fail"` (default), `"warn-and-skip"`, or `"fallback-local"` |
|
|
43
|
+
| `diff.comparisonPolicy` | `{ "version": 1, "threshold": number }` | Optional local opt-in to top-left-aligned unequal-dimension comparison; absent preserves strict legacy dimension handling |
|
|
43
44
|
|
|
44
45
|
When `provider: "snap"` is set, the `snap` block is required. Exactly one of `snap.apiKeyEnv` or `snap.apiKey` must be present. `snap.apiKey` accepts `${VAR}` interpolation (for example `"${SNAP_API_KEY}"`); the referenced environment variable must be set at runtime or the config loader throws.
|
|
45
46
|
|
|
47
|
+
Changed-file scoping treats a GitHub `renamed` record as both the current
|
|
48
|
+
`filename` and its nonempty `previous_filename`. This keeps a route selected
|
|
49
|
+
when a watched file moves out of its configured `changePaths`, and also detects
|
|
50
|
+
renames into watched or shared paths. Duplicate paths are ignored; other change
|
|
51
|
+
statuses use only their current filename. The `pr-diff` wrapper's explicit
|
|
52
|
+
`route-ids` and `force-run` inputs take precedence over this lookup; the
|
|
53
|
+
standalone `scope` action supports `force-run` and otherwise derives its
|
|
54
|
+
selection from the changed files. If GitHub returns the maximum 3,000 file
|
|
55
|
+
records, both actions run all configured routes with reason
|
|
56
|
+
`changed_files_truncated` because the list may be incomplete.
|
|
57
|
+
|
|
58
|
+
Route ids are sanitized before local captures and Snap baseline exports write
|
|
59
|
+
`screenshots/<route-id>.png`: `..` becomes `_`, path separators become `_`, and
|
|
60
|
+
control characters are removed. Distinct route ids that produce the same
|
|
61
|
+
sanitized filename are rejected before capture starts. Rename one of the route
|
|
62
|
+
ids and recapture the affected baseline; existing screenshot filenames remain
|
|
63
|
+
unchanged for noncolliding ids.
|
|
64
|
+
|
|
46
65
|
### Example (local provider)
|
|
47
66
|
|
|
48
67
|
```json
|
|
@@ -101,6 +120,27 @@ The published baseline bundle contains:
|
|
|
101
120
|
| `manifest.json` | Screenshot manifest with ids, paths, and dimensions |
|
|
102
121
|
| `screenshots/*.png` | Captured screenshot images |
|
|
103
122
|
|
|
123
|
+
Every manifest entry must use a unique staged screenshot filename (the basename
|
|
124
|
+
of `imagePath`). Duplicate paths or paths that flatten to the same filename are
|
|
125
|
+
rejected before comparison, even when the run selects only one of the affected
|
|
126
|
+
route ids, because they can point two logical screenshots at the same pixels.
|
|
127
|
+
Rename the conflicting route ids and recapture the baseline and current bundle.
|
|
128
|
+
|
|
129
|
+
### Baseline resolution
|
|
130
|
+
|
|
131
|
+
The `actions/resolve-baseline` action and the `actions/pr-diff` wrapper distinguish three lookup
|
|
132
|
+
outcomes: `found` means a successful search selected a non-expired artifact, `missing` means a
|
|
133
|
+
successful search found no usable artifact, and `error` means GitHub returned an API, network, or
|
|
134
|
+
malformed-response failure. The boolean `found` output remains available for compatibility; use
|
|
135
|
+
`resolution-status` on `resolve-baseline` or `baseline-resolution-status` on `pr-diff` when the
|
|
136
|
+
reason matters. A missing baseline may produce the intentional first-run skipped summary. A lookup
|
|
137
|
+
error fails local comparisons and local Snap fallbacks, so infrastructure failures cannot be
|
|
138
|
+
reported as a clean or intentional missing-baseline result. Healthy hosted Snap diffs may continue
|
|
139
|
+
using their hosted baseline path after a GitHub artifact lookup error.
|
|
140
|
+
The standalone `actions/resolve-baseline` action fails its step for `error`; custom workflows
|
|
141
|
+
should branch on `resolution-status` rather than treating `found: false` as proof that no baseline
|
|
142
|
+
exists.
|
|
143
|
+
|
|
104
144
|
## Drift artifact
|
|
105
145
|
|
|
106
146
|
The pull request drift bundle contains:
|
|
@@ -115,6 +155,7 @@ The pull request drift bundle contains:
|
|
|
115
155
|
| `current/results.json` | Current capture results |
|
|
116
156
|
| `current/manifest.json` | Current manifest |
|
|
117
157
|
| `current/screenshots/*.png` | Current images |
|
|
158
|
+
| `diffs/*.png` | Generated v1 diff images for changed screenshots |
|
|
118
159
|
|
|
119
160
|
## Screenshot manifest shape
|
|
120
161
|
|
|
@@ -169,6 +210,18 @@ The pull request drift bundle contains:
|
|
|
169
210
|
| Field | Type | Description |
|
|
170
211
|
|:------|:-----|:------------|
|
|
171
212
|
| `dashboardUrl` | `string?` | Snap dashboard URL for the run (set by `SnapProvider`; omitted by `LocalProvider`) |
|
|
213
|
+
| `comparisonPolicy` | `{ "version": 1, "threshold": number }?` | Exact v1 comparison policy used by the local adapter when opted in |
|
|
214
|
+
|
|
215
|
+
When v1 comparison is enabled, each affected `changed[]` item may also contain
|
|
216
|
+
`comparison` with `baseline`, `current`, and `canvas` `{ width, height }`
|
|
217
|
+
objects, `dimensionsChanged`, and the effective `totalPixels` denominator. A
|
|
218
|
+
local changed item with a generated image contains `diffImagePath`, relative to
|
|
219
|
+
the diff output bundle (for example `diffs/home.png`).
|
|
220
|
+
|
|
221
|
+
When a changed item has a generated local `diffImagePath`, its PR comment cell
|
|
222
|
+
links to the uploaded report artifact (or the workflow run when no artifact URL
|
|
223
|
+
was supplied) and shows the bundle-relative path. It does not use that relative
|
|
224
|
+
path as a GitHub-hosted image URL.
|
|
172
225
|
|
|
173
226
|
### Status values
|
|
174
227
|
|
|
@@ -198,7 +251,9 @@ Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
|
|
|
198
251
|
- Mismatch ratio is `different_pixels / total_pixels`
|
|
199
252
|
- `diff.threshold` applies per screenshot
|
|
200
253
|
- Missing captures are counted separately from drift signals
|
|
201
|
-
-
|
|
254
|
+
- Without `diff.comparisonPolicy`, dimension mismatches skip pixel comparison and land in `dimensionChanges[]`.
|
|
255
|
+
- With the exact v1 policy `{ "version": 1, "threshold": number }`, images are top-left aligned on a max-dimension union canvas with no scaling. Overlap pixels are compared, one-sided pixels count as changed (including transparent pixels), and empty union corners do not enter the denominator. Dimension changes land in `changed[]` with comparison metadata and a generated local diff image.
|
|
256
|
+
- For v1, `diff.comparisonPolicy.threshold` is applied after pixel aggregation. A mismatch exactly equal to the threshold is matched; a dimension change is still a changed signal independent of ratio.
|
|
202
257
|
- `diff.mode` controls enforcement, not summary generation
|
|
203
258
|
|
|
204
259
|
## Drift modes
|
|
@@ -207,7 +262,7 @@ Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
|
|
|
207
262
|
|:-----|:-------------------|
|
|
208
263
|
| `report-only` | Never |
|
|
209
264
|
| `fail-on-changes` | `changedScreenshots > 0` |
|
|
210
|
-
| `fail-on-incomplete` | Errors
|
|
265
|
+
| `fail-on-incomplete` | Errors or missing captures occur; a completed v1 dimension comparison alone does not fail |
|
|
211
266
|
| `strict` | Any drift or incomplete comparison appears |
|
|
212
267
|
|
|
213
268
|
A summary with `status: "skipped"` — or any summary carrying no `diff.mode` —
|
|
@@ -328,6 +383,8 @@ A hosted baseline is an authoritative complete snapshot. For `purpose: "baseline
|
|
|
328
383
|
|
|
329
384
|
The capture `results.json` records `configuredRouteIds`, `selectedRouteIds`, and `expectedCaptures` (route id, path, and viewport descriptor), plus one resolved `refBranch` / `refSha`, publication workflow ref, and publication sequence. The run-creation request sends the same immutable set and sequence as `capturePlan` and sends the ref as `branch` / `prHeadSha`. Before publication, SnapDrift requires the source run to belong to the configured project, settle to terminal `new`, and contain exactly one successful `new` capture with a non-empty `currentObjectKey` for every expected route/viewport identity. It keeps polling while any expected capture remains non-terminal, even when an older server reports the run itself as terminal, but fails fast after a stable incomplete capture set. Failed, missing, duplicate, extra, path-mismatched, or malformed captures abort before the baseline request. If another default-branch baseline wins first, Snap returns `409 baseline_stale_source`; rerun the current baseline job.
|
|
330
385
|
|
|
386
|
+
Hosted PR diffs use the same persisted `expectedCaptures` identities. `SnapProvider.diff()` requires `purpose: "diff"` plus a nonempty, unique expected set consistent with `selectedRouteIds`; results written by older SnapDrift versions without that set must be recaptured. It polls until the expected capture count is available, verifies the returned run id, and reconciles every returned capture by route id, path, and normalized viewport descriptor. Missing expected captures, duplicate or unexpected identities, pending or unknown statuses, run or capture errors, missing current objects, and invalid `diffPct` values produce an `incomplete` summary and are never counted as clean matches. A valid comparison requires a current object, a baseline object, and a finite numeric mismatch ratio from 0 through 1. The `diff.mode` setting then controls whether that incomplete summary fails the check.
|
|
387
|
+
|
|
331
388
|
Publication sends `publicationMode: "complete"` and the source run id both as top-level `sourceRunId` and as `manifest.sourceRunId`. The baseline id is deterministically derived from the run id, and manifest routes are sorted, so a retry sends the same identity and payload. Reusing that id with different persisted fields is rejected; rerun the baseline job to obtain a new source run. `refBranch` / `refSha` come from the persisted capture metadata rather than resolving git a second time. Snap accepts complete publication only from the project's default branch (effective default `main`).
|
|
332
389
|
|
|
333
390
|
### Local-capture hybrid
|
|
@@ -356,9 +413,21 @@ SnapDrift uses a small, stable subset of the Snap API:
|
|
|
356
413
|
The Snap HTTP client classifies responses and applies the following rules:
|
|
357
414
|
|
|
358
415
|
- **2xx** — success, return the parsed body.
|
|
359
|
-
- **4xx** — non-retryable. The client throws `SnapApiError(status, message, path)` immediately. `onUnavailable` is **not** consulted for 4xx — a 404 from `/baselines/latest` is a "no baseline yet" signal, but a 4xx from `/runs` is a configuration error that retrying won't fix.
|
|
360
|
-
- **5xx** — retryable up to 3 attempts with exponential backoff (`1 s` → `2 s` → `4 s`, capped at `30 s`
|
|
361
|
-
- **Network errors** — same retry/backoff behavior as 5xx. After exhaustion, falls through to the `onUnavailable` handler.
|
|
416
|
+
- **4xx** — non-retryable. The client throws `SnapApiError(status, message, path)` immediately. `onUnavailable` is **not** consulted for 4xx — a 404 from `/baselines/latest` is a "no baseline yet" signal, but a 4xx from `/runs` is a configuration error that retrying won't fix. If the diagnostic body itself stalls or is unavailable, the same `SnapApiError` retains the HTTP status and includes that diagnostic failure.
|
|
417
|
+
- **5xx** — retryable up to 3 attempts with exponential backoff (`1 s` → `2 s` → `4 s`, with each delay capped at `30 s`). If the final attempt still returns 5xx, the client falls through to the `onUnavailable` handler.
|
|
418
|
+
- **Network errors and transport timeouts** — same retry/backoff behavior as 5xx. After exhaustion, falls through to the `onUnavailable` handler.
|
|
419
|
+
|
|
420
|
+
Every JSON request attempt has a 30-second limit; binary export attempts have a
|
|
421
|
+
120-second limit. Headers and response bodies share the applicable attempt
|
|
422
|
+
limit, and each public Snap operation has one 10-minute deadline covering all
|
|
423
|
+
of its requests, retries, backoff, and polling. The client passes an
|
|
424
|
+
`AbortSignal` to fetch and aborts/cancels stalled bodies; injected transports
|
|
425
|
+
that ignore cancellation are still released by the client-side deadline.
|
|
426
|
+
Retry and poll waits are clipped to the remaining operation time, so no new
|
|
427
|
+
request starts after the operation deadline. Deadline exhaustion is treated as
|
|
428
|
+
Snap unavailability and follows `onUnavailable`; received 4xx responses keep
|
|
429
|
+
their immediate non-retryable behavior, and a stalled 4xx diagnostic body
|
|
430
|
+
retains its HTTP status with a timeout diagnostic.
|
|
362
431
|
|
|
363
432
|
`onUnavailable` is consulted once retries are exhausted:
|
|
364
433
|
|
|
@@ -376,7 +445,7 @@ alike:
|
|
|
376
445
|
| Phase | `warn-and-skip` | `fallback-local` |
|
|
377
446
|
|:------|:----------------|:-----------------|
|
|
378
447
|
| Capture | Write a skipped `summary.json`/`summary.md` with reason `snap_unavailable`, expose the summary outputs, stage the report, exit 0. | Capture with `LocalProvider` and report the **effective** provider (`local`) so the rest of the pipeline uses local artifacts and the local pixel engine. |
|
|
379
|
-
| Diff | Same skipped summary, exit 0. | Diff with `LocalProvider`. A capture that Snap rendered server-side has no PNGs on the runner, so the routes are **recaptured locally first**; the recaptured paths replace the Snap ones in the staged bundle. If no
|
|
448
|
+
| Diff | Same skipped summary, exit 0. | Diff with `LocalProvider`. A capture that Snap rendered server-side has no PNGs on the runner, so the routes are **recaptured locally first**; the recaptured paths replace the Snap ones in the staged bundle. If baseline resolution succeeded but no artifact was found, the run reports `missing_main_baseline_artifact`; a lookup error fails instead of entering the missing-baseline path. |
|
|
380
449
|
| Baseline publish | Skip the publish and exit 0 without an artifact. | Capture locally and stage/upload that bundle, so the run still leaves a usable baseline. |
|
|
381
450
|
|
|
382
451
|
Enforcement of `diff.mode` never runs against a skipped summary — a skipped run
|
|
@@ -402,13 +471,17 @@ All four error classes are exported from `lib/provider.mjs` and `lib/snap-provid
|
|
|
402
471
|
|
|
403
472
|
| Class | Thrown when | Typical handler |
|
|
404
473
|
|:------|:------------|:----------------|
|
|
405
|
-
| `SnapApiError` | A 4xx response was received,
|
|
406
|
-
| `SnapUnavailableError` | A network error or
|
|
474
|
+
| `SnapApiError` | A 4xx response was received, including a response whose diagnostic body stalled. Carries `status` and `path` properties. A final 5xx response also retains its status when it reaches the outage handler. | Surface the message; do not retry. |
|
|
475
|
+
| `SnapUnavailableError` | A retryable network error, transport timeout, or operation deadline was exhausted. In fail mode this may be surfaced directly; skip and fallback modes wrap it in their policy error. | Treat as a temporary outage. |
|
|
407
476
|
| `SnapFallbackError` | `onUnavailable: "fallback-local"` is set and Snap could not be reached. | Catch and switch to `LocalProvider` for the rest of the pipeline. |
|
|
408
477
|
| `SnapSkipError` | `onUnavailable: "warn-and-skip"` is set and Snap could not be reached. | Catch and exit cleanly with a skipped summary. |
|
|
409
478
|
|
|
410
479
|
The wrapper actions (`actions/baseline`, `actions/pr-diff`) and the CLI both handle `SnapSkipError` and `SnapFallbackError` through `lib/outage-policy.mjs` (`captureWithPolicy`, `diffWithPolicy`, `publishBaselineWithPolicy`). Custom orchestrations that call `provider.capture()`, `provider.diff()` or `provider.publishBaseline()` directly should use those helpers rather than re-implementing the matrix.
|
|
411
480
|
|
|
481
|
+
`SnapTransportTimeoutError` is an internal diagnostic name used while a
|
|
482
|
+
request or response body is being bounded. It is not an additional public
|
|
483
|
+
error class; callers should handle the exported error classes above.
|
|
484
|
+
|
|
412
485
|
## Primary entrypoints
|
|
413
486
|
|
|
414
487
|
- `actions/baseline`
|
|
@@ -490,6 +563,7 @@ Snap's server-side notification posting should render the same template from the
|
|
|
490
563
|
| Field | Type | Description |
|
|
491
564
|
|:------|:-----|:------------|
|
|
492
565
|
| `artifactName` | `string?` | PR diff artifact label |
|
|
566
|
+
| `artifactUrl` | `string?` | Authenticated GitHub Actions artifact URL for diff links |
|
|
493
567
|
| `runUrl` | `string?` | GitHub Actions run URL (adds `[View run]` link) |
|
|
494
568
|
| `dashboardUrl` | `string?` | Snap dashboard URL (adds `[View in dashboard →]` link; SnapProvider only) |
|
|
495
569
|
| `maxChangedRows` | `number` | Max drift-signal rows before truncation (default 20) |
|
|
@@ -505,3 +579,45 @@ The `VisualProvider` interface requires `buildCommentBody(summary, meta?)`:
|
|
|
505
579
|
|
|
506
580
|
- **`LocalProvider`** — delegates to `buildReportCommentBody` without a `dashboardUrl`.
|
|
507
581
|
- **`SnapProvider`** — constructs `dashboardUrl` from `{apiUrl}/projects/{projectId}/runs/{lastRunId}` and passes it through.
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
## Workspace TypeScript contracts
|
|
585
|
+
|
|
586
|
+
The four `@snapdrift/*` workspace packages expose their declarations through a
|
|
587
|
+
`types` export condition before the existing JavaScript entrypoint. ESM consumers
|
|
588
|
+
can use Bundler, Node16, or NodeNext module resolution with `strict: true` and
|
|
589
|
+
`skipLibCheck: false`; Node16/NodeNext consumers should use an ESM package or an
|
|
590
|
+
`.mts` entrypoint. The top-level `types` field remains for older tooling.
|
|
591
|
+
|
|
592
|
+
Install `typescript` and `@types/node` in the consuming development environment
|
|
593
|
+
for APIs that use Node buffers. Cross-package declaration dependencies are
|
|
594
|
+
declared by the package that needs them. `npm run check:package-types` packs the
|
|
595
|
+
workspace packages and checks isolated consumers in all three resolution modes,
|
|
596
|
+
including rejected invalid calls. `npm run typecheck` also checks declaration
|
|
597
|
+
bodies without skipping library checks.
|
|
598
|
+
|
|
599
|
+
These guarantees cover the workspace package entrypoints. They do not add type
|
|
600
|
+
entrypoints for the root `snapdrift` package's JavaScript subpaths. Package release
|
|
601
|
+
publication is required before existing registry consumers receive these fixes.
|
|
602
|
+
|
|
603
|
+
Comment renderers accept `VisualReportSummary`: a complete comparison summary or
|
|
604
|
+
a `VisualDriftStatusSummary` with a required status and reason. `buildDriftSummary`
|
|
605
|
+
and `writeDriftSummary` return the latter, preserving their supported non-skipped
|
|
606
|
+
statuses as well as intentional skips. Unrelated object literals are rejected.
|
|
607
|
+
Known viewport presets have complete descriptors; arbitrary string lookups may
|
|
608
|
+
return `undefined` and must be checked by consumers.
|
|
609
|
+
|
|
610
|
+
The packed type gate runs in PR CI and before release publication. It validates
|
|
611
|
+
the candidate workspace tarballs together, not already-published registry
|
|
612
|
+
versions. Release preparation must bump every changed published package and
|
|
613
|
+
raise sibling dependency floors to the versions that contain these type fixes
|
|
614
|
+
before publication; the gate alone does not validate that release-version step.
|
|
615
|
+
|
|
616
|
+
Each workspace package also declares every runtime package it imports. In
|
|
617
|
+
particular, `@snapdrift/adapter-fs` declares `@snapdrift/adapter-report-md`, so
|
|
618
|
+
it can be installed and imported without the SnapDrift repository or a sibling
|
|
619
|
+
workspace masking a missing dependency. `npm run check:package-runtime` packs
|
|
620
|
+
each workspace package, installs it in an isolated temporary consumer together
|
|
621
|
+
with only its declared local workspace dependency closure, and imports its
|
|
622
|
+
public entrypoint. The gate runs in pull-request and release workflows; publish
|
|
623
|
+
the updated package before registry consumers can receive a dependency fix.
|
|
@@ -46,7 +46,7 @@ SnapDrift owns route selection, capture, comparison, skipped-report generation,
|
|
|
46
46
|
|
|
47
47
|
```yaml
|
|
48
48
|
- name: SnapDrift Baseline
|
|
49
|
-
uses: ranacseruet/snapdrift@v0.
|
|
49
|
+
uses: ranacseruet/snapdrift@v0.10.0
|
|
50
50
|
with:
|
|
51
51
|
mode: baseline
|
|
52
52
|
repo-config-path: .github/snapdrift.json
|
|
@@ -71,7 +71,7 @@ Then add SnapDrift after the app is running:
|
|
|
71
71
|
|
|
72
72
|
```yaml
|
|
73
73
|
- name: SnapDrift Report
|
|
74
|
-
uses: ranacseruet/snapdrift@v0.
|
|
74
|
+
uses: ranacseruet/snapdrift@v0.10.0
|
|
75
75
|
with:
|
|
76
76
|
mode: pr-diff
|
|
77
77
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -86,8 +86,8 @@ The wrappers remain published in their own right, and are equivalent to the disp
|
|
|
86
86
|
|
|
87
87
|
| Entry point | Equivalent to |
|
|
88
88
|
|-------------|---------------|
|
|
89
|
-
| `ranacseruet/snapdrift@v0.
|
|
90
|
-
| `ranacseruet/snapdrift@v0.
|
|
89
|
+
| `ranacseruet/snapdrift@v0.10.0` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.10.0` |
|
|
90
|
+
| `ranacseruet/snapdrift@v0.10.0` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.10.0` |
|
|
91
91
|
|
|
92
92
|
Use the wrappers directly when you want to skip the dispatcher's input indirection, and the lower-level `capture`, `compare`, `scope`, `resolve-baseline`, `stage`, `comment`, and `enforce` actions when you need to orchestrate the stages yourself.
|
|
93
93
|
|
|
@@ -130,7 +130,7 @@ jobs:
|
|
|
130
130
|
done
|
|
131
131
|
|
|
132
132
|
- name: SnapDrift Report
|
|
133
|
-
uses: ranacseruet/snapdrift@v0.
|
|
133
|
+
uses: ranacseruet/snapdrift@v0.10.0
|
|
134
134
|
with:
|
|
135
135
|
mode: pr-diff
|
|
136
136
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -170,7 +170,7 @@ jobs:
|
|
|
170
170
|
done
|
|
171
171
|
|
|
172
172
|
- name: SnapDrift Report
|
|
173
|
-
uses: ranacseruet/snapdrift@v0.
|
|
173
|
+
uses: ranacseruet/snapdrift@v0.10.0
|
|
174
174
|
with:
|
|
175
175
|
mode: pr-diff
|
|
176
176
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -188,6 +188,15 @@ jobs:
|
|
|
188
188
|
- Upserts the PR report
|
|
189
189
|
- Enforces `diff.mode` after publication completes
|
|
190
190
|
|
|
191
|
+
When the wrapper scopes a pull request from GitHub's changed-file list, a
|
|
192
|
+
renamed file contributes both its new and previous paths. A move out of a
|
|
193
|
+
watched directory therefore still selects the route that owned the old path,
|
|
194
|
+
while a move into a watched or shared path selects the new scope as well.
|
|
195
|
+
Repeated paths are deduplicated, and `route-ids` or `force-run` keep their
|
|
196
|
+
existing precedence. GitHub caps this file listing at 3,000 records; if that
|
|
197
|
+
limit is reached, the actions run all configured routes with scope reason
|
|
198
|
+
`changed_files_truncated` instead of trusting a potentially incomplete list.
|
|
199
|
+
|
|
191
200
|
## Useful overrides
|
|
192
201
|
|
|
193
202
|
| Input | Default | Purpose |
|
|
@@ -205,7 +214,7 @@ jobs:
|
|
|
205
214
|
| `max-changed-rows` | `20` | Max changed-route rows shown in the PR comment before truncation |
|
|
206
215
|
| `max-error-rows` | `10` | Max error rows shown in the PR comment before truncation |
|
|
207
216
|
|
|
208
|
-
The `pr-diff` action also exposes outputs you can use in subsequent steps: `should-run`, `scope-reason`, `selected-route-ids`, `baseline-found`, `status`, `summary-path`, `markdown-path`, `artifact-name`, `bundle-dir`. See the wrapper action's `outputs:` block for the canonical list.
|
|
217
|
+
The `pr-diff` action also exposes outputs you can use in subsequent steps: `should-run`, `scope-reason`, `selected-route-ids`, `baseline-found`, `baseline-resolution-status`, `status`, `summary-path`, `markdown-path`, `artifact-name`, `bundle-dir`. `baseline-resolution-status` is `found`, `missing`, or `error`; `missing` is the intentional first-baseline case, while `error` means the GitHub lookup failed and causes local comparisons or local Snap fallbacks to fail. Healthy hosted Snap diffs can continue without the downloaded GitHub artifact. See the wrapper action's `outputs:` block for the canonical list.
|
|
209
218
|
|
|
210
219
|
## Hosted Snap provider
|
|
211
220
|
|
|
@@ -234,7 +243,7 @@ Both behaviors apply at every phase of a run — capture, diff, and baseline pub
|
|
|
234
243
|
|
|
235
244
|
Hosted baseline publication is complete and default-branch-only. SnapDrift rejects scoped baseline capture before creating the run, records the configured/selected route ids and exact route/viewport identities, and publishes only when every expected capture finishes successfully with a stored image. The run records one resolved branch and commit (`branch` / `prHeadSha`) plus a publication workflow ref and monotonic sequence. GitHub Actions provides these automatically; other CI systems may set `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`. Snap uses the sequence for same-workflow ordering and source-run timestamps for cross-workflow freshness. Missing, failed, duplicate, or unexpected captures abort publication without replacing the accepted baseline. If a source run loses the race to a newer candidate, the API returns `409 baseline_stale_source`; an inconsistent manifest/source workflow identity returns `409 baseline_source_run_mismatch` with `details.reason = "workflow_mismatch"`. Workflow-ref rotation itself is supported. This restriction does not apply to non-publishing hosted captures, hosted PR-diff runs, or local-provider baselines.
|
|
236
245
|
|
|
237
|
-
The Snap API client retries 5xx
|
|
246
|
+
The Snap API client retries 5xx, network errors, and transport timeouts with exponential backoff (3 attempts, 1 s → 2 s → 4 s, with each delay capped at 30 s). JSON request attempts are limited to 30 seconds and binary baseline exports to 120 seconds. Each Snap operation has a 10-minute deadline covering its requests, retries, backoff, and polling; stalled headers and response bodies are aborted and cannot keep the workflow waiting indefinitely. Retry and poll waits are clipped to the remaining operation time. Received 4xx errors never retry or fall back; if a 4xx diagnostic body stalls, the response remains a non-retryable error with its HTTP status and timeout diagnostic. When a deadline or other retryable transport failure is exhausted, `onUnavailable` controls fail, warn-and-skip, or fallback-local behavior.
|
|
238
247
|
|
|
239
248
|
When `provider: "snap"` and `baseUrl` points to a local address (localhost, `127.0.0.0/8`, `::1`, or `0.0.0.0`), SnapDrift uses a **local-capture hybrid**: Playwright runs on the runner to render the page, then SnapDrift uploads the resulting screenshots to Snap. This makes it possible to point SnapDrift at a server that only the runner can reach (a typical case) without exposing the server to Snap's render worker.
|
|
240
249
|
|
|
@@ -267,10 +276,13 @@ The `pr-diff` wrapper composes the following low-level steps. They're still avai
|
|
|
267
276
|
- `actions/capture` — capture routes and emit `results.json` + `manifest.json`
|
|
268
277
|
- `actions/compare` — diff current capture against a baseline
|
|
269
278
|
- `actions/scope` — decide whether to run and which routes to select from changed files
|
|
270
|
-
- `actions/resolve-baseline` — find and download the latest successful baseline artifact
|
|
279
|
+
- `actions/resolve-baseline` — find and download the latest successful baseline artifact; its
|
|
280
|
+
`resolution-status` output distinguishes `found`, `missing`, and `error`; the action fails for
|
|
281
|
+
`error`, while `missing` remains the intentional first-baseline result
|
|
271
282
|
- `actions/stage` — assemble the baseline or diff bundle for upload
|
|
272
283
|
- `actions/enforce` — evaluate the summary against `diff.mode` and fail when required
|
|
273
|
-
- `actions/comment` — upsert a PR comment from a summary (provider-aware)
|
|
284
|
+
- `actions/comment` — upsert a PR comment from a summary (provider-aware); pass
|
|
285
|
+
`artifact-url` from `upload-artifact` when local diff PNGs are present
|
|
274
286
|
|
|
275
287
|
The two wrapper actions that orchestrate the full pipeline are `actions/baseline` (publish) and `actions/pr-diff` (drift detection). They are the primary integration points.
|
|
276
288
|
|
|
@@ -298,7 +310,12 @@ See the [Local CLI guide](local-cli.md) for full command reference, flags, direc
|
|
|
298
310
|
|
|
299
311
|
## Refresh the baseline automatically
|
|
300
312
|
|
|
301
|
-
After an intentional layout change
|
|
313
|
+
After an intentional layout change merges, the baseline can be republished as
|
|
314
|
+
usual. Legacy local comparisons still require like-for-like dimensions; an
|
|
315
|
+
updated local client may opt into the exact
|
|
316
|
+
`diff.comparisonPolicy: { "version": 1, "threshold": number }` contract to
|
|
317
|
+
compare unequal frames on a union canvas and keep the dimension metadata in the
|
|
318
|
+
report.
|
|
302
319
|
|
|
303
320
|
Use the provided workflow template to refresh the baseline automatically on every push to your default branch (i.e. every merge):
|
|
304
321
|
|
|
@@ -313,6 +330,12 @@ Run this job after every successful default-branch build. Hosted publication is
|
|
|
313
330
|
**"No non-expired SnapDrift baseline artifact was found"**
|
|
314
331
|
The baseline workflow has not completed successfully on `main`, or the artifact expired. With the Snap provider, the equivalent situation is a 404 from `/v1/visual/projects/:id/baselines/latest`; `SnapProvider` swallows that 404 and proceeds without a baseline — `onUnavailable` is **not** consulted for this case (a 404 is the legitimate "no baseline yet" signal, not a Snap outage). If you want the PR pipeline to tolerate the first-run case, set `diff.mode: "report-only"`; `onUnavailable: "warn-and-skip"` will not help here.
|
|
315
332
|
|
|
333
|
+
**"Unable to resolve the SnapDrift baseline artifact"**
|
|
334
|
+
GitHub failed while listing workflow runs or artifacts. Check the job's `actions: read` permission,
|
|
335
|
+
repository/workflow/branch inputs, and GitHub API availability. Snap hosted diffs can still use the
|
|
336
|
+
hosted comparison path, but local comparisons and `fallback-local` require a successful GitHub
|
|
337
|
+
artifact lookup.
|
|
338
|
+
|
|
316
339
|
**403 when posting the PR report**
|
|
317
340
|
Grant `issues: write` and `pull-requests: write` to the job.
|
|
318
341
|
|
|
@@ -320,10 +343,36 @@ Grant `issues: write` and `pull-requests: write` to the job.
|
|
|
320
343
|
The Snap client never retries 4xx and never falls back, even when `onUnavailable` is set. Inspect the response body — the most common cause is a project-id mismatch between `GITHUB_REPOSITORY` (auto-derived) and the project's id on Snap.
|
|
321
344
|
|
|
322
345
|
**Screenshots have different dimensions**
|
|
323
|
-
SnapDrift reports this as a dimension
|
|
346
|
+
Without `diff.comparisonPolicy`, SnapDrift reports this as a legacy dimension
|
|
347
|
+
shift and skips pixel comparison. With the exact v1 policy, it compares the
|
|
348
|
+
top-left-aligned union canvas, records the route in `changed[]`, and writes a
|
|
349
|
+
local diff PNG; refresh the baseline after an intentional change when you want
|
|
350
|
+
future runs to return to equal-size comparisons.
|
|
324
351
|
|
|
325
352
|
**A route appears in `errors[]`**
|
|
326
353
|
Capture failed before comparison. Confirm the app is fully ready and reachable before SnapDrift runs.
|
|
327
354
|
|
|
328
355
|
**Playwright install runs even though `provider: "snap"`**
|
|
329
356
|
That's expected for the Snap local-capture hybrid. Playwright runs locally to render the page, then the resulting screenshot is uploaded to Snap. The hybrid kicks in only when `baseUrl` points to a local address; for remote `baseUrl` Snap's render worker captures directly.
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
## TypeScript package consumers
|
|
360
|
+
|
|
361
|
+
Import APIs and types from the public `@snapdrift/*` package names. Their export
|
|
362
|
+
maps support Bundler, Node16, and NodeNext resolution; enable `strict: true` and
|
|
363
|
+
`skipLibCheck: false` to validate the contracts in your project. For Node16 or
|
|
364
|
+
NodeNext, use `"type": "module"` or an `.mts` entrypoint. Install `typescript` and
|
|
365
|
+
`@types/node` as development dependencies when using the Node buffer APIs.
|
|
366
|
+
|
|
367
|
+
No TypeScript path aliases or imports into workspace source directories are
|
|
368
|
+
needed. Root `snapdrift/lib/*` subpath typing is separate from these workspace
|
|
369
|
+
contracts. The fixes become available to registry consumers after the packages
|
|
370
|
+
are released and their installed versions are updated.
|
|
371
|
+
|
|
372
|
+
The workspace packages are independently installable runtime packages. Install
|
|
373
|
+
the package you use from the registry and let npm resolve its declared runtime
|
|
374
|
+
dependencies; do not rely on a repository checkout or workspace links. The
|
|
375
|
+
filesystem adapter includes its reporting dependency, so `@snapdrift/adapter-fs`
|
|
376
|
+
can load config, capture, comparison, and report entrypoints from an isolated
|
|
377
|
+
consumer. Release a new adapter version before expecting an existing registry
|
|
378
|
+
installation to include a dependency correction.
|
package/docs/local-cli.md
CHANGED
|
@@ -225,6 +225,7 @@ After running `capture` and `diff` with default paths:
|
|
|
225
225
|
summary.json # structured drift summary
|
|
226
226
|
summary.md # human-readable markdown report
|
|
227
227
|
report.html # self-contained HTML report with side-by-side images
|
|
228
|
+
diffs/ # v1 generated diff PNGs, when comparisonPolicy is enabled
|
|
228
229
|
```
|
|
229
230
|
|
|
230
231
|
Add `.snapdrift/` to your `.gitignore` to keep local run output out of version control.
|
|
@@ -271,7 +272,7 @@ Comparing against baseline ...
|
|
|
271
272
|
Report: .snapdrift/diff/report.html
|
|
272
273
|
```
|
|
273
274
|
|
|
274
|
-
**Incomplete run** (errors, missing captures, or dimension shifts):
|
|
275
|
+
**Incomplete run** (errors, missing captures, or legacy dimension shifts):
|
|
275
276
|
|
|
276
277
|
```
|
|
277
278
|
❌ SnapDrift — incomplete
|
|
@@ -289,6 +290,19 @@ The `Missing`, `Errors`, and `Dim diff` lines only print when their count is gre
|
|
|
289
290
|
|
|
290
291
|
The `report.html` path is printed whenever the status is anything other than `clean`; pass `--open` to also launch it in your default browser.
|
|
291
292
|
|
|
293
|
+
To compare unequal local screenshot dimensions, add the exact opt-in policy to
|
|
294
|
+
`diff`:
|
|
295
|
+
|
|
296
|
+
```json
|
|
297
|
+
"comparisonPolicy": { "version": 1, "threshold": 0.01 }
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The v1 report includes the baseline, current, and union-canvas dimensions in
|
|
301
|
+
`summary.md` and `report.html`, and stores changed-route diff PNGs under
|
|
302
|
+
`.snapdrift/diff/diffs/`. A completed dimension comparison is a normal changed
|
|
303
|
+
signal, so `fail-on-incomplete` does not fail solely because its dimensions
|
|
304
|
+
differ; `fail-on-changes` and `strict` still enforce it.
|
|
305
|
+
|
|
292
306
|
---
|
|
293
307
|
|
|
294
308
|
## Environment variables
|