snapdrift 0.8.2 → 0.9.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 +48 -0
- package/README.md +7 -2
- package/action.yml +10 -2
- package/actions/pr-diff/action.yml +92 -52
- package/actions/resolve-baseline/action.yml +36 -50
- package/actions/scope/action.yml +24 -1
- package/bin/snapdrift.mjs +6 -3
- package/docs/contracts.md +106 -7
- package/docs/integration-guide.md +48 -9
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +1 -1
- package/lib/cli.mjs +125 -41
- package/lib/init-from-action.mjs +24 -13
- package/lib/outage-policy.mjs +35 -2
- package/lib/provider.mjs +20 -2
- package/lib/resolve-baseline-artifact.mjs +147 -0
- package/lib/resolve-baseline-paths.mjs +40 -0
- package/lib/snap-provider.mjs +896 -280
- package/package.json +13 -9
- package/types/visual-diff-types.d.ts +2 -0
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 |
|
|
@@ -43,6 +43,24 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
|
|
|
43
43
|
|
|
44
44
|
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
45
|
|
|
46
|
+
Changed-file scoping treats a GitHub `renamed` record as both the current
|
|
47
|
+
`filename` and its nonempty `previous_filename`. This keeps a route selected
|
|
48
|
+
when a watched file moves out of its configured `changePaths`, and also detects
|
|
49
|
+
renames into watched or shared paths. Duplicate paths are ignored; other change
|
|
50
|
+
statuses use only their current filename. The `pr-diff` wrapper's explicit
|
|
51
|
+
`route-ids` and `force-run` inputs take precedence over this lookup; the
|
|
52
|
+
standalone `scope` action supports `force-run` and otherwise derives its
|
|
53
|
+
selection from the changed files. If GitHub returns the maximum 3,000 file
|
|
54
|
+
records, both actions run all configured routes with reason
|
|
55
|
+
`changed_files_truncated` because the list may be incomplete.
|
|
56
|
+
|
|
57
|
+
Route ids are sanitized before local captures and Snap baseline exports write
|
|
58
|
+
`screenshots/<route-id>.png`: `..` becomes `_`, path separators become `_`, and
|
|
59
|
+
control characters are removed. Distinct route ids that produce the same
|
|
60
|
+
sanitized filename are rejected before capture starts. Rename one of the route
|
|
61
|
+
ids and recapture the affected baseline; existing screenshot filenames remain
|
|
62
|
+
unchanged for noncolliding ids.
|
|
63
|
+
|
|
46
64
|
### Example (local provider)
|
|
47
65
|
|
|
48
66
|
```json
|
|
@@ -101,6 +119,27 @@ The published baseline bundle contains:
|
|
|
101
119
|
| `manifest.json` | Screenshot manifest with ids, paths, and dimensions |
|
|
102
120
|
| `screenshots/*.png` | Captured screenshot images |
|
|
103
121
|
|
|
122
|
+
Every manifest entry must use a unique staged screenshot filename (the basename
|
|
123
|
+
of `imagePath`). Duplicate paths or paths that flatten to the same filename are
|
|
124
|
+
rejected before comparison, even when the run selects only one of the affected
|
|
125
|
+
route ids, because they can point two logical screenshots at the same pixels.
|
|
126
|
+
Rename the conflicting route ids and recapture the baseline and current bundle.
|
|
127
|
+
|
|
128
|
+
### Baseline resolution
|
|
129
|
+
|
|
130
|
+
The `actions/resolve-baseline` action and the `actions/pr-diff` wrapper distinguish three lookup
|
|
131
|
+
outcomes: `found` means a successful search selected a non-expired artifact, `missing` means a
|
|
132
|
+
successful search found no usable artifact, and `error` means GitHub returned an API, network, or
|
|
133
|
+
malformed-response failure. The boolean `found` output remains available for compatibility; use
|
|
134
|
+
`resolution-status` on `resolve-baseline` or `baseline-resolution-status` on `pr-diff` when the
|
|
135
|
+
reason matters. A missing baseline may produce the intentional first-run skipped summary. A lookup
|
|
136
|
+
error fails local comparisons and local Snap fallbacks, so infrastructure failures cannot be
|
|
137
|
+
reported as a clean or intentional missing-baseline result. Healthy hosted Snap diffs may continue
|
|
138
|
+
using their hosted baseline path after a GitHub artifact lookup error.
|
|
139
|
+
The standalone `actions/resolve-baseline` action fails its step for `error`; custom workflows
|
|
140
|
+
should branch on `resolution-status` rather than treating `found: false` as proof that no baseline
|
|
141
|
+
exists.
|
|
142
|
+
|
|
104
143
|
## Drift artifact
|
|
105
144
|
|
|
106
145
|
The pull request drift bundle contains:
|
|
@@ -328,6 +367,8 @@ A hosted baseline is an authoritative complete snapshot. For `purpose: "baseline
|
|
|
328
367
|
|
|
329
368
|
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
369
|
|
|
370
|
+
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.
|
|
371
|
+
|
|
331
372
|
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
373
|
|
|
333
374
|
### Local-capture hybrid
|
|
@@ -356,9 +397,21 @@ SnapDrift uses a small, stable subset of the Snap API:
|
|
|
356
397
|
The Snap HTTP client classifies responses and applies the following rules:
|
|
357
398
|
|
|
358
399
|
- **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.
|
|
400
|
+
- **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.
|
|
401
|
+
- **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.
|
|
402
|
+
- **Network errors and transport timeouts** — same retry/backoff behavior as 5xx. After exhaustion, falls through to the `onUnavailable` handler.
|
|
403
|
+
|
|
404
|
+
Every JSON request attempt has a 30-second limit; binary export attempts have a
|
|
405
|
+
120-second limit. Headers and response bodies share the applicable attempt
|
|
406
|
+
limit, and each public Snap operation has one 10-minute deadline covering all
|
|
407
|
+
of its requests, retries, backoff, and polling. The client passes an
|
|
408
|
+
`AbortSignal` to fetch and aborts/cancels stalled bodies; injected transports
|
|
409
|
+
that ignore cancellation are still released by the client-side deadline.
|
|
410
|
+
Retry and poll waits are clipped to the remaining operation time, so no new
|
|
411
|
+
request starts after the operation deadline. Deadline exhaustion is treated as
|
|
412
|
+
Snap unavailability and follows `onUnavailable`; received 4xx responses keep
|
|
413
|
+
their immediate non-retryable behavior, and a stalled 4xx diagnostic body
|
|
414
|
+
retains its HTTP status with a timeout diagnostic.
|
|
362
415
|
|
|
363
416
|
`onUnavailable` is consulted once retries are exhausted:
|
|
364
417
|
|
|
@@ -376,7 +429,7 @@ alike:
|
|
|
376
429
|
| Phase | `warn-and-skip` | `fallback-local` |
|
|
377
430
|
|:------|:----------------|:-----------------|
|
|
378
431
|
| 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
|
|
432
|
+
| 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
433
|
| 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
434
|
|
|
382
435
|
Enforcement of `diff.mode` never runs against a skipped summary — a skipped run
|
|
@@ -402,13 +455,17 @@ All four error classes are exported from `lib/provider.mjs` and `lib/snap-provid
|
|
|
402
455
|
|
|
403
456
|
| Class | Thrown when | Typical handler |
|
|
404
457
|
|:------|:------------|:----------------|
|
|
405
|
-
| `SnapApiError` | A 4xx response was received,
|
|
406
|
-
| `SnapUnavailableError` | A network error or
|
|
458
|
+
| `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. |
|
|
459
|
+
| `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
460
|
| `SnapFallbackError` | `onUnavailable: "fallback-local"` is set and Snap could not be reached. | Catch and switch to `LocalProvider` for the rest of the pipeline. |
|
|
408
461
|
| `SnapSkipError` | `onUnavailable: "warn-and-skip"` is set and Snap could not be reached. | Catch and exit cleanly with a skipped summary. |
|
|
409
462
|
|
|
410
463
|
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
464
|
|
|
465
|
+
`SnapTransportTimeoutError` is an internal diagnostic name used while a
|
|
466
|
+
request or response body is being bounded. It is not an additional public
|
|
467
|
+
error class; callers should handle the exported error classes above.
|
|
468
|
+
|
|
412
469
|
## Primary entrypoints
|
|
413
470
|
|
|
414
471
|
- `actions/baseline`
|
|
@@ -505,3 +562,45 @@ The `VisualProvider` interface requires `buildCommentBody(summary, meta?)`:
|
|
|
505
562
|
|
|
506
563
|
- **`LocalProvider`** — delegates to `buildReportCommentBody` without a `dashboardUrl`.
|
|
507
564
|
- **`SnapProvider`** — constructs `dashboardUrl` from `{apiUrl}/projects/{projectId}/runs/{lastRunId}` and passes it through.
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
## Workspace TypeScript contracts
|
|
568
|
+
|
|
569
|
+
The four `@snapdrift/*` workspace packages expose their declarations through a
|
|
570
|
+
`types` export condition before the existing JavaScript entrypoint. ESM consumers
|
|
571
|
+
can use Bundler, Node16, or NodeNext module resolution with `strict: true` and
|
|
572
|
+
`skipLibCheck: false`; Node16/NodeNext consumers should use an ESM package or an
|
|
573
|
+
`.mts` entrypoint. The top-level `types` field remains for older tooling.
|
|
574
|
+
|
|
575
|
+
Install `typescript` and `@types/node` in the consuming development environment
|
|
576
|
+
for APIs that use Node buffers. Cross-package declaration dependencies are
|
|
577
|
+
declared by the package that needs them. `npm run check:package-types` packs the
|
|
578
|
+
workspace packages and checks isolated consumers in all three resolution modes,
|
|
579
|
+
including rejected invalid calls. `npm run typecheck` also checks declaration
|
|
580
|
+
bodies without skipping library checks.
|
|
581
|
+
|
|
582
|
+
These guarantees cover the workspace package entrypoints. They do not add type
|
|
583
|
+
entrypoints for the root `snapdrift` package's JavaScript subpaths. Package release
|
|
584
|
+
publication is required before existing registry consumers receive these fixes.
|
|
585
|
+
|
|
586
|
+
Comment renderers accept `VisualReportSummary`: a complete comparison summary or
|
|
587
|
+
a `VisualDriftStatusSummary` with a required status and reason. `buildDriftSummary`
|
|
588
|
+
and `writeDriftSummary` return the latter, preserving their supported non-skipped
|
|
589
|
+
statuses as well as intentional skips. Unrelated object literals are rejected.
|
|
590
|
+
Known viewport presets have complete descriptors; arbitrary string lookups may
|
|
591
|
+
return `undefined` and must be checked by consumers.
|
|
592
|
+
|
|
593
|
+
The packed type gate runs in PR CI and before release publication. It validates
|
|
594
|
+
the candidate workspace tarballs together, not already-published registry
|
|
595
|
+
versions. Release preparation must bump every changed published package and
|
|
596
|
+
raise sibling dependency floors to the versions that contain these type fixes
|
|
597
|
+
before publication; the gate alone does not validate that release-version step.
|
|
598
|
+
|
|
599
|
+
Each workspace package also declares every runtime package it imports. In
|
|
600
|
+
particular, `@snapdrift/adapter-fs` declares `@snapdrift/adapter-report-md`, so
|
|
601
|
+
it can be installed and imported without the SnapDrift repository or a sibling
|
|
602
|
+
workspace masking a missing dependency. `npm run check:package-runtime` packs
|
|
603
|
+
each workspace package, installs it in an isolated temporary consumer together
|
|
604
|
+
with only its declared local workspace dependency closure, and imports its
|
|
605
|
+
public entrypoint. The gate runs in pull-request and release workflows; publish
|
|
606
|
+
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.9.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.9.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.9.0` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.9.0` |
|
|
90
|
+
| `ranacseruet/snapdrift@v0.9.0` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.9.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.9.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.9.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,7 +276,9 @@ 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
284
|
- `actions/comment` — upsert a PR comment from a summary (provider-aware)
|
|
@@ -313,6 +324,12 @@ Run this job after every successful default-branch build. Hosted publication is
|
|
|
313
324
|
**"No non-expired SnapDrift baseline artifact was found"**
|
|
314
325
|
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
326
|
|
|
327
|
+
**"Unable to resolve the SnapDrift baseline artifact"**
|
|
328
|
+
GitHub failed while listing workflow runs or artifacts. Check the job's `actions: read` permission,
|
|
329
|
+
repository/workflow/branch inputs, and GitHub API availability. Snap hosted diffs can still use the
|
|
330
|
+
hosted comparison path, but local comparisons and `fallback-local` require a successful GitHub
|
|
331
|
+
artifact lookup.
|
|
332
|
+
|
|
316
333
|
**403 when posting the PR report**
|
|
317
334
|
Grant `issues: write` and `pull-requests: write` to the job.
|
|
318
335
|
|
|
@@ -327,3 +344,25 @@ Capture failed before comparison. Confirm the app is fully ready and reachable b
|
|
|
327
344
|
|
|
328
345
|
**Playwright install runs even though `provider: "snap"`**
|
|
329
346
|
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.
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
## TypeScript package consumers
|
|
350
|
+
|
|
351
|
+
Import APIs and types from the public `@snapdrift/*` package names. Their export
|
|
352
|
+
maps support Bundler, Node16, and NodeNext resolution; enable `strict: true` and
|
|
353
|
+
`skipLibCheck: false` to validate the contracts in your project. For Node16 or
|
|
354
|
+
NodeNext, use `"type": "module"` or an `.mts` entrypoint. Install `typescript` and
|
|
355
|
+
`@types/node` as development dependencies when using the Node buffer APIs.
|
|
356
|
+
|
|
357
|
+
No TypeScript path aliases or imports into workspace source directories are
|
|
358
|
+
needed. Root `snapdrift/lib/*` subpath typing is separate from these workspace
|
|
359
|
+
contracts. The fixes become available to registry consumers after the packages
|
|
360
|
+
are released and their installed versions are updated.
|
|
361
|
+
|
|
362
|
+
The workspace packages are independently installable runtime packages. Install
|
|
363
|
+
the package you use from the registry and let npm resolve its declared runtime
|
|
364
|
+
dependencies; do not rely on a repository checkout or workspace links. The
|
|
365
|
+
filesystem adapter includes its reporting dependency, so `@snapdrift/adapter-fs`
|
|
366
|
+
can load config, capture, comparison, and report entrypoints from an isolated
|
|
367
|
+
consumer. Release a new adapter version before expecting an existing registry
|
|
368
|
+
installation to include a dependency correction.
|
package/lib/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import fs from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
@@ -49,6 +49,11 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
|
|
|
49
49
|
* --accept-cross-engine Allow importing baselines from a different capture engine
|
|
50
50
|
* --from-snap-action <path> Snap action workflow YAML path (init command)
|
|
51
51
|
*
|
|
52
|
+
* Values may be given inline as `--flag=value` (so values beginning with a hyphen,
|
|
53
|
+
* e.g. `--routes=-mobile`, work) or after the `--` end-of-options marker. Unknown
|
|
54
|
+
* flags and missing option values are rejected with a non-zero error; invalid
|
|
55
|
+
* *values* for --to/--from remain silently ignored (validated downstream).
|
|
56
|
+
*
|
|
52
57
|
* @param {string[]} argv - process.argv
|
|
53
58
|
* @returns {CliOptions}
|
|
54
59
|
*/
|
|
@@ -71,41 +76,105 @@ export function parseArgs(argv) {
|
|
|
71
76
|
/** @type {string | undefined} */
|
|
72
77
|
let fromSnapAction;
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
79
|
+
// Flags that consume the following token as their value. Unknown flags and
|
|
80
|
+
// missing option values are rejected rather than silently ignored, so a typo
|
|
81
|
+
// cannot fall back to the default config/directories. Invalid *values* for
|
|
82
|
+
// --to/--from stay silently ignored to preserve the migrate-baselines contract
|
|
83
|
+
// (their validation happens downstream). Values that begin with a hyphen (e.g.
|
|
84
|
+
// a route id like `-mobile`) may be supplied inline as `--flag=value` or bare.
|
|
85
|
+
const VALUE_FLAGS = new Set([
|
|
86
|
+
'--config',
|
|
87
|
+
'--routes',
|
|
88
|
+
'--baseline-dir',
|
|
89
|
+
'--current-dir',
|
|
90
|
+
'--diff-dir',
|
|
91
|
+
'--to',
|
|
92
|
+
'--from',
|
|
93
|
+
'--from-snap-action'
|
|
94
|
+
]);
|
|
95
|
+
const BOOLEAN_FLAGS = new Set(['--open', '--accept-cross-engine']);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Assign a parsed value (from an inline `--flag=value` or a following token)
|
|
99
|
+
* to the option named by `flag`.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} flag
|
|
102
|
+
* @param {string} value
|
|
103
|
+
*/
|
|
104
|
+
function assignFlag(flag, value) {
|
|
105
|
+
if (flag === '--routes') {
|
|
106
|
+
routes.push(...value.split(',').map((r) => r.trim()).filter(Boolean));
|
|
107
|
+
} else if (flag === '--config') {
|
|
108
|
+
configPath = value;
|
|
109
|
+
} else if (flag === '--baseline-dir') {
|
|
110
|
+
baselineDir = path.resolve(value);
|
|
111
|
+
} else if (flag === '--current-dir') {
|
|
112
|
+
currentDir = path.resolve(value);
|
|
113
|
+
} else if (flag === '--diff-dir') {
|
|
114
|
+
diffDir = path.resolve(value);
|
|
115
|
+
} else if (flag === '--to') {
|
|
116
|
+
if (value === 'snap' || value === 'local') to = value;
|
|
117
|
+
} else if (flag === '--from') {
|
|
118
|
+
if (value === 'snap') from = value;
|
|
119
|
+
} else if (flag === '--from-snap-action') {
|
|
120
|
+
fromSnapAction = value;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let endOfOptions = false;
|
|
125
|
+
|
|
126
|
+
for (let i = 1; i < args.length; i++) {
|
|
127
|
+
const raw = args[i];
|
|
128
|
+
|
|
129
|
+
if (raw === '--') {
|
|
130
|
+
endOfOptions = true;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// End-of-options: after `--`, tokens are values/positionals, never flags.
|
|
135
|
+
if (endOfOptions) {
|
|
136
|
+
if (VALUE_FLAGS.has(raw)) {
|
|
137
|
+
const value = args[i + 1];
|
|
138
|
+
if (value === undefined) {
|
|
139
|
+
throw new Error(`Missing value for flag ${raw}`);
|
|
140
|
+
}
|
|
141
|
+
assignFlag(raw, value);
|
|
142
|
+
i++; // consume the following token so it is not reprocessed as a flag
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Inline `--flag=value` form (also handles values that begin with a hyphen).
|
|
148
|
+
const eq = raw.indexOf('=');
|
|
149
|
+
if (raw.startsWith('--') && eq > 0) {
|
|
150
|
+
const flag = raw.slice(0, eq);
|
|
151
|
+
if (!VALUE_FLAGS.has(flag)) {
|
|
152
|
+
throw new Error(`Unknown flag: ${flag}`);
|
|
153
|
+
}
|
|
154
|
+
assignFlag(flag, raw.slice(eq + 1));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (BOOLEAN_FLAGS.has(raw)) {
|
|
159
|
+
if (raw === '--open') {
|
|
160
|
+
open = true;
|
|
161
|
+
} else if (raw === '--accept-cross-engine') {
|
|
162
|
+
acceptCrossEngine = true;
|
|
163
|
+
}
|
|
164
|
+
} else if (VALUE_FLAGS.has(raw)) {
|
|
165
|
+
const value = args[i + 1];
|
|
166
|
+
// A hyphenated following token (e.g. a route id like `-mobile`) is a
|
|
167
|
+
// legitimate value; only a missing token, the `--` end-of-options marker,
|
|
168
|
+
// or a following *recognized flag* means the value is absent.
|
|
169
|
+
if (value === undefined || value === '--' || VALUE_FLAGS.has(value) || BOOLEAN_FLAGS.has(value)) {
|
|
170
|
+
throw new Error(`Missing value for flag ${raw}`);
|
|
171
|
+
}
|
|
172
|
+
assignFlag(raw, value);
|
|
173
|
+
i++; // consume the following token so it is not reprocessed as a flag
|
|
174
|
+
} else if (raw.startsWith('-')) {
|
|
175
|
+
throw new Error(`Unknown flag: ${raw}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
109
178
|
|
|
110
179
|
return { command, open, configPath, routes, baselineDir, currentDir, diffDir, to, from, acceptCrossEngine, fromSnapAction };
|
|
111
180
|
}
|
|
@@ -163,15 +232,30 @@ export function printSummary(summary) {
|
|
|
163
232
|
* @returns {void}
|
|
164
233
|
*/
|
|
165
234
|
function openFile(filePath) {
|
|
166
|
-
const cmd =
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
execSync(`${cmd} "${filePath}"`);
|
|
170
|
-
} catch {
|
|
235
|
+
const { cmd, args } = buildOpenCommand(filePath);
|
|
236
|
+
const result = spawnSync(cmd, args);
|
|
237
|
+
if (result.error) {
|
|
171
238
|
process.stderr.write(`Could not open file: ${filePath}\n`);
|
|
172
239
|
}
|
|
173
240
|
}
|
|
174
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Build the platform-correct invocation to open a file with the OS default
|
|
244
|
+
* viewer. The path is passed as a separate argument (never shell-interpolated)
|
|
245
|
+
* so metacharacters in a local path cannot execute commands. On Windows, `start`
|
|
246
|
+
* is a cmd.exe built-in rather than an executable, so spawnSync (which does not
|
|
247
|
+
* use a shell by default) returns ENOENT; open the registered handler through
|
|
248
|
+
* rundll32, a real executable, keeping the path as a separate argument.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} filePath
|
|
251
|
+
* @returns {{ cmd: string, args: string[] }}
|
|
252
|
+
*/
|
|
253
|
+
export function buildOpenCommand(filePath, platform = process.platform) {
|
|
254
|
+
if (platform === 'darwin') return { cmd: 'open', args: [filePath] };
|
|
255
|
+
if (platform === 'linux') return { cmd: 'xdg-open', args: [filePath] };
|
|
256
|
+
return { cmd: 'rundll32', args: ['url.dll,FileProtocolHandler', filePath] };
|
|
257
|
+
}
|
|
258
|
+
|
|
175
259
|
/**
|
|
176
260
|
* Run the `capture` command — capture screenshots to a local baseline directory.
|
|
177
261
|
*
|
package/lib/init-from-action.mjs
CHANGED
|
@@ -87,19 +87,30 @@ function translateStepToConfig(step) {
|
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
90
|
+
// Snap provider config (if present in inputs). A hosted Snap provider requires
|
|
91
|
+
// an API-key source; only enable provider: "snap" when one is present. When a
|
|
92
|
+
// project ID exists without an API-key source, keep a valid (local) config and
|
|
93
|
+
// emit a clear migration warning rather than emitting provider: "snap" with no
|
|
94
|
+
// API-key source, which later fails validation.
|
|
95
|
+
/** @type {JsonObject | undefined} */
|
|
96
|
+
let snap;
|
|
97
|
+
const snapApiUrl = inputs['snap-api-url'] || inputs.snap_api_url;
|
|
98
|
+
const snapApiKeyEnv = inputs['snap-api-key-env'] || inputs.snap_api_key_env;
|
|
99
|
+
const snapProjectId = inputs['snap-project-id'] || inputs.snap_project_id;
|
|
100
|
+
|
|
101
|
+
if (snapApiKeyEnv) {
|
|
102
|
+
snap = {};
|
|
103
|
+
if (snapApiUrl) snap.apiUrl = snapApiUrl;
|
|
104
|
+
snap.apiKeyEnv = snapApiKeyEnv;
|
|
105
|
+
if (snapProjectId) snap.projectId = snapProjectId;
|
|
106
|
+
} else if (snapProjectId) {
|
|
107
|
+
warnings.push({
|
|
108
|
+
field: 'snap.projectId',
|
|
109
|
+
originalValue: snapProjectId,
|
|
110
|
+
message: `Snap project ID "${snapProjectId}" is present but no API-key source (snap-api-key-env) was configured. SnapDrift keeps the local provider; add a Snap API key source to enable hosted baselines.`,
|
|
111
|
+
severity: 'warning'
|
|
112
|
+
});
|
|
113
|
+
}
|
|
103
114
|
|
|
104
115
|
const config = {
|
|
105
116
|
baselineArtifactName: 'snapdrift-baseline',
|
package/lib/outage-policy.mjs
CHANGED
|
@@ -41,6 +41,25 @@ export const MISSING_BASELINE_REASON = 'missing_main_baseline_artifact';
|
|
|
41
41
|
/** @returns {void} */
|
|
42
42
|
function noop() {}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* @param {unknown} outageError
|
|
46
|
+
* @param {'capture' | 'diff'} phase
|
|
47
|
+
* @param {string | undefined} lookupMessage
|
|
48
|
+
* @returns {Error}
|
|
49
|
+
*/
|
|
50
|
+
function createBaselineLookupFallbackError(outageError, phase, lookupMessage) {
|
|
51
|
+
const lookupDetail = typeof lookupMessage === 'string' && lookupMessage.trim().length > 0
|
|
52
|
+
? ` ${lookupMessage.trim()}`
|
|
53
|
+
: '';
|
|
54
|
+
const outageDetail = outageError instanceof Error && outageError.message
|
|
55
|
+
? ` Snap fallback reason: ${outageError.message}`
|
|
56
|
+
: '';
|
|
57
|
+
return new Error(
|
|
58
|
+
`Cannot fall back to a local ${phase} because the GitHub baseline lookup failed.${lookupDetail}${outageDetail}`,
|
|
59
|
+
{ cause: outageError }
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
44
63
|
/**
|
|
45
64
|
* Whether the current capture leaves real PNGs on the runner's filesystem.
|
|
46
65
|
*
|
|
@@ -70,7 +89,9 @@ export function hasLocalScreenshots(providerName, config) {
|
|
|
70
89
|
* config?: VisualRegressionConfig,
|
|
71
90
|
* createLocalProvider?: () => VisualProvider,
|
|
72
91
|
* onSkip?: (error: SnapSkipError) => void,
|
|
73
|
-
* onFallback?: (error: SnapFallbackError) => void
|
|
92
|
+
* onFallback?: (error: SnapFallbackError) => void,
|
|
93
|
+
* baselineResolutionStatus?: string,
|
|
94
|
+
* baselineResolutionMessage?: string
|
|
74
95
|
* }} options
|
|
75
96
|
* @returns {Promise<{
|
|
76
97
|
* outcome: 'captured' | 'skipped',
|
|
@@ -100,6 +121,9 @@ export async function captureWithPolicy(options) {
|
|
|
100
121
|
}
|
|
101
122
|
if (error instanceof SnapFallbackError) {
|
|
102
123
|
onFallback(error);
|
|
124
|
+
if (options.baselineResolutionStatus === 'error') {
|
|
125
|
+
throw createBaselineLookupFallbackError(error, 'capture', options.baselineResolutionMessage);
|
|
126
|
+
}
|
|
103
127
|
const result = await createLocalProvider().capture(options.captureOptions);
|
|
104
128
|
// The rest of the pipeline must be told the *effective* provider, not the
|
|
105
129
|
// configured one — otherwise it rebuilds SnapProvider around local output.
|
|
@@ -134,7 +158,9 @@ export async function captureWithPolicy(options) {
|
|
|
134
158
|
* onSkip?: (error: SnapSkipError) => void,
|
|
135
159
|
* onFallback?: (error: SnapFallbackError) => void,
|
|
136
160
|
* onRecapture?: () => void,
|
|
137
|
-
* onBaselineUnavailable?: () => void
|
|
161
|
+
* onBaselineUnavailable?: () => void,
|
|
162
|
+
* baselineResolutionStatus?: string,
|
|
163
|
+
* baselineResolutionMessage?: string
|
|
138
164
|
* }} options
|
|
139
165
|
* @returns {Promise<{
|
|
140
166
|
* outcome: 'diffed' | 'skipped' | 'baseline-unavailable',
|
|
@@ -170,9 +196,16 @@ export async function diffWithPolicy(options) {
|
|
|
170
196
|
: Boolean(options.diffOptions.baselineResultsPath);
|
|
171
197
|
if (!baselineAvailable) {
|
|
172
198
|
onBaselineUnavailable();
|
|
199
|
+
if (options.baselineResolutionStatus === 'error') {
|
|
200
|
+
throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
|
|
201
|
+
}
|
|
173
202
|
return { outcome: 'baseline-unavailable', providerName: 'local' };
|
|
174
203
|
}
|
|
175
204
|
|
|
205
|
+
if (options.baselineResolutionStatus === 'error') {
|
|
206
|
+
throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
|
|
207
|
+
}
|
|
208
|
+
|
|
176
209
|
const localProvider = createLocalProvider();
|
|
177
210
|
let diffOptions = options.diffOptions;
|
|
178
211
|
/** @type {CaptureResult | undefined} */
|