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 +20 -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/provider.mjs +2 -2
- package/lib/snap-provider.mjs +334 -35
- package/package.json +2 -2
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
-
|
|
296
|
+
- id: config
|
|
293
297
|
if: steps.scope.outputs.should_run == 'true'
|
|
294
298
|
shell: bash
|
|
295
|
-
|
|
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
|
|
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 {
|
|
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
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
package/actions/scope/action.yml
CHANGED
|
@@ -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
|
-
|
|
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
|
|