snapdrift 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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) {
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 };