snapdrift 0.8.0 → 0.8.2
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 +19 -0
- package/README.md +7 -4
- package/action.yml +9 -8
- package/actions/baseline/action.yml +68 -47
- package/actions/pr-diff/action.yml +119 -58
- package/docs/contracts.md +36 -3
- package/docs/integration-guide.md +12 -10
- package/docs/local-cli.md +6 -5
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +25 -13
- package/lib/cli.mjs +118 -127
- package/lib/outage-policy.mjs +245 -0
- package/lib/snap-provider.mjs +371 -44
- package/package.json +3 -1
- package/types/visual-diff-types.d.ts +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.2 - 2026-08-24
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
- **Hosted baseline publication is now complete and fail-closed** — a scoped `route-ids` / `--routes` baseline run could publish only the changed routes and replace a larger accepted baseline, causing every omitted route to appear missing on the next PR (i2Dev-com/snap#720). `provider: "snap"` baseline capture now requires every configured route (local baselines, non-publishing captures, and hosted PR diffs remain scopeable), records the exact configured/selected route and viewport set plus one resolved branch/commit and publication workflow identity/sequence, and publishes only when the source run contains a successful stored capture for every expected identity. Complete requests send `publicationMode: "complete"` and `sourceRunId`, reuse the run's `branch` / `prHeadSha` as publication `refBranch` / `refSha`, and derive the baseline id deterministically from the run id so retries are stable. GitHub Actions supplies publication metadata automatically; other CI systems may set the explicit publication metadata variables. Missing, failed, duplicate, unexpected, or stale captures abort without publishing.
|
|
8
|
+
- **Snap outage handling is consistent across the CLI and both wrapper actions** — `warn-and-skip` and `fallback-local` were re-implemented independently in `lib/cli.mjs`, `actions/pr-diff` and `actions/baseline`, and the copies had drifted. `lib/outage-policy.mjs` now holds the single implementation (`captureWithPolicy`, `diffWithPolicy`, `publishBaselineWithPolicy`) that all three call, and the following are fixed as a result (#125):
|
|
9
|
+
- A `fallback-local` capture in `actions/pr-diff` captured locally but still emitted `provider=snap`, so the compare step rebuilt `SnapProvider` and handed it local results carrying no run id. The steps now receive the *effective* provider.
|
|
10
|
+
- A `fallback-local` diff swapped only the diff provider. A capture Snap had rendered server-side has a manifest but no PNGs on the runner, so the local pixel engine produced a dimension-only result rather than a comparison. The routes are now recaptured locally first, and the recaptured artifacts are what get staged and uploaded. When no baseline artifact was resolved, the run reports `missing_main_baseline_artifact` instead of crashing the local diff on a missing results file.
|
|
11
|
+
- `warn-and-skip` exited the wrapper without writing the documented skipped summary, so the PR comment fell through to its "Capture Failed" body. Capture-time and diff-time skips now write a skipped `summary.json`/`summary.md` with reason `snap_unavailable`, expose the summary outputs, stage the report, and exit 0. `diff.mode` is not enforced against a skipped summary.
|
|
12
|
+
- `actions/baseline` applied no policy at all to `publishBaseline()`. `warn-and-skip` now exits 0 without an artifact, and `fallback-local` captures locally and stages/uploads that bundle, so the run still leaves a usable baseline.
|
|
13
|
+
- `snapdrift diff` let a capture-time `SnapSkipError` reach the CLI entry point, which exits 1 — the opposite of what `warn-and-skip` is configured to do.
|
|
14
|
+
- **`shouldFailDriftCheck` no longer throws on a skipped summary** — it assumed every summary carried the full diff shape, so the final branch dereferenced `summaryData.errors.length` on `undefined` for the `{ status, reason, message, selectedRoutes }` shape a skipped run writes. `actions/enforce` defaults `summary-path` to exactly where those summaries land, so anyone orchestrating with the low-level actions rather than `actions/pr-diff` got a raw `TypeError` instead of a clean pass whenever a run was skipped for scope, a missing baseline, or a Snap outage. A summary with `status: "skipped"` or no `diff.mode` now returns `false` before any mode branch, and both `shouldFailDriftCheck` and `determineDriftStatus` default their array and counter reads (#132).
|
|
15
|
+
|
|
16
|
+
## 0.8.1 - 2026-08-06
|
|
17
|
+
|
|
18
|
+
### Fixes
|
|
19
|
+
|
|
20
|
+
- **The root action can actually be published to the Marketplace** — its description was 130 characters, and GitHub rejects the listing at 125 or more, so ticking "Publish this Action to the GitHub Marketplace" on the `v0.8.0` release failed validation. Shortened to 115 characters, leading with "visual regression" so the listing matches what people search for. `npm run validate:actions` and `tests/marketplace-metadata.test.js` now enforce the limit against the root action; the sub-action descriptions are unaffected, since the rule only applies to the published entry point.
|
|
21
|
+
|
|
3
22
|
## 0.8.0 - 2026-08-06
|
|
4
23
|
|
|
5
24
|
### Features
|
package/README.md
CHANGED
|
@@ -48,17 +48,19 @@ You keep ownership of checkout, build, startup, readiness, and teardown. SnapDri
|
|
|
48
48
|
|
|
49
49
|
```yaml
|
|
50
50
|
- name: SnapDrift Baseline
|
|
51
|
-
uses: ranacseruet/snapdrift@v0.8.
|
|
51
|
+
uses: ranacseruet/snapdrift@v0.8.2
|
|
52
52
|
with:
|
|
53
53
|
mode: baseline
|
|
54
54
|
repo-config-path: .github/snapdrift.json
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
With `provider: "snap"`, baseline publication is a complete, fail-closed snapshot: it must run from the project's default branch and capture every configured route. Snap records the publication workflow identity plus a monotonic sequence so the API can reject stale overlapping publishers. GitHub Actions supplies these values automatically; other CI systems can provide `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`. Route scoping remains available for pull request diffs, non-publishing `snapdrift capture`, and local-provider baselines.
|
|
58
|
+
|
|
57
59
|
**3. Run SnapDrift on pull requests:**
|
|
58
60
|
|
|
59
61
|
```yaml
|
|
60
62
|
- name: SnapDrift Report
|
|
61
|
-
uses: ranacseruet/snapdrift@v0.8.
|
|
63
|
+
uses: ranacseruet/snapdrift@v0.8.2
|
|
62
64
|
with:
|
|
63
65
|
mode: pr-diff
|
|
64
66
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -71,10 +73,10 @@ The root action dispatches on `mode`. The same pipelines are also published as s
|
|
|
71
73
|
|
|
72
74
|
## Local CLI
|
|
73
75
|
|
|
74
|
-
SnapDrift ships a `snapdrift` CLI for running captures, diffs, migrations, and config initialization locally against a running app
|
|
76
|
+
SnapDrift ships a `snapdrift` CLI for running captures, diffs, migrations, and config initialization locally against a running app. Use it during development to validate UI changes before pushing. Canonical hosted baseline publication is the exception: `provider: "snap"` baselines require the project default branch plus explicit CI publication metadata; GitHub Actions provides that metadata automatically.
|
|
75
77
|
|
|
76
78
|
```bash
|
|
77
|
-
# Establish a
|
|
79
|
+
# Establish a local-provider baseline
|
|
78
80
|
snapdrift baseline
|
|
79
81
|
|
|
80
82
|
# Capture screenshots without publishing anything
|
|
@@ -111,6 +113,7 @@ Start with `report-only` while baselines settle. Move to `fail-on-changes` or st
|
|
|
111
113
|
- One global `diff.threshold`
|
|
112
114
|
- Dimension shifts are reported separately from pixel drift
|
|
113
115
|
- 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))
|
|
116
|
+
- Hosted Snap baselines publish only from the default branch and always include the complete configured route set
|
|
114
117
|
|
|
115
118
|
## Outgrew GitHub artifacts?
|
|
116
119
|
|
package/action.yml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name: SnapDrift
|
|
2
|
-
description:
|
|
2
|
+
description: Visual regression for Playwright apps — capture routes, diff against a baseline, and report drift on pull requests.
|
|
3
3
|
author: CodeSamplez
|
|
4
4
|
|
|
5
5
|
branding:
|
|
@@ -15,10 +15,11 @@ branding:
|
|
|
15
15
|
# action's own directory. `tests/marketplace-metadata.test.js` fails the build if those pins drift
|
|
16
16
|
# from the root `package.json` version.
|
|
17
17
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
18
|
+
# Released versions use commit SHAs, so a consumer pinning this action to a SHA gets an immutable
|
|
19
|
+
# chain rather than a mutable tag one level down. Before that release commit exists, an unreleased
|
|
20
|
+
# version uses its upcoming `@vX.Y.Z` tag; the release process replaces it with the commit that holds
|
|
21
|
+
# the wrapper content. The contract test accepts either form and requires the declared version to
|
|
22
|
+
# match package.json.
|
|
22
23
|
|
|
23
24
|
inputs:
|
|
24
25
|
mode:
|
|
@@ -30,7 +31,7 @@ inputs:
|
|
|
30
31
|
required: false
|
|
31
32
|
default: .github/snapdrift.json
|
|
32
33
|
route-ids:
|
|
33
|
-
description: Optional comma-separated route ids
|
|
34
|
+
description: Optional comma-separated route ids for PR diffs or local baselines. Hosted baselines must capture every configured route.
|
|
34
35
|
required: false
|
|
35
36
|
default: ''
|
|
36
37
|
artifact-retention-days:
|
|
@@ -146,7 +147,7 @@ runs:
|
|
|
146
147
|
- name: SnapDrift baseline
|
|
147
148
|
id: baseline
|
|
148
149
|
if: inputs.mode == 'baseline'
|
|
149
|
-
uses: ranacseruet/snapdrift/actions/baseline@
|
|
150
|
+
uses: ranacseruet/snapdrift/actions/baseline@f1ded4c7c0eb944b35eb8f70f8711f4f84397813 # v0.8.2
|
|
150
151
|
with:
|
|
151
152
|
repo-config-path: ${{ inputs.repo-config-path }}
|
|
152
153
|
route-ids: ${{ inputs.route-ids }}
|
|
@@ -156,7 +157,7 @@ runs:
|
|
|
156
157
|
- name: SnapDrift pull request diff
|
|
157
158
|
id: pr-diff
|
|
158
159
|
if: inputs.mode == 'pr-diff'
|
|
159
|
-
uses: ranacseruet/snapdrift/actions/pr-diff@
|
|
160
|
+
uses: ranacseruet/snapdrift/actions/pr-diff@f1ded4c7c0eb944b35eb8f70f8711f4f84397813 # v0.8.2
|
|
160
161
|
with:
|
|
161
162
|
github-token: ${{ inputs.github-token }}
|
|
162
163
|
repo-config-path: ${{ inputs.repo-config-path }}
|
|
@@ -11,7 +11,7 @@ inputs:
|
|
|
11
11
|
required: false
|
|
12
12
|
default: .github/snapdrift.json
|
|
13
13
|
route-ids:
|
|
14
|
-
description: Optional comma-separated route ids
|
|
14
|
+
description: Optional comma-separated route ids for local-provider baselines. Hosted Snap baselines must capture every configured route.
|
|
15
15
|
required: false
|
|
16
16
|
default: ''
|
|
17
17
|
artifact-retention-days:
|
|
@@ -32,16 +32,16 @@ outputs:
|
|
|
32
32
|
value: ${{ steps.stage.outputs.bundle_dir }}
|
|
33
33
|
results-file:
|
|
34
34
|
description: Absolute path to the generated baseline results file.
|
|
35
|
-
value: ${{ steps.capture.outputs.results_file }}
|
|
35
|
+
value: ${{ steps.publish.outputs.results_file || steps.capture.outputs.results_file }}
|
|
36
36
|
manifest-file:
|
|
37
37
|
description: Absolute path to the generated baseline manifest file.
|
|
38
|
-
value: ${{ steps.capture.outputs.manifest_file }}
|
|
38
|
+
value: ${{ steps.publish.outputs.manifest_file || steps.capture.outputs.manifest_file }}
|
|
39
39
|
screenshots-root:
|
|
40
40
|
description: Absolute path to the generated screenshots root.
|
|
41
|
-
value: ${{ steps.capture.outputs.screenshots_root }}
|
|
41
|
+
value: ${{ steps.publish.outputs.screenshots_root || steps.capture.outputs.screenshots_root }}
|
|
42
42
|
selected-route-ids:
|
|
43
43
|
description: Comma-separated route ids captured in this run.
|
|
44
|
-
value: ${{ steps.capture.outputs.selected_route_ids }}
|
|
44
|
+
value: ${{ steps.publish.outputs.selected_route_ids || steps.capture.outputs.selected_route_ids }}
|
|
45
45
|
|
|
46
46
|
runs:
|
|
47
47
|
using: composite
|
|
@@ -103,49 +103,37 @@ runs:
|
|
|
103
103
|
|
|
104
104
|
const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
|
|
105
105
|
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
106
|
-
const
|
|
106
|
+
const policyModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/outage-policy.mjs');
|
|
107
|
+
const { createProvider } = await import(pathToFileURL(providerModulePath));
|
|
107
108
|
const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
|
|
109
|
+
const { captureWithPolicy } = await import(pathToFileURL(policyModulePath));
|
|
108
110
|
|
|
109
111
|
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
110
|
-
const
|
|
112
|
+
const providerName = config.provider || 'local';
|
|
111
113
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
const captured = await captureWithPolicy({
|
|
115
|
+
provider: createProvider(providerName, config),
|
|
116
|
+
providerName,
|
|
117
|
+
config,
|
|
118
|
+
captureOptions: {
|
|
115
119
|
configPath: process.env.REPO_CONFIG_PATH || undefined,
|
|
116
120
|
routeIds: splitCommaList(process.env.ROUTE_IDS),
|
|
117
121
|
purpose: 'baseline'
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
process.stderr.write(`[SnapDrift] Snap unavailable — falling back to local provider.\n`);
|
|
127
|
-
const { createProvider: cp2 } = await import(pathToFileURL(providerModulePath));
|
|
128
|
-
result = await cp2('local', config).capture({
|
|
129
|
-
configPath: process.env.REPO_CONFIG_PATH || undefined,
|
|
130
|
-
routeIds: splitCommaList(process.env.ROUTE_IDS)
|
|
131
|
-
});
|
|
132
|
-
// Report as local so publish is skipped and stage/upload run instead
|
|
133
|
-
const outputs = [
|
|
134
|
-
`provider=local`,
|
|
135
|
-
`results_file=${result.resultsPath}`,
|
|
136
|
-
`manifest_file=${result.manifestPath}`,
|
|
137
|
-
`screenshots_root=${result.screenshotsRoot}`,
|
|
138
|
-
`selected_route_ids=${result.selectedRouteIds.join(',')}`
|
|
139
|
-
].join('\n') + '\n';
|
|
140
|
-
await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
|
|
141
|
-
process.exit(0);
|
|
142
|
-
} else {
|
|
143
|
-
throw err;
|
|
144
|
-
}
|
|
122
|
+
},
|
|
123
|
+
onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable — skipping baseline capture (warn-and-skip).\n'),
|
|
124
|
+
onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable — falling back to local provider.\n')
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (captured.outcome === 'skipped') {
|
|
128
|
+
await fs.appendFile(process.env.GITHUB_OUTPUT, `provider=skipped\n`);
|
|
129
|
+
process.exit(0);
|
|
145
130
|
}
|
|
146
131
|
|
|
132
|
+
const result = captured.result;
|
|
147
133
|
const outputs = [
|
|
148
|
-
|
|
134
|
+
// The effective provider: reporting "local" after a fallback-local
|
|
135
|
+
// capture is what makes publish skip and stage/upload run instead.
|
|
136
|
+
`provider=${captured.providerName}`,
|
|
149
137
|
`results_file=${result.resultsPath}`,
|
|
150
138
|
`manifest_file=${result.manifestPath}`,
|
|
151
139
|
`screenshots_root=${result.screenshotsRoot}`,
|
|
@@ -160,34 +148,67 @@ runs:
|
|
|
160
148
|
shell: bash
|
|
161
149
|
env:
|
|
162
150
|
REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
|
|
151
|
+
ROUTE_IDS: ${{ inputs.route-ids }}
|
|
163
152
|
RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
|
|
164
153
|
run: |
|
|
165
154
|
node --input-type=module <<'EOF'
|
|
155
|
+
import fs from 'node:fs/promises';
|
|
166
156
|
import path from 'node:path';
|
|
167
157
|
import { pathToFileURL } from 'node:url';
|
|
168
158
|
|
|
169
159
|
const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
|
|
170
160
|
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
161
|
+
const policyModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/outage-policy.mjs');
|
|
171
162
|
const { createProvider } = await import(pathToFileURL(providerModulePath));
|
|
172
|
-
const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
|
|
163
|
+
const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
|
|
164
|
+
const { publishBaselineWithPolicy } = await import(pathToFileURL(policyModulePath));
|
|
173
165
|
|
|
174
166
|
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
175
|
-
const provider = createProvider('snap', config);
|
|
176
167
|
|
|
177
|
-
|
|
178
|
-
|
|
168
|
+
// Snap can go down after the capture submission succeeds — while polling
|
|
169
|
+
// the render or posting the baseline. onUnavailable has to cover that
|
|
170
|
+
// window too, or warn-and-skip still fails the build and fallback-local
|
|
171
|
+
// still leaves the repo with no baseline artifact. See #125.
|
|
172
|
+
const published = await publishBaselineWithPolicy({
|
|
173
|
+
provider: createProvider('snap', config),
|
|
174
|
+
publishOptions: { resultsPath: process.env.RESULTS_PATH || undefined },
|
|
175
|
+
captureOptions: {
|
|
176
|
+
configPath: process.env.REPO_CONFIG_PATH || undefined,
|
|
177
|
+
routeIds: splitCommaList(process.env.ROUTE_IDS),
|
|
178
|
+
purpose: 'baseline'
|
|
179
|
+
},
|
|
180
|
+
onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable — skipping baseline publish (warn-and-skip).\n'),
|
|
181
|
+
onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable during publish — capturing locally so the run still produces a baseline artifact.\n')
|
|
179
182
|
});
|
|
180
183
|
|
|
184
|
+
if (published.outcome === 'skipped') {
|
|
185
|
+
await fs.appendFile(process.env.GITHUB_OUTPUT, `provider=skipped\n`);
|
|
186
|
+
process.exit(0);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (published.outcome === 'fell-back') {
|
|
190
|
+
const recapture = published.recapture;
|
|
191
|
+
const outputs = [
|
|
192
|
+
`provider=local`,
|
|
193
|
+
`results_file=${recapture.resultsPath}`,
|
|
194
|
+
`manifest_file=${recapture.manifestPath}`,
|
|
195
|
+
`screenshots_root=${recapture.screenshotsRoot}`,
|
|
196
|
+
`selected_route_ids=${recapture.selectedRouteIds.join(',')}`
|
|
197
|
+
].join('\n') + '\n';
|
|
198
|
+
await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
|
|
181
202
|
process.stdout.write('[SnapDrift] Snap baseline published successfully.\n');
|
|
182
203
|
EOF
|
|
183
204
|
|
|
184
205
|
- id: stage
|
|
185
|
-
if: steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped'
|
|
206
|
+
if: (steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped') || steps.publish.outputs.provider == 'local'
|
|
186
207
|
shell: bash
|
|
187
208
|
env:
|
|
188
|
-
RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
|
|
189
|
-
MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
|
|
190
|
-
SCREENSHOTS_DIR: ${{ steps.capture.outputs.screenshots_root }}/screenshots
|
|
209
|
+
RESULTS_PATH: ${{ steps.publish.outputs.results_file || steps.capture.outputs.results_file }}
|
|
210
|
+
MANIFEST_PATH: ${{ steps.publish.outputs.manifest_file || steps.capture.outputs.manifest_file }}
|
|
211
|
+
SCREENSHOTS_DIR: ${{ steps.publish.outputs.screenshots_root || steps.capture.outputs.screenshots_root }}/screenshots
|
|
191
212
|
run: |
|
|
192
213
|
node --input-type=module <<'EOF'
|
|
193
214
|
import fs from 'node:fs/promises';
|
|
@@ -207,7 +228,7 @@ runs:
|
|
|
207
228
|
await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
|
|
208
229
|
EOF
|
|
209
230
|
|
|
210
|
-
- if: inputs.upload-artifact == 'true' && steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped'
|
|
231
|
+
- if: inputs.upload-artifact == 'true' && ((steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped') || steps.publish.outputs.provider == 'local')
|
|
211
232
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
|
212
233
|
with:
|
|
213
234
|
name: ${{ steps.config.outputs.artifact_name }}
|
|
@@ -340,38 +340,54 @@ runs:
|
|
|
340
340
|
|
|
341
341
|
const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
|
|
342
342
|
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
343
|
-
const
|
|
343
|
+
const policyModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/outage-policy.mjs');
|
|
344
|
+
const summaryModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/drift-summary.mjs');
|
|
345
|
+
const { createProvider } = await import(pathToFileURL(providerModulePath));
|
|
344
346
|
const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
|
|
347
|
+
const { captureWithPolicy, SNAP_UNAVAILABLE_REASON } = await import(pathToFileURL(policyModulePath));
|
|
348
|
+
const { writeDriftSummary } = await import(pathToFileURL(summaryModulePath));
|
|
345
349
|
|
|
346
350
|
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
351
|
+
const providerName = config.provider || 'local';
|
|
352
|
+
const routeIds = splitCommaList(process.env.ROUTE_IDS);
|
|
353
|
+
|
|
354
|
+
const captured = await captureWithPolicy({
|
|
355
|
+
provider: createProvider(providerName, config),
|
|
356
|
+
providerName,
|
|
357
|
+
config,
|
|
358
|
+
captureOptions: {
|
|
352
359
|
configPath: process.env.REPO_CONFIG_PATH || undefined,
|
|
353
|
-
routeIds
|
|
360
|
+
routeIds
|
|
361
|
+
},
|
|
362
|
+
onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable — skipping visual regression (warn-and-skip).\n'),
|
|
363
|
+
onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable — falling back to local provider.\n')
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
if (captured.outcome === 'skipped') {
|
|
367
|
+
// warn-and-skip is documented as "write a skipped summary and exit 0".
|
|
368
|
+
// Without the summary the report step falls through to its
|
|
369
|
+
// "Capture Failed" body, which is not what happened. See #125.
|
|
370
|
+
const skipped = await writeDriftSummary({
|
|
371
|
+
reason: SNAP_UNAVAILABLE_REASON,
|
|
372
|
+
selectedRouteIds: routeIds
|
|
354
373
|
});
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const { createProvider: cp2 } = await import(pathToFileURL(providerModulePath));
|
|
364
|
-
result = await cp2('local', config).capture({
|
|
365
|
-
configPath: process.env.REPO_CONFIG_PATH || undefined,
|
|
366
|
-
routeIds: splitCommaList(process.env.ROUTE_IDS)
|
|
367
|
-
});
|
|
368
|
-
} else {
|
|
369
|
-
throw err;
|
|
370
|
-
}
|
|
374
|
+
const skipOutputs = [
|
|
375
|
+
`provider=skipped`,
|
|
376
|
+
`summary_path=${skipped.summaryPath}`,
|
|
377
|
+
`markdown_path=${skipped.markdownPath}`,
|
|
378
|
+
`status=${skipped.summary.status || ''}`
|
|
379
|
+
].join('\n') + '\n';
|
|
380
|
+
await fs.appendFile(process.env.GITHUB_OUTPUT, skipOutputs);
|
|
381
|
+
process.exit(0);
|
|
371
382
|
}
|
|
372
383
|
|
|
384
|
+
const result = captured.result;
|
|
373
385
|
const outputs = [
|
|
374
|
-
|
|
386
|
+
// The *effective* provider, not the configured one: after a
|
|
387
|
+
// fallback-local capture the compare step must not rebuild
|
|
388
|
+
// SnapProvider around local results that carry no run id.
|
|
389
|
+
`provider=${captured.providerName}`,
|
|
390
|
+
`local_screenshots=${captured.localScreenshots ? 'true' : 'false'}`,
|
|
375
391
|
`results_file=${result.resultsPath}`,
|
|
376
392
|
`manifest_file=${result.manifestPath}`,
|
|
377
393
|
`screenshots_root=${result.screenshotsRoot}`,
|
|
@@ -394,6 +410,10 @@ runs:
|
|
|
394
410
|
BASELINE_SOURCE_SHA: ${{ steps.baseline.outputs.head_sha }}
|
|
395
411
|
CURRENT_RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
|
|
396
412
|
CURRENT_MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
|
|
413
|
+
CURRENT_SCREENSHOTS_ROOT: ${{ steps.capture.outputs.screenshots_root }}
|
|
414
|
+
CAPTURE_PROVIDER: ${{ steps.capture.outputs.provider }}
|
|
415
|
+
LOCAL_SCREENSHOTS: ${{ steps.capture.outputs.local_screenshots }}
|
|
416
|
+
BASELINE_FOUND: ${{ steps.baseline.outputs.found }}
|
|
397
417
|
run: |
|
|
398
418
|
node --input-type=module <<'EOF'
|
|
399
419
|
import fs from 'node:fs/promises';
|
|
@@ -402,45 +422,75 @@ runs:
|
|
|
402
422
|
|
|
403
423
|
const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
|
|
404
424
|
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
405
|
-
const
|
|
425
|
+
const policyModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/outage-policy.mjs');
|
|
426
|
+
const summaryModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/drift-summary.mjs');
|
|
427
|
+
const { createProvider } = await import(pathToFileURL(providerModulePath));
|
|
406
428
|
const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
|
|
429
|
+
const { diffWithPolicy, MISSING_BASELINE_REASON, SNAP_UNAVAILABLE_REASON } = await import(pathToFileURL(policyModulePath));
|
|
430
|
+
const { writeDriftSummary } = await import(pathToFileURL(summaryModulePath));
|
|
407
431
|
|
|
408
432
|
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
409
|
-
|
|
433
|
+
// The capture step reports the provider that actually ran, which is
|
|
434
|
+
// "local" once a fallback-local capture has happened.
|
|
435
|
+
const providerName = process.env.CAPTURE_PROVIDER || config.provider || 'local';
|
|
436
|
+
const routeIds = splitCommaList(process.env.ROUTE_IDS);
|
|
410
437
|
|
|
411
438
|
const resolvedOutDir = path.resolve('qa-artifacts/snapdrift/drift/current');
|
|
412
439
|
const resolvedSummaryPath = path.join(resolvedOutDir, 'summary.json');
|
|
413
440
|
const resolvedMarkdownPath = path.join(resolvedOutDir, 'summary.md');
|
|
414
441
|
|
|
442
|
+
const captureOptions = {
|
|
443
|
+
configPath: process.env.REPO_CONFIG_PATH || undefined,
|
|
444
|
+
routeIds
|
|
445
|
+
};
|
|
446
|
+
|
|
415
447
|
const diffOptions = {
|
|
416
448
|
configPath: process.env.REPO_CONFIG_PATH || undefined,
|
|
417
449
|
baselineResultsPath: process.env.BASELINE_RESULTS_PATH || undefined,
|
|
418
450
|
baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
|
|
419
451
|
baselineRunDir: process.env.BASELINE_RUN_DIR || undefined,
|
|
420
|
-
routeIds
|
|
452
|
+
routeIds,
|
|
421
453
|
baselineArtifactName: process.env.BASELINE_ARTIFACT_NAME || undefined,
|
|
422
454
|
baselineSourceSha: process.env.BASELINE_SOURCE_SHA || undefined,
|
|
423
455
|
currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
|
|
424
|
-
currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined
|
|
456
|
+
currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
|
|
457
|
+
currentRunDir: process.env.CURRENT_SCREENSHOTS_ROOT || undefined
|
|
425
458
|
};
|
|
426
459
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
460
|
+
const diffed = await diffWithPolicy({
|
|
461
|
+
provider: createProvider(providerName, config),
|
|
462
|
+
providerName,
|
|
463
|
+
diffOptions,
|
|
464
|
+
captureOptions,
|
|
465
|
+
localScreenshots: process.env.LOCAL_SCREENSHOTS !== 'false',
|
|
466
|
+
baselineAvailable: process.env.BASELINE_FOUND === 'true',
|
|
467
|
+
onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable during diff — skipping (warn-and-skip).\n'),
|
|
468
|
+
onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable during diff — falling back to local provider.\n'),
|
|
469
|
+
onRecapture: () => process.stderr.write('[SnapDrift] The current capture was rendered by Snap, so it has no local screenshots — recapturing locally before diffing.\n'),
|
|
470
|
+
onBaselineUnavailable: () => process.stderr.write('[SnapDrift] No baseline artifact is available, so the local diff cannot run.\n')
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// Every non-throwing outcome writes a summary. A wrapper run that ends
|
|
474
|
+
// without one makes the report step claim "Capture Failed". See #125.
|
|
475
|
+
if (diffed.outcome !== 'diffed') {
|
|
476
|
+
const skipped = await writeDriftSummary({
|
|
477
|
+
reason: diffed.outcome === 'skipped' ? SNAP_UNAVAILABLE_REASON : MISSING_BASELINE_REASON,
|
|
478
|
+
baselineAvailable: diffed.outcome === 'skipped' ? undefined : false,
|
|
479
|
+
currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
|
|
480
|
+
selectedRouteIds: routeIds,
|
|
481
|
+
outDir: resolvedOutDir
|
|
482
|
+
});
|
|
483
|
+
const skipOutputs = [
|
|
484
|
+
`skipped=true`,
|
|
485
|
+
`summary_path=${skipped.summaryPath}`,
|
|
486
|
+
`markdown_path=${skipped.markdownPath}`,
|
|
487
|
+
`status=${skipped.summary.status || ''}`
|
|
488
|
+
].join('\n') + '\n';
|
|
489
|
+
await fs.appendFile(process.env.GITHUB_OUTPUT, skipOutputs);
|
|
490
|
+
process.exit(0);
|
|
442
491
|
}
|
|
443
|
-
|
|
492
|
+
|
|
493
|
+
const { summary, markdown } = diffed.result;
|
|
444
494
|
|
|
445
495
|
await fs.mkdir(resolvedOutDir, { recursive: true });
|
|
446
496
|
await Promise.all([
|
|
@@ -449,9 +499,16 @@ runs:
|
|
|
449
499
|
]);
|
|
450
500
|
|
|
451
501
|
const outputs = [
|
|
502
|
+
`skipped=false`,
|
|
452
503
|
`summary_path=${resolvedSummaryPath}`,
|
|
453
504
|
`markdown_path=${resolvedMarkdownPath}`,
|
|
454
|
-
`status=${summary.status || ''}
|
|
505
|
+
`status=${summary.status || ''}`,
|
|
506
|
+
// A fallback-local diff of a remote Snap capture recaptures on the
|
|
507
|
+
// runner; the staged bundle has to carry those PNGs, not the empty
|
|
508
|
+
// placeholder paths the Snap capture step emitted.
|
|
509
|
+
`current_results_file=${diffed.recapture?.resultsPath || ''}`,
|
|
510
|
+
`current_manifest_file=${diffed.recapture?.manifestPath || ''}`,
|
|
511
|
+
`current_screenshots_root=${diffed.recapture?.screenshotsRoot || ''}`
|
|
455
512
|
].join('\n') + '\n';
|
|
456
513
|
|
|
457
514
|
await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
|
|
@@ -486,7 +543,7 @@ runs:
|
|
|
486
543
|
EOF
|
|
487
544
|
|
|
488
545
|
- id: skipped_baseline
|
|
489
|
-
if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found != 'true' && steps.capture.outputs.provider != 'snap'
|
|
546
|
+
if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found != 'true' && steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped'
|
|
490
547
|
shell: bash
|
|
491
548
|
env:
|
|
492
549
|
MESSAGE: ${{ steps.baseline.outputs.message }}
|
|
@@ -521,14 +578,14 @@ runs:
|
|
|
521
578
|
- id: stage
|
|
522
579
|
shell: bash
|
|
523
580
|
env:
|
|
524
|
-
SUMMARY_JSON_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
|
|
525
|
-
SUMMARY_MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
|
|
581
|
+
SUMMARY_JSON_PATH: ${{ steps.compare.outputs.summary_path || steps.capture.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
|
|
582
|
+
SUMMARY_MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.capture.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
|
|
526
583
|
BASELINE_RESULTS_PATH: ${{ steps.baseline_paths.outputs.results_file }}
|
|
527
|
-
CURRENT_RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
|
|
584
|
+
CURRENT_RESULTS_PATH: ${{ steps.compare.outputs.current_results_file || steps.capture.outputs.results_file }}
|
|
528
585
|
BASELINE_MANIFEST_PATH: ${{ steps.baseline_paths.outputs.manifest_file }}
|
|
529
|
-
CURRENT_MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
|
|
586
|
+
CURRENT_MANIFEST_PATH: ${{ steps.compare.outputs.current_manifest_file || steps.capture.outputs.manifest_file }}
|
|
530
587
|
BASELINE_SCREENSHOTS_DIR: ${{ steps.baseline_paths.outputs.screenshots_dir }}
|
|
531
|
-
|
|
588
|
+
CURRENT_SCREENSHOTS_ROOT: ${{ steps.compare.outputs.current_screenshots_root || steps.capture.outputs.screenshots_root }}
|
|
532
589
|
run: |
|
|
533
590
|
node --input-type=module <<'EOF'
|
|
534
591
|
import fs from 'node:fs/promises';
|
|
@@ -538,6 +595,8 @@ runs:
|
|
|
538
595
|
const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/stage-artifacts.mjs');
|
|
539
596
|
const { stageArtifacts } = await import(pathToFileURL(modulePath));
|
|
540
597
|
|
|
598
|
+
const currentScreenshotsRoot = process.env.CURRENT_SCREENSHOTS_ROOT || '';
|
|
599
|
+
|
|
541
600
|
const result = await stageArtifacts({
|
|
542
601
|
artifactType: 'diff',
|
|
543
602
|
summaryJsonPath: process.env.SUMMARY_JSON_PATH || undefined,
|
|
@@ -547,7 +606,7 @@ runs:
|
|
|
547
606
|
baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
|
|
548
607
|
currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
|
|
549
608
|
baselineScreenshotsDir: process.env.BASELINE_SCREENSHOTS_DIR || undefined,
|
|
550
|
-
currentScreenshotsDir:
|
|
609
|
+
currentScreenshotsDir: currentScreenshotsRoot ? path.join(currentScreenshotsRoot, 'screenshots') : undefined
|
|
551
610
|
});
|
|
552
611
|
|
|
553
612
|
await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
|
|
@@ -563,7 +622,7 @@ runs:
|
|
|
563
622
|
- if: always()
|
|
564
623
|
shell: bash
|
|
565
624
|
env:
|
|
566
|
-
SUMMARY_FILE: ${{ steps.compare.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
|
|
625
|
+
SUMMARY_FILE: ${{ steps.compare.outputs.markdown_path || steps.capture.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
|
|
567
626
|
run: |
|
|
568
627
|
if [ -f "$SUMMARY_FILE" ]; then
|
|
569
628
|
cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
|
|
@@ -573,7 +632,7 @@ runs:
|
|
|
573
632
|
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
|
574
633
|
env:
|
|
575
634
|
PR_NUMBER: ${{ steps.context.outputs.pr_number }}
|
|
576
|
-
SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
|
|
635
|
+
SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.capture.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
|
|
577
636
|
ARTIFACT_NAME: ${{ steps.context.outputs.artifact_name }}
|
|
578
637
|
RUN_URL: ${{ steps.context.outputs.run_url }}
|
|
579
638
|
REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
|
|
@@ -674,7 +733,9 @@ runs:
|
|
|
674
733
|
});
|
|
675
734
|
}
|
|
676
735
|
|
|
677
|
-
|
|
736
|
+
# A skipped compare writes a summary with no diff counters, so enforcement
|
|
737
|
+
# has nothing to evaluate — `warn-and-skip` means "do not fail my build".
|
|
738
|
+
- if: steps.scope.outputs.should_run == 'true' && steps.capture.outputs.provider != 'skipped' && steps.compare.outputs.skipped != 'true' && (steps.baseline.outputs.found == 'true' || steps.capture.outputs.provider == 'snap')
|
|
678
739
|
shell: bash
|
|
679
740
|
run: |
|
|
680
741
|
node --input-type=module <<'EOF'
|
|
@@ -700,9 +761,9 @@ runs:
|
|
|
700
761
|
SCOPE_REASON: ${{ steps.scope.outputs.reason }}
|
|
701
762
|
SELECTED_ROUTE_IDS: ${{ steps.scope.outputs.selected_route_ids }}
|
|
702
763
|
BASELINE_FOUND: ${{ steps.baseline.outputs.found || 'false' }}
|
|
703
|
-
STATUS: ${{ steps.compare.outputs.status || steps.skipped_baseline.outputs.status || steps.skipped_scope.outputs.status || '' }}
|
|
704
|
-
SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path || '' }}
|
|
705
|
-
MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path || '' }}
|
|
764
|
+
STATUS: ${{ steps.compare.outputs.status || steps.capture.outputs.status || steps.skipped_baseline.outputs.status || steps.skipped_scope.outputs.status || '' }}
|
|
765
|
+
SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.capture.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path || '' }}
|
|
766
|
+
MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.capture.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path || '' }}
|
|
706
767
|
BUNDLE_DIR: ${{ steps.stage.outputs.bundle_dir }}
|
|
707
768
|
run: |
|
|
708
769
|
{
|