snapdrift 0.3.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,43 @@
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
+
23
+ ## 0.4.0 - 2026-05-26
24
+
25
+ ### Features
26
+
27
+ - **VisualProvider interface** — new `VisualProvider` interface (`capture`, `diff`, `publishBaseline`, `fetchLatestBaseline`, `buildCommentBody`) with `LocalProvider` and `SnapProvider` implementations. Config `provider` field (`"local"` | `"snap"`) selects the backend; defaults to `"local"` for zero-config backward compatibility.
28
+ - **SnapProvider** — calls Snap's `/v1/visual/*` API for capture, diff, and baseline management. Retries on 5xx with exponential backoff (3 attempts, 30 s cap). Honors `snap.onUnavailable` (`"fail"` | `"warn-and-skip"` | `"fallback-local"`). 4xx errors never retry or fall back.
29
+ - **Snap config** — new `snap` config block (`apiUrl`, `apiKeyEnv`/`apiKey`, `projectId`, `onUnavailable`) in `snapdrift.json`. Exactly one of `apiKeyEnv` or `apiKey` is required when `provider: "snap"`.
30
+ - **PR comment unification** — both providers now produce identical PR comment markdown via a shared `buildCommentBody(summary, meta?)` method. `SnapProvider` appends a "View in dashboard →" link when `dashboardUrl` is present; `LocalProvider` omits it. Comment steps route through the provider instead of calling `buildReportCommentBody` directly, with a defensive fallback to `LocalProvider` if config loading fails.
31
+ - **`migrate-baselines` command** — one-shot CLI command (`snapdrift migrate-baselines --to snap` / `--to local`) to upload local baselines to Snap or export Snap baselines back to a local directory. Supports `--accept-cross-engine` for engine-mismatch scenarios.
32
+ - **`init --from-snap-action` codemod** — translates a Snap `github-action` workflow YAML into `snapdrift.json` with `provider: "snap"`. Emits structured warnings for features with no direct equivalent (jpeg format, baseline tags, single-shot artifact upload).
33
+
34
+ ### Infrastructure
35
+
36
+ - **`@snapdrift/manifest` v1.1.0** — added `ProviderCommentMeta`, `dashboardUrl` on `VisualDiffSummary`, `buildCommentBody` on `VisualProvider`, `SnapConfig` re-export.
37
+ - **`@snapdrift/adapter-report-md` v1.1.0** — `buildReportCommentBody` now accepts `dashboardUrl` in meta; renders "View in dashboard →" link when present and valid.
38
+ - **Action contract tests** — `snapdrift-actions-contract` test suite now asserts that comment and pr-diff steps include `createProvider` and `buildCommentBody` calls.
39
+ - **Provider unification tests** — new `buildCommentBody — provider unification` test block verifying identical markdown output across providers, dashboard link inclusion/exclusion, and URL validation.
40
+
3
41
  ## 0.3.0 - 2026-05-26
4
42
 
5
43
  ### Infrastructure
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 }}
@@ -20,6 +20,10 @@ inputs:
20
20
  description: Artifact label to include in the comment.
21
21
  required: false
22
22
  default: ''
23
+ repo-config-path:
24
+ description: Path to the SnapDrift config file (needed for provider-aware comment rendering).
25
+ required: false
26
+ default: .github/snapdrift.json
23
27
  max-changed-rows:
24
28
  description: Maximum number of changed-route rows shown in the PR comment before truncation.
25
29
  required: false
@@ -36,12 +40,22 @@ runs:
36
40
  shell: bash
37
41
  run: echo "ACTION_ROOT=$(cd "$GITHUB_ACTION_PATH/../.." && pwd)" >> "$GITHUB_ENV"
38
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
+
39
52
  - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
40
53
  env:
41
54
  VISUAL_DIFF_SUMMARY_PATH: ${{ inputs.summary-path }}
42
55
  VISUAL_DIFF_RUN_URL: ${{ inputs.run-url }}
43
56
  VISUAL_DIFF_ARTIFACT_NAME: ${{ inputs.artifact-name }}
44
57
  INPUT_PR_NUMBER: ${{ inputs.pr-number }}
58
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
45
59
  MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
46
60
  MAX_ERROR_ROWS: ${{ inputs.max-error-rows }}
47
61
  with:
@@ -63,15 +77,29 @@ runs:
63
77
  return;
64
78
  }
65
79
 
66
- const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/pr-comment.mjs');
67
- const { buildReportCommentBody, PR_COMMENT_MARKERS } = await import(pathToFileURL(modulePath));
80
+ const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
81
+ const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
82
+ const prCommentModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/pr-comment.mjs');
83
+ const { createProvider } = await import(pathToFileURL(providerModulePath));
84
+ const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
85
+ const { PR_COMMENT_MARKERS } = await import(pathToFileURL(prCommentModulePath));
86
+
87
+ // Provider-loading block duplicated in actions/pr-diff/action.yml — keep in sync.
88
+ let provider;
89
+ try {
90
+ const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
91
+ provider = createProvider(config.provider || 'local', config);
92
+ } catch (err) {
93
+ core.warning(`Could not load SnapDrift config; falling back to LocalProvider. ${err.message}`);
94
+ provider = createProvider('local');
95
+ }
68
96
 
69
97
  const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
70
98
  const runUrl = process.env.VISUAL_DIFF_RUN_URL || `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
71
99
 
72
100
  const maxChangedRows = Number(process.env.MAX_CHANGED_ROWS) || 20;
73
101
  const maxErrorRows = Number(process.env.MAX_ERROR_ROWS) || 10;
74
- const body = buildReportCommentBody(summary, {
102
+ const body = provider.buildCommentBody(summary, {
75
103
  artifactName: process.env.VISUAL_DIFF_ARTIFACT_NAME || '',
76
104
  runUrl,
77
105
  maxChangedRows,
@@ -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 }}
@@ -498,6 +572,7 @@ runs:
498
572
  SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
499
573
  ARTIFACT_NAME: ${{ steps.context.outputs.artifact_name }}
500
574
  RUN_URL: ${{ steps.context.outputs.run_url }}
575
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
501
576
  MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
502
577
  MAX_ERROR_ROWS: ${{ inputs.max-error-rows }}
503
578
  with:
@@ -531,13 +606,25 @@ runs:
531
606
  return;
532
607
  }
533
608
 
534
- const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/pr-comment.mjs');
535
- const { buildReportCommentBody } = await import(pathToFileURL(modulePath));
609
+ const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
610
+ const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
611
+ const { createProvider } = await import(pathToFileURL(providerModulePath));
612
+ const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
613
+
614
+ // Provider-loading block duplicated in actions/comment/action.yml — keep in sync.
615
+ let provider;
616
+ try {
617
+ const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
618
+ provider = createProvider(config.provider || 'local', config);
619
+ } catch (err) {
620
+ core.warning(`Could not load SnapDrift config; falling back to LocalProvider. ${err.message}`);
621
+ provider = createProvider('local');
622
+ }
536
623
 
537
624
  const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
538
625
  const maxChangedRows = Number(process.env.MAX_CHANGED_ROWS) || 20;
539
626
  const maxErrorRows = Number(process.env.MAX_ERROR_ROWS) || 10;
540
- const body = buildReportCommentBody(summary, {
627
+ const body = provider.buildCommentBody(summary, {
541
628
  artifactName: process.env.ARTIFACT_NAME || '',
542
629
  runUrl: process.env.RUN_URL || '',
543
630
  maxChangedRows,
@@ -583,7 +670,7 @@ runs:
583
670
  });
584
671
  }
585
672
 
586
- - 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')
587
674
  shell: bash
588
675
  run: |
589
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: