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 +38 -0
- package/README.md +17 -7
- package/actions/baseline/action.yml +83 -13
- package/actions/comment/action.yml +31 -3
- package/actions/pr-diff/action.yml +110 -23
- package/actions/scope/action.yml +9 -0
- package/docs/contracts.md +237 -1
- 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 +161 -26
- package/lib/init-from-action.mjs +244 -0
- package/lib/migrate-baselines.mjs +199 -0
- package/lib/provider.mjs +106 -0
- package/lib/snap-provider.mjs +1033 -0
- package/lib/snapdrift-config.mjs +3 -1
- package/package.json +9 -5
- package/types/visual-diff-types.d.ts +65 -0
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
|
|
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 }}
|
|
@@ -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
|
|
67
|
-
const
|
|
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 =
|
|
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
|
-
-
|
|
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 }}
|
|
@@ -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
|
|
535
|
-
const
|
|
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 =
|
|
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'
|
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:
|