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 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.12.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.12.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@e4b92c2243c6e69594cab7405f68eb58fe94587e # v0.12.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@e4b92c2243c6e69594cab7405f68eb58fe94587e # v0.12.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(','));
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 context (positive integer, default `5`). Set to `1` to restore serial behaviour for apps with shared session/auth state. |
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.12.0
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.12.0
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.12.0` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.12.0` |
90
- | `ranacseruet/snapdrift@v0.12.0` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.12.0` |
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.12.0
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.12.0
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"` during local development to always get a report without a failing exit code.
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 context, and per-route resolve/compare during `diff`. Set to `1` to restore serial behavior for apps with shared session or auth state. |
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
 
@@ -82,7 +82,7 @@ jobs:
82
82
 
83
83
  - name: Publish SnapDrift baseline
84
84
  if: steps.baseline_candidate.outputs.current == 'true'
85
- uses: ranacseruet/snapdrift@v0.12.0
85
+ uses: ranacseruet/snapdrift@v0.13.0
86
86
  with:
87
87
  mode: baseline
88
88
  repo-config-path: .github/snapdrift.json
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
+ }
@@ -73,10 +73,21 @@ function createBaselineLookupFallbackError(outageError, phase, lookupMessage) {
73
73
  * @returns {boolean}
74
74
  */
75
75
  export function hasLocalScreenshots(providerName, config) {
76
- if (providerName !== 'snap') {
77
- return true;
78
- }
79
- return isLocalBaseUrl(config?.baseUrl);
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
- localScreenshots: hasLocalScreenshots(providerName, options.config)
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
- return { outcome: 'captured', providerName: 'local', result, localScreenshots: true };
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 (options.localScreenshots === false) {
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
- return runBaselineCapture(options);
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(),
@@ -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.12.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.3.0",
106
- "@snapdrift/adapter-report-md": "^1.2.1",
107
- "@snapdrift/compare-core": "^1.3.0",
108
- "@snapdrift/manifest": "^1.4.0",
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;