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/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 |
@@ -297,7 +370,29 @@ dimensions.
297
370
 
298
371
  The comparator reports this condition with the stable error code
299
372
  `comparison_too_large` and includes the baseline, current, and union-canvas
300
- dimensions in the error message. Approximate maximum full-page CSS heights below
373
+ dimensions in the error message. The ceiling that was exceeded is included too,
374
+ since a caller may raise it per call.
375
+
376
+ `32 × 1024 × 1024` is the *default* ceiling, not an algorithm property. The cost
377
+ is dominated by the decoded images: both RGBA inputs (4 bytes per pixel each) are
378
+ retained plus, when the diff image is rendered (the default), a union-sized diff
379
+ canvas — at least ~12 bytes per union pixel when both inputs approach the union
380
+ dimensions, before PNG decode/encode overhead. Measured end-to-end RSS on real
381
+ full-page captures is ~32-35 bytes per union pixel (~1.16 GB at 33.8 M pixels,
382
+ ~3.37 GB at 103.9 M), so the default bounds a comparison to a process with a few
383
+ GB of headroom.
384
+
385
+ Direct `@snapdrift/compare-core` callers running in a larger memory envelope can
386
+ pass `compareImages(..., { maxPixels })` to raise it for that call. **Size
387
+ `maxPixels` against your own measured peak**, not against the byte-per-pixel
388
+ arithmetic: PNG decode and encode overhead, GC, and concurrency all add to it. A
389
+ missing, non-finite, or sub-1 `maxPixels` falls back to the default; fractions are
390
+ floored (`16.9` becomes `16`). The sub-1 rule matters because a positive fraction
391
+ such as `0.5` floors to `0`, which would otherwise reject every comparison.
392
+ SnapDrift's own local and hosted paths keep the default, because the host process
393
+ — not the image — decides how much memory is actually safe.
394
+
395
+ Approximate maximum full-page CSS heights below
301
396
  assume that the raster width remains equal to the configured CSS width and that
302
397
  the document height scales by the same device scale factor:
303
398
 
@@ -327,6 +422,14 @@ that matches the product behavior. Do not resize or crop screenshots, or lower
327
422
  | Screenshot animations | `disabled` (Playwright finishes/cancels CSS animations before capture) |
328
423
  | Capture concurrency | 5 routes per viewport (overridable via `SNAPDRIFT_CAPTURE_CONCURRENCY`)|
329
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
+
330
433
  ## Local CLI directory layout
331
434
 
332
435
  When using the `snapdrift` CLI, outputs are written to `.snapdrift/` by default:
@@ -353,18 +456,16 @@ All three directories can be overridden with `--baseline-dir`, `--current-dir`,
353
456
 
354
457
  ### migrate-baselines
355
458
 
356
- Migrate baselines between local storage and Snap. Both directions require a `snap` block in `snapdrift.json` (or `--to local` for the `snap → local` direction, since the export call still talks to Snap first).
459
+ Move baselines between local storage and Snap. Only the `snap → local` direction is supported; it requires a `snap` block in `snapdrift.json` (the export call talks to Snap).
357
460
 
358
- **Upload local baselines to Snap:**
461
+ **Upload local baselines to Snap — not supported:**
359
462
 
360
463
  ```
361
- snapdrift migrate-baselines --to snap [--config <path>] [--baseline-dir <dir>]
464
+ snapdrift migrate-baselines --to snap # always fails
362
465
  ```
363
466
 
364
- - Reads `results.json`, `manifest.json`, and `screenshots/*.png` from the local baseline directory.
365
- - Uploads as the initial accepted baseline on Snap via `POST /v1/visual/projects/:id/baselines`.
366
- - Idempotent: if a baseline already exists for the same commit SHA (derived from `GITHUB_SHA` or `git rev-parse HEAD`), the upload is skipped.
367
- - Screenshots are base64-encoded in the request body; very large suites may want to migrate per-route.
467
+ - The command exits with an error. Snap cannot accept a pre-built local baseline bundle: the screenshots it carries are never uploaded to Snap storage, and its manifest references local filenames rather than Snap object keys, so the call could only ever create a baseline with no pixels behind it. Snap rejects the request with `400 unsupported_baseline_body` (i2Dev-com/snap#653); the legacy `SnapProvider.migrateBaselineFromLocal()` path was removed in 0.7.0.
468
+ - **Use `snapdrift baseline` instead** — with `provider: "snap"` it captures each route through Snap so the images actually land in storage, then publishes a baseline referencing the stored objects. Canonical hosted publication is CI-only (default-branch job); see [`snapdrift baseline`](local-cli.md#snapdrift-baseline).
368
469
 
369
470
  **Download Snap baselines to local:**
370
471
 
@@ -521,6 +622,50 @@ The wrapper actions (`actions/baseline`, `actions/pr-diff`) and the CLI both han
521
622
  request or response body is being bounded. It is not an additional public
522
623
  error class; callers should handle the exported error classes above.
523
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
+
524
669
  ## Primary entrypoints
525
670
 
526
671
  - `actions/baseline`
@@ -547,7 +692,7 @@ These are for custom orchestration only. Wrapper actions set them automatically.
547
692
  | `SNAPDRIFT_BASELINE_ARTIFACT_NAME` | `compare-results.mjs` | Baseline artifact label to embed in the report |
548
693
  | `SNAPDRIFT_BASELINE_SOURCE_SHA` | `compare-results.mjs` | Baseline source SHA to embed in the report |
549
694
  | `SNAPDRIFT_ENFORCE_OUTCOME` | `compare-results.mjs` | Set to `0` to disable enforcement in direct CLI usage |
550
- | `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. |
551
696
 
552
697
  ## PR comment markdown shape
553
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.11.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.11.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.11.0` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.11.0` |
90
- | `ranacseruet/snapdrift@v0.11.0` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.11.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.11.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.11.0
173
+ uses: ranacseruet/snapdrift@v0.13.0
174
174
  with:
175
175
  mode: pr-diff
176
176
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -249,10 +249,11 @@ When `provider: "snap"` and `baseUrl` points to a local address (localhost, `127
249
249
 
250
250
  With the Snap provider, the PR comment includes a **"View in dashboard →"** link that points to `${apiUrl}/dashboard/visual/${projectId}/runs/${runId}`. The local provider omits the link.
251
251
 
252
- Migrating an existing local repo to the Snap provider is a one-shot CLI command:
252
+ To adopt the hosted provider, seed the project with a hosted baseline. `migrate-baselines --to snap` is **not** the way to do it — Snap cannot accept a pre-built local baseline bundle, because the screenshots it carries are never uploaded to Snap storage and its manifest references local filenames rather than Snap object keys. Use `snapdrift baseline` with `provider: "snap"` instead: it captures each route through Snap so the pixels actually land in storage before the baseline manifest is published. Canonical hosted publication is CI-only — run it from your default-branch job, where GitHub Actions supplies the publication metadata automatically (other CI systems set `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`); a plain local checkout fails before creating a hosted run.
253
253
 
254
254
  ```bash
255
- snapdrift migrate-baselines --to snap
255
+ # In CI, on the default branch, with provider: "snap"
256
+ snapdrift baseline
256
257
  ```
257
258
 
258
259
  Conversely, downloading a Snap baseline back to a local directory (useful for reproducible local debugging) is:
@@ -297,9 +298,6 @@ snapdrift capture
297
298
  # After making changes, compare and open the HTML report
298
299
  snapdrift diff --open
299
300
 
300
- # Migrate an established local baseline to the hosted Snap backend
301
- snapdrift migrate-baselines --to snap
302
-
303
301
  # Translate a snap/github-action workflow into snapdrift.json
304
302
  snapdrift init --from-snap-action .github/workflows/snap.yml
305
303
  ```
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.11.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
+ }