snapdrift 0.8.2 → 0.10.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.
@@ -105,9 +105,32 @@ runs:
105
105
  per_page: 100
106
106
  });
107
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
+ ]))];
108
131
  const scope = selectRoutesForChangedFiles(
109
132
  config,
110
- files.map((file) => file.filename)
133
+ changedFiles
111
134
  );
112
135
  setOutputs(scope);
113
136
  } catch (error) {
@@ -57,6 +57,10 @@ inputs:
57
57
  description: Current screenshots dir for diff artifact staging.
58
58
  required: false
59
59
  default: ''
60
+ diff-images-dir:
61
+ description: Local diff PNG directory for diff artifact staging.
62
+ required: false
63
+ default: ''
60
64
 
61
65
  outputs:
62
66
  bundle-dir:
@@ -91,6 +95,7 @@ runs:
91
95
  CURRENT_MANIFEST_PATH: ${{ inputs.current-manifest-path }}
92
96
  BASELINE_SCREENSHOTS_DIR: ${{ inputs.baseline-screenshots-dir }}
93
97
  CURRENT_SCREENSHOTS_DIR: ${{ inputs.current-screenshots-dir }}
98
+ DIFF_IMAGES_DIR: ${{ inputs.diff-images-dir }}
94
99
  run: |
95
100
  node --input-type=module <<'EOF'
96
101
  import fs from 'node:fs/promises';
@@ -113,7 +118,8 @@ runs:
113
118
  baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
114
119
  currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
115
120
  baselineScreenshotsDir: process.env.BASELINE_SCREENSHOTS_DIR || undefined,
116
- currentScreenshotsDir: process.env.CURRENT_SCREENSHOTS_DIR || undefined
121
+ currentScreenshotsDir: process.env.CURRENT_SCREENSHOTS_DIR || undefined,
122
+ diffImagesDir: process.env.DIFF_IMAGES_DIR || undefined
117
123
  });
118
124
 
119
125
  await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
package/bin/snapdrift.mjs CHANGED
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
3
 
4
- import { pathToFileURL } from 'node:url';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
 
6
6
  // Resolve cli.mjs relative to this file so the bin entry works regardless of
7
- // where the consumer installs the package.
8
- const cliPath = new URL('../lib/cli.mjs', import.meta.url).pathname;
7
+ // where the consumer installs the package. Use fileURLToPath(new URL(...)) rather
8
+ // than URL.pathname so paths containing spaces or Windows drive letters survive
9
+ // the conversion (URL.pathname percent-encodes them, then pathToFileURL re-encodes
10
+ // the result, producing a path that does not exist).
11
+ const cliPath = fileURLToPath(new URL('../lib/cli.mjs', import.meta.url));
9
12
  const { main } = await import(pathToFileURL(cliPath).href);
10
13
 
11
14
  main(process.argv).catch((error) => {
package/docs/contracts.md CHANGED
@@ -22,7 +22,7 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
22
22
 
23
23
  | Field | Type | Description |
24
24
  |:------|:-----|:------------|
25
- | `id` | `string` | Unique route identifier across runs |
25
+ | `id` | `string` | Unique route identifier across runs. Its sanitized screenshot filename must also be unique across the full configured route set. |
26
26
  | `path` | `string` | URL path appended to `baseUrl` |
27
27
  | `viewport` | `string` or `object` | Preset name (`"desktop"`, `"mobile"`) or a custom object `{ "width": number, "height": number }` |
28
28
  | `changePaths` | `string[]` | Optional prefixes used for changed-file scoping |
@@ -40,9 +40,28 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
40
40
  | `snap.apiKey` | `string` | Inline API key with `${VAR}` interpolation (mutually exclusive with `snap.apiKeyEnv`) |
41
41
  | `snap.projectId` | `string` | Snap project ID or `"auto"` (default: `"auto"`, derives from `GITHUB_REPOSITORY`) |
42
42
  | `snap.onUnavailable` | `string` | Behavior when Snap is unreachable: `"fail"` (default), `"warn-and-skip"`, or `"fallback-local"` |
43
+ | `diff.comparisonPolicy` | `{ "version": 1, "threshold": number }` | Optional local opt-in to top-left-aligned unequal-dimension comparison; absent preserves strict legacy dimension handling |
43
44
 
44
45
  When `provider: "snap"` is set, the `snap` block is required. Exactly one of `snap.apiKeyEnv` or `snap.apiKey` must be present. `snap.apiKey` accepts `${VAR}` interpolation (for example `"${SNAP_API_KEY}"`); the referenced environment variable must be set at runtime or the config loader throws.
45
46
 
47
+ Changed-file scoping treats a GitHub `renamed` record as both the current
48
+ `filename` and its nonempty `previous_filename`. This keeps a route selected
49
+ when a watched file moves out of its configured `changePaths`, and also detects
50
+ renames into watched or shared paths. Duplicate paths are ignored; other change
51
+ statuses use only their current filename. The `pr-diff` wrapper's explicit
52
+ `route-ids` and `force-run` inputs take precedence over this lookup; the
53
+ standalone `scope` action supports `force-run` and otherwise derives its
54
+ selection from the changed files. If GitHub returns the maximum 3,000 file
55
+ records, both actions run all configured routes with reason
56
+ `changed_files_truncated` because the list may be incomplete.
57
+
58
+ Route ids are sanitized before local captures and Snap baseline exports write
59
+ `screenshots/<route-id>.png`: `..` becomes `_`, path separators become `_`, and
60
+ control characters are removed. Distinct route ids that produce the same
61
+ sanitized filename are rejected before capture starts. Rename one of the route
62
+ ids and recapture the affected baseline; existing screenshot filenames remain
63
+ unchanged for noncolliding ids.
64
+
46
65
  ### Example (local provider)
47
66
 
48
67
  ```json
@@ -101,6 +120,27 @@ The published baseline bundle contains:
101
120
  | `manifest.json` | Screenshot manifest with ids, paths, and dimensions |
102
121
  | `screenshots/*.png` | Captured screenshot images |
103
122
 
123
+ Every manifest entry must use a unique staged screenshot filename (the basename
124
+ of `imagePath`). Duplicate paths or paths that flatten to the same filename are
125
+ rejected before comparison, even when the run selects only one of the affected
126
+ route ids, because they can point two logical screenshots at the same pixels.
127
+ Rename the conflicting route ids and recapture the baseline and current bundle.
128
+
129
+ ### Baseline resolution
130
+
131
+ The `actions/resolve-baseline` action and the `actions/pr-diff` wrapper distinguish three lookup
132
+ outcomes: `found` means a successful search selected a non-expired artifact, `missing` means a
133
+ successful search found no usable artifact, and `error` means GitHub returned an API, network, or
134
+ malformed-response failure. The boolean `found` output remains available for compatibility; use
135
+ `resolution-status` on `resolve-baseline` or `baseline-resolution-status` on `pr-diff` when the
136
+ reason matters. A missing baseline may produce the intentional first-run skipped summary. A lookup
137
+ error fails local comparisons and local Snap fallbacks, so infrastructure failures cannot be
138
+ reported as a clean or intentional missing-baseline result. Healthy hosted Snap diffs may continue
139
+ using their hosted baseline path after a GitHub artifact lookup error.
140
+ The standalone `actions/resolve-baseline` action fails its step for `error`; custom workflows
141
+ should branch on `resolution-status` rather than treating `found: false` as proof that no baseline
142
+ exists.
143
+
104
144
  ## Drift artifact
105
145
 
106
146
  The pull request drift bundle contains:
@@ -115,6 +155,7 @@ The pull request drift bundle contains:
115
155
  | `current/results.json` | Current capture results |
116
156
  | `current/manifest.json` | Current manifest |
117
157
  | `current/screenshots/*.png` | Current images |
158
+ | `diffs/*.png` | Generated v1 diff images for changed screenshots |
118
159
 
119
160
  ## Screenshot manifest shape
120
161
 
@@ -169,6 +210,18 @@ The pull request drift bundle contains:
169
210
  | Field | Type | Description |
170
211
  |:------|:-----|:------------|
171
212
  | `dashboardUrl` | `string?` | Snap dashboard URL for the run (set by `SnapProvider`; omitted by `LocalProvider`) |
213
+ | `comparisonPolicy` | `{ "version": 1, "threshold": number }?` | Exact v1 comparison policy used by the local adapter when opted in |
214
+
215
+ When v1 comparison is enabled, each affected `changed[]` item may also contain
216
+ `comparison` with `baseline`, `current`, and `canvas` `{ width, height }`
217
+ objects, `dimensionsChanged`, and the effective `totalPixels` denominator. A
218
+ local changed item with a generated image contains `diffImagePath`, relative to
219
+ the diff output bundle (for example `diffs/home.png`).
220
+
221
+ When a changed item has a generated local `diffImagePath`, its PR comment cell
222
+ links to the uploaded report artifact (or the workflow run when no artifact URL
223
+ was supplied) and shows the bundle-relative path. It does not use that relative
224
+ path as a GitHub-hosted image URL.
172
225
 
173
226
  ### Status values
174
227
 
@@ -198,7 +251,9 @@ Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
198
251
  - Mismatch ratio is `different_pixels / total_pixels`
199
252
  - `diff.threshold` applies per screenshot
200
253
  - Missing captures are counted separately from drift signals
201
- - Dimension mismatches skip pixel comparison and land in `dimensionChanges[]`
254
+ - Without `diff.comparisonPolicy`, dimension mismatches skip pixel comparison and land in `dimensionChanges[]`.
255
+ - With the exact v1 policy `{ "version": 1, "threshold": number }`, 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.
256
+ - For v1, `diff.comparisonPolicy.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.
202
257
  - `diff.mode` controls enforcement, not summary generation
203
258
 
204
259
  ## Drift modes
@@ -207,7 +262,7 @@ Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
207
262
  |:-----|:-------------------|
208
263
  | `report-only` | Never |
209
264
  | `fail-on-changes` | `changedScreenshots > 0` |
210
- | `fail-on-incomplete` | Errors, dimension shifts, or missing captures occur |
265
+ | `fail-on-incomplete` | Errors or missing captures occur; a completed v1 dimension comparison alone does not fail |
211
266
  | `strict` | Any drift or incomplete comparison appears |
212
267
 
213
268
  A summary with `status: "skipped"` — or any summary carrying no `diff.mode` —
@@ -328,6 +383,8 @@ A hosted baseline is an authoritative complete snapshot. For `purpose: "baseline
328
383
 
329
384
  The capture `results.json` records `configuredRouteIds`, `selectedRouteIds`, and `expectedCaptures` (route id, path, and viewport descriptor), plus one resolved `refBranch` / `refSha`, publication workflow ref, and publication sequence. The run-creation request sends the same immutable set and sequence as `capturePlan` and sends the ref as `branch` / `prHeadSha`. Before publication, SnapDrift requires the source run to belong to the configured project, settle to terminal `new`, and contain exactly one successful `new` capture with a non-empty `currentObjectKey` for every expected route/viewport identity. It keeps polling while any expected capture remains non-terminal, even when an older server reports the run itself as terminal, but fails fast after a stable incomplete capture set. Failed, missing, duplicate, extra, path-mismatched, or malformed captures abort before the baseline request. If another default-branch baseline wins first, Snap returns `409 baseline_stale_source`; rerun the current baseline job.
330
385
 
386
+ Hosted PR diffs use the same persisted `expectedCaptures` identities. `SnapProvider.diff()` requires `purpose: "diff"` plus a nonempty, unique expected set consistent with `selectedRouteIds`; results written by older SnapDrift versions without that set must be recaptured. It polls until the expected capture count is available, verifies the returned run id, and reconciles every returned capture by route id, path, and normalized viewport descriptor. Missing expected captures, duplicate or unexpected identities, pending or unknown statuses, run or capture errors, missing current objects, and invalid `diffPct` values produce an `incomplete` summary and are never counted as clean matches. A valid comparison requires a current object, a baseline object, and a finite numeric mismatch ratio from 0 through 1. The `diff.mode` setting then controls whether that incomplete summary fails the check.
387
+
331
388
  Publication sends `publicationMode: "complete"` and the source run id both as top-level `sourceRunId` and as `manifest.sourceRunId`. The baseline id is deterministically derived from the run id, and manifest routes are sorted, so a retry sends the same identity and payload. Reusing that id with different persisted fields is rejected; rerun the baseline job to obtain a new source run. `refBranch` / `refSha` come from the persisted capture metadata rather than resolving git a second time. Snap accepts complete publication only from the project's default branch (effective default `main`).
332
389
 
333
390
  ### Local-capture hybrid
@@ -356,9 +413,21 @@ SnapDrift uses a small, stable subset of the Snap API:
356
413
  The Snap HTTP client classifies responses and applies the following rules:
357
414
 
358
415
  - **2xx** — success, return the parsed body.
359
- - **4xx** — non-retryable. The client throws `SnapApiError(status, message, path)` immediately. `onUnavailable` is **not** consulted for 4xx — a 404 from `/baselines/latest` is a "no baseline yet" signal, but a 4xx from `/runs` is a configuration error that retrying won't fix.
360
- - **5xx** — retryable up to 3 attempts with exponential backoff (`1 s` → `2 s` → `4 s`, capped at `30 s` total). If the final attempt still returns 5xx, the client falls through to the `onUnavailable` handler.
361
- - **Network errors** — same retry/backoff behavior as 5xx. After exhaustion, falls through to the `onUnavailable` handler.
416
+ - **4xx** — non-retryable. The client throws `SnapApiError(status, message, path)` immediately. `onUnavailable` is **not** consulted for 4xx — a 404 from `/baselines/latest` is a "no baseline yet" signal, but a 4xx from `/runs` is a configuration error that retrying won't fix. If the diagnostic body itself stalls or is unavailable, the same `SnapApiError` retains the HTTP status and includes that diagnostic failure.
417
+ - **5xx** — retryable up to 3 attempts with exponential backoff (`1 s` → `2 s` → `4 s`, with each delay capped at `30 s`). If the final attempt still returns 5xx, the client falls through to the `onUnavailable` handler.
418
+ - **Network errors and transport timeouts** — same retry/backoff behavior as 5xx. After exhaustion, falls through to the `onUnavailable` handler.
419
+
420
+ Every JSON request attempt has a 30-second limit; binary export attempts have a
421
+ 120-second limit. Headers and response bodies share the applicable attempt
422
+ limit, and each public Snap operation has one 10-minute deadline covering all
423
+ of its requests, retries, backoff, and polling. The client passes an
424
+ `AbortSignal` to fetch and aborts/cancels stalled bodies; injected transports
425
+ that ignore cancellation are still released by the client-side deadline.
426
+ Retry and poll waits are clipped to the remaining operation time, so no new
427
+ request starts after the operation deadline. Deadline exhaustion is treated as
428
+ Snap unavailability and follows `onUnavailable`; received 4xx responses keep
429
+ their immediate non-retryable behavior, and a stalled 4xx diagnostic body
430
+ retains its HTTP status with a timeout diagnostic.
362
431
 
363
432
  `onUnavailable` is consulted once retries are exhausted:
364
433
 
@@ -376,7 +445,7 @@ alike:
376
445
  | Phase | `warn-and-skip` | `fallback-local` |
377
446
  |:------|:----------------|:-----------------|
378
447
  | Capture | Write a skipped `summary.json`/`summary.md` with reason `snap_unavailable`, expose the summary outputs, stage the report, exit 0. | Capture with `LocalProvider` and report the **effective** provider (`local`) so the rest of the pipeline uses local artifacts and the local pixel engine. |
379
- | Diff | Same skipped summary, exit 0. | Diff with `LocalProvider`. A capture that Snap rendered server-side has no PNGs on the runner, so the routes are **recaptured locally first**; the recaptured paths replace the Snap ones in the staged bundle. If no baseline artifact was resolved, the run reports `missing_main_baseline_artifact` instead of crashing the local diff. |
448
+ | Diff | Same skipped summary, exit 0. | Diff with `LocalProvider`. A capture that Snap rendered server-side has no PNGs on the runner, so the routes are **recaptured locally first**; the recaptured paths replace the Snap ones in the staged bundle. If baseline resolution succeeded but no artifact was found, the run reports `missing_main_baseline_artifact`; a lookup error fails instead of entering the missing-baseline path. |
380
449
  | Baseline publish | Skip the publish and exit 0 without an artifact. | Capture locally and stage/upload that bundle, so the run still leaves a usable baseline. |
381
450
 
382
451
  Enforcement of `diff.mode` never runs against a skipped summary — a skipped run
@@ -402,13 +471,17 @@ All four error classes are exported from `lib/provider.mjs` and `lib/snap-provid
402
471
 
403
472
  | Class | Thrown when | Typical handler |
404
473
  |:------|:------------|:----------------|
405
- | `SnapApiError` | A 4xx response was received, or a 5xx/network error was retried to exhaustion. Carries `status` and `path` properties. | Surface the message; do not retry. |
406
- | `SnapUnavailableError` | A network error or 5xx was retried to exhaustion (used internally; usually re-wrapped as `SnapApiError`). | Treat as a temporary outage. |
474
+ | `SnapApiError` | A 4xx response was received, including a response whose diagnostic body stalled. Carries `status` and `path` properties. A final 5xx response also retains its status when it reaches the outage handler. | Surface the message; do not retry. |
475
+ | `SnapUnavailableError` | A retryable network error, transport timeout, or operation deadline was exhausted. In fail mode this may be surfaced directly; skip and fallback modes wrap it in their policy error. | Treat as a temporary outage. |
407
476
  | `SnapFallbackError` | `onUnavailable: "fallback-local"` is set and Snap could not be reached. | Catch and switch to `LocalProvider` for the rest of the pipeline. |
408
477
  | `SnapSkipError` | `onUnavailable: "warn-and-skip"` is set and Snap could not be reached. | Catch and exit cleanly with a skipped summary. |
409
478
 
410
479
  The wrapper actions (`actions/baseline`, `actions/pr-diff`) and the CLI both handle `SnapSkipError` and `SnapFallbackError` through `lib/outage-policy.mjs` (`captureWithPolicy`, `diffWithPolicy`, `publishBaselineWithPolicy`). Custom orchestrations that call `provider.capture()`, `provider.diff()` or `provider.publishBaseline()` directly should use those helpers rather than re-implementing the matrix.
411
480
 
481
+ `SnapTransportTimeoutError` is an internal diagnostic name used while a
482
+ request or response body is being bounded. It is not an additional public
483
+ error class; callers should handle the exported error classes above.
484
+
412
485
  ## Primary entrypoints
413
486
 
414
487
  - `actions/baseline`
@@ -490,6 +563,7 @@ Snap's server-side notification posting should render the same template from the
490
563
  | Field | Type | Description |
491
564
  |:------|:-----|:------------|
492
565
  | `artifactName` | `string?` | PR diff artifact label |
566
+ | `artifactUrl` | `string?` | Authenticated GitHub Actions artifact URL for diff links |
493
567
  | `runUrl` | `string?` | GitHub Actions run URL (adds `[View run]` link) |
494
568
  | `dashboardUrl` | `string?` | Snap dashboard URL (adds `[View in dashboard →]` link; SnapProvider only) |
495
569
  | `maxChangedRows` | `number` | Max drift-signal rows before truncation (default 20) |
@@ -505,3 +579,45 @@ The `VisualProvider` interface requires `buildCommentBody(summary, meta?)`:
505
579
 
506
580
  - **`LocalProvider`** — delegates to `buildReportCommentBody` without a `dashboardUrl`.
507
581
  - **`SnapProvider`** — constructs `dashboardUrl` from `{apiUrl}/projects/{projectId}/runs/{lastRunId}` and passes it through.
582
+
583
+
584
+ ## Workspace TypeScript contracts
585
+
586
+ The four `@snapdrift/*` workspace packages expose their declarations through a
587
+ `types` export condition before the existing JavaScript entrypoint. ESM consumers
588
+ can use Bundler, Node16, or NodeNext module resolution with `strict: true` and
589
+ `skipLibCheck: false`; Node16/NodeNext consumers should use an ESM package or an
590
+ `.mts` entrypoint. The top-level `types` field remains for older tooling.
591
+
592
+ Install `typescript` and `@types/node` in the consuming development environment
593
+ for APIs that use Node buffers. Cross-package declaration dependencies are
594
+ declared by the package that needs them. `npm run check:package-types` packs the
595
+ workspace packages and checks isolated consumers in all three resolution modes,
596
+ including rejected invalid calls. `npm run typecheck` also checks declaration
597
+ bodies without skipping library checks.
598
+
599
+ These guarantees cover the workspace package entrypoints. They do not add type
600
+ entrypoints for the root `snapdrift` package's JavaScript subpaths. Package release
601
+ publication is required before existing registry consumers receive these fixes.
602
+
603
+ Comment renderers accept `VisualReportSummary`: a complete comparison summary or
604
+ a `VisualDriftStatusSummary` with a required status and reason. `buildDriftSummary`
605
+ and `writeDriftSummary` return the latter, preserving their supported non-skipped
606
+ statuses as well as intentional skips. Unrelated object literals are rejected.
607
+ Known viewport presets have complete descriptors; arbitrary string lookups may
608
+ return `undefined` and must be checked by consumers.
609
+
610
+ The packed type gate runs in PR CI and before release publication. It validates
611
+ the candidate workspace tarballs together, not already-published registry
612
+ versions. Release preparation must bump every changed published package and
613
+ raise sibling dependency floors to the versions that contain these type fixes
614
+ before publication; the gate alone does not validate that release-version step.
615
+
616
+ Each workspace package also declares every runtime package it imports. In
617
+ particular, `@snapdrift/adapter-fs` declares `@snapdrift/adapter-report-md`, so
618
+ it can be installed and imported without the SnapDrift repository or a sibling
619
+ workspace masking a missing dependency. `npm run check:package-runtime` packs
620
+ each workspace package, installs it in an isolated temporary consumer together
621
+ with only its declared local workspace dependency closure, and imports its
622
+ public entrypoint. The gate runs in pull-request and release workflows; publish
623
+ the updated package before registry consumers can receive a dependency fix.
@@ -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.8.2
49
+ uses: ranacseruet/snapdrift@v0.10.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.8.2
74
+ uses: ranacseruet/snapdrift@v0.10.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.8.2` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.8.2` |
90
- | `ranacseruet/snapdrift@v0.8.2` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.8.2` |
89
+ | `ranacseruet/snapdrift@v0.10.0` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.10.0` |
90
+ | `ranacseruet/snapdrift@v0.10.0` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.10.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.8.2
133
+ uses: ranacseruet/snapdrift@v0.10.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.8.2
173
+ uses: ranacseruet/snapdrift@v0.10.0
174
174
  with:
175
175
  mode: pr-diff
176
176
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -188,6 +188,15 @@ jobs:
188
188
  - Upserts the PR report
189
189
  - Enforces `diff.mode` after publication completes
190
190
 
191
+ When the wrapper scopes a pull request from GitHub's changed-file list, a
192
+ renamed file contributes both its new and previous paths. A move out of a
193
+ watched directory therefore still selects the route that owned the old path,
194
+ while a move into a watched or shared path selects the new scope as well.
195
+ Repeated paths are deduplicated, and `route-ids` or `force-run` keep their
196
+ existing precedence. GitHub caps this file listing at 3,000 records; if that
197
+ limit is reached, the actions run all configured routes with scope reason
198
+ `changed_files_truncated` instead of trusting a potentially incomplete list.
199
+
191
200
  ## Useful overrides
192
201
 
193
202
  | Input | Default | Purpose |
@@ -205,7 +214,7 @@ jobs:
205
214
  | `max-changed-rows` | `20` | Max changed-route rows shown in the PR comment before truncation |
206
215
  | `max-error-rows` | `10` | Max error rows shown in the PR comment before truncation |
207
216
 
208
- The `pr-diff` action also exposes outputs you can use in subsequent steps: `should-run`, `scope-reason`, `selected-route-ids`, `baseline-found`, `status`, `summary-path`, `markdown-path`, `artifact-name`, `bundle-dir`. See the wrapper action's `outputs:` block for the canonical list.
217
+ The `pr-diff` action also exposes outputs you can use in subsequent steps: `should-run`, `scope-reason`, `selected-route-ids`, `baseline-found`, `baseline-resolution-status`, `status`, `summary-path`, `markdown-path`, `artifact-name`, `bundle-dir`. `baseline-resolution-status` is `found`, `missing`, or `error`; `missing` is the intentional first-baseline case, while `error` means the GitHub lookup failed and causes local comparisons or local Snap fallbacks to fail. Healthy hosted Snap diffs can continue without the downloaded GitHub artifact. See the wrapper action's `outputs:` block for the canonical list.
209
218
 
210
219
  ## Hosted Snap provider
211
220
 
@@ -234,7 +243,7 @@ Both behaviors apply at every phase of a run — capture, diff, and baseline pub
234
243
 
235
244
  Hosted baseline publication is complete and default-branch-only. SnapDrift rejects scoped baseline capture before creating the run, records the configured/selected route ids and exact route/viewport identities, and publishes only when every expected capture finishes successfully with a stored image. The run records one resolved branch and commit (`branch` / `prHeadSha`) plus a publication workflow ref and monotonic sequence. GitHub Actions provides these automatically; other CI systems may set `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`. Snap uses the sequence for same-workflow ordering and source-run timestamps for cross-workflow freshness. Missing, failed, duplicate, or unexpected captures abort publication without replacing the accepted baseline. If a source run loses the race to a newer candidate, the API returns `409 baseline_stale_source`; an inconsistent manifest/source workflow identity returns `409 baseline_source_run_mismatch` with `details.reason = "workflow_mismatch"`. Workflow-ref rotation itself is supported. This restriction does not apply to non-publishing hosted captures, hosted PR-diff runs, or local-provider baselines.
236
245
 
237
- The Snap API client retries 5xx and network errors with exponential backoff (3 attempts, 1 s → 2 s → 4 s, capped at 30 s). 4xx errors never retry and never fall back.
246
+ The Snap API client retries 5xx, network errors, and transport timeouts with exponential backoff (3 attempts, 1 s → 2 s → 4 s, with each delay capped at 30 s). JSON request attempts are limited to 30 seconds and binary baseline exports to 120 seconds. Each Snap operation has a 10-minute deadline covering its requests, retries, backoff, and polling; stalled headers and response bodies are aborted and cannot keep the workflow waiting indefinitely. Retry and poll waits are clipped to the remaining operation time. Received 4xx errors never retry or fall back; if a 4xx diagnostic body stalls, the response remains a non-retryable error with its HTTP status and timeout diagnostic. When a deadline or other retryable transport failure is exhausted, `onUnavailable` controls fail, warn-and-skip, or fallback-local behavior.
238
247
 
239
248
  When `provider: "snap"` and `baseUrl` points to a local address (localhost, `127.0.0.0/8`, `::1`, or `0.0.0.0`), SnapDrift uses a **local-capture hybrid**: Playwright runs on the runner to render the page, then SnapDrift uploads the resulting screenshots to Snap. This makes it possible to point SnapDrift at a server that only the runner can reach (a typical case) without exposing the server to Snap's render worker.
240
249
 
@@ -267,10 +276,13 @@ The `pr-diff` wrapper composes the following low-level steps. They're still avai
267
276
  - `actions/capture` — capture routes and emit `results.json` + `manifest.json`
268
277
  - `actions/compare` — diff current capture against a baseline
269
278
  - `actions/scope` — decide whether to run and which routes to select from changed files
270
- - `actions/resolve-baseline` — find and download the latest successful baseline artifact
279
+ - `actions/resolve-baseline` — find and download the latest successful baseline artifact; its
280
+ `resolution-status` output distinguishes `found`, `missing`, and `error`; the action fails for
281
+ `error`, while `missing` remains the intentional first-baseline result
271
282
  - `actions/stage` — assemble the baseline or diff bundle for upload
272
283
  - `actions/enforce` — evaluate the summary against `diff.mode` and fail when required
273
- - `actions/comment` — upsert a PR comment from a summary (provider-aware)
284
+ - `actions/comment` — upsert a PR comment from a summary (provider-aware); pass
285
+ `artifact-url` from `upload-artifact` when local diff PNGs are present
274
286
 
275
287
  The two wrapper actions that orchestrate the full pipeline are `actions/baseline` (publish) and `actions/pr-diff` (drift detection). They are the primary integration points.
276
288
 
@@ -298,7 +310,12 @@ See the [Local CLI guide](local-cli.md) for full command reference, flags, direc
298
310
 
299
311
  ## Refresh the baseline automatically
300
312
 
301
- After an intentional layout change or dimension shift merges, the baseline must be republished before SnapDrift can compare like-for-like frames again. Without automation, this is a manual step.
313
+ After an intentional layout change merges, the baseline can be republished as
314
+ usual. Legacy local comparisons still require like-for-like dimensions; an
315
+ updated local client may opt into the exact
316
+ `diff.comparisonPolicy: { "version": 1, "threshold": number }` contract to
317
+ compare unequal frames on a union canvas and keep the dimension metadata in the
318
+ report.
302
319
 
303
320
  Use the provided workflow template to refresh the baseline automatically on every push to your default branch (i.e. every merge):
304
321
 
@@ -313,6 +330,12 @@ Run this job after every successful default-branch build. Hosted publication is
313
330
  **"No non-expired SnapDrift baseline artifact was found"**
314
331
  The baseline workflow has not completed successfully on `main`, or the artifact expired. With the Snap provider, the equivalent situation is a 404 from `/v1/visual/projects/:id/baselines/latest`; `SnapProvider` swallows that 404 and proceeds without a baseline — `onUnavailable` is **not** consulted for this case (a 404 is the legitimate "no baseline yet" signal, not a Snap outage). If you want the PR pipeline to tolerate the first-run case, set `diff.mode: "report-only"`; `onUnavailable: "warn-and-skip"` will not help here.
315
332
 
333
+ **"Unable to resolve the SnapDrift baseline artifact"**
334
+ GitHub failed while listing workflow runs or artifacts. Check the job's `actions: read` permission,
335
+ repository/workflow/branch inputs, and GitHub API availability. Snap hosted diffs can still use the
336
+ hosted comparison path, but local comparisons and `fallback-local` require a successful GitHub
337
+ artifact lookup.
338
+
316
339
  **403 when posting the PR report**
317
340
  Grant `issues: write` and `pull-requests: write` to the job.
318
341
 
@@ -320,10 +343,36 @@ Grant `issues: write` and `pull-requests: write` to the job.
320
343
  The Snap client never retries 4xx and never falls back, even when `onUnavailable` is set. Inspect the response body — the most common cause is a project-id mismatch between `GITHUB_REPOSITORY` (auto-derived) and the project's id on Snap.
321
344
 
322
345
  **Screenshots have different dimensions**
323
- SnapDrift reports this as a dimension shift and skips pixel comparison for that route. Refresh the baseline after the change lands.
346
+ Without `diff.comparisonPolicy`, SnapDrift reports this as a legacy dimension
347
+ shift and skips pixel comparison. With the exact v1 policy, it compares the
348
+ top-left-aligned union canvas, records the route in `changed[]`, and writes a
349
+ local diff PNG; refresh the baseline after an intentional change when you want
350
+ future runs to return to equal-size comparisons.
324
351
 
325
352
  **A route appears in `errors[]`**
326
353
  Capture failed before comparison. Confirm the app is fully ready and reachable before SnapDrift runs.
327
354
 
328
355
  **Playwright install runs even though `provider: "snap"`**
329
356
  That's expected for the Snap local-capture hybrid. Playwright runs locally to render the page, then the resulting screenshot is uploaded to Snap. The hybrid kicks in only when `baseUrl` points to a local address; for remote `baseUrl` Snap's render worker captures directly.
357
+
358
+
359
+ ## TypeScript package consumers
360
+
361
+ Import APIs and types from the public `@snapdrift/*` package names. Their export
362
+ maps support Bundler, Node16, and NodeNext resolution; enable `strict: true` and
363
+ `skipLibCheck: false` to validate the contracts in your project. For Node16 or
364
+ NodeNext, use `"type": "module"` or an `.mts` entrypoint. Install `typescript` and
365
+ `@types/node` as development dependencies when using the Node buffer APIs.
366
+
367
+ No TypeScript path aliases or imports into workspace source directories are
368
+ needed. Root `snapdrift/lib/*` subpath typing is separate from these workspace
369
+ contracts. The fixes become available to registry consumers after the packages
370
+ are released and their installed versions are updated.
371
+
372
+ The workspace packages are independently installable runtime packages. Install
373
+ the package you use from the registry and let npm resolve its declared runtime
374
+ dependencies; do not rely on a repository checkout or workspace links. The
375
+ filesystem adapter includes its reporting dependency, so `@snapdrift/adapter-fs`
376
+ can load config, capture, comparison, and report entrypoints from an isolated
377
+ consumer. Release a new adapter version before expecting an existing registry
378
+ installation to include a dependency correction.
package/docs/local-cli.md CHANGED
@@ -225,6 +225,7 @@ After running `capture` and `diff` with default paths:
225
225
  summary.json # structured drift summary
226
226
  summary.md # human-readable markdown report
227
227
  report.html # self-contained HTML report with side-by-side images
228
+ diffs/ # v1 generated diff PNGs, when comparisonPolicy is enabled
228
229
  ```
229
230
 
230
231
  Add `.snapdrift/` to your `.gitignore` to keep local run output out of version control.
@@ -271,7 +272,7 @@ Comparing against baseline ...
271
272
  Report: .snapdrift/diff/report.html
272
273
  ```
273
274
 
274
- **Incomplete run** (errors, missing captures, or dimension shifts):
275
+ **Incomplete run** (errors, missing captures, or legacy dimension shifts):
275
276
 
276
277
  ```
277
278
  ❌ SnapDrift — incomplete
@@ -289,6 +290,19 @@ The `Missing`, `Errors`, and `Dim diff` lines only print when their count is gre
289
290
 
290
291
  The `report.html` path is printed whenever the status is anything other than `clean`; pass `--open` to also launch it in your default browser.
291
292
 
293
+ To compare unequal local screenshot dimensions, add the exact opt-in policy to
294
+ `diff`:
295
+
296
+ ```json
297
+ "comparisonPolicy": { "version": 1, "threshold": 0.01 }
298
+ ```
299
+
300
+ The v1 report includes the baseline, current, and union-canvas dimensions in
301
+ `summary.md` and `report.html`, and stores changed-route diff PNGs under
302
+ `.snapdrift/diff/diffs/`. A completed dimension comparison is a normal changed
303
+ signal, so `fail-on-incomplete` does not fail solely because its dimensions
304
+ differ; `fail-on-changes` and `strict` still enforce it.
305
+
292
306
  ---
293
307
 
294
308
  ## Environment variables
@@ -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.8.2
85
+ uses: ranacseruet/snapdrift@v0.10.0
86
86
  with:
87
87
  mode: baseline
88
88
  repo-config-path: .github/snapdrift.json