snapdrift 0.4.0 → 0.5.1

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
@@ -41,7 +41,9 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
41
41
  | `snap.projectId` | `string` | Snap project ID or `"auto"` (default: `"auto"`, derives from `GITHUB_REPOSITORY`) |
42
42
  | `snap.onUnavailable` | `string` | Behavior when Snap is unreachable: `"fail"` (default), `"warn-and-skip"`, or `"fallback-local"` |
43
43
 
44
- ### Example
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
+
46
+ ### Example (local provider)
45
47
 
46
48
  ```json
47
49
  {
@@ -62,6 +64,33 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
62
64
  }
63
65
  ```
64
66
 
67
+ ### Example (Snap provider)
68
+
69
+ ```json
70
+ {
71
+ "baselineArtifactName": "ui-foundation-snapdrift-baseline",
72
+ "workingDirectory": ".",
73
+ "baseUrl": "http://127.0.0.1:8080",
74
+ "resultsFile": "qa-artifacts/snapdrift/baseline/current/results.json",
75
+ "manifestFile": "qa-artifacts/snapdrift/baseline/current/manifest.json",
76
+ "screenshotsRoot": "qa-artifacts/snapdrift/baseline/current",
77
+ "routes": [
78
+ { "id": "root-index-desktop", "path": "/", "viewport": "desktop" },
79
+ { "id": "root-index-mobile", "path": "/", "viewport": "mobile" }
80
+ ],
81
+ "diff": {
82
+ "threshold": 0.01,
83
+ "mode": "report-only"
84
+ },
85
+ "provider": "snap",
86
+ "snap": {
87
+ "apiKeyEnv": "SNAP_API_KEY",
88
+ "projectId": "auto",
89
+ "onUnavailable": "fail"
90
+ }
91
+ }
92
+ ```
93
+
65
94
  ## Baseline artifact
66
95
 
67
96
  The published baseline bundle contains:
@@ -224,7 +253,7 @@ All three directories can be overridden with `--baseline-dir`, `--current-dir`,
224
253
 
225
254
  ### migrate-baselines
226
255
 
227
- Migrate baselines between local storage and Snap.
256
+ Migrate baselines between local storage and Snap. Both directions require a `snap` block in `snapdrift.json` (or `--to local` for the `snap → local` direction, since the export call still talks to Snap first).
228
257
 
229
258
  **Upload local baselines to Snap:**
230
259
 
@@ -232,10 +261,10 @@ Migrate baselines between local storage and Snap.
232
261
  snapdrift migrate-baselines --to snap [--config <path>] [--baseline-dir <dir>]
233
262
  ```
234
263
 
235
- - Reads local manifest + baseline images from the baseline directory.
264
+ - Reads `results.json`, `manifest.json`, and `screenshots/*.png` from the local baseline directory.
236
265
  - Uploads as the initial accepted baseline on Snap via `POST /v1/visual/projects/:id/baselines`.
237
- - Idempotent: skips upload if a baseline already exists with the matching commit SHA.
238
- - Requires snapdrift.json with `provider: "snap"` (or snap config).
266
+ - Idempotent: if a baseline already exists for the same commit SHA (derived from `GITHUB_SHA` or `git rev-parse HEAD`), the upload is skipped.
267
+ - Screenshots are base64-encoded in the request body; very large suites may want to migrate per-route.
239
268
 
240
269
  **Download Snap baselines to local:**
241
270
 
@@ -244,9 +273,11 @@ snapdrift migrate-baselines --to local --from snap [--accept-cross-engine] [--co
244
273
  ```
245
274
 
246
275
  - Downloads baselines from Snap's export endpoint.
247
- - Writes to the local baseline directory.
248
- - Without `--accept-cross-engine`: hard-errors if the exported baselines were captured by a different engine.
249
- - With `--accept-cross-engine`: overrides the engine name to `snapdrift-local` in the imported manifest (visual differences may occur).
276
+ - Writes `results.json`, `manifest.json`, and `screenshots/*.png` to the local baseline directory.
277
+ - Writes `.migration-metadata.json` next to the baseline recording `source`, `migratedAt`, and the engine that produced the export.
278
+ - Without `--accept-cross-engine`: hard-errors if the exported manifest's engine name is not `snapdrift-local`.
279
+ - With `--accept-cross-engine`: overrides the engine name to `snapdrift-local` in the imported manifest (visual differences may occur when the captures came from a different engine).
280
+ - Requires the Snap export endpoint to be available; if it is not yet wired up, the command fails with an actionable message instead of producing a partial baseline.
250
281
 
251
282
  ### init
252
283
 
@@ -256,10 +287,93 @@ snapdrift migrate-baselines --to local --from snap [--accept-cross-engine] [--co
256
287
  snapdrift init --from-snap-action <workflow-yaml-path>
257
288
  ```
258
289
 
259
- - Reads an existing Snap `github-action/` workflow YAML.
260
- - Translates Snap action inputs into `.github/snapdrift.json`.
261
- - Emits `.github/MIGRATION_NOTES.md` listing warnings and deferred decisions.
262
- - Idempotent: refuses to overwrite an existing `snapdrift.json`.
290
+ - Reads an existing `snap/github-action` workflow YAML.
291
+ - Locates the step that uses `snap/github-action` (or any action whose `uses:` matches `snap/.../action`).
292
+ - Translates known inputs into `.github/snapdrift.json`. Sets `provider: "snap"` (and the `snap` block) only when the source workflow declared `snap-api-key-env` or `snap-project-id`; workflows without Snap-specific inputs produce a local-provider config.
293
+ - Emits `.github/MIGRATION_NOTES.md` grouped by severity (warnings first, then notes).
294
+ - Idempotent against `snapdrift.json`: refuses to overwrite an existing file.
295
+
296
+ The codemod maps fields one-to-one when it can. The full mapping is:
297
+
298
+ | Snap action input | snapdrift config |
299
+ |:------------------|:-----------------|
300
+ | `threshold` / `diff-threshold` | `diff.threshold` |
301
+ | `fail-on-changes` | `diff.mode: "fail-on-changes"` |
302
+ | `fail-on-incomplete` | `diff.mode: "fail-on-incomplete"` |
303
+ | (no enforcement flag) | `diff.mode: "report-only"` |
304
+ | `snap-api-key-env` | `snap.apiKeyEnv` |
305
+ | `snap-api-url` | `snap.apiUrl` |
306
+ | `snap-project-id` | `snap.projectId` |
307
+ | `format` | dropped (warning — SnapDrift is PNG-only) |
308
+ | `baseline_tag` | dropped (warning — SnapDrift uses commit-based baselines) |
309
+ | `routes` / page list | left empty (warning — fill in `routes[]` manually) |
310
+ | `baseUrl` | placeholder `http://localhost:3000` (warning — update to your real app) |
311
+
312
+ Two files are written: `.github/snapdrift.json` and `.github/MIGRATION_NOTES.md`.
313
+
314
+ ## Snap provider
315
+
316
+ 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.
317
+
318
+ ### Local-capture hybrid
319
+
320
+ 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.
321
+
322
+ The hybrid path is transparent to the user: the same provider factory picks it, and the output `results.json` carries `provider: "snap"` plus `captureMode: "local-upload"` so downstream consumers can tell which path produced the run. The action's `Install Playwright Chromium` step is gated on this hybrid, so a `provider: "snap"` config with a remote `baseUrl` will not download Playwright Chromium.
323
+
324
+ ### API contract
325
+
326
+ SnapDrift uses a small, stable subset of the Snap API:
327
+
328
+ | Endpoint | Used by |
329
+ |:---------|:--------|
330
+ | `POST /v1/visual/projects/:id/runs` | Create a run; client passes `baseUrl`, `trigger`, optional `baselineId` and `branch` |
331
+ | `POST /v1/visual/runs/:run_id/captures` | Submit each route as a capture; client passes `routeId`, `routePath`, `viewportDescriptorJson` |
332
+ | `POST /v1/visual/captures/:capture_id/local-result` | Hybrid path: upload the locally rendered PNG and its dimensions |
333
+ | `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) |
334
+ | `GET /v1/visual/projects/:id/baselines/latest` | Resolve the latest accepted baseline for diff runs |
335
+ | `GET /v1/visual/runs/:run_id` | Poll a run until it reaches a terminal state (`pass`, `fail`, `error`, or `new`) |
336
+
337
+ `new` is a terminal state for baseline runs: a fresh capture with no baseline to diff against settles to `new` and `publishBaseline` harvests its object keys. Treating `new` as in-flight (the natural reading) would have the client poll forever.
338
+
339
+ ### Retry and fallback
340
+
341
+ The Snap HTTP client classifies responses and applies the following rules:
342
+
343
+ - **2xx** — success, return the parsed body.
344
+ - **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.
345
+ - **5xx** — retryable up to 3 attempts with exponential backoff (`1 s` → `2 s` → `4 s`, capped at `30 s` total). If the final attempt still returns 5xx, the client falls through to the `onUnavailable` handler.
346
+ - **Network errors** — same retry/backoff behavior as 5xx. After exhaustion, falls through to the `onUnavailable` handler.
347
+
348
+ `onUnavailable` is consulted once retries are exhausted:
349
+
350
+ - `"fail"` (default) — throw the underlying error to fail the action.
351
+ - `"warn-and-skip"` — log a warning, throw `SnapSkipError`. The wrapper actions catch this and write a skipped summary, exiting 0.
352
+ - `"fallback-local"` — log a warning, throw `SnapFallbackError`. The wrapper actions catch this and continue the pipeline with `LocalProvider`.
353
+
354
+ ### `isLocalBaseUrl` detection
355
+
356
+ `isLocalBaseUrl(baseUrl)` returns `true` when the URL hostname is:
357
+
358
+ - `localhost` or any subdomain ending in `.localhost`
359
+ - `0.0.0.0`
360
+ - `::1`
361
+ - Any IPv4 address in `127.0.0.0/8` (for example `127.0.0.1`)
362
+
363
+ The function is intentionally permissive about bracketed IPv6 (`[::1]`) and case (`LocalHost`). It returns `false` on any URL that fails to parse.
364
+
365
+ ### Error classes
366
+
367
+ All four error classes are exported from `lib/provider.mjs` and `lib/snap-provider.mjs`. They are the contract for "Snap cannot proceed" — wrapper actions and CLI commands handle them as a group.
368
+
369
+ | Class | Thrown when | Typical handler |
370
+ |:------|:------------|:----------------|
371
+ | `SnapApiError` | A 4xx response was received, or a 5xx/network error was retried to exhaustion. Carries `status` and `path` properties. | Surface the message; do not retry. |
372
+ | `SnapUnavailableError` | A network error or 5xx was retried to exhaustion (used internally; usually re-wrapped as `SnapApiError`). | Treat as a temporary outage. |
373
+ | `SnapFallbackError` | `onUnavailable: "fallback-local"` is set and Snap could not be reached. | Catch and switch to `LocalProvider` for the rest of the pipeline. |
374
+ | `SnapSkipError` | `onUnavailable: "warn-and-skip"` is set and Snap could not be reached. | Catch and exit cleanly with a skipped summary. |
375
+
376
+ 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.
263
377
 
264
378
  ## Primary entrypoints
265
379
 
@@ -46,13 +46,13 @@ SnapDrift owns route selection, capture, comparison, skipped-report generation,
46
46
 
47
47
  ```yaml
48
48
  - name: SnapDrift Baseline
49
- uses: ranacseruet/snapdrift/actions/baseline@v0.2.1
49
+ uses: ranacseruet/snapdrift/actions/baseline@v0.4.0
50
50
  with:
51
51
  repo-config-path: .github/snapdrift.json
52
52
  artifact-retention-days: '30'
53
53
  ```
54
54
 
55
- If you want only part of the route set, pass `route-ids`.
55
+ 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
56
 
57
57
  ## Step 3: Run the pull request report
58
58
 
@@ -70,7 +70,7 @@ Then add SnapDrift after the app is running:
70
70
 
71
71
  ```yaml
72
72
  - name: SnapDrift Report
73
- uses: ranacseruet/snapdrift/actions/pr-diff@v0.2.1
73
+ uses: ranacseruet/snapdrift/actions/pr-diff@v0.4.0
74
74
  with:
75
75
  github-token: ${{ secrets.GITHUB_TOKEN }}
76
76
  repo-config-path: .github/snapdrift.json
@@ -111,7 +111,7 @@ jobs:
111
111
  done
112
112
 
113
113
  - name: SnapDrift Report
114
- uses: ranacseruet/snapdrift/actions/pr-diff@v0.2.1
114
+ uses: ranacseruet/snapdrift/actions/pr-diff@v0.4.0
115
115
  with:
116
116
  github-token: ${{ secrets.GITHUB_TOKEN }}
117
117
  repo-config-path: .github/snapdrift.json
@@ -150,7 +150,7 @@ jobs:
150
150
  done
151
151
 
152
152
  - name: SnapDrift Report
153
- uses: ranacseruet/snapdrift/actions/pr-diff@v0.2.1
153
+ uses: ranacseruet/snapdrift/actions/pr-diff@v0.4.0
154
154
  with:
155
155
  github-token: ${{ secrets.GITHUB_TOKEN }}
156
156
  repo-config-path: .github/snapdrift.json
@@ -169,33 +169,89 @@ jobs:
169
169
 
170
170
  ## Useful overrides
171
171
 
172
- | Input | Purpose |
173
- |-------|---------|
174
- | `pr-number` | Explicit PR number override |
175
- | `route-ids` | Bypass scope detection and capture named routes |
176
- | `force-run` | Run the full route set |
177
- | `baseline-repository` | Read baselines from another repo |
178
- | `baseline-workflow-id` | Override the baseline workflow id |
179
- | `baseline-branch` | Override the baseline branch |
180
- | `comment-on-pr` | Set to `false` to suppress the PR report |
181
- | `max-changed-rows` | Max changed-route rows shown before truncation (default: `20`) |
182
- | `max-error-rows` | Max error rows shown before truncation (default: `10`) |
172
+ | Input | Default | Purpose |
173
+ |-------|---------|---------|
174
+ | `repo-config-path` | `.github/snapdrift.json` | Override the config file path |
175
+ | `pr-number` | inferred from event | Explicit PR number override |
176
+ | `route-ids` | scope-derived | Bypass scope detection and capture named routes (comma-separated) |
177
+ | `force-run` | `false` | Run the full route set instead of using changed-file scope |
178
+ | `force-run-reason` | `forced` | Reason string emitted to scope outputs when `force-run: true` |
179
+ | `baseline-repository` | current repo | Read baselines from another repo (`owner/name`) |
180
+ | `baseline-workflow-id` | `ci.yml` | Override the baseline workflow id or filename |
181
+ | `baseline-branch` | `main` | Override the branch the baseline was published from |
182
+ | `artifact-retention-days` | `30` | Retention period for the uploaded diff artifact |
183
+ | `comment-on-pr` | `true` | Set to `false` to suppress the PR report upsert |
184
+ | `max-changed-rows` | `20` | Max changed-route rows shown in the PR comment before truncation |
185
+ | `max-error-rows` | `10` | Max error rows shown in the PR comment before truncation |
186
+
187
+ 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.
188
+
189
+ ## Hosted Snap provider
190
+
191
+ By default SnapDrift writes baselines and reports to the runner filesystem (`provider: "local"`). For a hosted backend with a shared baseline store and a run-detail dashboard, configure `provider: "snap"` in `snapdrift.json`:
192
+
193
+ ```json
194
+ {
195
+ "provider": "snap",
196
+ "snap": {
197
+ "apiKeyEnv": "SNAP_API_KEY",
198
+ "projectId": "auto",
199
+ "onUnavailable": "fail"
200
+ }
201
+ }
202
+ ```
203
+
204
+ - `apiKeyEnv` — name of an environment variable holding the Snap API key. Mutually exclusive with `apiKey`. Exactly one of `apiKeyEnv` or `apiKey` is required.
205
+ - `apiKey` — inline API key with `${VAR}` interpolation (e.g. `"${SNAP_API_KEY}"`). Mutually exclusive with `apiKeyEnv`.
206
+ - `projectId` — Snap project id, or `"auto"` to derive it from `GITHUB_REPOSITORY` (default).
207
+ - `onUnavailable` — behavior when the Snap API cannot be reached:
208
+ - `"fail"` (default) — fail the action with a non-retryable error.
209
+ - `"warn-and-skip"` — log a warning, write a skipped summary, and exit 0.
210
+ - `"fallback-local"` — log a warning and run the rest of the pipeline with `LocalProvider`.
211
+
212
+ 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.
213
+
214
+ 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.
215
+
216
+ With the Snap provider, the PR comment includes a **"View in dashboard →"** link that points to `${apiUrl}/dashboard/visual/${projectId}/runs/${runId}`. The local provider omits the link.
217
+
218
+ Migrating an existing local repo to the Snap provider is a one-shot CLI command:
219
+
220
+ ```bash
221
+ snapdrift migrate-baselines --to snap
222
+ ```
223
+
224
+ Conversely, downloading a Snap baseline back to a local directory (useful for reproducible local debugging) is:
225
+
226
+ ```bash
227
+ snapdrift migrate-baselines --to local --from snap
228
+ ```
229
+
230
+ The reverse direction requires the Snap export endpoint to be available; if it isn't, the command fails with an actionable error. See the [Contracts reference](contracts.md#migration-commands) for the full flag set, the engine-compatibility check, and the metadata file written for idempotency.
231
+
232
+ If you're adopting SnapDrift as a replacement for the upstream `snap/github-action`, the `init` codemod translates the workflow YAML and emits a `MIGRATION_NOTES.md` with everything that couldn't be auto-translated:
233
+
234
+ ```bash
235
+ snapdrift init --from-snap-action .github/workflows/snap.yml
236
+ ```
183
237
 
184
238
  ## Low-level actions
185
239
 
186
- For custom orchestration:
240
+ The `pr-diff` wrapper composes the following low-level steps. They're still available for custom orchestration but most consumers don't need to reach for them:
187
241
 
188
- - `actions/capture`
189
- - `actions/compare`
190
- - `actions/scope`
191
- - `actions/enforce`
192
- - `actions/comment`
193
- - `actions/resolve-baseline`
194
- - `actions/stage`
242
+ - `actions/capture` — capture routes and emit `results.json` + `manifest.json`
243
+ - `actions/compare` — diff current capture against a baseline
244
+ - `actions/scope` — decide whether to run and which routes to select from changed files
245
+ - `actions/resolve-baseline` — find and download the latest successful baseline artifact
246
+ - `actions/stage` — assemble the baseline or diff bundle for upload
247
+ - `actions/enforce` — evaluate the summary against `diff.mode` and fail when required
248
+ - `actions/comment` — upsert a PR comment from a summary (provider-aware)
249
+
250
+ The two wrapper actions that orchestrate the full pipeline are `actions/baseline` (publish) and `actions/pr-diff` (drift detection). They are the primary integration points.
195
251
 
196
252
  ## Local development
197
253
 
198
- The `snapdrift` CLI lets you run captures and diffs locally against a running app. Use it to validate UI changes on your machine before pushing — the same capture and comparison engine is used in both contexts.
254
+ The `snapdrift` CLI lets you run captures, diffs, migrations, and config initialization locally against a running app. Use it to validate UI changes on your machine before pushing — the same capture and comparison engine is used in both contexts.
199
255
 
200
256
  ```bash
201
257
  # Establish a baseline before making UI changes
@@ -203,6 +259,12 @@ snapdrift capture
203
259
 
204
260
  # After making changes, compare and open the HTML report
205
261
  snapdrift diff --open
262
+
263
+ # Migrate an established local baseline to the hosted Snap backend
264
+ snapdrift migrate-baselines --to snap
265
+
266
+ # Translate a snap/github-action workflow into snapdrift.json
267
+ snapdrift init --from-snap-action .github/workflows/snap.yml
206
268
  ```
207
269
 
208
270
  The CLI reads the same `.github/snapdrift.json` config used by the Actions workflow. Local outputs land in `.snapdrift/` by default and can be overridden with `--baseline-dir`, `--current-dir`, and `--diff-dir`.
@@ -226,13 +288,19 @@ If you don't want to republish the baseline on every push, the template includes
226
288
  ## Troubleshooting
227
289
 
228
290
  **"No non-expired SnapDrift baseline artifact was found"**
229
- The baseline workflow has not completed successfully on `main`, or the artifact expired.
291
+ 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.
230
292
 
231
293
  **403 when posting the PR report**
232
294
  Grant `issues: write` and `pull-requests: write` to the job.
233
295
 
296
+ **Snap API request keeps failing with 4xx**
297
+ The Snap client never retries 4xx and never falls back, even when `onUnavailable` is set. Inspect the response body — the most common cause is a project-id mismatch between `GITHUB_REPOSITORY` (auto-derived) and the project's id on Snap.
298
+
234
299
  **Screenshots have different dimensions**
235
300
  SnapDrift reports this as a dimension shift and skips pixel comparison for that route. Refresh the baseline after the change lands.
236
301
 
237
302
  **A route appears in `errors[]`**
238
303
  Capture failed before comparison. Confirm the app is fully ready and reachable before SnapDrift runs.
304
+
305
+ **Playwright install runs even though `provider: "snap"`**
306
+ 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.
package/docs/local-cli.md CHANGED
@@ -80,6 +80,75 @@ snapdrift diff --baseline-dir snapshots/baseline --diff-dir snapshots/diff
80
80
 
81
81
  ---
82
82
 
83
+ ### `snapdrift migrate-baselines`
84
+
85
+ Move baselines between the local filesystem and the hosted Snap backend. Requires a config with `provider: "snap"` (or a `snap` block) for the direction being targeted.
86
+
87
+ **Upload local baselines to Snap:**
88
+
89
+ ```bash
90
+ snapdrift migrate-baselines --to snap
91
+ ```
92
+
93
+ Reads `results.json`, `manifest.json`, and `screenshots/*.png` from the local baseline directory, then POSTs them to Snap as the initial accepted baseline for the current commit. Idempotent: if a baseline already exists for the same commit SHA the upload is skipped.
94
+
95
+ **Download Snap baselines to a local directory:**
96
+
97
+ ```bash
98
+ snapdrift migrate-baselines --to local --from snap
99
+ ```
100
+
101
+ Snap must expose its export endpoint for this direction to succeed. If it doesn't, the command fails with an actionable error. By default the engine name on the exported manifest must be `snapdrift-local`; pass `--accept-cross-engine` to override the engine name in the imported manifest (visual differences may occur).
102
+
103
+ **Options**
104
+
105
+ | Flag | Default | Description |
106
+ |:-----|:--------|:------------|
107
+ | `--to <snap\|local>` | — | Migration target (required) |
108
+ | `--from <snap>` | — | Migration source (required when `--to local`) |
109
+ | `--accept-cross-engine` | off | Override the engine-name check when importing from Snap |
110
+ | `--config <path>` | `.github/snapdrift.json` | Path to the config file |
111
+ | `--baseline-dir <path>` | `.snapdrift/baseline` | Local baseline directory to read from or write to |
112
+
113
+ **Side effects**
114
+
115
+ A `.migration-metadata.json` file is written next to the local baseline after a successful download — it records the source engine, the migration timestamp, and a stable id used for idempotency on re-runs.
116
+
117
+ ---
118
+
119
+ ### `snapdrift init`
120
+
121
+ Translate an existing `snap/github-action` workflow into `snapdrift.json` with `provider: "snap"`. Use this when migrating an existing consumer repo from the upstream Snap action to SnapDrift.
122
+
123
+ ```
124
+ snapdrift init --from-snap-action <workflow-yaml-path>
125
+ ```
126
+
127
+ Reads the workflow YAML, locates the step that uses the Snap action, and translates its inputs:
128
+
129
+ | Snap action input | snapdrift config |
130
+ |:------------------|:-----------------|
131
+ | `threshold` / `diff-threshold` | `diff.threshold` |
132
+ | `fail-on-changes` | `diff.mode` (`"fail-on-changes"`) |
133
+ | `fail-on-incomplete` | `diff.mode` (`"fail-on-incomplete"`) |
134
+ | (none of the above) | `diff.mode` (`"report-only"`) |
135
+ | `snap-api-key-env` | `snap.apiKeyEnv` |
136
+ | `snap-api-url` | `snap.apiUrl` |
137
+ | `snap-project-id` | `snap.projectId` |
138
+ | `format` | (warning — PNG-only, format dropped) |
139
+ | `baseline_tag` | (warning — commit-based only) |
140
+ | `routes` / page list | (warning — fill in `routes[]` manually) |
141
+ | `baseUrl` | placeholder `http://localhost:3000` (warning — update to your real app) |
142
+
143
+ The codemod writes two files:
144
+
145
+ - `.github/snapdrift.json` — the translated config
146
+ - `.github/MIGRATION_NOTES.md` — every warning and deferred decision, grouped by severity
147
+
148
+ The command is **idempotent against `snapdrift.json`**: if the file already exists, the command refuses to overwrite and tells you to remove it manually.
149
+
150
+ ---
151
+
83
152
  ## Typical local workflow
84
153
 
85
154
  ```bash
@@ -131,8 +200,8 @@ Add `.snapdrift/` to your `.gitignore` to keep local run output out of version c
131
200
 
132
201
  | Code | Meaning |
133
202
  |:-----|:--------|
134
- | `0` | Clean — no drift above threshold, or `diff.mode` is `report-only` |
135
- | `1` | Drift enforced — `diff.mode` caused the run to fail (see [Drift modes](../README.md#drift-modes)) |
203
+ | `0` | Clean — no drift above threshold, `diff.mode` is `report-only`, the diff was intentionally skipped (e.g. `SnapSkipError` from `onUnavailable: "warn-and-skip"`), or the command was a `capture` / `migrate-baselines` / `init` that completed |
204
+ | `1` | Drift enforced — `diff.mode` caused the run to fail, a required command argument was missing, or the `--to local` engine-name check failed (see [Drift modes](../README.md#drift-modes)) |
136
205
 
137
206
  Enforcement follows the same `diff.mode` rules as the GitHub Actions workflow. Set `"mode": "report-only"` during local development to always get a report without a failing exit code.
138
207
 
@@ -140,7 +209,9 @@ Enforcement follows the same `diff.mode` rules as the GitHub Actions workflow. S
140
209
 
141
210
  ## Console output
142
211
 
143
- `snapdrift diff` prints a summary to stdout:
212
+ `snapdrift diff` prints a summary to stdout.
213
+
214
+ **Clean run:**
144
215
 
145
216
  ```
146
217
  Capturing current screenshots to .snapdrift/current ...
@@ -151,7 +222,7 @@ Comparing against baseline ...
151
222
  Matched: 2
152
223
  ```
153
224
 
154
- When drift is detected:
225
+ **Drift detected** (one or more routes changed):
155
226
 
156
227
  ```
157
228
  🟡 SnapDrift — Drift detected
@@ -165,6 +236,36 @@ When drift is detected:
165
236
  Report: .snapdrift/diff/report.html
166
237
  ```
167
238
 
239
+ **Incomplete run** (errors, missing captures, or dimension shifts):
240
+
241
+ ```
242
+ ❌ SnapDrift — incomplete
243
+ Routes: 4
244
+ Matched: 2
245
+ Changed: 0
246
+ Missing: 1
247
+ Errors: 1
248
+ Dim diff: 1
249
+
250
+ Report: .snapdrift/diff/report.html
251
+ ```
252
+
253
+ The `Missing`, `Errors`, and `Dim diff` lines only print when their count is greater than zero. `summary.status` is the unstyled value from the run (`clean`, `changes-detected`, `incomplete`, or `skipped`).
254
+
255
+ The `report.html` path is printed whenever the status is anything other than `clean`; pass `--open` to also launch it in your default browser.
256
+
257
+ ---
258
+
259
+ ## Environment variables
260
+
261
+ | Variable | Applies to | Description |
262
+ |:---------|:-----------|:------------|
263
+ | `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. |
264
+ | `SNAPDRIFT_CONFIG_PATH` | `capture`, `diff`, `migrate-baselines` | Override the config file path. Equivalent to `--config`. |
265
+ | `SNAPDRIFT_ROUTE_IDS` | `capture`, `diff` | Comma-separated route ids to scope to. Equivalent to `--routes`. |
266
+
267
+ `SNAPDRIFT_CAPTURE_CONCURRENCY` is the same env var consumed by the GitHub Actions wrapper; tweak it the same way for both environments.
268
+
168
269
  ---
169
270
 
170
271
  ## Tips
@@ -172,3 +273,5 @@ Report: .snapdrift/diff/report.html
172
273
  - **Partial runs**: use `--routes` to capture or compare only the routes you are actively changing.
173
274
  - **Multiple baselines**: use `--baseline-dir` to maintain separate baselines per branch or feature.
174
275
  - **CI parity**: the CLI uses the same capture and comparison engine as the GitHub Actions workflow, so results are comparable.
276
+ - **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.)
277
+ - **Provider-aware behavior**: with `provider: "snap"`, the `capture` and `diff` commands will hit the Snap API. The `--open` flag still opens the locally generated `report.html`; the Snap dashboard link lives in the PR comment, not the local output.
@@ -70,7 +70,7 @@ jobs:
70
70
  # ────────────────────────────────────────────────────────────────────────
71
71
 
72
72
  - name: Publish SnapDrift baseline
73
- uses: ranacseruet/snapdrift/actions/baseline@v0.2.1
73
+ uses: ranacseruet/snapdrift/actions/baseline@v0.4.0
74
74
  with:
75
75
  repo-config-path: .github/snapdrift.json
76
76
  artifact-retention-days: '30'
package/lib/cli.mjs CHANGED
@@ -185,7 +185,8 @@ export async function runCaptureCommand(opts) {
185
185
  const { selectedRouteIds } = await provider.capture({
186
186
  configPath: opts.configPath,
187
187
  routeIds: opts.routes.length > 0 ? opts.routes : undefined,
188
- outDir: opts.baselineDir
188
+ outDir: opts.baselineDir,
189
+ purpose: 'baseline'
189
190
  });
190
191
  process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
191
192
  } catch (error) {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import yaml from 'js-yaml';
5
+ import * as yaml from 'js-yaml';
6
6
 
7
7
  /** @typedef {import('../types/visual-diff-types').InitWarning} InitWarning */
8
8
 
package/lib/provider.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { runBaselineCapture, generateDriftReport, stageArtifacts } from '@snapdrift/adapter-fs';
4
4
  import { buildReportCommentBody } from '@snapdrift/adapter-report-md';
5
- import { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError } from './snap-provider.mjs';
5
+ import { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError, isLocalBaseUrl } from './snap-provider.mjs';
6
6
 
7
7
  /** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
8
8
  /** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
@@ -103,4 +103,4 @@ export function createProvider(providerName, config) {
103
103
  return factory(config);
104
104
  }
105
105
 
106
- export { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError };
106
+ export { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError, isLocalBaseUrl };