snapdrift 0.11.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 CHANGED
@@ -1,5 +1,136 @@
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
+
87
+ ## 0.12.0 - 2026-09-16
88
+
89
+ ### Fixes
90
+
91
+ - **PR comments no longer render `[object Object]` for a viewport.** A viewport is
92
+ either a preset name or a `{ width, height }` object, and the PR-comment renderer
93
+ stringified it directly. Hosted diff runs always build the object form, so every
94
+ hosted report printed the useless placeholder in its Error, Drift signals, and
95
+ Dimension shifts tables. All three report renderers now share one
96
+ `formatViewport` helper (exported from `@snapdrift/adapter-report-md`).
97
+
98
+ ### Features
99
+
100
+ - **`compareImages` accepts a per-call `maxPixels` union-canvas ceiling.**
101
+ `MAX_COMPARISON_PIXELS` (32 Mi) is a *process memory* bound, not an algorithm
102
+ property, so callers with a larger envelope — a dedicated diff Lambda — can raise
103
+ it while callers sharing a small host keep the default. A missing, non-positive,
104
+ or non-finite value falls back to the default, so the guard cannot be removed by
105
+ accident. The `ComparisonTooLargeError` message now reports the ceiling that was
106
+ actually exceeded.
107
+
108
+ ### Documentation
109
+
110
+ - Document that `migrate-baselines --to snap` is unsupported and point to
111
+ `snapdrift baseline` as the supported way to seed a hosted project. The
112
+ integration guide still described the retired upload direction as a working
113
+ one-shot command, and the contracts reference documented its request body,
114
+ idempotency, and storage behavior as if it succeeded. Both contradicted the
115
+ CLI, which has failed the direction since 0.7.0 with a pointer to
116
+ `snapdrift baseline`.
117
+ - Document the screenshot size budget's memory cost for `maxPixels` callers: the
118
+ comparison retains both decoded RGBA inputs plus the optional diff canvas, so the
119
+ floor is roughly 12 bytes per union pixel before PNG overhead (~32-35 measured end
120
+ to end), and the ceiling should be sized against a measured peak rather than that
121
+ arithmetic.
122
+
123
+ ### Workspace packages
124
+
125
+ - `@snapdrift/adapter-report-md` 1.2.1 publishes the `[object Object]` viewport fix;
126
+ `@snapdrift/compare-core` 1.3.0 publishes the per-call `maxPixels` option and the
127
+ new size-budget guidance.
128
+ - The root package's dependency floors move to `^1.2.1` (adapter-report-md) and
129
+ `^1.3.0` (compare-core) so `snapdrift` 0.12.0 actually requires those fixes. A
130
+ retained lockfile resolution of adapter-report-md 1.2.0 still satisfies `^1.2.0`,
131
+ so without the floor bump an upgrade of `snapdrift` alone could install the
132
+ unfixed report renderer.
133
+
3
134
  ## 0.11.0 - 2026-09-14
4
135
 
5
136
  ### Behavior changes
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.11.0
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.11.0
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@799cfb16ff9d51e57d515cdddd4b329950574ef2 # v0.11.0
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@799cfb16ff9d51e57d515cdddd4b329950574ef2 # v0.11.0
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 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(','));