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/CHANGELOG.md +31 -0
- package/README.md +17 -7
- package/actions/baseline/action.yml +83 -13
- package/actions/comment/action.yml +9 -0
- package/actions/pr-diff/action.yml +94 -20
- package/actions/scope/action.yml +9 -0
- package/docs/contracts.md +126 -12
- package/docs/integration-guide.md +94 -26
- package/docs/local-cli.md +107 -4
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +1 -1
- package/lib/cli.mjs +2 -1
- package/lib/init-from-action.mjs +1 -1
- package/lib/provider.mjs +2 -2
- package/lib/snap-provider.mjs +364 -35
- package/package.json +5 -5
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
|
-
|
|
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
|
|
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:
|
|
238
|
-
-
|
|
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
|
-
-
|
|
249
|
-
-
|
|
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
|
|
260
|
-
-
|
|
261
|
-
-
|
|
262
|
-
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
179
|
-
| `baseline-
|
|
180
|
-
| `
|
|
181
|
-
| `
|
|
182
|
-
| `
|
|
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
|
-
|
|
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/
|
|
192
|
-
- `actions/
|
|
193
|
-
- `actions/
|
|
194
|
-
- `actions/
|
|
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
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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) {
|
package/lib/init-from-action.mjs
CHANGED
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 };
|