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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.5.0 - 2026-06-08
6
+
7
+ ### Features
8
+
9
+ - **Snap local-capture hybrid** — when `provider: "snap"` and `baseUrl` resolves to a local address (localhost, `127.0.0.0/8`, `::1`, `0.0.0.0`), Playwright now runs on the runner to render the page, and the resulting PNG is uploaded to Snap via `POST /v1/visual/captures/:id/local-result`. This makes it possible to point SnapDrift at a server only the runner can reach — the common case — without exposing the server to Snap's render worker. Captures are flagged with `localCapture: true` so Snap keeps them out of the render queue, and the output `results.json` records `provider: "snap"` plus `captureMode: "local-upload"`.
10
+
11
+ ### Fixes
12
+
13
+ - **Snap baseline run no longer diffs against an old baseline** — `publishBaseline` now sets `skipBaselineResolution: true` on baseline-publish runs and omits `baselineId` from run creation. Previously, publishing a fresh baseline would diff it against the prior baseline, which failed on dimension mismatch and burned an upload.
14
+ - **`captureProfileJson` no longer sent on Snap run creation** — the render environment is owned by Snap's render worker, not the client, and the server dereferences nested fields (browser, platform, fonts, viewport) that SnapDrift's minimal profile doesn't populate, which crashed it with a 500. The render worker already defaults locale/timezone/viewport when no profile is present, so omitting it is behaviour-preserving.
15
+ - **Run-creation includes `branch` and `baselineId`** — `branch` is sent from `GITHUB_HEAD_REF` (PR source) or `GITHUB_REF_NAME` (push); `baselineId` is the latest accepted baseline for the project, so the backend can diff every capture. Without `baselineId` Snap short-circuits to `diffed` with no comparison data.
16
+ - **4xx never falls back, even with `onUnavailable` set** — clarifies the contract: `onUnavailable` is consulted only after 5xx/network retries are exhausted, never on a 4xx (which is a configuration error, not an outage).
17
+ - **`actions/pr-diff` no longer installs Playwright when `provider: "snap"` and the run won't need it** — gated on the same `isLocalBaseUrl` check the `baseline` action uses.
18
+
19
+ ### Infrastructure
20
+
21
+ - **`@snapdrift/manifest` v1.2.0** — added the `ProviderCaptureOptions.purpose` type contract for baseline-publish versus diff captures.
22
+
3
23
  ## 0.4.0 - 2026-05-26
4
24
 
5
25
  ### Features
package/README.md CHANGED
@@ -8,15 +8,18 @@
8
8
 
9
9
  SnapDrift captures full-page application frames, compares them against a known baseline, and reports drift directly in GitHub Actions.
10
10
 
11
- SnapDrift is ready to integrate from public GitHub releases. Workflow examples below use the first public tag for readability; security-conscious consumers can pin the resolved commit SHA instead.
11
+ SnapDrift is ready to integrate from public GitHub releases. Workflow examples below use the latest public tag for readability; security-conscious consumers can pin the resolved commit SHA instead.
12
12
 
13
13
  ## What SnapDrift handles
14
14
 
15
15
  - Baseline capture on `main`
16
16
  - Pull request drift detection against the latest successful baseline
17
17
  - Route scoping from changed files
18
- - PR report upserts
18
+ - PR report upserts (with optional Snap dashboard link)
19
19
  - Drift enforcement through `diff.mode`
20
+ - Pluggable backends: local filesystem (default) or hosted Snap (`provider: "snap"`)
21
+ - One-shot baseline migration between backends
22
+ - Codemod that translates an existing Snap `github-action` workflow to `snapdrift.json`
20
23
 
21
24
  You keep ownership of checkout, build, startup, readiness, and teardown. SnapDrift takes over once the app is reachable.
22
25
 
@@ -44,7 +47,7 @@ You keep ownership of checkout, build, startup, readiness, and teardown. SnapDri
44
47
 
45
48
  ```yaml
46
49
  - name: SnapDrift Baseline
47
- uses: ranacseruet/snapdrift/actions/baseline@v0.2.1
50
+ uses: ranacseruet/snapdrift/actions/baseline@v0.4.0
48
51
  with:
49
52
  repo-config-path: .github/snapdrift.json
50
53
  ```
@@ -53,17 +56,17 @@ You keep ownership of checkout, build, startup, readiness, and teardown. SnapDri
53
56
 
54
57
  ```yaml
55
58
  - name: SnapDrift Report
56
- uses: ranacseruet/snapdrift/actions/pr-diff@v0.2.1
59
+ uses: ranacseruet/snapdrift/actions/pr-diff@v0.4.0
57
60
  with:
58
61
  github-token: ${{ secrets.GITHUB_TOKEN }}
59
62
  repo-config-path: .github/snapdrift.json
60
63
  ```
61
64
 
62
- That is the full integration. See the [Integration Guide](docs/integration-guide.md) for workflow examples, permissions, compatibility notes, and advanced overrides.
65
+ That is the full integration. See the [Integration Guide](docs/integration-guide.md) for workflow examples, permissions, compatibility notes, advanced overrides, and the hosted Snap backend (`provider: "snap"`).
63
66
 
64
67
  ## Local CLI
65
68
 
66
- SnapDrift ships a `snapdrift` CLI for running captures and diffs locally against a running app — no GitHub Actions required. Use it during development to validate UI changes before pushing.
69
+ SnapDrift ships a `snapdrift` CLI for running captures, diffs, migrations, and config initialization locally against a running app — no GitHub Actions required. Use it during development to validate UI changes before pushing.
67
70
 
68
71
  ```bash
69
72
  # Capture a baseline
@@ -71,6 +74,12 @@ snapdrift capture
71
74
 
72
75
  # Compare against it after making UI changes
73
76
  snapdrift diff --open
77
+
78
+ # Migrate local baselines to the hosted Snap backend
79
+ snapdrift migrate-baselines --to snap
80
+
81
+ # Translate an existing Snap github-action workflow into snapdrift.json
82
+ snapdrift init --from-snap-action .github/workflows/snap.yml
74
83
  ```
75
84
 
76
85
  See the [Local CLI guide](docs/local-cli.md) for installation, all flags, directory layout, and examples.
@@ -88,11 +97,12 @@ Start with `report-only` while baselines settle. Move to `fail-on-changes` or st
88
97
 
89
98
  ## Current constraints
90
99
 
91
- - Ubuntu runners only
100
+ - Ubuntu runners only (local CLI works on any OS Node 22+ supports)
92
101
  - Full-page capture only
93
102
  - Viewport presets: `desktop` (1440×900) and `mobile` (390×844), or custom `{ "width": number, "height": number }`
94
103
  - One global `diff.threshold`
95
104
  - Dimension shifts are reported separately from pixel drift
105
+ - Local provider writes artifacts to the runner filesystem; for a hosted backend with a dashboard and a shared baseline store, configure `provider: "snap"` (see the [Integration Guide](docs/integration-guide.md#hosted-snap-provider))
96
106
 
97
107
  ## Docs
98
108
 
@@ -51,6 +51,10 @@ runs:
51
51
  with:
52
52
  node-version: '22'
53
53
 
54
+ - name: Install action dependencies
55
+ shell: bash
56
+ run: npm ci --prefix "$ACTION_ROOT"
57
+
54
58
  - id: config
55
59
  shell: bash
56
60
  env:
@@ -62,17 +66,22 @@ runs:
62
66
  import { pathToFileURL } from 'node:url';
63
67
 
64
68
  const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
69
+ const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
65
70
  const { loadSnapdriftConfig } = await import(pathToFileURL(modulePath));
71
+ const { isLocalBaseUrl } = await import(pathToFileURL(providerModulePath));
66
72
  const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
67
73
 
68
- await fs.appendFile(process.env.GITHUB_OUTPUT, `artifact_name=${config.baselineArtifactName}\n`);
74
+ const outputs = [
75
+ `artifact_name=${config.baselineArtifactName}`,
76
+ `provider=${config.provider || 'local'}`,
77
+ `on_unavailable=${config.snap?.onUnavailable || 'fail'}`,
78
+ `snap_local_capture=${(config.provider || 'local') === 'snap' && isLocalBaseUrl(config.baseUrl) ? 'true' : 'false'}`
79
+ ].join('\n') + '\n';
80
+ await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
69
81
  EOF
70
82
 
71
- - name: Install shared action dependencies
72
- shell: bash
73
- run: npm ci --prefix "$ACTION_ROOT"
74
-
75
83
  - name: Install Playwright Chromium
84
+ if: steps.config.outputs.provider != 'snap' || steps.config.outputs.on_unavailable == 'fallback-local' || steps.config.outputs.snap_local_capture == 'true'
76
85
  shell: bash
77
86
  run: |
78
87
  "$ACTION_ROOT/node_modules/.bin/playwright" install --with-deps chromium
@@ -88,17 +97,51 @@ runs:
88
97
  import path from 'node:path';
89
98
  import { pathToFileURL } from 'node:url';
90
99
 
91
- const captureModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/capture-routes.mjs');
100
+ const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
92
101
  const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
93
- const { runBaselineCapture } = await import(pathToFileURL(captureModulePath));
94
- const { splitCommaList } = await import(pathToFileURL(configModulePath));
102
+ const { createProvider, SnapSkipError, SnapFallbackError } = await import(pathToFileURL(providerModulePath));
103
+ const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
95
104
 
96
- const result = await runBaselineCapture({
97
- configPath: process.env.REPO_CONFIG_PATH || undefined,
98
- routeIds: splitCommaList(process.env.ROUTE_IDS)
99
- });
105
+ const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
106
+ const provider = createProvider(config.provider || 'local', config);
107
+
108
+ let result;
109
+ try {
110
+ result = await provider.capture({
111
+ configPath: process.env.REPO_CONFIG_PATH || undefined,
112
+ routeIds: splitCommaList(process.env.ROUTE_IDS),
113
+ purpose: 'baseline'
114
+ });
115
+ } catch (err) {
116
+ if (err instanceof SnapSkipError) {
117
+ process.stderr.write(`[SnapDrift] Snap unavailable — skipping baseline capture (warn-and-skip).\n`);
118
+ await fs.appendFile(process.env.GITHUB_OUTPUT, `provider=skipped\n`);
119
+ process.exit(0);
120
+ }
121
+ if (err instanceof SnapFallbackError) {
122
+ process.stderr.write(`[SnapDrift] Snap unavailable — falling back to local provider.\n`);
123
+ const { createProvider: cp2 } = await import(pathToFileURL(providerModulePath));
124
+ result = await cp2('local', config).capture({
125
+ configPath: process.env.REPO_CONFIG_PATH || undefined,
126
+ routeIds: splitCommaList(process.env.ROUTE_IDS)
127
+ });
128
+ // Report as local so publish is skipped and stage/upload run instead
129
+ const outputs = [
130
+ `provider=local`,
131
+ `results_file=${result.resultsPath}`,
132
+ `manifest_file=${result.manifestPath}`,
133
+ `screenshots_root=${result.screenshotsRoot}`,
134
+ `selected_route_ids=${result.selectedRouteIds.join(',')}`
135
+ ].join('\n') + '\n';
136
+ await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
137
+ process.exit(0);
138
+ } else {
139
+ throw err;
140
+ }
141
+ }
100
142
 
101
143
  const outputs = [
144
+ `provider=${config.provider || 'local'}`,
102
145
  `results_file=${result.resultsPath}`,
103
146
  `manifest_file=${result.manifestPath}`,
104
147
  `screenshots_root=${result.screenshotsRoot}`,
@@ -108,7 +151,34 @@ runs:
108
151
  await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
109
152
  EOF
110
153
 
154
+ - id: publish
155
+ if: steps.capture.outputs.provider == 'snap'
156
+ shell: bash
157
+ env:
158
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
159
+ RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
160
+ run: |
161
+ node --input-type=module <<'EOF'
162
+ import path from 'node:path';
163
+ import { pathToFileURL } from 'node:url';
164
+
165
+ const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
166
+ const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
167
+ const { createProvider } = await import(pathToFileURL(providerModulePath));
168
+ const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
169
+
170
+ const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
171
+ const provider = createProvider('snap', config);
172
+
173
+ await provider.publishBaseline({
174
+ resultsPath: process.env.RESULTS_PATH || undefined
175
+ });
176
+
177
+ process.stdout.write('[SnapDrift] Snap baseline published successfully.\n');
178
+ EOF
179
+
111
180
  - id: stage
181
+ if: steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped'
112
182
  shell: bash
113
183
  env:
114
184
  RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
@@ -133,7 +203,7 @@ runs:
133
203
  await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
134
204
  EOF
135
205
 
136
- - if: inputs.upload-artifact == 'true'
206
+ - if: inputs.upload-artifact == 'true' && steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped'
137
207
  uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
138
208
  with:
139
209
  name: ${{ steps.config.outputs.artifact_name }}
@@ -40,6 +40,15 @@ runs:
40
40
  shell: bash
41
41
  run: echo "ACTION_ROOT=$(cd "$GITHUB_ACTION_PATH/../.." && pwd)" >> "$GITHUB_ENV"
42
42
 
43
+ - name: Set up Node.js
44
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
45
+ with:
46
+ node-version: '22'
47
+
48
+ - name: Install action dependencies
49
+ shell: bash
50
+ run: npm ci --prefix "$ACTION_ROOT"
51
+
43
52
  - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
44
53
  env:
45
54
  VISUAL_DIFF_SUMMARY_PATH: ${{ inputs.summary-path }}
@@ -95,6 +95,10 @@ runs:
95
95
  with:
96
96
  node-version: '22'
97
97
 
98
+ - name: Install action dependencies
99
+ shell: bash
100
+ run: npm ci --prefix "$ACTION_ROOT"
101
+
98
102
  - id: context
99
103
  uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
100
104
  env:
@@ -289,13 +293,31 @@ runs:
289
293
  echo "screenshots_dir=$(dirname "$MANIFEST_FILE")/screenshots"
290
294
  } >> "$GITHUB_OUTPUT"
291
295
 
292
- - name: Install shared action dependencies
296
+ - id: config
293
297
  if: steps.scope.outputs.should_run == 'true'
294
298
  shell: bash
295
- run: npm ci --prefix "$ACTION_ROOT"
299
+ env:
300
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
301
+ run: |
302
+ node --input-type=module <<'EOF'
303
+ import fs from 'node:fs/promises';
304
+ import path from 'node:path';
305
+ import { pathToFileURL } from 'node:url';
306
+ const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
307
+ const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
308
+ const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
309
+ const { isLocalBaseUrl } = await import(pathToFileURL(providerModulePath));
310
+ const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
311
+ const outputs = [
312
+ `provider=${config.provider || 'local'}`,
313
+ `on_unavailable=${config.snap?.onUnavailable || 'fail'}`,
314
+ `snap_local_capture=${(config.provider || 'local') === 'snap' && isLocalBaseUrl(config.baseUrl) ? 'true' : 'false'}`
315
+ ].join('\n') + '\n';
316
+ await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
317
+ EOF
296
318
 
297
319
  - name: Install Playwright Chromium
298
- if: steps.scope.outputs.should_run == 'true'
320
+ if: steps.scope.outputs.should_run == 'true' && (steps.config.outputs.provider != 'snap' || steps.config.outputs.on_unavailable == 'fallback-local' || steps.config.outputs.snap_local_capture == 'true')
299
321
  shell: bash
300
322
  run: |
301
323
  "$ACTION_ROOT/node_modules/.bin/playwright" install --with-deps chromium
@@ -312,17 +334,40 @@ runs:
312
334
  import path from 'node:path';
313
335
  import { pathToFileURL } from 'node:url';
314
336
 
315
- const captureModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/capture-routes.mjs');
337
+ const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
316
338
  const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
317
- const { runBaselineCapture } = await import(pathToFileURL(captureModulePath));
318
- const { splitCommaList } = await import(pathToFileURL(configModulePath));
339
+ const { createProvider, SnapSkipError, SnapFallbackError } = await import(pathToFileURL(providerModulePath));
340
+ const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
319
341
 
320
- const result = await runBaselineCapture({
321
- configPath: process.env.REPO_CONFIG_PATH || undefined,
322
- routeIds: splitCommaList(process.env.ROUTE_IDS)
323
- });
342
+ const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
343
+ const provider = createProvider(config.provider || 'local', config);
344
+
345
+ let result;
346
+ try {
347
+ result = await provider.capture({
348
+ configPath: process.env.REPO_CONFIG_PATH || undefined,
349
+ routeIds: splitCommaList(process.env.ROUTE_IDS)
350
+ });
351
+ } catch (err) {
352
+ if (err instanceof SnapSkipError) {
353
+ process.stderr.write(`[SnapDrift] Snap unavailable — skipping visual regression (warn-and-skip).\n`);
354
+ await fs.appendFile(process.env.GITHUB_OUTPUT, `provider=skipped\n`);
355
+ process.exit(0);
356
+ }
357
+ if (err instanceof SnapFallbackError) {
358
+ process.stderr.write(`[SnapDrift] Snap unavailable — falling back to local provider.\n`);
359
+ const { createProvider: cp2 } = await import(pathToFileURL(providerModulePath));
360
+ result = await cp2('local', config).capture({
361
+ configPath: process.env.REPO_CONFIG_PATH || undefined,
362
+ routeIds: splitCommaList(process.env.ROUTE_IDS)
363
+ });
364
+ } else {
365
+ throw err;
366
+ }
367
+ }
324
368
 
325
369
  const outputs = [
370
+ `provider=${config.provider || 'local'}`,
326
371
  `results_file=${result.resultsPath}`,
327
372
  `manifest_file=${result.manifestPath}`,
328
373
  `screenshots_root=${result.screenshotsRoot}`,
@@ -333,7 +378,7 @@ runs:
333
378
  EOF
334
379
 
335
380
  - id: compare
336
- if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found == 'true'
381
+ if: steps.scope.outputs.should_run == 'true' && steps.capture.outputs.provider != 'skipped' && (steps.baseline.outputs.found == 'true' || steps.capture.outputs.provider == 'snap')
337
382
  shell: bash
338
383
  env:
339
384
  REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
@@ -343,22 +388,27 @@ runs:
343
388
  BASELINE_RUN_DIR: ${{ steps.baseline_paths.outputs.run_dir }}
344
389
  BASELINE_ARTIFACT_NAME: ${{ steps.baseline.outputs.artifact_name }}
345
390
  BASELINE_SOURCE_SHA: ${{ steps.baseline.outputs.head_sha }}
391
+ CURRENT_RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
392
+ CURRENT_MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
346
393
  run: |
347
394
  node --input-type=module <<'EOF'
348
395
  import fs from 'node:fs/promises';
349
396
  import path from 'node:path';
350
397
  import { pathToFileURL } from 'node:url';
351
398
 
352
- const compareModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/compare-results.mjs');
399
+ const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
353
400
  const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
354
- const { runDriftCheckCli } = await import(pathToFileURL(compareModulePath));
355
- const { splitCommaList } = await import(pathToFileURL(configModulePath));
401
+ const { createProvider, SnapSkipError, SnapFallbackError } = await import(pathToFileURL(providerModulePath));
402
+ const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
403
+
404
+ const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
405
+ const provider = createProvider(config.provider || 'local', config);
356
406
 
357
407
  const resolvedOutDir = path.resolve('qa-artifacts/snapdrift/drift/current');
358
408
  const resolvedSummaryPath = path.join(resolvedOutDir, 'summary.json');
359
409
  const resolvedMarkdownPath = path.join(resolvedOutDir, 'summary.md');
360
410
 
361
- await runDriftCheckCli({
411
+ const diffOptions = {
362
412
  configPath: process.env.REPO_CONFIG_PATH || undefined,
363
413
  baselineResultsPath: process.env.BASELINE_RESULTS_PATH || undefined,
364
414
  baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
@@ -366,10 +416,34 @@ runs:
366
416
  routeIds: splitCommaList(process.env.ROUTE_IDS),
367
417
  baselineArtifactName: process.env.BASELINE_ARTIFACT_NAME || undefined,
368
418
  baselineSourceSha: process.env.BASELINE_SOURCE_SHA || undefined,
369
- enforceOutcome: false
370
- });
419
+ currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
420
+ currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined
421
+ };
422
+
423
+ let diffResult;
424
+ try {
425
+ diffResult = await provider.diff(diffOptions);
426
+ } catch (err) {
427
+ if (err instanceof SnapSkipError) {
428
+ process.stderr.write(`[SnapDrift] Snap unavailable during diff — skipping (warn-and-skip).\n`);
429
+ process.exit(0);
430
+ }
431
+ if (err instanceof SnapFallbackError) {
432
+ process.stderr.write(`[SnapDrift] Snap unavailable during diff — falling back to local provider.\n`);
433
+ const { createProvider: cp2 } = await import(pathToFileURL(providerModulePath));
434
+ diffResult = await cp2('local', config).diff(diffOptions);
435
+ } else {
436
+ throw err;
437
+ }
438
+ }
439
+ const { summary, markdown } = diffResult;
440
+
441
+ await fs.mkdir(resolvedOutDir, { recursive: true });
442
+ await Promise.all([
443
+ fs.writeFile(resolvedSummaryPath, JSON.stringify(summary, null, 2)),
444
+ fs.writeFile(resolvedMarkdownPath, markdown)
445
+ ]);
371
446
 
372
- const summary = JSON.parse(await fs.readFile(resolvedSummaryPath, 'utf8'));
373
447
  const outputs = [
374
448
  `summary_path=${resolvedSummaryPath}`,
375
449
  `markdown_path=${resolvedMarkdownPath}`,
@@ -408,7 +482,7 @@ runs:
408
482
  EOF
409
483
 
410
484
  - id: skipped_baseline
411
- if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found != 'true'
485
+ if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found != 'true' && steps.capture.outputs.provider != 'snap'
412
486
  shell: bash
413
487
  env:
414
488
  MESSAGE: ${{ steps.baseline.outputs.message }}
@@ -596,7 +670,7 @@ runs:
596
670
  });
597
671
  }
598
672
 
599
- - if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found == 'true'
673
+ - if: steps.scope.outputs.should_run == 'true' && steps.capture.outputs.provider != 'skipped' && (steps.baseline.outputs.found == 'true' || steps.capture.outputs.provider == 'snap')
600
674
  shell: bash
601
675
  run: |
602
676
  node --input-type=module <<'EOF'
@@ -40,6 +40,15 @@ runs:
40
40
  shell: bash
41
41
  run: echo "ACTION_ROOT=$(cd "$GITHUB_ACTION_PATH/../.." && pwd)" >> "$GITHUB_ENV"
42
42
 
43
+ - name: Set up Node.js
44
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
45
+ with:
46
+ node-version: '22'
47
+
48
+ - name: Install action dependencies
49
+ shell: bash
50
+ run: npm ci --prefix "$ACTION_ROOT"
51
+
43
52
  - id: scope
44
53
  uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
45
54
  env:
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