snapdrift 0.12.0 → 0.13.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 +84 -0
- package/README.md +6 -2
- package/action.yml +2 -2
- package/actions/comment/action.yml +8 -37
- package/actions/pr-diff/action.yml +24 -121
- package/actions/scope/action.yml +15 -73
- package/docs/contracts.md +128 -3
- package/docs/integration-guide.md +6 -6
- package/docs/local-cli.md +57 -2
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +1 -1
- package/lib/cli.mjs +4 -0
- package/lib/github-requests.mjs +134 -0
- package/lib/outage-policy.mjs +49 -14
- package/lib/provider.mjs +19 -1
- package/lib/snap-provider.mjs +2 -1
- package/package.json +6 -5
- package/types/visual-diff-types.d.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,89 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0 - 2026-09-19
|
|
4
|
+
|
|
5
|
+
Workspace releases this version:
|
|
6
|
+
|
|
7
|
+
- `@snapdrift/compare-core` 1.4.0 — the semantic diff palette (`addedColor`/`removedColor`
|
|
8
|
+
options and the orange default for changed pixels).
|
|
9
|
+
- `@snapdrift/adapter-report-md` 1.3.0 — the exported `DIFF_IMAGE_LEGEND` constant rendered
|
|
10
|
+
alongside diff images.
|
|
11
|
+
- `@snapdrift/adapter-fs` 1.4.0 — cross-route browser storage isolation (#171), capture
|
|
12
|
+
profile v2 validation and the compatibility-before-pixels gate (#172), and the semantic
|
|
13
|
+
diff image handoff to the reporting packages.
|
|
14
|
+
- `@snapdrift/manifest` 1.5.0 — capture profile v2 schema, capture-profile helpers, and the
|
|
15
|
+
`captureCompatibility` summary types (#172).
|
|
16
|
+
|
|
17
|
+
The root dependency floors move to `@snapdrift/adapter-fs ^1.4.0`,
|
|
18
|
+
`@snapdrift/compare-core ^1.4.0`, and `@snapdrift/manifest ^1.5.0`; `adapter-fs` and
|
|
19
|
+
`adapter-report-md` in turn require `compare-core ^1.4.0`, `adapter-report-md ^1.3.0`, and
|
|
20
|
+
`manifest ^1.5.0`. The dispatcher pins 3967590 — the #174 merge commit on `main`, which
|
|
21
|
+
contains the newest `actions/` implementation (#173) and the `lib/`/`packages/` runtime the
|
|
22
|
+
wrappers load.
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
- Local manifests now persist capture profile v2: engine, browser/Playwright, OS,
|
|
27
|
+
locale/timezone, and rendering settings. Local comparison validates manifests,
|
|
28
|
+
requires exact v2 profile equality, and checks configured route paths and
|
|
29
|
+
normalized viewports before reading PNGs. Incompatibilities are
|
|
30
|
+
`incompatible_capture` errors, not product drift from changed image dimensions;
|
|
31
|
+
the summary's `captureCompatibility` status describes profiles only.
|
|
32
|
+
- Legacy missing/v1 profiles remain unverified and pixel-comparable after route
|
|
33
|
+
checks unless shared metadata conflicts or an explicit foreign engine is found.
|
|
34
|
+
The unverified diagnostic appears in CLI, Markdown, and HTML reports as well as
|
|
35
|
+
JSON summaries and PR comments. Malformed or unsupported profiles are rejected.
|
|
36
|
+
Refresh using `snapdrift baseline`
|
|
37
|
+
(`provider: "local"`) or the existing baseline action; `report-only` acknowledges
|
|
38
|
+
incompatibility nonblockingly but never bypasses it to compare pixels.
|
|
39
|
+
- Local diff images now use a semantic palette so the rendered PNG reflects the
|
|
40
|
+
kind of change, not just that pixels differ. `@snapdrift/compare-core` union-canvas
|
|
41
|
+
comparisons render pixels present only in the current capture in green (`addedColor`,
|
|
42
|
+
default `[0, 170, 0, 255]`), pixels present only in the baseline in red (`removedColor`,
|
|
43
|
+
default `[255, 0, 0, 255]`), and overlapping changed pixels in orange (`highlightColor`,
|
|
44
|
+
default `[255, 140, 0, 255]`). Metrics are unchanged — one-sided pixels still count as
|
|
45
|
+
changed under comparison policy v1. Strict same-dimension `generateDiffImage` diffs
|
|
46
|
+
switch their default highlight from red to orange for parity and cannot contain
|
|
47
|
+
added/removed pixels. Markdown, HTML, and PR-comment reports render a legend
|
|
48
|
+
("orange = changed · green = added · red = removed") whenever a local diff image is
|
|
49
|
+
embedded. Consumers comparing diff-PNG bytes between versions will see different
|
|
50
|
+
output by design; the new options restore any previous palette.
|
|
51
|
+
|
|
52
|
+
### Refactoring
|
|
53
|
+
|
|
54
|
+
- GitHub-script steps in `actions/scope`, `actions/pr-diff` (scope and PR report),
|
|
55
|
+
and `actions/comment` now delegate to the shared, dependency-injected helpers in
|
|
56
|
+
`lib/github-requests.mjs` (`fetchPullRequestFiles`, `resolveScopeDecision`,
|
|
57
|
+
`resolvePullRequestScope`, `upsertPullRequestReportComment`), so the changed-file
|
|
58
|
+
selection and comment-upsert behavior is defined once and exercised by unit and
|
|
59
|
+
real-YAML parity tests. Outputs, reasons, warnings, markers, and fallback bodies
|
|
60
|
+
are byte-for-byte identical.
|
|
61
|
+
- Providers and `captureWithPolicy`/`diffWithPolicy` results now include an explicit
|
|
62
|
+
`artifacts` descriptor (`describeCaptureArtifacts`: `localScreenshots` plus
|
|
63
|
+
`artifactsRoot` for local or hosted local-capture hybrid runs). Fallback decisions
|
|
64
|
+
prefer provider-returned capabilities, with provider-name/URL inference retained
|
|
65
|
+
for legacy results and explicit caller overrides taking precedence. Diff callers
|
|
66
|
+
without capability information or config retain the no-recapture legacy default.
|
|
67
|
+
`localScreenshots` remains, and
|
|
68
|
+
`hasLocalScreenshots` is kept as a thin wrapper. `lib/provider.mjs` exports
|
|
69
|
+
`PROVIDER_CAPABILITIES` and `providerSupports(provider, capability)` so the
|
|
70
|
+
unavailable `LocalProvider.fetchLatestBaseline` baseline-store capability is
|
|
71
|
+
discoverable without a runtime throw.
|
|
72
|
+
|
|
73
|
+
### Behavior changes
|
|
74
|
+
|
|
75
|
+
- Local capture explicitly sets `en-US` / `UTC`; localized text/date changes may
|
|
76
|
+
require baseline recapture. Fonts are not fingerprinted. See the
|
|
77
|
+
[capture profile contract](docs/contracts.md#local-capture-profile-v2) and
|
|
78
|
+
[baseline refresh guide](docs/local-cli.md#refreshing-or-acknowledging-local-baselines).
|
|
79
|
+
|
|
80
|
+
### Fixes
|
|
81
|
+
|
|
82
|
+
- Isolate each local route capture and retry in a fresh browser context, preventing
|
|
83
|
+
cookies and browser storage from leaking between routes sharing a viewport.
|
|
84
|
+
Per-viewport concurrency and result ordering are preserved. This also fixes
|
|
85
|
+
Snap local-capture hybrid runs; recapture baselines affected by leaked storage.
|
|
86
|
+
|
|
3
87
|
## 0.12.0 - 2026-09-16
|
|
4
88
|
|
|
5
89
|
### Fixes
|
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ 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.
|
|
51
|
+
uses: ranacseruet/snapdrift@v0.13.0
|
|
52
52
|
with:
|
|
53
53
|
mode: baseline
|
|
54
54
|
repo-config-path: .github/snapdrift.json
|
|
@@ -60,7 +60,7 @@ With `provider: "snap"`, baseline publication is a complete, fail-closed snapsho
|
|
|
60
60
|
|
|
61
61
|
```yaml
|
|
62
62
|
- name: SnapDrift Report
|
|
63
|
-
uses: ranacseruet/snapdrift@v0.
|
|
63
|
+
uses: ranacseruet/snapdrift@v0.13.0
|
|
64
64
|
with:
|
|
65
65
|
mode: pr-diff
|
|
66
66
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -110,6 +110,10 @@ Start with `report-only` while baselines settle. Move to `fail-on-changes` or st
|
|
|
110
110
|
| `fail-on-incomplete` | Captures are missing or comparison errors occur |
|
|
111
111
|
| `strict` | Any drift signal or incomplete comparison appears |
|
|
112
112
|
|
|
113
|
+
Local comparisons check capture profiles and configured route/path/viewport identity
|
|
114
|
+
before pixels. Incompatible captures produce incomplete errors, not product drift;
|
|
115
|
+
`report-only` never bypasses these checks. See [compatibility and baseline refresh](docs/local-cli.md#refreshing-or-acknowledging-local-baselines).
|
|
116
|
+
|
|
113
117
|
## Current constraints
|
|
114
118
|
|
|
115
119
|
- Ubuntu runners only (local CLI works on any OS Node 22+ supports)
|
package/action.yml
CHANGED
|
@@ -155,7 +155,7 @@ runs:
|
|
|
155
155
|
- name: SnapDrift baseline
|
|
156
156
|
id: baseline
|
|
157
157
|
if: inputs.mode == 'baseline'
|
|
158
|
-
uses: ranacseruet/snapdrift/actions/baseline@
|
|
158
|
+
uses: ranacseruet/snapdrift/actions/baseline@3967590687d420479ba5ec351e1394de174c1838 # v0.13.0
|
|
159
159
|
with:
|
|
160
160
|
repo-config-path: ${{ inputs.repo-config-path }}
|
|
161
161
|
route-ids: ${{ inputs.route-ids }}
|
|
@@ -165,7 +165,7 @@ runs:
|
|
|
165
165
|
- name: SnapDrift pull request diff
|
|
166
166
|
id: pr-diff
|
|
167
167
|
if: inputs.mode == 'pr-diff'
|
|
168
|
-
uses: ranacseruet/snapdrift/actions/pr-diff@
|
|
168
|
+
uses: ranacseruet/snapdrift/actions/pr-diff@3967590687d420479ba5ec351e1394de174c1838 # v0.13.0
|
|
169
169
|
with:
|
|
170
170
|
github-token: ${{ inputs.github-token }}
|
|
171
171
|
repo-config-path: ${{ inputs.repo-config-path }}
|
|
@@ -116,41 +116,12 @@ runs:
|
|
|
116
116
|
maxErrorRows
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
const requestsModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/github-requests.mjs');
|
|
120
|
+
const { upsertPullRequestReportComment } = await import(pathToFileURL(requestsModulePath));
|
|
121
|
+
await upsertPullRequestReportComment({
|
|
122
|
+
github,
|
|
123
|
+
...context.repo,
|
|
124
|
+
issueNumber,
|
|
125
|
+
body,
|
|
126
|
+
markers: PR_COMMENT_MARKERS
|
|
124
127
|
});
|
|
125
|
-
const matchingComments = comments
|
|
126
|
-
.filter((comment) => comment.body && PR_COMMENT_MARKERS.some((marker) => comment.body.includes(marker)))
|
|
127
|
-
.sort((left, right) => {
|
|
128
|
-
const leftTime = new Date(left.updated_at || left.created_at).getTime();
|
|
129
|
-
const rightTime = new Date(right.updated_at || right.created_at).getTime();
|
|
130
|
-
return rightTime - leftTime;
|
|
131
|
-
});
|
|
132
|
-
const existing = matchingComments[0];
|
|
133
|
-
|
|
134
|
-
if (existing) {
|
|
135
|
-
await github.rest.issues.updateComment({
|
|
136
|
-
owner: context.repo.owner,
|
|
137
|
-
repo: context.repo.repo,
|
|
138
|
-
comment_id: existing.id,
|
|
139
|
-
body
|
|
140
|
-
});
|
|
141
|
-
} else {
|
|
142
|
-
await github.rest.issues.createComment({
|
|
143
|
-
owner: context.repo.owner,
|
|
144
|
-
repo: context.repo.repo,
|
|
145
|
-
issue_number: issueNumber,
|
|
146
|
-
body
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
for (const duplicate of matchingComments.slice(1)) {
|
|
151
|
-
await github.rest.issues.deleteComment({
|
|
152
|
-
owner: context.repo.owner,
|
|
153
|
-
repo: context.repo.repo,
|
|
154
|
-
comment_id: duplicate.id
|
|
155
|
-
});
|
|
156
|
-
}
|
|
@@ -137,91 +137,23 @@ runs:
|
|
|
137
137
|
const { pathToFileURL } = require('url');
|
|
138
138
|
|
|
139
139
|
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
140
|
-
const { loadSnapdriftConfig
|
|
140
|
+
const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
|
|
141
|
+
const requestsModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/github-requests.mjs');
|
|
142
|
+
const { resolvePullRequestScope } = await import(pathToFileURL(requestsModulePath));
|
|
141
143
|
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
reason: 'explicit_route_ids',
|
|
156
|
-
selectedRouteIds: selected.selectedRouteIds
|
|
157
|
-
});
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (process.env.FORCE_RUN === 'true') {
|
|
162
|
-
setOutputs({
|
|
163
|
-
shouldRun: true,
|
|
164
|
-
reason: process.env.FORCE_RUN_REASON || 'forced',
|
|
165
|
-
selectedRouteIds: allRouteIds
|
|
166
|
-
});
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const prNumber = Number(process.env.INPUT_PR_NUMBER || context.payload.pull_request?.number || '');
|
|
171
|
-
if (!Number.isFinite(prNumber) || prNumber <= 0) {
|
|
172
|
-
setOutputs({
|
|
173
|
-
shouldRun: true,
|
|
174
|
-
reason: 'missing_pr_number',
|
|
175
|
-
selectedRouteIds: allRouteIds
|
|
176
|
-
});
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
const files = await github.paginate(github.rest.pulls.listFiles, {
|
|
182
|
-
owner: context.repo.owner,
|
|
183
|
-
repo: context.repo.repo,
|
|
184
|
-
pull_number: prNumber,
|
|
185
|
-
per_page: 100
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
if (!Array.isArray(files)) {
|
|
189
|
-
throw new Error('Malformed GitHub changed-file response: expected an array of file records.');
|
|
190
|
-
}
|
|
191
|
-
for (const [index, file] of files.entries()) {
|
|
192
|
-
if (!file || typeof file !== 'object' || typeof file.filename !== 'string' || file.filename.length === 0) {
|
|
193
|
-
throw new Error(`Malformed GitHub changed-file response: file record ${index} must include a non-empty string filename.`);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
if (files.length >= 3000) {
|
|
197
|
-
core.warning('GitHub returned the maximum 3000 changed-file records; the list may be truncated, so running all configured captures.');
|
|
198
|
-
setOutputs({
|
|
199
|
-
shouldRun: true,
|
|
200
|
-
reason: 'changed_files_truncated',
|
|
201
|
-
selectedRouteIds: allRouteIds
|
|
202
|
-
});
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
const changedFiles = [...new Set(files.flatMap((file) => [
|
|
206
|
-
file.filename,
|
|
207
|
-
...(file.status === 'renamed' && typeof file.previous_filename === 'string' && file.previous_filename
|
|
208
|
-
? [file.previous_filename]
|
|
209
|
-
: [])
|
|
210
|
-
]))];
|
|
211
|
-
setOutputs(
|
|
212
|
-
selectRoutesForChangedFiles(
|
|
213
|
-
config,
|
|
214
|
-
changedFiles
|
|
215
|
-
)
|
|
216
|
-
);
|
|
217
|
-
} catch (error) {
|
|
218
|
-
core.warning(`Unable to inspect PR files for SnapDrift scope; running all configured captures instead. ${error.message}`);
|
|
219
|
-
setOutputs({
|
|
220
|
-
shouldRun: true,
|
|
221
|
-
reason: 'snapdrift_scope_check_failed',
|
|
222
|
-
selectedRouteIds: allRouteIds
|
|
223
|
-
});
|
|
224
|
-
}
|
|
144
|
+
const scope = await resolvePullRequestScope({
|
|
145
|
+
github,
|
|
146
|
+
...context.repo,
|
|
147
|
+
config,
|
|
148
|
+
pullNumber: Number(process.env.INPUT_PR_NUMBER || context.payload.pull_request?.number || ''),
|
|
149
|
+
routeIds: process.env.ROUTE_IDS || '',
|
|
150
|
+
forceRun: process.env.FORCE_RUN === 'true',
|
|
151
|
+
forceRunReason: process.env.FORCE_RUN_REASON || 'forced',
|
|
152
|
+
warning: (message) => core.warning(message)
|
|
153
|
+
});
|
|
154
|
+
core.setOutput('should_run', scope.shouldRun ? 'true' : 'false');
|
|
155
|
+
core.setOutput('reason', scope.reason);
|
|
156
|
+
core.setOutput('selected_route_ids', (scope.selectedRouteIds || []).join(','));
|
|
225
157
|
|
|
226
158
|
- id: baseline
|
|
227
159
|
if: steps.scope.outputs.should_run == 'true'
|
|
@@ -739,44 +671,15 @@ runs:
|
|
|
739
671
|
maxErrorRows
|
|
740
672
|
});
|
|
741
673
|
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
674
|
+
const requestsModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/github-requests.mjs');
|
|
675
|
+
const { upsertPullRequestReportComment } = await import(pathToFileURL(requestsModulePath));
|
|
676
|
+
await upsertPullRequestReportComment({
|
|
677
|
+
github,
|
|
678
|
+
...context.repo,
|
|
679
|
+
issueNumber,
|
|
680
|
+
body,
|
|
681
|
+
markers: PR_COMMENT_MARKERS
|
|
747
682
|
});
|
|
748
|
-
const matchingComments = comments
|
|
749
|
-
.filter((comment) => comment.body && PR_COMMENT_MARKERS.some((marker) => comment.body.includes(marker)))
|
|
750
|
-
.sort((left, right) => {
|
|
751
|
-
const leftTime = new Date(left.updated_at || left.created_at).getTime();
|
|
752
|
-
const rightTime = new Date(right.updated_at || right.created_at).getTime();
|
|
753
|
-
return rightTime - leftTime;
|
|
754
|
-
});
|
|
755
|
-
const existing = matchingComments[0];
|
|
756
|
-
|
|
757
|
-
if (existing) {
|
|
758
|
-
await github.rest.issues.updateComment({
|
|
759
|
-
owner: context.repo.owner,
|
|
760
|
-
repo: context.repo.repo,
|
|
761
|
-
comment_id: existing.id,
|
|
762
|
-
body
|
|
763
|
-
});
|
|
764
|
-
} else {
|
|
765
|
-
await github.rest.issues.createComment({
|
|
766
|
-
owner: context.repo.owner,
|
|
767
|
-
repo: context.repo.repo,
|
|
768
|
-
issue_number: issueNumber,
|
|
769
|
-
body
|
|
770
|
-
});
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
for (const duplicate of matchingComments.slice(1)) {
|
|
774
|
-
await github.rest.issues.deleteComment({
|
|
775
|
-
owner: context.repo.owner,
|
|
776
|
-
repo: context.repo.repo,
|
|
777
|
-
comment_id: duplicate.id
|
|
778
|
-
});
|
|
779
|
-
}
|
|
780
683
|
|
|
781
684
|
# A skipped compare writes a summary with no diff counters, so enforcement
|
|
782
685
|
# has nothing to evaluate — `warn-and-skip` means "do not fail my build".
|
package/actions/scope/action.yml
CHANGED
|
@@ -67,77 +67,19 @@ runs:
|
|
|
67
67
|
const { pathToFileURL } = require('url');
|
|
68
68
|
|
|
69
69
|
const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
70
|
-
const { loadSnapdriftConfig
|
|
70
|
+
const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
|
|
71
|
+
const requestsModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/github-requests.mjs');
|
|
72
|
+
const { resolvePullRequestScope } = await import(pathToFileURL(requestsModulePath));
|
|
71
73
|
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
shouldRun: true,
|
|
85
|
-
reason: process.env.FORCE_RUN_REASON || 'forced',
|
|
86
|
-
selectedRouteIds: allRouteIds
|
|
87
|
-
});
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (!Number.isFinite(resolvedPrNumber) || resolvedPrNumber <= 0) {
|
|
92
|
-
setOutputs({
|
|
93
|
-
shouldRun: true,
|
|
94
|
-
reason: 'missing_pr_number',
|
|
95
|
-
selectedRouteIds: allRouteIds
|
|
96
|
-
});
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
try {
|
|
101
|
-
const files = await github.paginate(github.rest.pulls.listFiles, {
|
|
102
|
-
owner: context.repo.owner,
|
|
103
|
-
repo: context.repo.repo,
|
|
104
|
-
pull_number: resolvedPrNumber,
|
|
105
|
-
per_page: 100
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
if (!Array.isArray(files)) {
|
|
109
|
-
throw new Error('Malformed GitHub changed-file response: expected an array of file records.');
|
|
110
|
-
}
|
|
111
|
-
for (const [index, file] of files.entries()) {
|
|
112
|
-
if (!file || typeof file !== 'object' || typeof file.filename !== 'string' || file.filename.length === 0) {
|
|
113
|
-
throw new Error(`Malformed GitHub changed-file response: file record ${index} must include a non-empty string filename.`);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
if (files.length >= 3000) {
|
|
117
|
-
core.warning('GitHub returned the maximum 3000 changed-file records; the list may be truncated, so running all configured captures.');
|
|
118
|
-
setOutputs({
|
|
119
|
-
shouldRun: true,
|
|
120
|
-
reason: 'changed_files_truncated',
|
|
121
|
-
selectedRouteIds: allRouteIds
|
|
122
|
-
});
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
const changedFiles = [...new Set(files.flatMap((file) => [
|
|
126
|
-
file.filename,
|
|
127
|
-
...(file.status === 'renamed' && typeof file.previous_filename === 'string' && file.previous_filename
|
|
128
|
-
? [file.previous_filename]
|
|
129
|
-
: [])
|
|
130
|
-
]))];
|
|
131
|
-
const scope = selectRoutesForChangedFiles(
|
|
132
|
-
config,
|
|
133
|
-
changedFiles
|
|
134
|
-
);
|
|
135
|
-
setOutputs(scope);
|
|
136
|
-
} catch (error) {
|
|
137
|
-
core.warning(`Unable to inspect PR files for SnapDrift scope; running all configured captures instead. ${error.message}`);
|
|
138
|
-
setOutputs({
|
|
139
|
-
shouldRun: true,
|
|
140
|
-
reason: 'snapdrift_scope_check_failed',
|
|
141
|
-
selectedRouteIds: allRouteIds
|
|
142
|
-
});
|
|
143
|
-
}
|
|
74
|
+
const scope = await resolvePullRequestScope({
|
|
75
|
+
github,
|
|
76
|
+
...context.repo,
|
|
77
|
+
config,
|
|
78
|
+
pullNumber: Number(process.env.INPUT_PR_NUMBER || context.payload.pull_request?.number || ''),
|
|
79
|
+
forceRun: process.env.FORCE_RUN === 'true',
|
|
80
|
+
forceRunReason: process.env.FORCE_RUN_REASON || 'forced',
|
|
81
|
+
warning: (message) => core.warning(message)
|
|
82
|
+
});
|
|
83
|
+
core.setOutput('should_run', scope.shouldRun ? 'true' : 'false');
|
|
84
|
+
core.setOutput('reason', scope.reason);
|
|
85
|
+
core.setOutput('selected_route_ids', (scope.selectedRouteIds || []).join(','));
|
package/docs/contracts.md
CHANGED
|
@@ -159,6 +159,8 @@ The pull request drift bundle contains:
|
|
|
159
159
|
|
|
160
160
|
## Screenshot manifest shape
|
|
161
161
|
|
|
162
|
+
Minimal legacy example (new local captures also write `captureProfile`):
|
|
163
|
+
|
|
162
164
|
```json
|
|
163
165
|
{
|
|
164
166
|
"generatedAt": "2024-01-01T00:00:00.000Z",
|
|
@@ -176,6 +178,75 @@ The pull request drift bundle contains:
|
|
|
176
178
|
}
|
|
177
179
|
```
|
|
178
180
|
|
|
181
|
+
### Local capture profile v2
|
|
182
|
+
|
|
183
|
+
New local captures persist `captureProfile` with `schemaVersion: 2`. This version
|
|
184
|
+
is independent of the outer manifest schema (which defaults to 1 when omitted)
|
|
185
|
+
and comparison policy v1. The profile records:
|
|
186
|
+
|
|
187
|
+
| Field | Local capture value |
|
|
188
|
+
|:------|:--------------------|
|
|
189
|
+
| `engine`, `engineVersion` | `{ "name": "snapdrift-local", "version": "<adapter-fs version>" }`; `engineVersion` equals `engine.version` |
|
|
190
|
+
| `browser`, `browserRevision`, `playwrightVersion` | `chromium`, the running browser's `browser.version()` string, and the installed Playwright version |
|
|
191
|
+
| `platform` | `name`, `architecture`, `release`, `version` from Node's OS APIs |
|
|
192
|
+
| `locale`, `timezone` | `en-US`, `UTC` |
|
|
193
|
+
| `settings.screenshot` | `fullPage: true`, `animations: "disabled"`, `caret: "hide"`, `scale: "device"`, `omitBackground: false`, `type: "png"` |
|
|
194
|
+
| `settings.readiness` | `waitUntil: "load"`, `settleDelayMs: 300` |
|
|
195
|
+
| `settings.context` | `isolation: "fresh-context-per-attempt"`, `colorScheme: "light"`, `reducedMotion: "no-preference"`, `forcedColors: "none"`, `javaScriptEnabled: true`, `serviceWorkers: "allow"` |
|
|
196
|
+
| `settings.launch` | `headless: true`, `args: ["--disable-gpu"]` |
|
|
197
|
+
|
|
198
|
+
These are persisted metadata, not new configuration knobs. Local v2 requires
|
|
199
|
+
all listed fields with valid types/settings and matching engine version fields.
|
|
200
|
+
Fonts are **not fingerprinted**: local capture does not populate the optional
|
|
201
|
+
`fontsHash`, so a verified profile is not proof of identical installed fonts.
|
|
202
|
+
Explicit `en-US` / `UTC` replaces environment-dependent defaults and may change
|
|
203
|
+
localized text or dates; recapture affected baselines after upgrading.
|
|
204
|
+
|
|
205
|
+
### Local compatibility before pixels
|
|
206
|
+
|
|
207
|
+
Both manifests are validated after loading and before manifest entries are
|
|
208
|
+
indexed and PNGs are resolved.
|
|
209
|
+
Malformed manifests/profiles and unsupported profile schema versions (anything
|
|
210
|
+
other than omitted, 1, or 2) throw before a comparison summary is generated;
|
|
211
|
+
`report-only` does not suppress these input errors.
|
|
212
|
+
|
|
213
|
+
- Two local v2 profiles must match exactly across the full profile, including
|
|
214
|
+
extra fields. Object key order is ignored; array order and values matter.
|
|
215
|
+
There is no version tolerance: adapter, Playwright, browser, or OS updates can
|
|
216
|
+
require a baseline refresh. The reason identifies the first differing field.
|
|
217
|
+
- For each selected configured route id present in both manifests, both entries
|
|
218
|
+
must have the exact configured `path` and the same normalized viewport as the
|
|
219
|
+
config: width, height, device scale factor, mobile, and touch. Paths are not
|
|
220
|
+
URL-normalized; `baseUrl` and timestamps are not compared. Custom viewports use
|
|
221
|
+
scale 1, mobile false, touch false: 1440×900 equals `desktop`, but 390×844 does
|
|
222
|
+
not equal `mobile`. Missing entries retain their missing/error classification.
|
|
223
|
+
- A profile or route-identity mismatch adds an `errors[]` item with
|
|
224
|
+
`status: "error"` and `code: "incompatible_capture"`. That route's PNGs are
|
|
225
|
+
not resolved, read, or pixel-compared, even if they would be byte-identical.
|
|
226
|
+
The summary is `incomplete`, not product drift. With compatible identity,
|
|
227
|
+
changed full-page raster dimensions remain normal union-canvas `changed[]`
|
|
228
|
+
signals, independent of threshold.
|
|
229
|
+
- Missing profiles or profiles with omitted/v1 schema remain usable but
|
|
230
|
+
`unverified`, with a warning in `summary.message`; pixels can still be compared
|
|
231
|
+
after route checks. If both profiles provide `browser`, `browserRevision`,
|
|
232
|
+
`fontsHash`, `timezone`, or `locale`, a difference in any shared field is
|
|
233
|
+
incompatible. An explicit foreign `engine.name` on either side is incompatible
|
|
234
|
+
even when the other profile is absent. Legacy engine versions alone do not
|
|
235
|
+
establish verified compatibility.
|
|
236
|
+
|
|
237
|
+
`summary.captureCompatibility` contains profile-level `status` (`verified`,
|
|
238
|
+
`unverified`, or `incompatible`) and an optional `reason`. It is **not** an
|
|
239
|
+
aggregate route status: profiles can be `verified` while route path/viewport
|
|
240
|
+
checks produce `incompatible_capture` errors. Unverified alone does not make a
|
|
241
|
+
run incomplete or fail enforcement.
|
|
242
|
+
|
|
243
|
+
Refresh the baseline using the [existing CLI or baseline action](local-cli.md#refreshing-or-acknowledging-local-baselines)
|
|
244
|
+
in the intended capture environment. For intentional nonblocking acknowledgement,
|
|
245
|
+
set `diff.mode` to `report-only`: it preserves incompatibility errors and never
|
|
246
|
+
overrides compatibility to compare pixels. `fail-on-incomplete` and `strict`
|
|
247
|
+
fail on those errors; `fail-on-changes` does not fail on incompatibility alone
|
|
248
|
+
but still fails if other comparable routes changed.
|
|
249
|
+
|
|
179
250
|
## Summary shape
|
|
180
251
|
|
|
181
252
|
```json
|
|
@@ -209,6 +280,7 @@ The pull request drift bundle contains:
|
|
|
209
280
|
|
|
210
281
|
| Field | Type | Description |
|
|
211
282
|
|:------|:-----|:------------|
|
|
283
|
+
| `captureCompatibility` | `{ status, reason? }?` | Local profile-level compatibility: `verified`, `unverified`, or `incompatible`; route identity errors are separate |
|
|
212
284
|
| `dashboardUrl` | `string?` | Snap dashboard URL for the run (set by `SnapProvider`; omitted by `LocalProvider`) |
|
|
213
285
|
| `comparisonPolicy` | `{ "version": 1, "threshold": number }?` | Effective v1 comparison policy used by the local adapter (synthesized from `diff.threshold` when not configured) |
|
|
214
286
|
|
|
@@ -247,11 +319,12 @@ Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
|
|
|
247
319
|
|
|
248
320
|
## Drift semantics
|
|
249
321
|
|
|
250
|
-
- Screenshots are matched by `id
|
|
322
|
+
- Screenshots are matched by `id`; local profile and configured path/normalized viewport compatibility are checked before pixels (see [Local compatibility before pixels](#local-compatibility-before-pixels))
|
|
251
323
|
- Mismatch ratio is `different_pixels / total_pixels`
|
|
252
324
|
- `diff.threshold` applies per screenshot
|
|
253
325
|
- Missing captures are counted separately from drift signals
|
|
254
326
|
- Comparison policy v1 `{ "version": 1, "threshold": number }` is always applied. When `diff.comparisonPolicy` is absent, SnapDrift synthesizes it from `diff.threshold`. Images are top-left aligned on a max-dimension union canvas with no scaling. Overlap pixels are compared, one-sided pixels count as changed (including transparent pixels), and empty union corners do not enter the denominator. Dimension changes land in `changed[]` with comparison metadata and a generated local diff image.
|
|
327
|
+
- Local diff images are rendered with a semantic palette: orange = pixels that differ between baseline and current, green = pixels present only in the current capture (added), red = pixels present only in the baseline (removed). The palette is a rendering detail of `@snapdrift/compare-core` (`addedColor`/`removedColor`/`highlightColor` options); the frozen v1 comparison metrics are unaffected. Equal-dimension strict diffs (`generateDiffImage`) use orange for changed pixels and cannot contain added/removed pixels. Reports render a legend whenever a local diff image is embedded; summaries that carry only comparison metadata (Snap provider runs, which render diffs hosted-side) render no legend.
|
|
255
328
|
- `dimensionChanges[]` remains part of the summary contract but is empty for local comparisons in v1. Strict same-dimension comparison (`compareBuffers`) remains available to direct `@snapdrift/compare-core`/`comparePngs` callers that pass no policy.
|
|
256
329
|
- Threshold is applied after pixel aggregation. A mismatch exactly equal to the threshold is matched; a dimension change is still a changed signal independent of ratio.
|
|
257
330
|
- `diff.mode` controls enforcement, not summary generation
|
|
@@ -260,7 +333,7 @@ Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
|
|
|
260
333
|
|
|
261
334
|
| Mode | Stops the run when |
|
|
262
335
|
|:-----|:-------------------|
|
|
263
|
-
| `report-only` | Never |
|
|
336
|
+
| `report-only` | Never from summary enforcement; validation/capture failures still fail |
|
|
264
337
|
| `fail-on-changes` | `changedScreenshots > 0` |
|
|
265
338
|
| `fail-on-incomplete` | Errors or missing captures occur; a completed v1 dimension comparison alone does not fail |
|
|
266
339
|
| `strict` | Any drift or incomplete comparison appears |
|
|
@@ -349,6 +422,14 @@ that matches the product behavior. Do not resize or crop screenshots, or lower
|
|
|
349
422
|
| Screenshot animations | `disabled` (Playwright finishes/cancels CSS animations before capture) |
|
|
350
423
|
| Capture concurrency | 5 routes per viewport (overridable via `SNAPDRIFT_CAPTURE_CONCURRENCY`)|
|
|
351
424
|
|
|
425
|
+
Local Playwright captures use a fresh browser context for every route attempt,
|
|
426
|
+
including retries. Cookies and browser storage are not shared between routes,
|
|
427
|
+
even when routes share a viewport or run serially. This also applies to Snap's
|
|
428
|
+
local-capture hybrid, not to rendering performed by the hosted service. Viewport
|
|
429
|
+
groups still run concurrently, with the configured limit applied per group.
|
|
430
|
+
Previously leaked storage may have affected existing baselines; recapture them
|
|
431
|
+
after upgrading if storage-dependent UI produces a one-time change.
|
|
432
|
+
|
|
352
433
|
## Local CLI directory layout
|
|
353
434
|
|
|
354
435
|
When using the `snapdrift` CLI, outputs are written to `.snapdrift/` by default:
|
|
@@ -541,6 +622,50 @@ The wrapper actions (`actions/baseline`, `actions/pr-diff`) and the CLI both han
|
|
|
541
622
|
request or response body is being bounded. It is not an additional public
|
|
542
623
|
error class; callers should handle the exported error classes above.
|
|
543
624
|
|
|
625
|
+
## Shared orchestration
|
|
626
|
+
|
|
627
|
+
GitHub-facing request handling is shared between the wrapper and standalone
|
|
628
|
+
actions through `lib/github-requests.mjs`. The github-script steps stay as thin
|
|
629
|
+
adapters that load the module, inject `{ github, owner, repo, context }`-style
|
|
630
|
+
inputs, and map the returned decision to step outputs:
|
|
631
|
+
|
|
632
|
+
- `fetchPullRequestFiles` paginates `pulls.listFiles` (100 per page) and
|
|
633
|
+
validates each record's `filename` before any scope decision is made.
|
|
634
|
+
- `resolveScopeDecision` is pure: renamed files contribute
|
|
635
|
+
`previous_filename`, paths are deduplicated, 3000+ records short-circuit to
|
|
636
|
+
running all routes (`changed_files_truncated`), and an empty list maps to
|
|
637
|
+
`no_changed_files`. Other reasons come from
|
|
638
|
+
`selectRoutesForChangedFiles`.
|
|
639
|
+
- `resolvePullRequestScope` composes the above with the
|
|
640
|
+
explicit-route/force-run/missing-PR shortcuts and the
|
|
641
|
+
`snapdrift_scope_check_failed` fallback warning.
|
|
642
|
+
- `upsertPullRequestReportComment` performs the marker-filtered, newest-first
|
|
643
|
+
comment upsert with duplicate deletion used by `actions/comment` and the
|
|
644
|
+
`actions/pr-diff` report step. The create-only "Capture Failed" /
|
|
645
|
+
"Baseline Lookup Failed" fallback body remains inline in `actions/pr-diff`.
|
|
646
|
+
|
|
647
|
+
The baseline resolver keeps its own module (`lib/resolve-baseline-artifact.mjs`)
|
|
648
|
+
because both actions already share it verbatim.
|
|
649
|
+
|
|
650
|
+
### Capture artifact capabilities
|
|
651
|
+
|
|
652
|
+
Providers now return `artifacts` on capture results, attached in the provider layer
|
|
653
|
+
rather than adapter-fs:
|
|
654
|
+
|
|
655
|
+
- `localScreenshots`: whether PNGs are available locally for pixel comparison.
|
|
656
|
+
- `artifactsRoot`: the capture's `screenshotsRoot` for local and Snap hybrid
|
|
657
|
+
captures (`localScreenshots: true`); `undefined` for hosted remote captures
|
|
658
|
+
(`localScreenshots: false`), even though their metadata has a local path.
|
|
659
|
+
|
|
660
|
+
`captureWithPolicy` prefers `result.artifacts` over provider-name/base-URL inference.
|
|
661
|
+
For `diffWithPolicy`, pass the capture as `captureResult` or forward its `artifacts`.
|
|
662
|
+
Precedence is the legacy `options.localScreenshots` boolean (if supplied), then
|
|
663
|
+
`options.artifacts`, then `captureResult.artifacts`, then provider/config inference.
|
|
664
|
+
The boolean overrides only `localScreenshots`; false clears `artifactsRoot`.
|
|
665
|
+
Inference remains for legacy results without a descriptor when config is supplied.
|
|
666
|
+
If no capability arguments, result descriptor, or config are supplied to
|
|
667
|
+
`diffWithPolicy`, it preserves the legacy local-screenshots default without recapture.
|
|
668
|
+
|
|
544
669
|
## Primary entrypoints
|
|
545
670
|
|
|
546
671
|
- `actions/baseline`
|
|
@@ -567,7 +692,7 @@ These are for custom orchestration only. Wrapper actions set them automatically.
|
|
|
567
692
|
| `SNAPDRIFT_BASELINE_ARTIFACT_NAME` | `compare-results.mjs` | Baseline artifact label to embed in the report |
|
|
568
693
|
| `SNAPDRIFT_BASELINE_SOURCE_SHA` | `compare-results.mjs` | Baseline source SHA to embed in the report |
|
|
569
694
|
| `SNAPDRIFT_ENFORCE_OUTCOME` | `compare-results.mjs` | Set to `0` to disable enforcement in direct CLI usage |
|
|
570
|
-
| `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture-routes.mjs` | Max concurrent route captures per viewport
|
|
695
|
+
| `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture-routes.mjs` | Max concurrent route captures per viewport group (positive integer, default `5`). Each local attempt uses a fresh browser context. Set to `1` to serialize each group without sharing storage. |
|
|
571
696
|
|
|
572
697
|
## PR comment markdown shape
|
|
573
698
|
|
|
@@ -46,7 +46,7 @@ SnapDrift owns route selection, capture, comparison, skipped-report generation,
|
|
|
46
46
|
|
|
47
47
|
```yaml
|
|
48
48
|
- name: SnapDrift Baseline
|
|
49
|
-
uses: ranacseruet/snapdrift@v0.
|
|
49
|
+
uses: ranacseruet/snapdrift@v0.13.0
|
|
50
50
|
with:
|
|
51
51
|
mode: baseline
|
|
52
52
|
repo-config-path: .github/snapdrift.json
|
|
@@ -71,7 +71,7 @@ Then add SnapDrift after the app is running:
|
|
|
71
71
|
|
|
72
72
|
```yaml
|
|
73
73
|
- name: SnapDrift Report
|
|
74
|
-
uses: ranacseruet/snapdrift@v0.
|
|
74
|
+
uses: ranacseruet/snapdrift@v0.13.0
|
|
75
75
|
with:
|
|
76
76
|
mode: pr-diff
|
|
77
77
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -86,8 +86,8 @@ The wrappers remain published in their own right, and are equivalent to the disp
|
|
|
86
86
|
|
|
87
87
|
| Entry point | Equivalent to |
|
|
88
88
|
|-------------|---------------|
|
|
89
|
-
| `ranacseruet/snapdrift@v0.
|
|
90
|
-
| `ranacseruet/snapdrift@v0.
|
|
89
|
+
| `ranacseruet/snapdrift@v0.13.0` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.13.0` |
|
|
90
|
+
| `ranacseruet/snapdrift@v0.13.0` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.13.0` |
|
|
91
91
|
|
|
92
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
93
|
|
|
@@ -130,7 +130,7 @@ jobs:
|
|
|
130
130
|
done
|
|
131
131
|
|
|
132
132
|
- name: SnapDrift Report
|
|
133
|
-
uses: ranacseruet/snapdrift@v0.
|
|
133
|
+
uses: ranacseruet/snapdrift@v0.13.0
|
|
134
134
|
with:
|
|
135
135
|
mode: pr-diff
|
|
136
136
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -170,7 +170,7 @@ jobs:
|
|
|
170
170
|
done
|
|
171
171
|
|
|
172
172
|
- name: SnapDrift Report
|
|
173
|
-
uses: ranacseruet/snapdrift@v0.
|
|
173
|
+
uses: ranacseruet/snapdrift@v0.13.0
|
|
174
174
|
with:
|
|
175
175
|
mode: pr-diff
|
|
176
176
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
package/docs/local-cli.md
CHANGED
|
@@ -203,6 +203,61 @@ The `--open` flag opens the HTML report in your default browser when the diff is
|
|
|
203
203
|
|
|
204
204
|
---
|
|
205
205
|
|
|
206
|
+
## Refreshing or acknowledging local baselines
|
|
207
|
+
|
|
208
|
+
New local manifests record a [v2 capture profile](contracts.md#local-capture-profile-v2)
|
|
209
|
+
with exact engine, Playwright/browser, OS, locale/timezone, and rendering settings.
|
|
210
|
+
Manifests are validated after loading; then, before resolving PNGs, comparison
|
|
211
|
+
checks the profiles and each selected route's id, exact configured path, and
|
|
212
|
+
normalized viewport. A mismatch produces an
|
|
213
|
+
`incompatible_capture` error and an `incomplete` summary, not a pixel drift
|
|
214
|
+
signal. Matching profiles can still have route-identity errors:
|
|
215
|
+
`summary.captureCompatibility.status` describes profiles only.
|
|
216
|
+
|
|
217
|
+
To refresh, serve the intended accepted application state using the same capture
|
|
218
|
+
environment and route config as future diffs. With `provider: "local"`, run:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
snapdrift baseline --config .github/snapdrift.json --baseline-dir .snapdrift/baseline
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
This overwrites baseline metadata and captured screenshots; `snapdrift capture`
|
|
225
|
+
with the same options is equivalent for the local provider. Capture all routes
|
|
226
|
+
for a complete replacement, without `--routes` or `SNAPDRIFT_ROUTE_IDS` scoping.
|
|
227
|
+
Then serve the candidate application state and run:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
snapdrift diff --config .github/snapdrift.json --baseline-dir .snapdrift/baseline --open
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
In CI, run the existing default-branch baseline workflow using the root action's
|
|
234
|
+
`mode: baseline` ([Quickstart](../README.md#quickstart)) or `actions/baseline`.
|
|
235
|
+
Use the same upgraded SnapDrift/action revision and runner environment for
|
|
236
|
+
baseline and PR captures, with artifact upload enabled; then rerun the PR diff.
|
|
237
|
+
A baseline captured on a different OS is not interchangeable merely because
|
|
238
|
+
both commands use Playwright.
|
|
239
|
+
|
|
240
|
+
For intentional nonblocking acknowledgement, set `diff.mode` to `report-only`
|
|
241
|
+
in the config and rerun `snapdrift diff --open`. This keeps the incompatibility
|
|
242
|
+
errors visible and **never overrides compatibility to compare pixels**; there
|
|
243
|
+
is no diff acceptance flag. `fail-on-incomplete` and `strict` fail on these
|
|
244
|
+
errors; `fail-on-changes` fails only if other comparable routes changed. Once
|
|
245
|
+
identity is compatible, a changed full-page PNG size is normal product drift,
|
|
246
|
+
not incompatibility, and fails `fail-on-changes` / `strict` even below threshold.
|
|
247
|
+
|
|
248
|
+
Legacy manifests with missing profiles or omitted/v1 profile schema remain
|
|
249
|
+
pixel-comparable after route checks, but report `unverified` with a warning.
|
|
250
|
+
Conflicting shared browser/revision, fonts hash, locale, or timezone fields, or
|
|
251
|
+
an explicit foreign engine on either side, are still incompatible. Malformed
|
|
252
|
+
manifests/profiles and unsupported profile schema versions abort comparison,
|
|
253
|
+
even in `report-only`; refresh rather than editing metadata to force a match.
|
|
254
|
+
|
|
255
|
+
Local capture now explicitly uses `en-US` and `UTC`; localized text/date changes
|
|
256
|
+
may require recapture after upgrading. Fonts are not fingerprinted, so keep
|
|
257
|
+
installed fonts consistent even when profiles are verified.
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
206
261
|
## Local directory layout
|
|
207
262
|
|
|
208
263
|
After running `capture` and `diff` with default paths:
|
|
@@ -239,7 +294,7 @@ Add `.snapdrift/` to your `.gitignore` to keep local run output out of version c
|
|
|
239
294
|
| `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 |
|
|
240
295
|
| `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)) |
|
|
241
296
|
|
|
242
|
-
Enforcement follows the same `diff.mode` rules as the GitHub Actions workflow. Set `"mode": "report-only"`
|
|
297
|
+
Enforcement follows the same `diff.mode` rules as the GitHub Actions workflow. Set `"mode": "report-only"` for nonblocking report inspection; capture, input-validation, and other command failures still exit 1 and can prevent report generation.
|
|
243
298
|
|
|
244
299
|
---
|
|
245
300
|
|
|
@@ -310,7 +365,7 @@ differ; `fail-on-changes` and `strict` still enforce it.
|
|
|
310
365
|
|
|
311
366
|
| Variable | Applies to | Description |
|
|
312
367
|
|:---------|:-----------|:------------|
|
|
313
|
-
| `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture`, `diff` | Max concurrent route operations (positive integer, default `5`): route captures per viewport
|
|
368
|
+
| `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture`, `diff` | Max concurrent route operations (positive integer, default `5`): route captures per viewport group, and per-route resolve/compare during `diff`. Each local capture attempt uses isolated browser storage. Set to `1` to serialize captures within each viewport group, without sharing storage. |
|
|
314
369
|
| `SNAPDRIFT_CONFIG_PATH` | `capture`, `diff`, `migrate-baselines` | Override the config file path. Equivalent to `--config`. |
|
|
315
370
|
| `SNAPDRIFT_ROUTE_IDS` | `capture`, `baseline`, `diff` | Comma-separated route ids to scope to. Equivalent to `--routes`; it may not reduce a hosted baseline's complete route set. |
|
|
316
371
|
|
package/lib/cli.mjs
CHANGED
|
@@ -214,6 +214,10 @@ export function printSummary(summary) {
|
|
|
214
214
|
if (errors > 0) process.stdout.write(` Errors: ${errors}\n`);
|
|
215
215
|
if (dimensionChanges > 0) process.stdout.write(` Dim diff: ${dimensionChanges}\n`);
|
|
216
216
|
|
|
217
|
+
if (summary.captureCompatibility?.status === 'unverified' && summary.message) {
|
|
218
|
+
process.stdout.write(` Note: ${summary.message}\n`);
|
|
219
|
+
}
|
|
220
|
+
|
|
217
221
|
if (summary.changed?.length) {
|
|
218
222
|
process.stdout.write('\n Changed routes:\n');
|
|
219
223
|
for (const item of summary.changed) {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { selectConfiguredRoutes, selectRoutesForChangedFiles, splitCommaList } from './snapdrift-config.mjs';
|
|
4
|
+
|
|
5
|
+
/** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
|
|
6
|
+
/** @typedef {{ filename: string, status?: string, previous_filename?: string }} PullRequestFile */
|
|
7
|
+
/** @typedef {ReturnType<typeof selectRoutesForChangedFiles>} ScopeDecision */
|
|
8
|
+
/** @typedef {{ paginate: Function, rest: { pulls: { listFiles: Function } } }} ScopeGitHubClient */
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{
|
|
11
|
+
* paginate: Function,
|
|
12
|
+
* rest: { issues: {
|
|
13
|
+
* listComments: Function,
|
|
14
|
+
* createComment: Function,
|
|
15
|
+
* updateComment: Function,
|
|
16
|
+
* deleteComment: Function
|
|
17
|
+
* } }
|
|
18
|
+
* }} CommentGitHubClient
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ github: ScopeGitHubClient, owner: string, repo: string, pullNumber: number }} options
|
|
23
|
+
* @returns {Promise<PullRequestFile[]>}
|
|
24
|
+
*/
|
|
25
|
+
export async function fetchPullRequestFiles({ github, owner, repo, pullNumber }) {
|
|
26
|
+
const files = await github.paginate(github.rest.pulls.listFiles, {
|
|
27
|
+
owner,
|
|
28
|
+
repo,
|
|
29
|
+
pull_number: pullNumber,
|
|
30
|
+
per_page: 100
|
|
31
|
+
});
|
|
32
|
+
if (!Array.isArray(files)) {
|
|
33
|
+
throw new Error('Malformed GitHub changed-file response: expected an array of file records.');
|
|
34
|
+
}
|
|
35
|
+
for (const [index, file] of files.entries()) {
|
|
36
|
+
if (!file || typeof file !== 'object' || typeof file.filename !== 'string' || file.filename.length === 0) {
|
|
37
|
+
throw new Error(`Malformed GitHub changed-file response: file record ${index} must include a non-empty string filename.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return files;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{ config: VisualRegressionConfig, files: PullRequestFile[] }} options
|
|
45
|
+
* @returns {ScopeDecision}
|
|
46
|
+
*/
|
|
47
|
+
export function resolveScopeDecision({ config, files }) {
|
|
48
|
+
if (files.length >= 3000) {
|
|
49
|
+
return { shouldRun: true, reason: 'changed_files_truncated', selectedRouteIds: (config.routes || []).map((route) => route.id) };
|
|
50
|
+
}
|
|
51
|
+
const changedFiles = [...new Set(files.flatMap((file) => [
|
|
52
|
+
file.filename,
|
|
53
|
+
...(file.status === 'renamed' && typeof file.previous_filename === 'string' && file.previous_filename
|
|
54
|
+
? [file.previous_filename]
|
|
55
|
+
: [])
|
|
56
|
+
]))];
|
|
57
|
+
return selectRoutesForChangedFiles(config, changedFiles);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {{
|
|
62
|
+
* github: ScopeGitHubClient,
|
|
63
|
+
* owner: string,
|
|
64
|
+
* repo: string,
|
|
65
|
+
* config: VisualRegressionConfig,
|
|
66
|
+
* pullNumber: number,
|
|
67
|
+
* routeIds?: string,
|
|
68
|
+
* forceRun?: boolean,
|
|
69
|
+
* forceRunReason?: string,
|
|
70
|
+
* warning?: (message: string) => void
|
|
71
|
+
* }} options
|
|
72
|
+
* @returns {Promise<ScopeDecision>}
|
|
73
|
+
*/
|
|
74
|
+
export async function resolvePullRequestScope({ github, owner, repo, config, pullNumber, routeIds = '', forceRun = false, forceRunReason = 'forced', warning = (_message) => {} }) {
|
|
75
|
+
const allRouteIds = (config.routes || []).map((route) => route.id);
|
|
76
|
+
const explicitRouteIds = splitCommaList(routeIds);
|
|
77
|
+
if (explicitRouteIds.length > 0) {
|
|
78
|
+
const selected = selectConfiguredRoutes(config, explicitRouteIds);
|
|
79
|
+
return { shouldRun: true, reason: 'explicit_route_ids', selectedRouteIds: selected.selectedRouteIds };
|
|
80
|
+
}
|
|
81
|
+
if (forceRun) {
|
|
82
|
+
return { shouldRun: true, reason: forceRunReason || 'forced', selectedRouteIds: allRouteIds };
|
|
83
|
+
}
|
|
84
|
+
if (!Number.isFinite(pullNumber) || pullNumber <= 0) {
|
|
85
|
+
return { shouldRun: true, reason: 'missing_pr_number', selectedRouteIds: allRouteIds };
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const files = await fetchPullRequestFiles({ github, owner, repo, pullNumber });
|
|
89
|
+
const scope = resolveScopeDecision({ config, files });
|
|
90
|
+
if (scope.reason === 'changed_files_truncated') {
|
|
91
|
+
warning('GitHub returned the maximum 3000 changed-file records; the list may be truncated, so running all configured captures.');
|
|
92
|
+
}
|
|
93
|
+
return scope;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
warning(`Unable to inspect PR files for SnapDrift scope; running all configured captures instead. ${error.message}`);
|
|
96
|
+
return { shouldRun: true, reason: 'snapdrift_scope_check_failed', selectedRouteIds: allRouteIds };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {{
|
|
102
|
+
* github: CommentGitHubClient,
|
|
103
|
+
* owner: string,
|
|
104
|
+
* repo: string,
|
|
105
|
+
* issueNumber: number,
|
|
106
|
+
* body: string,
|
|
107
|
+
* markers: readonly string[]
|
|
108
|
+
* }} options
|
|
109
|
+
* @returns {Promise<void>}
|
|
110
|
+
*/
|
|
111
|
+
export async function upsertPullRequestReportComment({ github, owner, repo, issueNumber, body, markers }) {
|
|
112
|
+
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
113
|
+
owner,
|
|
114
|
+
repo,
|
|
115
|
+
issue_number: issueNumber,
|
|
116
|
+
per_page: 100
|
|
117
|
+
});
|
|
118
|
+
const matchingComments = comments
|
|
119
|
+
.filter((comment) => comment.body && markers.some((marker) => comment.body.includes(marker)))
|
|
120
|
+
.sort((left, right) => {
|
|
121
|
+
const leftTime = new Date(left.updated_at || left.created_at).getTime();
|
|
122
|
+
const rightTime = new Date(right.updated_at || right.created_at).getTime();
|
|
123
|
+
return rightTime - leftTime;
|
|
124
|
+
});
|
|
125
|
+
const existing = matchingComments[0];
|
|
126
|
+
if (existing) {
|
|
127
|
+
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
|
128
|
+
} else {
|
|
129
|
+
await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body });
|
|
130
|
+
}
|
|
131
|
+
for (const duplicate of matchingComments.slice(1)) {
|
|
132
|
+
await github.rest.issues.deleteComment({ owner, repo, comment_id: duplicate.id });
|
|
133
|
+
}
|
|
134
|
+
}
|
package/lib/outage-policy.mjs
CHANGED
|
@@ -73,10 +73,21 @@ function createBaselineLookupFallbackError(outageError, phase, lookupMessage) {
|
|
|
73
73
|
* @returns {boolean}
|
|
74
74
|
*/
|
|
75
75
|
export function hasLocalScreenshots(providerName, config) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
return describeCaptureArtifacts(providerName, undefined, config).localScreenshots;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Prefer provider-returned artifacts; infer capabilities only for legacy results.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} providerName
|
|
83
|
+
* @param {Partial<CaptureResult>} [result]
|
|
84
|
+
* @param {VisualRegressionConfig} [config]
|
|
85
|
+
* @returns {{ localScreenshots: boolean, artifactsRoot?: string }}
|
|
86
|
+
*/
|
|
87
|
+
export function describeCaptureArtifacts(providerName, result, config) {
|
|
88
|
+
if (result?.artifacts) return result.artifacts;
|
|
89
|
+
const localScreenshots = providerName !== 'snap' || isLocalBaseUrl(config?.baseUrl);
|
|
90
|
+
return { localScreenshots, artifactsRoot: localScreenshots ? result?.screenshotsRoot : undefined };
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
/**
|
|
@@ -97,7 +108,8 @@ export function hasLocalScreenshots(providerName, config) {
|
|
|
97
108
|
* outcome: 'captured' | 'skipped',
|
|
98
109
|
* providerName: string,
|
|
99
110
|
* result?: CaptureResult,
|
|
100
|
-
* localScreenshots: boolean
|
|
111
|
+
* localScreenshots: boolean,
|
|
112
|
+
* artifacts: ReturnType<typeof describeCaptureArtifacts>
|
|
101
113
|
* }>}
|
|
102
114
|
*/
|
|
103
115
|
export async function captureWithPolicy(options) {
|
|
@@ -108,16 +120,18 @@ export async function captureWithPolicy(options) {
|
|
|
108
120
|
|
|
109
121
|
try {
|
|
110
122
|
const result = await options.provider.capture(options.captureOptions);
|
|
123
|
+
const artifacts = describeCaptureArtifacts(providerName, result, options.config);
|
|
111
124
|
return {
|
|
112
125
|
outcome: 'captured',
|
|
113
126
|
providerName,
|
|
114
127
|
result,
|
|
115
|
-
|
|
128
|
+
artifacts,
|
|
129
|
+
localScreenshots: artifacts.localScreenshots
|
|
116
130
|
};
|
|
117
131
|
} catch (error) {
|
|
118
132
|
if (error instanceof SnapSkipError) {
|
|
119
133
|
onSkip(error);
|
|
120
|
-
return { outcome: 'skipped', providerName, localScreenshots: false };
|
|
134
|
+
return { outcome: 'skipped', providerName, localScreenshots: false, artifacts: { localScreenshots: false } };
|
|
121
135
|
}
|
|
122
136
|
if (error instanceof SnapFallbackError) {
|
|
123
137
|
onFallback(error);
|
|
@@ -127,7 +141,8 @@ export async function captureWithPolicy(options) {
|
|
|
127
141
|
const result = await createLocalProvider().capture(options.captureOptions);
|
|
128
142
|
// The rest of the pipeline must be told the *effective* provider, not the
|
|
129
143
|
// configured one — otherwise it rebuilds SnapProvider around local output.
|
|
130
|
-
|
|
144
|
+
const artifacts = describeCaptureArtifacts('local', result, options.config);
|
|
145
|
+
return { outcome: 'captured', providerName: 'local', result, localScreenshots: artifacts.localScreenshots, artifacts };
|
|
131
146
|
}
|
|
132
147
|
throw error;
|
|
133
148
|
}
|
|
@@ -147,12 +162,19 @@ export async function captureWithPolicy(options) {
|
|
|
147
162
|
* does not, so we recapture locally first rather than diffing a manifest of
|
|
148
163
|
* zero-dimension placeholders.
|
|
149
164
|
*
|
|
165
|
+
* Capability precedence: legacy `localScreenshots` overrides `artifacts`, then
|
|
166
|
+
* `captureResult.artifacts`, then provider/config inference. Without any of those
|
|
167
|
+
* inputs (including config), preserve the legacy local-screenshots default.
|
|
168
|
+
*
|
|
150
169
|
* @param {{
|
|
151
170
|
* provider: VisualProvider,
|
|
152
171
|
* providerName?: string,
|
|
153
172
|
* diffOptions: DiffOptions,
|
|
173
|
+
* captureResult?: CaptureResult,
|
|
154
174
|
* captureOptions?: CaptureOptions,
|
|
155
175
|
* localScreenshots?: boolean,
|
|
176
|
+
* artifacts?: ReturnType<typeof describeCaptureArtifacts>,
|
|
177
|
+
* config?: VisualRegressionConfig,
|
|
156
178
|
* baselineAvailable?: boolean,
|
|
157
179
|
* createLocalProvider?: () => VisualProvider,
|
|
158
180
|
* onSkip?: (error: SnapSkipError) => void,
|
|
@@ -166,11 +188,23 @@ export async function captureWithPolicy(options) {
|
|
|
166
188
|
* outcome: 'diffed' | 'skipped' | 'baseline-unavailable',
|
|
167
189
|
* providerName: string,
|
|
168
190
|
* result?: DiffResult,
|
|
169
|
-
* recapture?: CaptureResult
|
|
191
|
+
* recapture?: CaptureResult,
|
|
192
|
+
* localScreenshots: boolean,
|
|
193
|
+
* artifacts: ReturnType<typeof describeCaptureArtifacts>
|
|
170
194
|
* }>}
|
|
171
195
|
*/
|
|
172
196
|
export async function diffWithPolicy(options) {
|
|
173
197
|
const providerName = options.providerName || 'local';
|
|
198
|
+
/** @type {Partial<CaptureResult>} */
|
|
199
|
+
const captureResult = options.captureResult || { screenshotsRoot: options.diffOptions.currentRunDir };
|
|
200
|
+
const described = options.artifacts || (captureResult.artifacts || options.config
|
|
201
|
+
? describeCaptureArtifacts(providerName, captureResult, options.config)
|
|
202
|
+
: { localScreenshots: true, artifactsRoot: captureResult.screenshotsRoot });
|
|
203
|
+
let artifacts = {
|
|
204
|
+
...described,
|
|
205
|
+
localScreenshots: options.localScreenshots ?? described.localScreenshots
|
|
206
|
+
};
|
|
207
|
+
if (!artifacts.localScreenshots) artifacts.artifactsRoot = undefined;
|
|
174
208
|
const createLocalProvider = options.createLocalProvider || (() => createProvider('local'));
|
|
175
209
|
const onSkip = options.onSkip || noop;
|
|
176
210
|
const onFallback = options.onFallback || noop;
|
|
@@ -179,11 +213,11 @@ export async function diffWithPolicy(options) {
|
|
|
179
213
|
|
|
180
214
|
try {
|
|
181
215
|
const result = await options.provider.diff(options.diffOptions);
|
|
182
|
-
return { outcome: 'diffed', providerName, result };
|
|
216
|
+
return { outcome: 'diffed', providerName, result, artifacts, localScreenshots: artifacts.localScreenshots };
|
|
183
217
|
} catch (error) {
|
|
184
218
|
if (error instanceof SnapSkipError) {
|
|
185
219
|
onSkip(error);
|
|
186
|
-
return { outcome: 'skipped', providerName };
|
|
220
|
+
return { outcome: 'skipped', providerName, artifacts, localScreenshots: artifacts.localScreenshots };
|
|
187
221
|
}
|
|
188
222
|
if (!(error instanceof SnapFallbackError)) {
|
|
189
223
|
throw error;
|
|
@@ -199,7 +233,7 @@ export async function diffWithPolicy(options) {
|
|
|
199
233
|
if (options.baselineResolutionStatus === 'error') {
|
|
200
234
|
throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
|
|
201
235
|
}
|
|
202
|
-
return { outcome: 'baseline-unavailable', providerName: 'local' };
|
|
236
|
+
return { outcome: 'baseline-unavailable', providerName: 'local', artifacts, localScreenshots: artifacts.localScreenshots };
|
|
203
237
|
}
|
|
204
238
|
|
|
205
239
|
if (options.baselineResolutionStatus === 'error') {
|
|
@@ -211,7 +245,7 @@ export async function diffWithPolicy(options) {
|
|
|
211
245
|
/** @type {CaptureResult | undefined} */
|
|
212
246
|
let recapture;
|
|
213
247
|
|
|
214
|
-
if (
|
|
248
|
+
if (!artifacts.localScreenshots) {
|
|
215
249
|
if (!options.captureOptions) {
|
|
216
250
|
throw new Error(
|
|
217
251
|
'Cannot fall back to a local diff: the current capture has no screenshots on disk ' +
|
|
@@ -221,6 +255,7 @@ export async function diffWithPolicy(options) {
|
|
|
221
255
|
}
|
|
222
256
|
onRecapture();
|
|
223
257
|
recapture = await localProvider.capture(options.captureOptions);
|
|
258
|
+
artifacts = describeCaptureArtifacts('local', recapture, options.config);
|
|
224
259
|
diffOptions = {
|
|
225
260
|
...options.diffOptions,
|
|
226
261
|
currentResultsPath: recapture.resultsPath,
|
|
@@ -230,7 +265,7 @@ export async function diffWithPolicy(options) {
|
|
|
230
265
|
}
|
|
231
266
|
|
|
232
267
|
const result = await localProvider.diff(diffOptions);
|
|
233
|
-
return { outcome: 'diffed', providerName: 'local', result, recapture };
|
|
268
|
+
return { outcome: 'diffed', providerName: 'local', result, recapture, artifacts, localScreenshots: artifacts.localScreenshots };
|
|
234
269
|
}
|
|
235
270
|
}
|
|
236
271
|
|
package/lib/provider.mjs
CHANGED
|
@@ -35,7 +35,8 @@ export class LocalProvider {
|
|
|
35
35
|
* @returns {Promise<CaptureResult>}
|
|
36
36
|
*/
|
|
37
37
|
async capture(options) {
|
|
38
|
-
|
|
38
|
+
const result = await runBaselineCapture(options);
|
|
39
|
+
return { ...result, artifacts: { localScreenshots: true, artifactsRoot: result.screenshotsRoot } };
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -58,6 +59,7 @@ export class LocalProvider {
|
|
|
58
59
|
/**
|
|
59
60
|
* @param {FetchBaselineOptions} _options
|
|
60
61
|
* @returns {Promise<never>}
|
|
62
|
+
* Guard with providerSupports(providerName, 'fetchLatestBaseline') before calling.
|
|
61
63
|
* @throws {Error} Not yet implemented for LocalProvider.
|
|
62
64
|
* Use the pr-diff action for baseline resolution, or wait for a future release.
|
|
63
65
|
*/
|
|
@@ -80,6 +82,22 @@ export class LocalProvider {
|
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
export const PROVIDER_CAPABILITIES = Object.freeze({
|
|
86
|
+
local: Object.freeze({ capture: true, diff: true, publishBaseline: true, fetchLatestBaseline: false, buildCommentBody: true }),
|
|
87
|
+
snap: Object.freeze({ capture: true, diff: true, publishBaseline: true, fetchLatestBaseline: true, buildCommentBody: true })
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} providerName
|
|
92
|
+
* @param {string} capability
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
export function providerSupports(providerName, capability) {
|
|
96
|
+
return Object.hasOwn(PROVIDER_CAPABILITIES, providerName) &&
|
|
97
|
+
Object.hasOwn(PROVIDER_CAPABILITIES[providerName], capability) &&
|
|
98
|
+
PROVIDER_CAPABILITIES[providerName][capability] === true;
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
/** @type {{ local: (config?: VisualRegressionConfig) => LocalProvider, snap: (config: VisualRegressionConfig) => SnapProvider }} */
|
|
84
102
|
const PROVIDER_FACTORIES = {
|
|
85
103
|
local: () => new LocalProvider(),
|
package/lib/snap-provider.mjs
CHANGED
|
@@ -948,6 +948,7 @@ export class SnapProvider {
|
|
|
948
948
|
resultsPath,
|
|
949
949
|
manifestPath,
|
|
950
950
|
screenshotsRoot: outDir,
|
|
951
|
+
artifacts: { localScreenshots: false, artifactsRoot: undefined },
|
|
951
952
|
selectedRouteIds
|
|
952
953
|
};
|
|
953
954
|
}
|
|
@@ -1963,7 +1964,7 @@ export class SnapProvider {
|
|
|
1963
1964
|
};
|
|
1964
1965
|
await fs.writeFile(localCapture.resultsPath, JSON.stringify(snapResults, null, 2));
|
|
1965
1966
|
|
|
1966
|
-
return localCapture;
|
|
1967
|
+
return { ...localCapture, artifacts: { localScreenshots: true, artifactsRoot: localCapture.screenshotsRoot } };
|
|
1967
1968
|
}
|
|
1968
1969
|
|
|
1969
1970
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snapdrift",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "SnapDrift captures, compares, and reports UI drift for Playwright-based app checks.",
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"./lib/cli.mjs": "./lib/cli.mjs",
|
|
82
82
|
"./lib/provider.mjs": "./lib/provider.mjs",
|
|
83
83
|
"./lib/outage-policy.mjs": "./lib/outage-policy.mjs",
|
|
84
|
+
"./lib/github-requests.mjs": "./lib/github-requests.mjs",
|
|
84
85
|
"./lib/snap-provider.mjs": "./lib/snap-provider.mjs",
|
|
85
86
|
"./lib/migrate-baselines.mjs": "./lib/migrate-baselines.mjs",
|
|
86
87
|
"./lib/init-from-action.mjs": "./lib/init-from-action.mjs",
|
|
@@ -102,10 +103,10 @@
|
|
|
102
103
|
"registry": "https://registry.npmjs.org"
|
|
103
104
|
},
|
|
104
105
|
"dependencies": {
|
|
105
|
-
"@snapdrift/adapter-fs": "^1.
|
|
106
|
-
"@snapdrift/adapter-report-md": "^1.
|
|
107
|
-
"@snapdrift/compare-core": "^1.
|
|
108
|
-
"@snapdrift/manifest": "^1.
|
|
106
|
+
"@snapdrift/adapter-fs": "^1.4.0",
|
|
107
|
+
"@snapdrift/adapter-report-md": "^1.3.0",
|
|
108
|
+
"@snapdrift/compare-core": "^1.4.0",
|
|
109
|
+
"@snapdrift/manifest": "^1.5.0",
|
|
109
110
|
"js-yaml": "^5.4.0",
|
|
110
111
|
"playwright": "^1.59.1",
|
|
111
112
|
"pngjs": "^7.0.0"
|
|
@@ -192,6 +192,7 @@ export interface VisualDiffSummary {
|
|
|
192
192
|
changed: VisualDiffChangedItem[];
|
|
193
193
|
missing: VisualDiffMissingItem[];
|
|
194
194
|
errors: VisualDiffErrorItem[];
|
|
195
|
+
captureCompatibility?: import('@snapdrift/manifest').CaptureCompatibility;
|
|
195
196
|
dimensionChanges: VisualDiffDimensionItem[];
|
|
196
197
|
comparisonPolicy?: ComparisonPolicy;
|
|
197
198
|
message?: string;
|