snapdrift 0.12.0 → 0.14.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 CHANGED
@@ -1,5 +1,118 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.14.0 - 2026-09-21
6
+
7
+ Workspace releases this version:
8
+
9
+ - `@snapdrift/compare-core` 1.5.0 — bounded vertical row alignment and comparison
10
+ policy v2, with conservative coordinate fallbacks and resource limits.
11
+ - `@snapdrift/manifest` 1.6.0 — policy v2 validation, constants, and comparison types.
12
+ - `@snapdrift/adapter-fs` 1.5.0 — local policy v2 comparison and summary types.
13
+ - `@snapdrift/adapter-report-md` 1.4.0 — alignment and fallback details in Markdown,
14
+ HTML, and bounded PR comments.
15
+
16
+ Root and workspace dependency floors require these versions so upgrades receive
17
+ all parts of the policy v2 implementation. The dispatcher pins `576e774`, the
18
+ #176 merge commit on `main`, including the runtime loaded by both wrapper actions.
19
+
20
+ ### Features
21
+
22
+ - Added opt-in comparison policy v2 with bounded vertical row alignment for
23
+ inserted and deleted page rows. Unchanged content that shifts after an
24
+ insertion remains matched; reports record aligned results and conservative
25
+ coordinate fallbacks. Deprecated policy v1 remains the compatibility default
26
+ so existing configurations are unchanged; new configurations should use v2.
27
+
28
+ - Snap-provider requests forward policy v2 and validate hosted acknowledgements
29
+ and alignment metadata. Hosted use requires matching Snap service support;
30
+ publishing this library does not deploy the hosted service.
31
+
32
+ ## 0.13.0 - 2026-09-19
33
+
34
+ Workspace releases this version:
35
+
36
+ - `@snapdrift/compare-core` 1.4.0 — the semantic diff palette (`addedColor`/`removedColor`
37
+ options and the orange default for changed pixels).
38
+ - `@snapdrift/adapter-report-md` 1.3.0 — the exported `DIFF_IMAGE_LEGEND` constant rendered
39
+ alongside diff images.
40
+ - `@snapdrift/adapter-fs` 1.4.0 — cross-route browser storage isolation (#171), capture
41
+ profile v2 validation and the compatibility-before-pixels gate (#172), and the semantic
42
+ diff image handoff to the reporting packages.
43
+ - `@snapdrift/manifest` 1.5.0 — capture profile v2 schema, capture-profile helpers, and the
44
+ `captureCompatibility` summary types (#172).
45
+
46
+ The root dependency floors move to `@snapdrift/adapter-fs ^1.4.0`,
47
+ `@snapdrift/compare-core ^1.4.0`, and `@snapdrift/manifest ^1.5.0`; `adapter-fs` and
48
+ `adapter-report-md` in turn require `compare-core ^1.4.0`, `adapter-report-md ^1.3.0`, and
49
+ `manifest ^1.5.0`. The dispatcher pins 3967590 — the #174 merge commit on `main`, which
50
+ contains the newest `actions/` implementation (#173) and the `lib/`/`packages/` runtime the
51
+ wrappers load.
52
+
53
+ ### Features
54
+
55
+ - Local manifests now persist capture profile v2: engine, browser/Playwright, OS,
56
+ locale/timezone, and rendering settings. Local comparison validates manifests,
57
+ requires exact v2 profile equality, and checks configured route paths and
58
+ normalized viewports before reading PNGs. Incompatibilities are
59
+ `incompatible_capture` errors, not product drift from changed image dimensions;
60
+ the summary's `captureCompatibility` status describes profiles only.
61
+ - Legacy missing/v1 profiles remain unverified and pixel-comparable after route
62
+ checks unless shared metadata conflicts or an explicit foreign engine is found.
63
+ The unverified diagnostic appears in CLI, Markdown, and HTML reports as well as
64
+ JSON summaries and PR comments. Malformed or unsupported profiles are rejected.
65
+ Refresh using `snapdrift baseline`
66
+ (`provider: "local"`) or the existing baseline action; `report-only` acknowledges
67
+ incompatibility nonblockingly but never bypasses it to compare pixels.
68
+ - Local diff images now use a semantic palette so the rendered PNG reflects the
69
+ kind of change, not just that pixels differ. `@snapdrift/compare-core` union-canvas
70
+ comparisons render pixels present only in the current capture in green (`addedColor`,
71
+ default `[0, 170, 0, 255]`), pixels present only in the baseline in red (`removedColor`,
72
+ default `[255, 0, 0, 255]`), and overlapping changed pixels in orange (`highlightColor`,
73
+ default `[255, 140, 0, 255]`). Metrics are unchanged — one-sided pixels still count as
74
+ changed under comparison policy v1. Strict same-dimension `generateDiffImage` diffs
75
+ switch their default highlight from red to orange for parity and cannot contain
76
+ added/removed pixels. Markdown, HTML, and PR-comment reports render a legend
77
+ ("orange = changed · green = added · red = removed") whenever a local diff image is
78
+ embedded. Consumers comparing diff-PNG bytes between versions will see different
79
+ output by design; the new options restore any previous palette.
80
+
81
+ ### Refactoring
82
+
83
+ - GitHub-script steps in `actions/scope`, `actions/pr-diff` (scope and PR report),
84
+ and `actions/comment` now delegate to the shared, dependency-injected helpers in
85
+ `lib/github-requests.mjs` (`fetchPullRequestFiles`, `resolveScopeDecision`,
86
+ `resolvePullRequestScope`, `upsertPullRequestReportComment`), so the changed-file
87
+ selection and comment-upsert behavior is defined once and exercised by unit and
88
+ real-YAML parity tests. Outputs, reasons, warnings, markers, and fallback bodies
89
+ are byte-for-byte identical.
90
+ - Providers and `captureWithPolicy`/`diffWithPolicy` results now include an explicit
91
+ `artifacts` descriptor (`describeCaptureArtifacts`: `localScreenshots` plus
92
+ `artifactsRoot` for local or hosted local-capture hybrid runs). Fallback decisions
93
+ prefer provider-returned capabilities, with provider-name/URL inference retained
94
+ for legacy results and explicit caller overrides taking precedence. Diff callers
95
+ without capability information or config retain the no-recapture legacy default.
96
+ `localScreenshots` remains, and
97
+ `hasLocalScreenshots` is kept as a thin wrapper. `lib/provider.mjs` exports
98
+ `PROVIDER_CAPABILITIES` and `providerSupports(provider, capability)` so the
99
+ unavailable `LocalProvider.fetchLatestBaseline` baseline-store capability is
100
+ discoverable without a runtime throw.
101
+
102
+ ### Behavior changes
103
+
104
+ - Local capture explicitly sets `en-US` / `UTC`; localized text/date changes may
105
+ require baseline recapture. Fonts are not fingerprinted. See the
106
+ [capture profile contract](docs/contracts.md#local-capture-profile-v2) and
107
+ [baseline refresh guide](docs/local-cli.md#refreshing-or-acknowledging-local-baselines).
108
+
109
+ ### Fixes
110
+
111
+ - Isolate each local route capture and retry in a fresh browser context, preventing
112
+ cookies and browser storage from leaking between routes sharing a viewport.
113
+ Per-viewport concurrency and result ordering are preserved. This also fixes
114
+ Snap local-capture hybrid runs; recapture baselines affected by leaked storage.
115
+
3
116
  ## 0.12.0 - 2026-09-16
4
117
 
5
118
  ### 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.12.0
51
+ uses: ranacseruet/snapdrift@v0.14.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.12.0
63
+ uses: ranacseruet/snapdrift@v0.14.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)
@@ -117,7 +121,7 @@ Start with `report-only` while baselines settle. Move to `fail-on-changes` or st
117
121
  - Viewport presets: `desktop` (1440×900) and `mobile` (390×844), or custom `{ "width": number, "height": number }`
118
122
  - Full-page comparisons are bounded to a 32 Mi-pixel union canvas; see the [screenshot size budget](docs/contracts.md#screenshot-size-budget) for viewport, device-scale, and remediation guidance
119
123
  - One global `diff.threshold`
120
- - Unequal dimensions compare on a top-left-aligned union canvas via comparison policy v1, which is always applied (synthesized from `diff.threshold` when `diff.comparisonPolicy` is absent)
124
+ - Unequal dimensions use deprecated top-left union comparison policy v1 by default for compatibility; new configurations should opt into policy v2 with `{ "version": 2, "threshold": number }` for bounded vertical row alignment of inserted/deleted content
121
125
  - 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))
122
126
  - Hosted Snap baselines publish only from the default branch and always include the complete configured route set
123
127
 
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@e4b92c2243c6e69594cab7405f68eb58fe94587e # v0.12.0
158
+ uses: ranacseruet/snapdrift/actions/baseline@576e774845c29288a0061365e4905009533e0e01 # v0.14.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@e4b92c2243c6e69594cab7405f68eb58fe94587e # v0.12.0
168
+ uses: ranacseruet/snapdrift/actions/pr-diff@576e774845c29288a0061365e4905009533e0e01 # v0.14.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 comments = await github.paginate(github.rest.issues.listComments, {
120
- owner: context.repo.owner,
121
- repo: context.repo.repo,
122
- issue_number: issueNumber,
123
- per_page: 100
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, selectConfiguredRoutes, selectRoutesForChangedFiles, splitCommaList } = await import(pathToFileURL(configModulePath));
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 allRouteIds = (config.routes || []).map((route) => route.id);
143
- const explicitRouteIds = splitCommaList(process.env.ROUTE_IDS);
144
-
145
- function setOutputs(scope) {
146
- core.setOutput('should_run', scope.shouldRun ? 'true' : 'false');
147
- core.setOutput('reason', scope.reason);
148
- core.setOutput('selected_route_ids', (scope.selectedRouteIds || []).join(','));
149
- }
150
-
151
- if (explicitRouteIds.length > 0) {
152
- const selected = selectConfiguredRoutes(config, explicitRouteIds);
153
- setOutputs({
154
- shouldRun: true,
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 comments = await github.paginate(github.rest.issues.listComments, {
743
- owner: context.repo.owner,
744
- repo: context.repo.repo,
745
- issue_number: issueNumber,
746
- per_page: 100
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".
@@ -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, selectRoutesForChangedFiles } = await import(pathToFileURL(configModulePath));
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
- const allRouteIds = (config.routes || []).map((route) => route.id);
74
- const resolvedPrNumber = Number(process.env.INPUT_PR_NUMBER || context.payload.pull_request?.number || '');
75
-
76
- function setOutputs(scope) {
77
- core.setOutput('should_run', scope.shouldRun ? 'true' : 'false');
78
- core.setOutput('reason', scope.reason);
79
- core.setOutput('selected_route_ids', (scope.selectedRouteIds || []).join(','));
80
- }
81
-
82
- if (process.env.FORCE_RUN === 'true') {
83
- setOutputs({
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(','));