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/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 rendered captures (primary path) or upload a pre-built local baseline (migration path) |
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`) catch `SnapSkipError` and `SnapFallbackError` themselves. Custom orchestrations that call `provider.capture()` or `provider.diff()` directly should do the same.
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.1
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
- If you want only part of the route set, pass `route-ids`. 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'`.
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.1
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.1` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.8.1` |
90
- | `ranacseruet/snapdrift@v0.8.1` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.8.1` |
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.1
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.1
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
- ### Label-gated refreshes
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"`** — captures each route through Snap, waits for the hosted renders to finish, then publishes a baseline referencing the stored objects. `snapdrift capture` alone does **not** do this: it submits the run and stops, leaving the project with no baseline.
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.** The published baseline records the branch and commit it was cut from. Inside GitHub Actions these come from `GITHUB_REF_NAME` / `GITHUB_HEAD_REF` and `GITHUB_SHA`; run locally, they are read from git (`rev-parse --abbrev-ref HEAD` and `rev-parse HEAD`). Outside a git repository they fall back to `main` and `unknown`, so run the command from your checkout if you want the baseline attributed correctly.
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 to capture |
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
- # Optional: gate on a label to avoid refreshing on every push.
39
- # Requires a preceding job that checks for the label on the most recent
40
- # merged PR and sets an output. Replace "snapdrift:refresh-baseline" with
41
- # your label name.
42
- # if: needs.check-label.outputs.refresh == 'true'
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
- uses: ranacseruet/snapdrift@v0.8.1
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 { SnapFallbackError, SnapSkipError } from './snap-provider.mjs';
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 to run
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 provider = createProvider(config.provider ?? 'local', config);
184
- try {
185
- const { selectedRouteIds } = await provider.capture({
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: 'baseline'
190
- });
191
- process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
192
- } catch (error) {
193
- // onUnavailable: warn-and-skip — the user asked not to fail the build when
194
- // Snap is down. `diff` and `baseline` both honour this; `capture` used to
195
- // let SnapSkipError reach bin/snapdrift.mjs, which exits 1. See #108.
196
- if (error instanceof SnapSkipError) {
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
- /** Capture locally so `fallback-local` still leaves the user with a baseline. */
244
- async function captureLocalFallback() {
245
- process.stdout.write('Falling back to local provider for capture...\n');
246
- const localProvider = createProvider('local');
247
- const { selectedRouteIds } = await localProvider.capture({
248
- configPath: opts.configPath,
249
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
250
- outDir: opts.baselineDir
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} (local fallback — nothing published to Snap)\n`
271
+ `Captured ${captured.result.selectedRouteIds.length} route(s) to ${opts.baselineDir}${suffix}\n`
254
272
  );
273
+ return;
255
274
  }
256
275
 
257
- /**
258
- * Apply the configured `onUnavailable` behaviour. Snap can go down during
259
- * *either* phase — the capture submission or the publish that follows it — and
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
- /** @type {{ resultsPath?: string, selectedRouteIds: string[] }} */
280
- let captured;
281
- try {
282
- captured = await provider.capture({
283
- configPath: opts.configPath,
284
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
285
- outDir: opts.baselineDir,
286
- purpose: 'baseline'
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 (providerName !== 'snap') {
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
- process.stdout.write(
301
- `Captured ${captured.selectedRouteIds.length} route(s); waiting for Snap to finish rendering ...\n`
302
- );
303
- try {
304
- await provider.publishBaseline({ resultsPath: captured.resultsPath });
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 provider = createProvider(config.provider ?? 'local', config);
313
+ const providerName = config.provider ?? 'local';
314
+ const provider = createProvider(providerName, config);
330
315
 
331
- let currentCapture;
332
- let usedLocalFallback = false;
333
- try {
334
- currentCapture = await provider.capture({
335
- configPath: opts.configPath,
336
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
337
- outDir: opts.currentDir
338
- });
339
- } catch (error) {
340
- if (error instanceof SnapFallbackError) {
341
- process.stdout.write('Falling back to local provider for capture...\n');
342
- const localProvider = createProvider('local');
343
- currentCapture = await localProvider.capture({
344
- configPath: opts.configPath,
345
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
346
- outDir: opts.currentDir
347
- });
348
- usedLocalFallback = true;
349
- } else {
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 diffProvider = usedLocalFallback ? createProvider('local') : provider;
365
- let diffResult;
366
- try {
367
- diffResult = await diffProvider.diff({
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
- } catch (error) {
378
- if (error instanceof SnapSkipError) {
379
- process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n');
380
- return;
381
- }
382
- throw error;
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 } = diffResult;
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: currentCapture.screenshotsRoot
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
+ }