snapdrift 0.6.0 → 0.8.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 +28 -0
- package/README.md +20 -6
- package/action.yml +173 -0
- package/actions/baseline/action.yml +4 -0
- package/actions/capture/action.yml +4 -0
- package/actions/comment/action.yml +4 -0
- package/actions/compare/action.yml +4 -0
- package/actions/enforce/action.yml +4 -0
- package/actions/pr-diff/action.yml +4 -0
- package/actions/resolve-baseline/action.yml +4 -0
- package/actions/scope/action.yml +4 -0
- package/actions/stage/action.yml +4 -0
- package/docs/integration-guide.md +25 -4
- package/docs/local-cli.md +39 -5
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +2 -1
- package/lib/cli.mjs +115 -2
- package/lib/init-from-action.mjs +9 -7
- package/lib/migrate-baselines.mjs +29 -33
- package/lib/snap-provider.mjs +84 -78
- package/package.json +3 -2
- package/types/visual-diff-types.d.ts +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 - 2026-08-06
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **Root `action.yml` for the GitHub Marketplace** — SnapDrift's nine composite actions all live under `actions/*`, and Marketplace lists exactly one action per repository whose metadata file must sit at the repository root, so none of them were publishable. `ranacseruet/snapdrift@vX` is now a dispatcher action that runs the baseline pipeline with `mode: baseline` and the pull request pipeline with `mode: pr-diff`, forwarding every input and output of the corresponding wrapper. Existing `ranacseruet/snapdrift/actions/*@vX` references are unaffected and stay supported. The dispatcher pins absolute inner refs rather than `uses: ./actions/...`, because a relative `uses:` inside a composite action resolves against the caller's workspace; `tests/marketplace-metadata.test.js` fails the build if those pins drift from the package version. Released versions pin the inner ref to a commit SHA so that a consumer pinning the dispatcher to a SHA gets an immutable chain rather than a mutable tag one level down.
|
|
8
|
+
- **`branding` on every action** — required for the Marketplace listing, and applied to all nine sub-actions so the metadata stays consistent if any of them is ever split into its own repository. `npm run validate:actions` now rejects a missing icon or a color outside GitHub's allowed set, and covers the root action.
|
|
9
|
+
|
|
10
|
+
### Documentation
|
|
11
|
+
|
|
12
|
+
- Updated the README, Integration Guide, and baseline workflow template action examples to use the current release.
|
|
13
|
+
- Documented the root action as the primary entry point across the README quickstart, the Integration Guide, and the baseline workflow template, with a new "Choosing an entry point" section mapping each `mode` to its equivalent wrapper action. The documentation version contract test now covers root-action references and prose references, not just `uses:` lines for the two wrappers.
|
|
14
|
+
|
|
15
|
+
## 0.7.0 - 2026-07-26
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- **`snapdrift baseline` command** — establishes a baseline for the configured provider. With `provider: "snap"` it captures each route through Snap, waits for the hosted renders, and then publishes a baseline from the stored object keys. Previously `publishBaseline()` was reachable only from `actions/baseline`, so anyone not running GitHub Actions had **no supported way to seed a hosted project** — `snapdrift capture` submits a run but never creates a baseline from it. With `provider: "local"` the command behaves like `capture`, since the captured files are themselves the baseline. Honours `onUnavailable: fallback-local` by skipping the publish step when the capture falls back locally. (ranacseruet/snapdrift#106)
|
|
20
|
+
|
|
21
|
+
### Fixes
|
|
22
|
+
|
|
23
|
+
- **`publishBaseline()` fails with the real cause instead of a doomed request** — when `results.json` was missing, unparseable, or carried no `runId`, it fell through to a legacy branch that POSTed `manifest`/`results` as objects. Snap has never read those fields, so that request could only fail — with an opaque 500 before, and `400 unsupported_baseline_body` since i2Dev-com/snap#653 — while a bare `catch {}` hid the underlying read error. The legacy branch is removed; the method now reports that no run id was found, including the read error when there was one. Reached whenever a non-Snap `results.json` (for example one written by the local provider) was passed in. (#109)
|
|
24
|
+
- **`snapdrift capture` honours `onUnavailable: "warn-and-skip"`** — the mode makes `#handleUnavailable` throw `SnapSkipError`, which `diff` and `baseline` catch and turn into a clean exit. `capture` let it propagate to the CLI entry point, which writes the error to stderr and exits 1 — failing the build the user explicitly configured *not* to fail when Snap is unavailable. (#108)
|
|
25
|
+
- **Baselines published outside GitHub Actions are attributed correctly** — `publishBaseline()` read the branch and commit only from `GITHUB_REF_NAME` / `GITHUB_HEAD_REF` / `GITHUB_SHA`, so a baseline published from a terminal was recorded as branch `main` at SHA `unknown`, making baselines cut from different commits indistinguishable in the dashboard and in exported metadata. It now falls back to `git rev-parse` when those variables are absent (and to `main` / `unknown` only outside a git repository). Previously unreachable, since there was no CLI publish path.
|
|
26
|
+
|
|
27
|
+
### Breaking changes
|
|
28
|
+
|
|
29
|
+
- **`migrate-baselines --to snap` now fails with a pointer to `snapdrift baseline`**, and `SnapProvider.migrateBaselineFromLocal()` has been removed. The body it posted (`manifest`/`results`/`screenshots` as objects) was never read by Snap's baseline endpoint: the PNGs were never uploaded to storage and the manifest referenced local filenames rather than Snap object keys, so the call could only ever create a baseline with no images behind it — one that `migrate-baselines --to local --from snap` would then refuse to export. The request previously failed with an opaque 500 and is now rejected with `400 unsupported_baseline_body` (i2Dev-com/snap#653). The reverse direction, `--to local --from snap`, is unaffected.
|
|
30
|
+
|
|
3
31
|
## 0.6.0 - 2026-07-17
|
|
4
32
|
|
|
5
33
|
### Features
|
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|

|
|
4
4
|
|
|
5
5
|

|
|
6
|
+
[](https://github.com/marketplace/actions/snapdrift)
|
|
6
7
|

|
|
7
8
|

|
|
8
9
|
|
|
@@ -47,8 +48,9 @@ You keep ownership of checkout, build, startup, readiness, and teardown. SnapDri
|
|
|
47
48
|
|
|
48
49
|
```yaml
|
|
49
50
|
- name: SnapDrift Baseline
|
|
50
|
-
uses: ranacseruet/snapdrift
|
|
51
|
+
uses: ranacseruet/snapdrift@v0.8.0
|
|
51
52
|
with:
|
|
53
|
+
mode: baseline
|
|
52
54
|
repo-config-path: .github/snapdrift.json
|
|
53
55
|
```
|
|
54
56
|
|
|
@@ -56,27 +58,33 @@ You keep ownership of checkout, build, startup, readiness, and teardown. SnapDri
|
|
|
56
58
|
|
|
57
59
|
```yaml
|
|
58
60
|
- name: SnapDrift Report
|
|
59
|
-
uses: ranacseruet/snapdrift
|
|
61
|
+
uses: ranacseruet/snapdrift@v0.8.0
|
|
60
62
|
with:
|
|
63
|
+
mode: pr-diff
|
|
61
64
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
62
65
|
repo-config-path: .github/snapdrift.json
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
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"`).
|
|
66
69
|
|
|
70
|
+
The root action dispatches on `mode`. The same pipelines are also published as standalone actions — `ranacseruet/snapdrift/actions/baseline` and `ranacseruet/snapdrift/actions/pr-diff` — along with the lower-level `capture`, `compare`, `scope`, `resolve-baseline`, `stage`, `comment`, and `enforce` actions for custom orchestration.
|
|
71
|
+
|
|
67
72
|
## Local CLI
|
|
68
73
|
|
|
69
74
|
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.
|
|
70
75
|
|
|
71
76
|
```bash
|
|
72
|
-
#
|
|
77
|
+
# Establish a baseline — for provider "snap" this also publishes it
|
|
78
|
+
snapdrift baseline
|
|
79
|
+
|
|
80
|
+
# Capture screenshots without publishing anything
|
|
73
81
|
snapdrift capture
|
|
74
82
|
|
|
75
|
-
# Compare against
|
|
83
|
+
# Compare against the baseline after making UI changes
|
|
76
84
|
snapdrift diff --open
|
|
77
85
|
|
|
78
|
-
#
|
|
79
|
-
snapdrift migrate-baselines --to snap
|
|
86
|
+
# Export hosted baselines back to the local layout
|
|
87
|
+
snapdrift migrate-baselines --to local --from snap
|
|
80
88
|
|
|
81
89
|
# Translate an existing Snap github-action workflow into snapdrift.json
|
|
82
90
|
snapdrift init --from-snap-action .github/workflows/snap.yml
|
|
@@ -104,6 +112,12 @@ Start with `report-only` while baselines settle. Move to `fail-on-changes` or st
|
|
|
104
112
|
- Dimension shifts are reported separately from pixel drift
|
|
105
113
|
- 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))
|
|
106
114
|
|
|
115
|
+
## Outgrew GitHub artifacts?
|
|
116
|
+
|
|
117
|
+
Baselines live as GitHub Actions artifacts by default — quick to start, but they expire (30 days by default), are scoped to one repo, and every PR re-renders on your CI runner. When visual regression becomes load-bearing, `provider: "snap"` moves baselines into durable, shared storage with a review dashboard — and, when `baseUrl` points at a public preview URL Snap can reach, renders on Snap's hosted fleet instead of your runner (with a localhost `baseUrl`, capture stays a local-Playwright-and-upload hybrid). Same routes, same diff config; add a `snap` config block and API key per the [hosted-provider setup](docs/integration-guide.md#hosted-snap-provider). Snap's hosted Visual CI is built for small teams (plans from $9.99/mo).
|
|
118
|
+
|
|
119
|
+
→ **[Hosted Visual CI with Snap](https://snap.i2dev.com/ci)**
|
|
120
|
+
|
|
107
121
|
## Docs
|
|
108
122
|
|
|
109
123
|
- [Integration Guide](docs/integration-guide.md)
|
package/action.yml
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
name: SnapDrift
|
|
2
|
+
description: Capture, compare, and report UI drift for Playwright-based apps — baseline publishing and pull request visual diffs in one action.
|
|
3
|
+
author: CodeSamplez
|
|
4
|
+
|
|
5
|
+
branding:
|
|
6
|
+
icon: eye
|
|
7
|
+
color: purple
|
|
8
|
+
|
|
9
|
+
# GitHub Marketplace lists exactly one action per repository, and its metadata file must live at
|
|
10
|
+
# the repository root. This dispatcher is that entry point: it forwards to the wrapper actions
|
|
11
|
+
# under `actions/`, which remain directly usable (and unchanged) for custom orchestration.
|
|
12
|
+
#
|
|
13
|
+
# The inner `uses:` refs are pinned to the current release rather than a relative path because
|
|
14
|
+
# `uses: ./path` inside a composite action resolves against the *caller's* workspace, not the
|
|
15
|
+
# action's own directory. `tests/marketplace-metadata.test.js` fails the build if those pins drift
|
|
16
|
+
# from the root `package.json` version.
|
|
17
|
+
#
|
|
18
|
+
# The pins are commit SHAs carrying the version in a trailing comment, so that a consumer pinning
|
|
19
|
+
# this action to a SHA gets an immutable chain rather than a mutable tag one level down. The SHA is
|
|
20
|
+
# the commit that introduced the release's wrapper actions; the release commit cannot reference its
|
|
21
|
+
# own SHA. The test accepts a plain `@vX.Y.Z` tag too, for unreleased refs.
|
|
22
|
+
|
|
23
|
+
inputs:
|
|
24
|
+
mode:
|
|
25
|
+
description: Which SnapDrift pipeline to run — `pr-diff` for pull request drift detection, or `baseline` to publish a baseline.
|
|
26
|
+
required: false
|
|
27
|
+
default: pr-diff
|
|
28
|
+
repo-config-path:
|
|
29
|
+
description: Path to the SnapDrift config file.
|
|
30
|
+
required: false
|
|
31
|
+
default: .github/snapdrift.json
|
|
32
|
+
route-ids:
|
|
33
|
+
description: Optional comma-separated route ids. In `pr-diff` mode, providing these skips changed-file scope detection.
|
|
34
|
+
required: false
|
|
35
|
+
default: ''
|
|
36
|
+
artifact-retention-days:
|
|
37
|
+
description: Retention period for the uploaded artifact.
|
|
38
|
+
required: false
|
|
39
|
+
default: '30'
|
|
40
|
+
github-token:
|
|
41
|
+
description: Token used to inspect pull request files, fetch baselines, and publish SnapDrift PR reports. Used in `pr-diff` mode only.
|
|
42
|
+
required: false
|
|
43
|
+
default: ${{ github.token }}
|
|
44
|
+
|
|
45
|
+
# `baseline` mode only.
|
|
46
|
+
upload-artifact:
|
|
47
|
+
description: Whether to upload the staged baseline artifact. Used in `baseline` mode only.
|
|
48
|
+
required: false
|
|
49
|
+
default: 'true'
|
|
50
|
+
|
|
51
|
+
# `pr-diff` mode only.
|
|
52
|
+
pr-number:
|
|
53
|
+
description: Optional pull request number override.
|
|
54
|
+
required: false
|
|
55
|
+
default: ''
|
|
56
|
+
force-run:
|
|
57
|
+
description: Whether to force a full run when route ids are not explicitly provided.
|
|
58
|
+
required: false
|
|
59
|
+
default: 'false'
|
|
60
|
+
force-run-reason:
|
|
61
|
+
description: Reason to report when force-run is enabled.
|
|
62
|
+
required: false
|
|
63
|
+
default: forced
|
|
64
|
+
baseline-repository:
|
|
65
|
+
description: Repository in owner/name format that publishes the baseline.
|
|
66
|
+
required: false
|
|
67
|
+
default: ''
|
|
68
|
+
baseline-workflow-id:
|
|
69
|
+
description: Workflow id or filename to search for the baseline.
|
|
70
|
+
required: false
|
|
71
|
+
default: ci.yml
|
|
72
|
+
baseline-branch:
|
|
73
|
+
description: Branch that publishes the baseline.
|
|
74
|
+
required: false
|
|
75
|
+
default: main
|
|
76
|
+
comment-on-pr:
|
|
77
|
+
description: Whether to publish the SnapDrift PR report.
|
|
78
|
+
required: false
|
|
79
|
+
default: 'true'
|
|
80
|
+
max-changed-rows:
|
|
81
|
+
description: Maximum number of changed-route rows shown in the PR comment before truncation.
|
|
82
|
+
required: false
|
|
83
|
+
default: '20'
|
|
84
|
+
max-error-rows:
|
|
85
|
+
description: Maximum number of error rows shown in the PR comment before truncation.
|
|
86
|
+
required: false
|
|
87
|
+
default: '10'
|
|
88
|
+
|
|
89
|
+
outputs:
|
|
90
|
+
# Shared across both modes.
|
|
91
|
+
artifact-name:
|
|
92
|
+
description: Name of the uploaded artifact.
|
|
93
|
+
value: ${{ steps.baseline.outputs.artifact-name || steps.pr-diff.outputs.artifact-name }}
|
|
94
|
+
bundle-dir:
|
|
95
|
+
description: Staged artifact bundle directory.
|
|
96
|
+
value: ${{ steps.baseline.outputs.bundle-dir || steps.pr-diff.outputs.bundle-dir }}
|
|
97
|
+
selected-route-ids:
|
|
98
|
+
description: Comma-separated route ids selected for the run.
|
|
99
|
+
value: ${{ steps.baseline.outputs.selected-route-ids || steps.pr-diff.outputs.selected-route-ids }}
|
|
100
|
+
|
|
101
|
+
# `baseline` mode only.
|
|
102
|
+
results-file:
|
|
103
|
+
description: Absolute path to the generated baseline results file.
|
|
104
|
+
value: ${{ steps.baseline.outputs.results-file }}
|
|
105
|
+
manifest-file:
|
|
106
|
+
description: Absolute path to the generated baseline manifest file.
|
|
107
|
+
value: ${{ steps.baseline.outputs.manifest-file }}
|
|
108
|
+
screenshots-root:
|
|
109
|
+
description: Absolute path to the generated screenshots root.
|
|
110
|
+
value: ${{ steps.baseline.outputs.screenshots-root }}
|
|
111
|
+
|
|
112
|
+
# `pr-diff` mode only.
|
|
113
|
+
should-run:
|
|
114
|
+
description: Whether the SnapDrift flow ran or intentionally skipped due to scope.
|
|
115
|
+
value: ${{ steps.pr-diff.outputs.should-run }}
|
|
116
|
+
scope-reason:
|
|
117
|
+
description: Reason for the final scope decision.
|
|
118
|
+
value: ${{ steps.pr-diff.outputs.scope-reason }}
|
|
119
|
+
baseline-found:
|
|
120
|
+
description: Whether a usable baseline was found.
|
|
121
|
+
value: ${{ steps.pr-diff.outputs.baseline-found }}
|
|
122
|
+
status:
|
|
123
|
+
description: Final SnapDrift status.
|
|
124
|
+
value: ${{ steps.pr-diff.outputs.status }}
|
|
125
|
+
summary-path:
|
|
126
|
+
description: Path to the final summary JSON file.
|
|
127
|
+
value: ${{ steps.pr-diff.outputs.summary-path }}
|
|
128
|
+
markdown-path:
|
|
129
|
+
description: Path to the final summary markdown file.
|
|
130
|
+
value: ${{ steps.pr-diff.outputs.markdown-path }}
|
|
131
|
+
|
|
132
|
+
runs:
|
|
133
|
+
using: composite
|
|
134
|
+
steps:
|
|
135
|
+
- name: Validate mode
|
|
136
|
+
shell: bash
|
|
137
|
+
env:
|
|
138
|
+
MODE: ${{ inputs.mode }}
|
|
139
|
+
run: |
|
|
140
|
+
set -euo pipefail
|
|
141
|
+
if [ "$MODE" != "pr-diff" ] && [ "$MODE" != "baseline" ]; then
|
|
142
|
+
echo "::error::Unsupported SnapDrift mode '$MODE'. Expected 'pr-diff' or 'baseline'."
|
|
143
|
+
exit 1
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
- name: SnapDrift baseline
|
|
147
|
+
id: baseline
|
|
148
|
+
if: inputs.mode == 'baseline'
|
|
149
|
+
uses: ranacseruet/snapdrift/actions/baseline@539953ab07065be805be393d69bd3c9b6d53cf6e # v0.8.0
|
|
150
|
+
with:
|
|
151
|
+
repo-config-path: ${{ inputs.repo-config-path }}
|
|
152
|
+
route-ids: ${{ inputs.route-ids }}
|
|
153
|
+
artifact-retention-days: ${{ inputs.artifact-retention-days }}
|
|
154
|
+
upload-artifact: ${{ inputs.upload-artifact }}
|
|
155
|
+
|
|
156
|
+
- name: SnapDrift pull request diff
|
|
157
|
+
id: pr-diff
|
|
158
|
+
if: inputs.mode == 'pr-diff'
|
|
159
|
+
uses: ranacseruet/snapdrift/actions/pr-diff@539953ab07065be805be393d69bd3c9b6d53cf6e # v0.8.0
|
|
160
|
+
with:
|
|
161
|
+
github-token: ${{ inputs.github-token }}
|
|
162
|
+
repo-config-path: ${{ inputs.repo-config-path }}
|
|
163
|
+
pr-number: ${{ inputs.pr-number }}
|
|
164
|
+
route-ids: ${{ inputs.route-ids }}
|
|
165
|
+
force-run: ${{ inputs.force-run }}
|
|
166
|
+
force-run-reason: ${{ inputs.force-run-reason }}
|
|
167
|
+
baseline-repository: ${{ inputs.baseline-repository }}
|
|
168
|
+
baseline-workflow-id: ${{ inputs.baseline-workflow-id }}
|
|
169
|
+
baseline-branch: ${{ inputs.baseline-branch }}
|
|
170
|
+
artifact-retention-days: ${{ inputs.artifact-retention-days }}
|
|
171
|
+
comment-on-pr: ${{ inputs.comment-on-pr }}
|
|
172
|
+
max-changed-rows: ${{ inputs.max-changed-rows }}
|
|
173
|
+
max-error-rows: ${{ inputs.max-error-rows }}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
name: SnapDrift Baseline
|
|
2
2
|
description: Capture configured routes, stage a SnapDrift baseline, and upload it with the configured artifact name.
|
|
3
3
|
|
|
4
|
+
branding:
|
|
5
|
+
icon: camera
|
|
6
|
+
color: purple
|
|
7
|
+
|
|
4
8
|
inputs:
|
|
5
9
|
repo-config-path:
|
|
6
10
|
description: Path to the SnapDrift config file.
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
name: SnapDrift PR Diff
|
|
2
2
|
description: Run the end-to-end SnapDrift pull request pipeline, including scope resolution, skipped-report handling, artifact upload, PR reporting, and enforcement.
|
|
3
3
|
|
|
4
|
+
branding:
|
|
5
|
+
icon: eye
|
|
6
|
+
color: purple
|
|
7
|
+
|
|
4
8
|
inputs:
|
|
5
9
|
github-token:
|
|
6
10
|
description: Token used to inspect pull request files, fetch baselines, and publish SnapDrift PR reports.
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
name: SnapDrift Resolve Baseline
|
|
2
2
|
description: Resolve and download the latest successful SnapDrift baseline from main.
|
|
3
3
|
|
|
4
|
+
branding:
|
|
5
|
+
icon: download-cloud
|
|
6
|
+
color: purple
|
|
7
|
+
|
|
4
8
|
inputs:
|
|
5
9
|
github-token:
|
|
6
10
|
description: Token used to query workflow runs and download artifacts.
|
package/actions/scope/action.yml
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
name: SnapDrift Scope
|
|
2
2
|
description: Determine whether SnapDrift should run and which configured routes should be selected for the current change set.
|
|
3
3
|
|
|
4
|
+
branding:
|
|
5
|
+
icon: filter
|
|
6
|
+
color: purple
|
|
7
|
+
|
|
4
8
|
inputs:
|
|
5
9
|
github-token:
|
|
6
10
|
description: Token used to query pull request changed files.
|
package/actions/stage/action.yml
CHANGED
|
@@ -46,8 +46,9 @@ SnapDrift owns route selection, capture, comparison, skipped-report generation,
|
|
|
46
46
|
|
|
47
47
|
```yaml
|
|
48
48
|
- name: SnapDrift Baseline
|
|
49
|
-
uses: ranacseruet/snapdrift
|
|
49
|
+
uses: ranacseruet/snapdrift@v0.8.0
|
|
50
50
|
with:
|
|
51
|
+
mode: baseline
|
|
51
52
|
repo-config-path: .github/snapdrift.json
|
|
52
53
|
artifact-retention-days: '30'
|
|
53
54
|
```
|
|
@@ -70,12 +71,30 @@ Then add SnapDrift after the app is running:
|
|
|
70
71
|
|
|
71
72
|
```yaml
|
|
72
73
|
- name: SnapDrift Report
|
|
73
|
-
uses: ranacseruet/snapdrift
|
|
74
|
+
uses: ranacseruet/snapdrift@v0.8.0
|
|
74
75
|
with:
|
|
76
|
+
mode: pr-diff
|
|
75
77
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
76
78
|
repo-config-path: .github/snapdrift.json
|
|
77
79
|
```
|
|
78
80
|
|
|
81
|
+
## Choosing an entry point
|
|
82
|
+
|
|
83
|
+
`ranacseruet/snapdrift` is the GitHub Marketplace entry point. It is a thin dispatcher: `mode: baseline` runs the baseline pipeline and `mode: pr-diff` runs the pull request pipeline, forwarding every input and output of the underlying wrapper action.
|
|
84
|
+
|
|
85
|
+
The wrappers remain published in their own right, and are equivalent to the dispatcher:
|
|
86
|
+
|
|
87
|
+
| Entry point | Equivalent to |
|
|
88
|
+
|-------------|---------------|
|
|
89
|
+
| `ranacseruet/snapdrift@v0.8.0` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.8.0` |
|
|
90
|
+
| `ranacseruet/snapdrift@v0.8.0` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.8.0` |
|
|
91
|
+
|
|
92
|
+
Use the wrappers directly when you want to skip the dispatcher's input indirection, and the lower-level `capture`, `compare`, `scope`, `resolve-baseline`, `stage`, `comment`, and `enforce` actions when you need to orchestrate the stages yourself.
|
|
93
|
+
|
|
94
|
+
### Pinning
|
|
95
|
+
|
|
96
|
+
The dispatcher loads its wrapper from a pinned ref of this same repository. Released versions pin that ref to a commit SHA, so resolving `ranacseruet/snapdrift` to a SHA freezes the whole chain. If you are consuming an unreleased ref (`@main`, or a branch), the inner ref is a tag instead, and a SHA pin on the dispatcher does not freeze what it delegates to — pin the wrapper actions directly if that matters to you.
|
|
97
|
+
|
|
79
98
|
## Example workflow (Node app)
|
|
80
99
|
|
|
81
100
|
```yaml
|
|
@@ -111,8 +130,9 @@ jobs:
|
|
|
111
130
|
done
|
|
112
131
|
|
|
113
132
|
- name: SnapDrift Report
|
|
114
|
-
uses: ranacseruet/snapdrift
|
|
133
|
+
uses: ranacseruet/snapdrift@v0.8.0
|
|
115
134
|
with:
|
|
135
|
+
mode: pr-diff
|
|
116
136
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
117
137
|
repo-config-path: .github/snapdrift.json
|
|
118
138
|
```
|
|
@@ -150,8 +170,9 @@ jobs:
|
|
|
150
170
|
done
|
|
151
171
|
|
|
152
172
|
- name: SnapDrift Report
|
|
153
|
-
uses: ranacseruet/snapdrift
|
|
173
|
+
uses: ranacseruet/snapdrift@v0.8.0
|
|
154
174
|
with:
|
|
175
|
+
mode: pr-diff
|
|
155
176
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
156
177
|
repo-config-path: .github/snapdrift.json
|
|
157
178
|
```
|
package/docs/local-cli.md
CHANGED
|
@@ -22,6 +22,40 @@ npx snapdrift <command>
|
|
|
22
22
|
|
|
23
23
|
## Commands
|
|
24
24
|
|
|
25
|
+
### `snapdrift baseline`
|
|
26
|
+
|
|
27
|
+
Establishes the baseline for whichever provider the config selects. This is the command to reach for when seeding a project.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
snapdrift baseline [options]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- **`provider: "local"`** — identical to `snapdrift capture`: the screenshots written to the baseline directory *are* the baseline.
|
|
34
|
+
- **`provider: "snap"`** — captures each route through Snap, waits for the hosted renders to finish, then publishes a baseline referencing the stored objects. `snapdrift capture` alone does **not** do this: it submits the run and stops, leaving the project with no baseline.
|
|
35
|
+
|
|
36
|
+
`snap.onUnavailable` is honoured across both phases — the capture *and* the publish that follows it. `warn-and-skip` exits 0 without a baseline; `fallback-local` captures locally so you still end up with one.
|
|
37
|
+
|
|
38
|
+
**Baseline attribution.** The published baseline records the branch and commit it was cut from. Inside GitHub Actions these come from `GITHUB_REF_NAME` / `GITHUB_HEAD_REF` and `GITHUB_SHA`; run locally, they are read from git (`rev-parse --abbrev-ref HEAD` and `rev-parse HEAD`). Outside a git repository they fall back to `main` and `unknown`, so run the command from your checkout if you want the baseline attributed correctly.
|
|
39
|
+
|
|
40
|
+
**`snap.projectId` must be an explicit `prj_...` id** for local runs. `"auto"` derives the id from `GITHUB_REPOSITORY`, which is not set outside Actions — the command fails early telling you to set an explicit id.
|
|
41
|
+
|
|
42
|
+
**Options**
|
|
43
|
+
|
|
44
|
+
| Flag | Default | Description |
|
|
45
|
+
|:-----|:--------|:------------|
|
|
46
|
+
| `--config <path>` | `.github/snapdrift.json` | Path to the config file |
|
|
47
|
+
| `--routes <ids>` | all routes | Comma-separated route IDs to capture |
|
|
48
|
+
| `--baseline-dir <path>` | `.snapdrift/baseline` | Directory to write baseline screenshots and metadata (local provider) |
|
|
49
|
+
|
|
50
|
+
**Example**
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
snapdrift baseline
|
|
54
|
+
snapdrift baseline --routes home-desktop,home-mobile
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
25
59
|
### `snapdrift capture`
|
|
26
60
|
|
|
27
61
|
Captures full-page screenshots of all configured routes and saves them as a local baseline.
|
|
@@ -82,15 +116,15 @@ snapdrift diff --baseline-dir snapshots/baseline --diff-dir snapshots/diff
|
|
|
82
116
|
|
|
83
117
|
### `snapdrift migrate-baselines`
|
|
84
118
|
|
|
85
|
-
|
|
119
|
+
Export baselines from the hosted Snap backend to the local filesystem. Requires a config with `provider: "snap"` (or a `snap` block).
|
|
86
120
|
|
|
87
|
-
|
|
121
|
+
**`--to snap` is not supported (removed in 0.7.0).**
|
|
88
122
|
|
|
89
123
|
```bash
|
|
90
|
-
snapdrift migrate-baselines --to snap
|
|
124
|
+
snapdrift migrate-baselines --to snap # always fails
|
|
91
125
|
```
|
|
92
126
|
|
|
93
|
-
|
|
127
|
+
Snap cannot accept a pre-built local baseline bundle: the screenshots it carries are never uploaded to Snap storage, and its manifest references local filenames rather than Snap object keys, so the result would be a baseline with no pixels behind it. The endpoint rejects the request with `400 unsupported_baseline_body`. Use [`snapdrift baseline`](#snapdrift-baseline) instead — it captures each route through Snap so the images actually land in storage, then publishes a manifest that points at them.
|
|
94
128
|
|
|
95
129
|
**Download Snap baselines to a local directory:**
|
|
96
130
|
|
|
@@ -200,7 +234,7 @@ Add `.snapdrift/` to your `.gitignore` to keep local run output out of version c
|
|
|
200
234
|
|
|
201
235
|
| Code | Meaning |
|
|
202
236
|
|:-----|:--------|
|
|
203
|
-
| `0` | Clean — no drift above threshold, `diff.mode` is `report-only`, the
|
|
237
|
+
| `0` | Clean — no drift above threshold, `diff.mode` is `report-only`, the command was intentionally skipped under `onUnavailable: "warn-and-skip"` (`diff`, `capture` and `baseline` all exit 0 in that case), or the command was a `capture` / `baseline` / `migrate-baselines --to local` / `init` that completed |
|
|
204
238
|
| `1` | Drift enforced — `diff.mode` caused the run to fail, a required command argument was missing, or the `--to local` engine-name check failed (see [Drift modes](../README.md#drift-modes)) |
|
|
205
239
|
|
|
206
240
|
Enforcement follows the same `diff.mode` rules as the GitHub Actions workflow. Set `"mode": "report-only"` during local development to always get a report without a failing exit code.
|
|
@@ -70,8 +70,9 @@ jobs:
|
|
|
70
70
|
# ────────────────────────────────────────────────────────────────────────
|
|
71
71
|
|
|
72
72
|
- name: Publish SnapDrift baseline
|
|
73
|
-
uses: ranacseruet/snapdrift
|
|
73
|
+
uses: ranacseruet/snapdrift@v0.8.0
|
|
74
74
|
with:
|
|
75
|
+
mode: baseline
|
|
75
76
|
repo-config-path: .github/snapdrift.json
|
|
76
77
|
artifact-retention-days: '30'
|
|
77
78
|
|
package/lib/cli.mjs
CHANGED
|
@@ -35,7 +35,7 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Parse CLI arguments from process.argv (or a provided argv array).
|
|
38
|
-
* Supported commands: capture, diff, migrate-baselines, init
|
|
38
|
+
* Supported commands: capture, baseline, diff, migrate-baselines, init
|
|
39
39
|
* Supported flags:
|
|
40
40
|
* --open Open the HTML report after diff
|
|
41
41
|
* --config <path> Path to snapdrift.json (default: .github/snapdrift.json)
|
|
@@ -190,6 +190,13 @@ export async function runCaptureCommand(opts) {
|
|
|
190
190
|
});
|
|
191
191
|
process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
|
|
192
192
|
} catch (error) {
|
|
193
|
+
// onUnavailable: warn-and-skip — the user asked not to fail the build when
|
|
194
|
+
// Snap is down. `diff` and `baseline` both honour this; `capture` used to
|
|
195
|
+
// let SnapSkipError reach bin/snapdrift.mjs, which exits 1. See #108.
|
|
196
|
+
if (error instanceof SnapSkipError) {
|
|
197
|
+
process.stdout.write('Capture skipped (Snap unavailable, warn-and-skip mode).\n');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
193
200
|
if (error instanceof SnapFallbackError) {
|
|
194
201
|
process.stdout.write('Falling back to local provider for capture...\n');
|
|
195
202
|
const localProvider = createProvider('local');
|
|
@@ -205,6 +212,110 @@ export async function runCaptureCommand(opts) {
|
|
|
205
212
|
}
|
|
206
213
|
}
|
|
207
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Run the `baseline` command — establish a baseline for the configured provider.
|
|
217
|
+
*
|
|
218
|
+
* For `provider: "local"` this is `capture`: the screenshots written to
|
|
219
|
+
* `--baseline-dir` *are* the baseline, and there is nothing to publish.
|
|
220
|
+
*
|
|
221
|
+
* For `provider: "snap"` the capture step only submits a run; the baseline is
|
|
222
|
+
* not created until `publishBaseline()` harvests the rendered object keys. That
|
|
223
|
+
* call previously existed only inside `actions/baseline`, which left anyone not
|
|
224
|
+
* running GitHub Actions with no supported way to seed a hosted project — see
|
|
225
|
+
* ranacseruet/snapdrift#106.
|
|
226
|
+
*
|
|
227
|
+
* Mirrors the two provider-conditional steps of `actions/baseline/action.yml`.
|
|
228
|
+
*
|
|
229
|
+
* @param {CliOptions} opts
|
|
230
|
+
* @returns {Promise<void>}
|
|
231
|
+
*/
|
|
232
|
+
export async function runBaselineCommand(opts) {
|
|
233
|
+
const { config } = await loadSnapdriftConfig(opts.configPath);
|
|
234
|
+
const providerName = config.provider ?? 'local';
|
|
235
|
+
const provider = createProvider(providerName, config);
|
|
236
|
+
|
|
237
|
+
process.stdout.write(
|
|
238
|
+
providerName === 'snap'
|
|
239
|
+
? 'Establishing baseline via Snap ...\n'
|
|
240
|
+
: `Capturing baseline to ${opts.baselineDir} ...\n`
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
/** Capture locally so `fallback-local` still leaves the user with a baseline. */
|
|
244
|
+
async function captureLocalFallback() {
|
|
245
|
+
process.stdout.write('Falling back to local provider for capture...\n');
|
|
246
|
+
const localProvider = createProvider('local');
|
|
247
|
+
const { selectedRouteIds } = await localProvider.capture({
|
|
248
|
+
configPath: opts.configPath,
|
|
249
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
250
|
+
outDir: opts.baselineDir
|
|
251
|
+
});
|
|
252
|
+
process.stdout.write(
|
|
253
|
+
`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir} (local fallback — nothing published to Snap)\n`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Apply the configured `onUnavailable` behaviour. Snap can go down during
|
|
259
|
+
* *either* phase — the capture submission or the publish that follows it — and
|
|
260
|
+
* the user's choice must hold for both, otherwise `warn-and-skip` still exits 1
|
|
261
|
+
* and `fallback-local` still leaves them with no baseline.
|
|
262
|
+
*
|
|
263
|
+
* @param {unknown} error
|
|
264
|
+
* @param {string} skipMessage
|
|
265
|
+
* @returns {Promise<boolean>} true when the error was absorbed
|
|
266
|
+
*/
|
|
267
|
+
async function handleUnavailable(error, skipMessage) {
|
|
268
|
+
if (error instanceof SnapSkipError) {
|
|
269
|
+
process.stdout.write(skipMessage);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
if (error instanceof SnapFallbackError) {
|
|
273
|
+
await captureLocalFallback();
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** @type {{ resultsPath?: string, selectedRouteIds: string[] }} */
|
|
280
|
+
let captured;
|
|
281
|
+
try {
|
|
282
|
+
captured = await provider.capture({
|
|
283
|
+
configPath: opts.configPath,
|
|
284
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
285
|
+
outDir: opts.baselineDir,
|
|
286
|
+
purpose: 'baseline'
|
|
287
|
+
});
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (await handleUnavailable(error, 'Baseline skipped (Snap unavailable, warn-and-skip mode).\n')) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (providerName !== 'snap') {
|
|
296
|
+
process.stdout.write(`Captured ${captured.selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
process.stdout.write(
|
|
301
|
+
`Captured ${captured.selectedRouteIds.length} route(s); waiting for Snap to finish rendering ...\n`
|
|
302
|
+
);
|
|
303
|
+
try {
|
|
304
|
+
await provider.publishBaseline({ resultsPath: captured.resultsPath });
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (
|
|
307
|
+
await handleUnavailable(
|
|
308
|
+
error,
|
|
309
|
+
'Baseline publish skipped (Snap unavailable, warn-and-skip mode).\n'
|
|
310
|
+
)
|
|
311
|
+
) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
process.stdout.write('Snap baseline published successfully.\n');
|
|
317
|
+
}
|
|
318
|
+
|
|
208
319
|
/**
|
|
209
320
|
* Run the `diff` command — capture current screenshots, compare against the local baseline,
|
|
210
321
|
* write a summary + HTML report, and optionally open it.
|
|
@@ -360,6 +471,8 @@ export async function main(argv) {
|
|
|
360
471
|
|
|
361
472
|
if (opts.command === 'capture') {
|
|
362
473
|
await runCaptureCommand(opts);
|
|
474
|
+
} else if (opts.command === 'baseline') {
|
|
475
|
+
await runBaselineCommand(opts);
|
|
363
476
|
} else if (opts.command === 'diff') {
|
|
364
477
|
await runDiffCommand(opts);
|
|
365
478
|
} else if (opts.command === 'migrate-baselines') {
|
|
@@ -368,7 +481,7 @@ export async function main(argv) {
|
|
|
368
481
|
await runInitCommand(opts);
|
|
369
482
|
} else {
|
|
370
483
|
process.stderr.write(
|
|
371
|
-
`Unknown command: ${opts.command}\nUsage: snapdrift <capture|diff|migrate-baselines|init> [options]\n`
|
|
484
|
+
`Unknown command: ${opts.command}\nUsage: snapdrift <capture|baseline|diff|migrate-baselines|init> [options]\n`
|
|
372
485
|
);
|
|
373
486
|
process.exitCode = 1;
|
|
374
487
|
}
|
package/lib/init-from-action.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import * as yaml from 'js-yaml';
|
|
6
6
|
|
|
7
7
|
/** @typedef {import('../types/visual-diff-types').InitWarning} InitWarning */
|
|
8
|
+
/** @typedef {import('../types/visual-diff-types').JsonObject} JsonObject */
|
|
8
9
|
|
|
9
10
|
const DEFAULT_OUTPUT_DIR = '.github';
|
|
10
11
|
const DEFAULT_CONFIG_FILENAME = 'snapdrift.json';
|
|
@@ -13,8 +14,8 @@ const MIGRATION_NOTES_FILENAME = 'MIGRATION_NOTES.md';
|
|
|
13
14
|
/**
|
|
14
15
|
* Find the step that uses the Snap visual testing action.
|
|
15
16
|
*
|
|
16
|
-
* @param {
|
|
17
|
-
* @returns {{ step:
|
|
17
|
+
* @param {JsonObject} workflow - Parsed YAML workflow
|
|
18
|
+
* @returns {{ step: JsonObject, jobName: string, stepIndex: number } | null}
|
|
18
19
|
*/
|
|
19
20
|
function findSnapActionStep(workflow) {
|
|
20
21
|
const jobs = workflow.jobs || {};
|
|
@@ -34,7 +35,7 @@ function findSnapActionStep(workflow) {
|
|
|
34
35
|
/**
|
|
35
36
|
* Map Snap action enforcement mode to snapdrift diff.mode.
|
|
36
37
|
*
|
|
37
|
-
* @param {
|
|
38
|
+
* @param {JsonObject} inputs
|
|
38
39
|
* @returns {string}
|
|
39
40
|
*/
|
|
40
41
|
function mapDiffMode(inputs) {
|
|
@@ -52,7 +53,7 @@ function mapDiffMode(inputs) {
|
|
|
52
53
|
/**
|
|
53
54
|
* Extract Snap action inputs and translate to snapdrift config fields.
|
|
54
55
|
*
|
|
55
|
-
* @param {
|
|
56
|
+
* @param {JsonObject} step - The workflow step using the Snap action
|
|
56
57
|
* @returns {{ config: object, warnings: InitWarning[] }}
|
|
57
58
|
*/
|
|
58
59
|
function translateStepToConfig(step) {
|
|
@@ -87,7 +88,7 @@ function translateStepToConfig(step) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
// Snap provider config (if present in inputs)
|
|
90
|
-
/** @type {
|
|
91
|
+
/** @type {JsonObject | undefined} */
|
|
91
92
|
let snap;
|
|
92
93
|
const snapApiUrl = inputs['snap-api-url'] || inputs.snap_api_url;
|
|
93
94
|
const snapApiKeyEnv = inputs['snap-api-key-env'] || inputs.snap_api_key_env;
|
|
@@ -195,10 +196,11 @@ export async function runInitFromAction(actionYamlPath) {
|
|
|
195
196
|
throw new Error(`Cannot read workflow file: ${actionYamlPath}`);
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
const
|
|
199
|
-
if (!
|
|
199
|
+
const parsedWorkflow = yaml.load(workflowContent);
|
|
200
|
+
if (!parsedWorkflow || typeof parsedWorkflow !== 'object') {
|
|
200
201
|
throw new Error(`Invalid workflow YAML: ${actionYamlPath}`);
|
|
201
202
|
}
|
|
203
|
+
const workflow = /** @type {JsonObject} */ (parsedWorkflow);
|
|
202
204
|
|
|
203
205
|
// Find the Snap action step
|
|
204
206
|
const snapStep = findSnapActionStep(workflow);
|
|
@@ -6,6 +6,8 @@ import path from 'node:path';
|
|
|
6
6
|
|
|
7
7
|
import { createProvider } from './provider.mjs';
|
|
8
8
|
|
|
9
|
+
/** @typedef {import('../types/visual-diff-types').JsonObject} JsonObject */
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* Resolve the current commit HEAD SHA.
|
|
11
13
|
* Tries GITHUB_SHA first, then git rev-parse HEAD.
|
|
@@ -28,7 +30,7 @@ function resolveHeadSha() {
|
|
|
28
30
|
* Read local baseline files from a directory.
|
|
29
31
|
*
|
|
30
32
|
* @param {string} baselineDir
|
|
31
|
-
* @returns {Promise<{ results:
|
|
33
|
+
* @returns {Promise<{ results: JsonObject, manifest: JsonObject, screenshots: Array<{ filename: string, data: string }>, headSha: string }>}
|
|
32
34
|
*/
|
|
33
35
|
export async function readLocalBaselines(baselineDir) {
|
|
34
36
|
const resultsPath = path.join(baselineDir, 'results.json');
|
|
@@ -86,39 +88,33 @@ function requireSnapConfig(config) {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
/**
|
|
89
|
-
* Run the migrate-to-snap direction.
|
|
90
|
-
* Reads local baselines and uploads them to Snap.
|
|
91
|
+
* Run the migrate-to-snap direction — retired.
|
|
91
92
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
93
|
+
* This uploaded a pre-built local baseline bundle to
|
|
94
|
+
* `POST /v1/visual/projects/:id/baselines`, but the server has never read the
|
|
95
|
+
* `manifest`/`results`/`screenshots` fields that body carries: the PNGs were
|
|
96
|
+
* never stored, and the manifest referenced local filenames rather than Snap
|
|
97
|
+
* object keys. The request used to fail with an opaque 500 and, since
|
|
98
|
+
* i2Dev-com/snap#653, is rejected outright with `400 unsupported_baseline_body`.
|
|
99
|
+
*
|
|
100
|
+
* `snapdrift baseline` is the supported replacement — it captures each route
|
|
101
|
+
* through Snap so the pixels actually land in storage, then publishes a manifest
|
|
102
|
+
* that references them (and that `--to local --from snap` can export back out).
|
|
103
|
+
*
|
|
104
|
+
* @param {import('../types/visual-diff-types').VisualRegressionConfig} _config
|
|
105
|
+
* @param {import('../types/visual-diff-types').CliOptions} _opts
|
|
106
|
+
* @returns {Promise<never>}
|
|
107
|
+
* @throws {Error} Always — the direction is not supported by the Snap API.
|
|
95
108
|
*/
|
|
96
|
-
export async function runMigrateToSnap(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
process.stdout.write(`Found ${screenshots.length} screenshot(s) for commit ${headSha.substring(0, 8)}\n`);
|
|
106
|
-
|
|
107
|
-
// Check idempotency — skip if baseline already exists
|
|
108
|
-
const existing = await provider.checkBaselineExists(headSha);
|
|
109
|
-
if (existing) {
|
|
110
|
-
process.stdout.write(`Baseline already exists for commit ${headSha.substring(0, 8)}. Skipping upload.\n`);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const { uploaded, baselineId } = await provider.migrateBaselineFromLocal({
|
|
115
|
-
results,
|
|
116
|
-
manifest,
|
|
117
|
-
screenshots,
|
|
118
|
-
headSha
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
process.stdout.write(`Uploaded ${uploaded} screenshot(s) as baseline ${baselineId}\n`);
|
|
109
|
+
export async function runMigrateToSnap(_config, _opts) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
'migrate-baselines --to snap is no longer supported: Snap cannot accept a pre-built local ' +
|
|
112
|
+
'baseline bundle, because its screenshots are never uploaded to Snap storage and its manifest ' +
|
|
113
|
+
'references local filenames rather than Snap object keys.\n' +
|
|
114
|
+
'Run `snapdrift baseline` instead — it captures each route through Snap and publishes a ' +
|
|
115
|
+
'baseline from the stored objects.\n' +
|
|
116
|
+
'Migration in the other direction (`migrate-baselines --to local --from snap`) is unaffected.'
|
|
117
|
+
);
|
|
122
118
|
}
|
|
123
119
|
|
|
124
120
|
/**
|
|
@@ -136,7 +132,7 @@ export async function runMigrateToLocal(config, opts) {
|
|
|
136
132
|
const provider = createProvider('snap', config);
|
|
137
133
|
const baselineDir = opts.baselineDir;
|
|
138
134
|
|
|
139
|
-
/** @type {{ results:
|
|
135
|
+
/** @type {{ results: JsonObject, manifest: JsonObject, screenshots: Array<{ filename: string, data: Buffer }>, engine: { name: string, version: string } }} */
|
|
140
136
|
let exported;
|
|
141
137
|
try {
|
|
142
138
|
exported = await provider.exportBaselines();
|
package/lib/snap-provider.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
3
4
|
import crypto from 'node:crypto';
|
|
4
5
|
import fs from 'node:fs/promises';
|
|
5
6
|
import fsSync from 'node:fs';
|
|
@@ -22,6 +23,7 @@ import { selectConfiguredRoutes, splitCommaList, determineDriftStatus, VIEWPORT_
|
|
|
22
23
|
/** @typedef {import('../types/visual-diff-types').ProviderBaselineData} BaselineData */
|
|
23
24
|
/** @typedef {import('../types/visual-diff-types').SnapConfig} SnapConfig */
|
|
24
25
|
/** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
|
|
26
|
+
/** @typedef {import('../types/visual-diff-types').JsonObject} JsonObject */
|
|
25
27
|
|
|
26
28
|
const DEFAULT_API_URL = 'https://snap.i2dev.com';
|
|
27
29
|
const MAX_RETRIES = 3;
|
|
@@ -71,6 +73,47 @@ function repoSlugToProjectId(repoSlug) {
|
|
|
71
73
|
return repoSlug.replace(/[/.]/g, '--');
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Run a git command, returning undefined outside a repository or when git is
|
|
78
|
+
* unavailable. execFileSync (not execSync) so no argument reaches a shell.
|
|
79
|
+
*
|
|
80
|
+
* @param {string[]} args
|
|
81
|
+
* @returns {string | undefined}
|
|
82
|
+
*/
|
|
83
|
+
function gitOutput(args) {
|
|
84
|
+
try {
|
|
85
|
+
const out = execFileSync('git', args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
86
|
+
const trimmed = out.trim();
|
|
87
|
+
return trimmed === '' ? undefined : trimmed;
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the branch and commit a baseline should be attributed to.
|
|
95
|
+
*
|
|
96
|
+
* GitHub Actions exports these; a developer running `snapdrift baseline` from a
|
|
97
|
+
* terminal does not. Without the git fallback every locally seeded baseline was
|
|
98
|
+
* recorded as branch `main` at SHA `unknown`, which misattributes it in the
|
|
99
|
+
* dashboard and in exported metadata — and makes two baselines cut from
|
|
100
|
+
* different commits indistinguishable. Detached HEAD yields no branch name, so
|
|
101
|
+
* `main` remains the last resort.
|
|
102
|
+
*
|
|
103
|
+
* @returns {{ refBranch: string, refSha: string }}
|
|
104
|
+
*/
|
|
105
|
+
export function resolveGitRef() {
|
|
106
|
+
const branch = gitOutput(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
107
|
+
return {
|
|
108
|
+
refBranch:
|
|
109
|
+
process.env.GITHUB_REF_NAME ||
|
|
110
|
+
process.env.GITHUB_HEAD_REF ||
|
|
111
|
+
(branch && branch !== 'HEAD' ? branch : undefined) ||
|
|
112
|
+
'main',
|
|
113
|
+
refSha: process.env.GITHUB_SHA || gitOutput(['rev-parse', 'HEAD']) || 'unknown'
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
74
117
|
/**
|
|
75
118
|
* Read the package version from package.json.
|
|
76
119
|
* @returns {string}
|
|
@@ -431,11 +474,30 @@ export class SnapProvider {
|
|
|
431
474
|
// create a baseline from the rendered S3 keys.
|
|
432
475
|
if (resultsPath) {
|
|
433
476
|
let metadata;
|
|
477
|
+
/** @type {unknown} */
|
|
478
|
+
let readError;
|
|
434
479
|
try {
|
|
435
480
|
metadata = JSON.parse(await fs.readFile(resultsPath, 'utf-8'));
|
|
436
|
-
} catch {
|
|
481
|
+
} catch (error) {
|
|
482
|
+
readError = error;
|
|
483
|
+
}
|
|
437
484
|
|
|
438
|
-
if (metadata?.runId) {
|
|
485
|
+
if (!metadata?.runId) {
|
|
486
|
+
// Previously this fell through to a "legacy" branch that POSTed
|
|
487
|
+
// `manifest`/`results` as objects. Snap has never read those fields, so
|
|
488
|
+
// the request could only ever fail — with an opaque 500 before, and with
|
|
489
|
+
// `400 unsupported_baseline_body` since i2Dev-com/snap#653. Reporting the
|
|
490
|
+
// real problem (no run id) beats emitting a request guaranteed to fail
|
|
491
|
+
// and surfacing its body-shape error. See #109.
|
|
492
|
+
throw new Error(
|
|
493
|
+
`Cannot publish a Snap baseline: no run id found in ${resultsPath}. ` +
|
|
494
|
+
'A baseline is created from the object keys of a completed Snap run, so the capture step ' +
|
|
495
|
+
'must have recorded one. Run `snapdrift baseline` (or actions/baseline) with ' +
|
|
496
|
+
`provider "snap".${readError ? ` Could not read that file: ${readError instanceof Error ? readError.message : String(readError)}` : ''}`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
{
|
|
439
501
|
const { runId } = metadata;
|
|
440
502
|
const idempotencyKey = `baseline-${runId}`;
|
|
441
503
|
|
|
@@ -487,8 +549,7 @@ export class SnapProvider {
|
|
|
487
549
|
`/v1/visual/projects/${this.#projectId}/baselines`,
|
|
488
550
|
{
|
|
489
551
|
id: baselineId,
|
|
490
|
-
|
|
491
|
-
refSha: process.env.GITHUB_SHA || 'unknown',
|
|
552
|
+
...resolveGitRef(),
|
|
492
553
|
manifestJson,
|
|
493
554
|
captureProfileJson: JSON.stringify(this.#buildCaptureProfile())
|
|
494
555
|
},
|
|
@@ -500,37 +561,12 @@ export class SnapProvider {
|
|
|
500
561
|
}
|
|
501
562
|
}
|
|
502
563
|
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const bResultsPath = options.resultsPath || path.join(bundleDir, 'results.json');
|
|
510
|
-
const manifestPath = options.manifestPath || path.join(bundleDir, 'manifest.json');
|
|
511
|
-
|
|
512
|
-
try {
|
|
513
|
-
const results = JSON.parse(await fs.readFile(bResultsPath, 'utf-8'));
|
|
514
|
-
body.results = results;
|
|
515
|
-
} catch { /* results.json may not exist */ }
|
|
516
|
-
|
|
517
|
-
try {
|
|
518
|
-
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
|
519
|
-
body.manifest = manifest;
|
|
520
|
-
} catch { /* manifest.json may not exist */ }
|
|
521
|
-
|
|
522
|
-
if (body.results?.runId) {
|
|
523
|
-
body.idempotencyKey = `baseline-${body.results.runId}`;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
await this.#request('POST', `/v1/visual/projects/${this.#projectId}/baselines`, body, idempotencyKey);
|
|
528
|
-
|
|
529
|
-
const bundleDir = options.bundleDir
|
|
530
|
-
? path.resolve(options.bundleDir)
|
|
531
|
-
: await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
|
|
532
|
-
|
|
533
|
-
return { bundleDir };
|
|
564
|
+
// No resultsPath and no bundleDir: there is no run to build a baseline from.
|
|
565
|
+
throw new Error(
|
|
566
|
+
'Cannot publish a Snap baseline: neither resultsPath nor bundleDir was provided, so there ' +
|
|
567
|
+
'is no Snap run to harvest object keys from. Run `snapdrift baseline` (or actions/baseline) ' +
|
|
568
|
+
'with provider "snap", which captures first and passes the run results through.'
|
|
569
|
+
);
|
|
534
570
|
}
|
|
535
571
|
|
|
536
572
|
/**
|
|
@@ -609,44 +645,14 @@ export class SnapProvider {
|
|
|
609
645
|
}
|
|
610
646
|
}
|
|
611
647
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
* @param {{
|
|
621
|
-
* results: object,
|
|
622
|
-
* manifest: object,
|
|
623
|
-
* screenshots: Array<{ filename: string, data: string }>,
|
|
624
|
-
* headSha: string
|
|
625
|
-
* }} options
|
|
626
|
-
* @returns {Promise<{ uploaded: number, skipped: number, baselineId: string }>}
|
|
627
|
-
*/
|
|
628
|
-
async migrateBaselineFromLocal(options) {
|
|
629
|
-
const { results, manifest, screenshots, headSha } = options;
|
|
630
|
-
const idempotencyKey = `baseline-migrate-${headSha}`;
|
|
631
|
-
|
|
632
|
-
const body = {
|
|
633
|
-
idempotencyKey,
|
|
634
|
-
results,
|
|
635
|
-
manifest,
|
|
636
|
-
screenshots,
|
|
637
|
-
headSha,
|
|
638
|
-
captureProfile: this.#buildCaptureProfile()
|
|
639
|
-
};
|
|
640
|
-
|
|
641
|
-
const response = await this.#request('POST', `/v1/visual/projects/${this.#projectId}/baselines`, body, idempotencyKey);
|
|
642
|
-
|
|
643
|
-
const screenshotCount = screenshots?.length ?? 0;
|
|
644
|
-
return {
|
|
645
|
-
uploaded: screenshotCount,
|
|
646
|
-
skipped: 0,
|
|
647
|
-
baselineId: response.id || idempotencyKey
|
|
648
|
-
};
|
|
649
|
-
}
|
|
648
|
+
// `migrateBaselineFromLocal()` was removed in 0.7.0. It POSTed a pre-built
|
|
649
|
+
// local bundle (`manifest`/`results`/`screenshots` as objects) to the
|
|
650
|
+
// baselines API, but the server never read those fields — the PNGs were never
|
|
651
|
+
// stored and the manifest referenced local filenames rather than Snap object
|
|
652
|
+
// keys, so the call could only ever produce an unexportable baseline. Snap now
|
|
653
|
+
// rejects that body with `400 unsupported_baseline_body` (i2Dev-com/snap#653).
|
|
654
|
+
// Use `publishBaseline()` after `capture()` — or the `snapdrift baseline`
|
|
655
|
+
// command, which wires both together. See ranacseruet/snapdrift#106.
|
|
650
656
|
|
|
651
657
|
/**
|
|
652
658
|
* Export baselines from Snap for local import.
|
|
@@ -662,8 +668,8 @@ export class SnapProvider {
|
|
|
662
668
|
*
|
|
663
669
|
* @param {{ tag?: string }} [_options]
|
|
664
670
|
* @returns {Promise<{
|
|
665
|
-
* results:
|
|
666
|
-
* manifest:
|
|
671
|
+
* results: JsonObject,
|
|
672
|
+
* manifest: JsonObject,
|
|
667
673
|
* screenshots: Array<{ filename: string, data: Buffer }>,
|
|
668
674
|
* engine: { name: string, version: string }
|
|
669
675
|
* }>}
|
|
@@ -746,7 +752,7 @@ export class SnapProvider {
|
|
|
746
752
|
|
|
747
753
|
/** @type {Array<{ filename: string, data: Buffer }>} */
|
|
748
754
|
const screenshots = [];
|
|
749
|
-
/** @type {
|
|
755
|
+
/** @type {JsonObject[]} */
|
|
750
756
|
const manifestEntries = [];
|
|
751
757
|
|
|
752
758
|
for (const route of routes) {
|
|
@@ -1204,7 +1210,7 @@ export class SnapProvider {
|
|
|
1204
1210
|
/**
|
|
1205
1211
|
* Poll a run until it reaches a terminal state.
|
|
1206
1212
|
* @param {string} runId
|
|
1207
|
-
* @returns {Promise<
|
|
1213
|
+
* @returns {Promise<JsonObject>}
|
|
1208
1214
|
*/
|
|
1209
1215
|
async #pollRun(runId) {
|
|
1210
1216
|
const startTime = Date.now();
|
|
@@ -1224,7 +1230,7 @@ export class SnapProvider {
|
|
|
1224
1230
|
|
|
1225
1231
|
/**
|
|
1226
1232
|
* Map a Snap run result to a VisualDiffSummary.
|
|
1227
|
-
* @param {
|
|
1233
|
+
* @param {JsonObject} run
|
|
1228
1234
|
* @param {VisualRegressionConfig} config
|
|
1229
1235
|
* @returns {import('../types/visual-diff-types').VisualDiffSummary}
|
|
1230
1236
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snapdrift",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "SnapDrift captures, compares, and reports UI drift for Playwright-based app checks.",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"eslint": "^10.2.0",
|
|
47
47
|
"globals": "^17.4.0",
|
|
48
48
|
"jest": "^30.3.0",
|
|
49
|
-
"typescript": "^
|
|
49
|
+
"typescript": "^7.0.2"
|
|
50
50
|
},
|
|
51
51
|
"jest": {
|
|
52
52
|
"testEnvironment": "node",
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
"./lib/init-from-action.mjs": "./lib/init-from-action.mjs"
|
|
83
83
|
},
|
|
84
84
|
"files": [
|
|
85
|
+
"action.yml",
|
|
85
86
|
"actions/",
|
|
86
87
|
"bin/",
|
|
87
88
|
"lib/",
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Object-shaped data decoded from an external JSON or YAML boundary. */
|
|
2
|
+
export type JsonObject = Record<string, any>;
|
|
3
|
+
|
|
1
4
|
export type VisualViewportPreset = 'desktop' | 'mobile';
|
|
2
5
|
export interface VisualCustomViewport { width: number; height: number; }
|
|
3
6
|
export type VisualViewport = VisualViewportPreset | VisualCustomViewport;
|
|
@@ -166,8 +169,8 @@ export interface MigrateToSnapResult {
|
|
|
166
169
|
}
|
|
167
170
|
|
|
168
171
|
export interface ExportedBaseline {
|
|
169
|
-
results:
|
|
170
|
-
manifest:
|
|
172
|
+
results: JsonObject;
|
|
173
|
+
manifest: JsonObject;
|
|
171
174
|
screenshots: Array<{ filename: string; data: Buffer }>;
|
|
172
175
|
engine: { name: string; version: string };
|
|
173
176
|
}
|