snapdrift 0.8.1 → 0.8.2
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 +13 -0
- package/README.md +7 -4
- package/action.yml +8 -9
- package/actions/baseline/action.yml +68 -47
- package/actions/pr-diff/action.yml +119 -58
- package/docs/contracts.md +36 -3
- package/docs/integration-guide.md +12 -10
- package/docs/local-cli.md +6 -5
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +25 -13
- package/lib/cli.mjs +118 -127
- package/lib/outage-policy.mjs +245 -0
- package/lib/snap-provider.mjs +371 -44
- package/package.json +3 -1
- package/types/visual-diff-types.d.ts +26 -0
package/docs/contracts.md
CHANGED
|
@@ -210,6 +210,12 @@ Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
|
|
|
210
210
|
| `fail-on-incomplete` | Errors, dimension shifts, or missing captures occur |
|
|
211
211
|
| `strict` | Any drift or incomplete comparison appears |
|
|
212
212
|
|
|
213
|
+
A summary with `status: "skipped"` — or any summary carrying no `diff.mode` —
|
|
214
|
+
never stops the run, whichever mode is configured. A skipped run has no diff
|
|
215
|
+
counters to enforce against, so `shouldFailDriftCheck` returns `false` for it
|
|
216
|
+
before any mode branch is reached. This holds for `actions/enforce` invoked
|
|
217
|
+
directly, not just for the guarded enforcement step inside `actions/pr-diff`.
|
|
218
|
+
|
|
213
219
|
## Viewport presets
|
|
214
220
|
|
|
215
221
|
| Preset | Width | Height | Scale factor | Mobile | Touch |
|
|
@@ -316,6 +322,14 @@ Two files are written: `.github/snapdrift.json` and `.github/MIGRATION_NOTES.md`
|
|
|
316
322
|
|
|
317
323
|
When `provider: "snap"`, every `capture` / `diff` / `publishBaseline` call goes through `SnapProvider`, which talks to Snap's hosted `/v1/visual/*` API instead of writing to the runner filesystem. The local provider keeps working exactly as before.
|
|
318
324
|
|
|
325
|
+
### Complete hosted baseline publication
|
|
326
|
+
|
|
327
|
+
A hosted baseline is an authoritative complete snapshot. For `purpose: "baseline"`, `SnapProvider.capture()` requires the selected route ids to equal the complete configured route set; `route-ids`, `--routes`, and `SNAPDRIFT_ROUTE_IDS` may not reduce it. It requires a resolved branch, 40-character commit SHA, publication workflow ref, and positive publication sequence. GitHub Actions provides these as `GITHUB_REF_NAME`, `GITHUB_SHA`, `GITHUB_WORKFLOW_REF`, and `GITHUB_RUN_NUMBER`; other CI systems may set `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`. Local-provider baselines, non-publishing hosted captures, and hosted PR-diff runs retain scoped behavior.
|
|
328
|
+
|
|
329
|
+
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
|
+
|
|
331
|
+
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
|
+
|
|
319
333
|
### Local-capture hybrid
|
|
320
334
|
|
|
321
335
|
If `baseUrl` resolves to a local address (see [`isLocalBaseUrl`](#islocalbaseurl-detection) below), SnapDrift runs Playwright on the runner to render the page and uploads the resulting screenshots to Snap. This is necessary whenever your `baseUrl` is a server only the runner can reach (the common case) — Snap's render worker cannot reach a `127.0.0.1` or `localhost` server.
|
|
@@ -328,10 +342,10 @@ SnapDrift uses a small, stable subset of the Snap API:
|
|
|
328
342
|
|
|
329
343
|
| Endpoint | Used by |
|
|
330
344
|
|:---------|:--------|
|
|
331
|
-
| `POST /v1/visual/projects/:id/runs` | Create a run; client passes `baseUrl`, `trigger`, optional `baselineId` and `branch` |
|
|
345
|
+
| `POST /v1/visual/projects/:id/runs` | Create a run; client passes `baseUrl`, `trigger`, optional `baselineId` and `branch`, plus the immutable configured/selected route and expected-capture plan; baseline runs also pass the resolved commit as `prHeadSha` |
|
|
332
346
|
| `POST /v1/visual/runs/:run_id/captures` | Submit each route as a capture; client passes `routeId`, `routePath`, `viewportDescriptorJson` |
|
|
333
347
|
| `POST /v1/visual/captures/:capture_id/local-result` | Hybrid path: upload the locally rendered PNG and its dimensions |
|
|
334
|
-
| `POST /v1/visual/projects/:id/baselines` | Publish a baseline from a run's
|
|
348
|
+
| `POST /v1/visual/projects/:id/baselines` | Publish a complete default-branch baseline from a source run's validated captures |
|
|
335
349
|
| `GET /v1/visual/projects/:id/baselines/latest` | Resolve the latest accepted baseline for diff runs |
|
|
336
350
|
| `GET /v1/visual/runs/:run_id` | Poll a run until it reaches a terminal state (`pass`, `fail`, `error`, or `new`) |
|
|
337
351
|
|
|
@@ -352,6 +366,25 @@ The Snap HTTP client classifies responses and applies the following rules:
|
|
|
352
366
|
- `"warn-and-skip"` — log a warning, throw `SnapSkipError`. The wrapper actions catch this and write a skipped summary, exiting 0.
|
|
353
367
|
- `"fallback-local"` — log a warning, throw `SnapFallbackError`. The wrapper actions catch this and continue the pipeline with `LocalProvider`.
|
|
354
368
|
|
|
369
|
+
### Outage policy
|
|
370
|
+
|
|
371
|
+
Snap can go down at any phase of a run, and the configured `onUnavailable`
|
|
372
|
+
behavior has to hold for all of them. `lib/outage-policy.mjs` is the single
|
|
373
|
+
implementation, used by `lib/cli.mjs`, `actions/baseline` and `actions/pr-diff`
|
|
374
|
+
alike:
|
|
375
|
+
|
|
376
|
+
| Phase | `warn-and-skip` | `fallback-local` |
|
|
377
|
+
|:------|:----------------|:-----------------|
|
|
378
|
+
| 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 baseline artifact was resolved, the run reports `missing_main_baseline_artifact` instead of crashing the local diff. |
|
|
380
|
+
| 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
|
+
|
|
382
|
+
Enforcement of `diff.mode` never runs against a skipped summary — a skipped run
|
|
383
|
+
carries no diff counters, and `warn-and-skip` means "do not fail my build".
|
|
384
|
+
|
|
385
|
+
A `fallback-local` capture leaves nothing on Snap: no run is published and no
|
|
386
|
+
baseline is created. The fallback is a local run, not a deferred hosted one.
|
|
387
|
+
|
|
355
388
|
### `isLocalBaseUrl` detection
|
|
356
389
|
|
|
357
390
|
`isLocalBaseUrl(baseUrl)` returns `true` when the URL hostname is:
|
|
@@ -374,7 +407,7 @@ All four error classes are exported from `lib/provider.mjs` and `lib/snap-provid
|
|
|
374
407
|
| `SnapFallbackError` | `onUnavailable: "fallback-local"` is set and Snap could not be reached. | Catch and switch to `LocalProvider` for the rest of the pipeline. |
|
|
375
408
|
| `SnapSkipError` | `onUnavailable: "warn-and-skip"` is set and Snap could not be reached. | Catch and exit cleanly with a skipped summary. |
|
|
376
409
|
|
|
377
|
-
The wrapper actions (`actions/baseline`, `actions/pr-diff`)
|
|
410
|
+
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.
|
|
378
411
|
|
|
379
412
|
## Primary entrypoints
|
|
380
413
|
|
|
@@ -46,14 +46,14 @@ SnapDrift owns route selection, capture, comparison, skipped-report generation,
|
|
|
46
46
|
|
|
47
47
|
```yaml
|
|
48
48
|
- name: SnapDrift Baseline
|
|
49
|
-
uses: ranacseruet/snapdrift@v0.8.
|
|
49
|
+
uses: ranacseruet/snapdrift@v0.8.2
|
|
50
50
|
with:
|
|
51
51
|
mode: baseline
|
|
52
52
|
repo-config-path: .github/snapdrift.json
|
|
53
53
|
artifact-retention-days: '30'
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
For a local-provider baseline, `route-ids` may select part of the route set. A hosted Snap baseline is authoritative and must run in GitHub Actions on the project default branch with every configured route, so a partial `route-ids` value or unresolved CI commit fails before a run is created. Route scoping remains supported in `pr-diff` mode. If you don't want to upload the artifact (for example when using the Snap provider, which uploads via its own API), set `upload-artifact: 'false'`.
|
|
57
57
|
|
|
58
58
|
## Step 3: Run the pull request report
|
|
59
59
|
|
|
@@ -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.8.
|
|
74
|
+
uses: ranacseruet/snapdrift@v0.8.2
|
|
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.8.
|
|
90
|
-
| `ranacseruet/snapdrift@v0.8.
|
|
89
|
+
| `ranacseruet/snapdrift@v0.8.2` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.8.2` |
|
|
90
|
+
| `ranacseruet/snapdrift@v0.8.2` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.8.2` |
|
|
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.8.
|
|
133
|
+
uses: ranacseruet/snapdrift@v0.8.2
|
|
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.8.
|
|
173
|
+
uses: ranacseruet/snapdrift@v0.8.2
|
|
174
174
|
with:
|
|
175
175
|
mode: pr-diff
|
|
176
176
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -230,6 +230,10 @@ By default SnapDrift writes baselines and reports to the runner filesystem (`pro
|
|
|
230
230
|
- `"warn-and-skip"` — log a warning, write a skipped summary, and exit 0.
|
|
231
231
|
- `"fallback-local"` — log a warning and run the rest of the pipeline with `LocalProvider`.
|
|
232
232
|
|
|
233
|
+
Both behaviors apply at every phase of a run — capture, diff, and baseline publish. `fallback-local` switches the whole remainder of the pipeline to local artifacts and the local pixel engine; if Snap had already rendered the current capture server-side, the routes are recaptured on the runner first, so `actions/pr-diff` needs Playwright available (the wrapper installs it whenever `onUnavailable` is `fallback-local`). Nothing is published to Snap on a fallback run. See [Outage policy](contracts.md#outage-policy) for the full matrix.
|
|
234
|
+
|
|
235
|
+
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
|
+
|
|
233
237
|
The Snap API client retries 5xx and network errors with exponential backoff (3 attempts, 1 s → 2 s → 4 s, capped at 30 s). 4xx errors never retry and never fall back.
|
|
234
238
|
|
|
235
239
|
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.
|
|
@@ -302,9 +306,7 @@ Use the provided workflow template to refresh the baseline automatically on ever
|
|
|
302
306
|
|
|
303
307
|
Drop a copy into your repo at `.github/workflows/snapdrift-refresh-baseline.yml`, then fill in the `TODO` blocks with your app's build and start steps — the same steps you use in your PR workflow. The template uses a `push` trigger on the default branch so published artifacts are discoverable by the baseline resolver in `actions/pr-diff`.
|
|
304
308
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
If you don't want to republish the baseline on every push, the template includes a commented `if` condition that gates on a label (e.g. `snapdrift:refresh-baseline`). A preceding job can check the most recent merged PR for the label and set an output to control whether the baseline refresh runs.
|
|
309
|
+
Run this job after every successful default-branch build. Hosted publication is the only operation that may intentionally remove a configured route from the canonical baseline, so skipping merges can leave later PRs comparing against stale route configuration.
|
|
308
310
|
|
|
309
311
|
## Troubleshooting
|
|
310
312
|
|
package/docs/local-cli.md
CHANGED
|
@@ -31,11 +31,11 @@ snapdrift baseline [options]
|
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
- **`provider: "local"`** — identical to `snapdrift capture`: the screenshots written to the baseline directory *are* the baseline.
|
|
34
|
-
- **`provider: "snap"`** —
|
|
34
|
+
- **`provider: "snap"`** — canonical baseline publication is CI-only. Run it on the Snap project's default branch; GitHub Actions supplies the publication metadata automatically, while another CI system may set `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`. It captures every configured route, waits for the complete hosted run, then publishes a baseline referencing the stored objects. A local checkout without those metadata values fails before creating a hosted run, and partial `--routes` selection is rejected. `snapdrift capture` may still be used locally for non-publishing diagnostics.
|
|
35
35
|
|
|
36
36
|
`snap.onUnavailable` is honoured across both phases — the capture *and* the publish that follows it. `warn-and-skip` exits 0 without a baseline; `fallback-local` captures locally so you still end up with one.
|
|
37
37
|
|
|
38
|
-
**Baseline attribution.**
|
|
38
|
+
**Baseline attribution.** A hosted publication records a 40-character commit and branch from GitHub Actions or the current git checkout, plus a publication workflow identity and sequence. Outside GitHub Actions, set `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`; without them SnapDrift refuses to create a canonical hosted baseline.
|
|
39
39
|
|
|
40
40
|
**`snap.projectId` must be an explicit `prj_...` id** for local runs. `"auto"` derives the id from `GITHUB_REPOSITORY`, which is not set outside Actions — the command fails early telling you to set an explicit id.
|
|
41
41
|
|
|
@@ -44,13 +44,14 @@ snapdrift baseline [options]
|
|
|
44
44
|
| Flag | Default | Description |
|
|
45
45
|
|:-----|:--------|:------------|
|
|
46
46
|
| `--config <path>` | `.github/snapdrift.json` | Path to the config file |
|
|
47
|
-
| `--routes <ids>` | all routes | Comma-separated route IDs
|
|
47
|
+
| `--routes <ids>` | all routes | Comma-separated route IDs for a local-provider baseline; hosted baselines require all configured routes |
|
|
48
48
|
| `--baseline-dir <path>` | `.snapdrift/baseline` | Directory to write baseline screenshots and metadata (local provider) |
|
|
49
49
|
|
|
50
50
|
**Example**
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
53
|
snapdrift baseline
|
|
54
|
+
# Scoped baseline capture remains available with provider: "local"
|
|
54
55
|
snapdrift baseline --routes home-desktop,home-mobile
|
|
55
56
|
```
|
|
56
57
|
|
|
@@ -296,7 +297,7 @@ The `report.html` path is printed whenever the status is anything other than `cl
|
|
|
296
297
|
|:---------|:-----------|:------------|
|
|
297
298
|
| `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture`, `diff` | Max concurrent route captures per viewport context (positive integer, default `5`). Set to `1` to restore serial behavior for apps with shared session or auth state. |
|
|
298
299
|
| `SNAPDRIFT_CONFIG_PATH` | `capture`, `diff`, `migrate-baselines` | Override the config file path. Equivalent to `--config`. |
|
|
299
|
-
| `SNAPDRIFT_ROUTE_IDS` | `capture`, `diff` | Comma-separated route ids to scope to. Equivalent to `--routes
|
|
300
|
+
| `SNAPDRIFT_ROUTE_IDS` | `capture`, `baseline`, `diff` | Comma-separated route ids to scope to. Equivalent to `--routes`; it may not reduce a hosted baseline's complete route set. |
|
|
300
301
|
|
|
301
302
|
`SNAPDRIFT_CAPTURE_CONCURRENCY` is the same env var consumed by the GitHub Actions wrapper; tweak it the same way for both environments.
|
|
302
303
|
|
|
@@ -304,7 +305,7 @@ The `report.html` path is printed whenever the status is anything other than `cl
|
|
|
304
305
|
|
|
305
306
|
## Tips
|
|
306
307
|
|
|
307
|
-
- **Partial runs**: use `--routes` to capture or compare only the routes you are actively changing.
|
|
308
|
+
- **Partial runs**: use `--routes` to capture or compare only the routes you are actively changing. A hosted baseline is the exception: its GitHub Actions run always requires the complete configured route set.
|
|
308
309
|
- **Multiple baselines**: use `--baseline-dir` to maintain separate baselines per branch or feature.
|
|
309
310
|
- **CI parity**: the CLI uses the same capture and comparison engine as the GitHub Actions workflow, so results are comparable.
|
|
310
311
|
- **Self-contained HTML report**: `report.html` embeds baseline, current, and diff images as base64. Open it from any machine — no server required, and no relative-path resolution surprises. (The HTML report is a local-CLI feature; the GitHub Actions wrapper ships only `summary.json` + `summary.md` in its artifact bundle.)
|
|
@@ -17,12 +17,6 @@
|
|
|
17
17
|
# resolver in actions/pr-diff (which filters on event: 'push'). A merge into
|
|
18
18
|
# the default branch produces a push event, so this fires exactly when needed.
|
|
19
19
|
#
|
|
20
|
-
# Optional — refresh only on a specific label:
|
|
21
|
-
# If you don't want to refresh on every push, you can gate on a label applied
|
|
22
|
-
# before merging. Use the pull_request: labeled event in a separate workflow to
|
|
23
|
-
# set an output, or simply restrict this workflow to specific paths.
|
|
24
|
-
# See the commented block below for a label-based gate using a preceding job.
|
|
25
|
-
|
|
26
20
|
name: SnapDrift Refresh Baseline
|
|
27
21
|
|
|
28
22
|
on:
|
|
@@ -35,12 +29,12 @@ permissions:
|
|
|
35
29
|
|
|
36
30
|
jobs:
|
|
37
31
|
refresh-baseline:
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
32
|
+
# Preserve the in-flight publisher and queue the next merge behind it. This
|
|
33
|
+
# keeps older SnapDrift clients safe during a rolling release; SnapDrift's
|
|
34
|
+
# workflow-scoped sequence is an additional stale-source guard.
|
|
35
|
+
concurrency:
|
|
36
|
+
group: snapdrift-baseline-${{ github.repository }}-${{ github.ref }}
|
|
37
|
+
cancel-in-progress: false
|
|
44
38
|
runs-on: ubuntu-latest
|
|
45
39
|
|
|
46
40
|
steps:
|
|
@@ -69,8 +63,26 @@ jobs:
|
|
|
69
63
|
# done
|
|
70
64
|
# ────────────────────────────────────────────────────────────────────────
|
|
71
65
|
|
|
66
|
+
- name: Verify current default-branch candidate
|
|
67
|
+
id: baseline_candidate
|
|
68
|
+
shell: bash
|
|
69
|
+
run: |
|
|
70
|
+
set -euo pipefail
|
|
71
|
+
latest_sha="$(git ls-remote origin "refs/heads/${GITHUB_REF_NAME}" | cut -f1)"
|
|
72
|
+
if [ -z "$latest_sha" ]; then
|
|
73
|
+
echo "::error::Could not resolve the current default-branch commit."
|
|
74
|
+
exit 1
|
|
75
|
+
fi
|
|
76
|
+
if [ "$GITHUB_SHA" = "$latest_sha" ]; then
|
|
77
|
+
echo "current=true" >> "$GITHUB_OUTPUT"
|
|
78
|
+
else
|
|
79
|
+
echo "current=false" >> "$GITHUB_OUTPUT"
|
|
80
|
+
echo "Skipping stale baseline candidate $GITHUB_SHA; current branch head is $latest_sha."
|
|
81
|
+
fi
|
|
82
|
+
|
|
72
83
|
- name: Publish SnapDrift baseline
|
|
73
|
-
|
|
84
|
+
if: steps.baseline_candidate.outputs.current == 'true'
|
|
85
|
+
uses: ranacseruet/snapdrift@v0.8.2
|
|
74
86
|
with:
|
|
75
87
|
mode: baseline
|
|
76
88
|
repo-config-path: .github/snapdrift.json
|
package/lib/cli.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import { loadSnapdriftConfig } from '@snapdrift/adapter-fs';
|
|
8
8
|
import { shouldFailDriftCheck } from '@snapdrift/manifest';
|
|
9
9
|
import { createProvider } from './provider.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { captureWithPolicy, diffWithPolicy, publishBaselineWithPolicy } from './outage-policy.mjs';
|
|
11
11
|
import { generateHtmlReport } from './report.mjs';
|
|
12
12
|
|
|
13
13
|
/** @typedef {import('../types/visual-diff-types').VisualDiffSummary} DriftSummary */
|
|
@@ -39,7 +39,8 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
|
|
|
39
39
|
* Supported flags:
|
|
40
40
|
* --open Open the HTML report after diff
|
|
41
41
|
* --config <path> Path to snapdrift.json (default: .github/snapdrift.json)
|
|
42
|
-
* --routes <ids> Comma-separated route IDs
|
|
42
|
+
* --routes <ids> Comma-separated route IDs (diff/capture/local baseline;
|
|
43
|
+
* hosted baselines require the complete configured set)
|
|
43
44
|
* --baseline-dir <p> Override local baseline directory (default: .snapdrift/baseline)
|
|
44
45
|
* --current-dir <p> Override local current-capture directory (default: .snapdrift/current)
|
|
45
46
|
* --diff-dir <p> Override local diff-output directory (default: .snapdrift/diff)
|
|
@@ -180,36 +181,33 @@ function openFile(filePath) {
|
|
|
180
181
|
export async function runCaptureCommand(opts) {
|
|
181
182
|
process.stdout.write(`Capturing baseline to ${opts.baselineDir} ...\n`);
|
|
182
183
|
const { config } = await loadSnapdriftConfig(opts.configPath);
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
const providerName = config.provider ?? 'local';
|
|
185
|
+
const provider = createProvider(providerName, config);
|
|
186
|
+
|
|
187
|
+
// onUnavailable: warn-and-skip — the user asked not to fail the build when
|
|
188
|
+
// Snap is down. `diff` and `baseline` both honour this; `capture` used to
|
|
189
|
+
// let SnapSkipError reach bin/snapdrift.mjs, which exits 1. See #108.
|
|
190
|
+
const captured = await captureWithPolicy({
|
|
191
|
+
provider,
|
|
192
|
+
providerName,
|
|
193
|
+
config,
|
|
194
|
+
captureOptions: {
|
|
186
195
|
configPath: opts.configPath,
|
|
187
196
|
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
188
197
|
outDir: opts.baselineDir,
|
|
189
|
-
purpose: '
|
|
190
|
-
}
|
|
191
|
-
process.stdout.write(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
process.stdout.write('Capture skipped (Snap unavailable, warn-and-skip mode).\n');
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
if (error instanceof SnapFallbackError) {
|
|
201
|
-
process.stdout.write('Falling back to local provider for capture...\n');
|
|
202
|
-
const localProvider = createProvider('local');
|
|
203
|
-
const { selectedRouteIds } = await localProvider.capture({
|
|
204
|
-
configPath: opts.configPath,
|
|
205
|
-
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
206
|
-
outDir: opts.baselineDir
|
|
207
|
-
});
|
|
208
|
-
process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
throw error;
|
|
198
|
+
purpose: 'capture'
|
|
199
|
+
},
|
|
200
|
+
onSkip: () => process.stdout.write('Capture skipped (Snap unavailable, warn-and-skip mode).\n'),
|
|
201
|
+
onFallback: () => process.stdout.write('Falling back to local provider for capture...\n')
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (captured.outcome === 'skipped') {
|
|
205
|
+
return;
|
|
212
206
|
}
|
|
207
|
+
|
|
208
|
+
process.stdout.write(
|
|
209
|
+
`Captured ${captured.result.selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`
|
|
210
|
+
);
|
|
213
211
|
}
|
|
214
212
|
|
|
215
213
|
/**
|
|
@@ -240,79 +238,65 @@ export async function runBaselineCommand(opts) {
|
|
|
240
238
|
: `Capturing baseline to ${opts.baselineDir} ...\n`
|
|
241
239
|
);
|
|
242
240
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
241
|
+
/** @type {import('../types/visual-diff-types').ProviderCaptureOptions} */
|
|
242
|
+
const captureOptions = {
|
|
243
|
+
configPath: opts.configPath,
|
|
244
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
245
|
+
outDir: opts.baselineDir,
|
|
246
|
+
purpose: 'baseline'
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const onFallback = () => process.stdout.write('Falling back to local provider for capture...\n');
|
|
250
|
+
|
|
251
|
+
// Snap can go down during *either* phase — the capture submission or the
|
|
252
|
+
// publish that follows it — and the user's `onUnavailable` choice must hold
|
|
253
|
+
// for both, otherwise `warn-and-skip` still exits 1 and `fallback-local`
|
|
254
|
+
// still leaves them with no baseline.
|
|
255
|
+
const captured = await captureWithPolicy({
|
|
256
|
+
provider,
|
|
257
|
+
providerName,
|
|
258
|
+
config,
|
|
259
|
+
captureOptions,
|
|
260
|
+
onSkip: () => process.stdout.write('Baseline skipped (Snap unavailable, warn-and-skip mode).\n'),
|
|
261
|
+
onFallback
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (captured.outcome === 'skipped') {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (captured.providerName !== 'snap') {
|
|
269
|
+
const suffix = providerName === 'snap' ? ' (local fallback — nothing published to Snap)' : '';
|
|
252
270
|
process.stdout.write(
|
|
253
|
-
`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}
|
|
271
|
+
`Captured ${captured.result.selectedRouteIds.length} route(s) to ${opts.baselineDir}${suffix}\n`
|
|
254
272
|
);
|
|
273
|
+
return;
|
|
255
274
|
}
|
|
256
275
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
* the user's choice must hold for both, otherwise `warn-and-skip` still exits 1
|
|
261
|
-
* and `fallback-local` still leaves them with no baseline.
|
|
262
|
-
*
|
|
263
|
-
* @param {unknown} error
|
|
264
|
-
* @param {string} skipMessage
|
|
265
|
-
* @returns {Promise<boolean>} true when the error was absorbed
|
|
266
|
-
*/
|
|
267
|
-
async function handleUnavailable(error, skipMessage) {
|
|
268
|
-
if (error instanceof SnapSkipError) {
|
|
269
|
-
process.stdout.write(skipMessage);
|
|
270
|
-
return true;
|
|
271
|
-
}
|
|
272
|
-
if (error instanceof SnapFallbackError) {
|
|
273
|
-
await captureLocalFallback();
|
|
274
|
-
return true;
|
|
275
|
-
}
|
|
276
|
-
return false;
|
|
277
|
-
}
|
|
276
|
+
process.stdout.write(
|
|
277
|
+
`Captured ${captured.result.selectedRouteIds.length} route(s); waiting for Snap to finish rendering ...\n`
|
|
278
|
+
);
|
|
278
279
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
});
|
|
288
|
-
} catch (error) {
|
|
289
|
-
if (await handleUnavailable(error, 'Baseline skipped (Snap unavailable, warn-and-skip mode).\n')) {
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
throw error;
|
|
293
|
-
}
|
|
280
|
+
const published = await publishBaselineWithPolicy({
|
|
281
|
+
provider,
|
|
282
|
+
publishOptions: { resultsPath: captured.result.resultsPath },
|
|
283
|
+
captureOptions,
|
|
284
|
+
onSkip: () =>
|
|
285
|
+
process.stdout.write('Baseline publish skipped (Snap unavailable, warn-and-skip mode).\n'),
|
|
286
|
+
onFallback
|
|
287
|
+
});
|
|
294
288
|
|
|
295
|
-
if (
|
|
296
|
-
process.stdout.write(`Captured ${captured.selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
|
|
289
|
+
if (published.outcome === 'skipped') {
|
|
297
290
|
return;
|
|
298
291
|
}
|
|
299
292
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
} catch (error) {
|
|
306
|
-
if (
|
|
307
|
-
await handleUnavailable(
|
|
308
|
-
error,
|
|
309
|
-
'Baseline publish skipped (Snap unavailable, warn-and-skip mode).\n'
|
|
310
|
-
)
|
|
311
|
-
) {
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
throw error;
|
|
293
|
+
if (published.outcome === 'fell-back') {
|
|
294
|
+
process.stdout.write(
|
|
295
|
+
`Captured ${published.recapture?.selectedRouteIds.length ?? 0} route(s) to ${opts.baselineDir} (local fallback — nothing published to Snap)\n`
|
|
296
|
+
);
|
|
297
|
+
return;
|
|
315
298
|
}
|
|
299
|
+
|
|
316
300
|
process.stdout.write('Snap baseline published successfully.\n');
|
|
317
301
|
}
|
|
318
302
|
|
|
@@ -326,31 +310,32 @@ export async function runBaselineCommand(opts) {
|
|
|
326
310
|
export async function runDiffCommand(opts) {
|
|
327
311
|
process.stdout.write(`Capturing current screenshots to ${opts.currentDir} ...\n`);
|
|
328
312
|
const { config } = await loadSnapdriftConfig(opts.configPath);
|
|
329
|
-
const
|
|
313
|
+
const providerName = config.provider ?? 'local';
|
|
314
|
+
const provider = createProvider(providerName, config);
|
|
330
315
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
throw error;
|
|
351
|
-
}
|
|
316
|
+
/** @type {import('../types/visual-diff-types').ProviderCaptureOptions} */
|
|
317
|
+
const captureOptions = {
|
|
318
|
+
configPath: opts.configPath,
|
|
319
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
320
|
+
outDir: opts.currentDir
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const captured = await captureWithPolicy({
|
|
324
|
+
provider,
|
|
325
|
+
providerName,
|
|
326
|
+
config,
|
|
327
|
+
captureOptions,
|
|
328
|
+
onSkip: () =>
|
|
329
|
+
process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n'),
|
|
330
|
+
onFallback: () => process.stdout.write('Falling back to local provider for capture...\n')
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
if (captured.outcome === 'skipped') {
|
|
334
|
+
return;
|
|
352
335
|
}
|
|
353
336
|
|
|
337
|
+
const currentCapture = captured.result;
|
|
338
|
+
|
|
354
339
|
process.stdout.write('Comparing against baseline ...\n');
|
|
355
340
|
|
|
356
341
|
const baselineResultsPath = path.join(opts.baselineDir, path.basename(config.resultsFile));
|
|
@@ -361,10 +346,12 @@ export async function runDiffCommand(opts) {
|
|
|
361
346
|
const markdownPath = path.join(opts.diffDir, 'summary.md');
|
|
362
347
|
const htmlPath = path.join(opts.diffDir, 'report.html');
|
|
363
348
|
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
349
|
+
const diffed = await diffWithPolicy({
|
|
350
|
+
provider: captured.providerName === 'local' ? createProvider('local') : provider,
|
|
351
|
+
providerName: captured.providerName,
|
|
352
|
+
localScreenshots: captured.localScreenshots,
|
|
353
|
+
captureOptions,
|
|
354
|
+
diffOptions: {
|
|
368
355
|
configPath: opts.configPath,
|
|
369
356
|
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
370
357
|
baselineResultsPath,
|
|
@@ -373,20 +360,24 @@ export async function runDiffCommand(opts) {
|
|
|
373
360
|
currentManifestPath: currentCapture.manifestPath,
|
|
374
361
|
baselineRunDir: opts.baselineDir,
|
|
375
362
|
currentRunDir: currentCapture.screenshotsRoot
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
363
|
+
},
|
|
364
|
+
onSkip: () =>
|
|
365
|
+
process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n'),
|
|
366
|
+
onFallback: () => process.stdout.write('Falling back to local provider for the diff...\n'),
|
|
367
|
+
onRecapture: () =>
|
|
368
|
+
process.stdout.write('Recapturing locally so the local diff has screenshots to compare...\n')
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (diffed.outcome !== 'diffed') {
|
|
372
|
+
return;
|
|
383
373
|
}
|
|
384
374
|
|
|
385
|
-
const { summary, markdown } =
|
|
375
|
+
const { summary, markdown } = diffed.result;
|
|
376
|
+
const currentRunDir = diffed.recapture?.screenshotsRoot ?? currentCapture.screenshotsRoot;
|
|
386
377
|
|
|
387
378
|
const html = await generateHtmlReport(summary, {
|
|
388
379
|
baselineRunDir: opts.baselineDir,
|
|
389
|
-
currentRunDir
|
|
380
|
+
currentRunDir
|
|
390
381
|
});
|
|
391
382
|
|
|
392
383
|
await Promise.all([
|
|
@@ -485,4 +476,4 @@ export async function main(argv) {
|
|
|
485
476
|
);
|
|
486
477
|
process.exitCode = 1;
|
|
487
478
|
}
|
|
488
|
-
}
|
|
479
|
+
}
|