snapdrift 0.17.0 → 0.18.1

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,6 +1,53 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.18.1 - 2026-11-03
4
+
5
+ Workspace releases this version:
6
+
7
+ - `@snapdrift/compare-core` 1.8.1 — a pixel that is fully transparent on both sides (alpha `0` on each side) is now visually equal regardless of its hidden RGB, in local comparison policy v1 and v2 (details under Fixes). This is the version the hosted Snap comparator must bump to for #224 parity.
8
+ - `snapdrift` 0.18.1 — root floor for `@snapdrift/compare-core` moves to `^1.8.1`; the previous `^1.8.0` floor still resolves the unfixed 1.8.0 engine. The Marketplace dispatcher pin moves to `2ac911f`, the #227 merge commit on `main`, which includes both the newest commit touching `actions/` (`82e2578`, the #226 action hardening) and the #223 comparator fix, so wrapper-action diffs get the fixed engine too.
9
+ - `@snapdrift/manifest` stays 1.8.0, `@snapdrift/adapter-fs` stays 1.8.0 (its `^1.8.0` compare-core range already resolves 1.8.1), and `@snapdrift/adapter-report-md` stays 1.4.0.
10
+
11
+ ### Fixes
12
+
13
+ - `lib/snap-provider.mjs` (`provider: "snap"`): HTTP `429` (rate limit) and `408` (request timeout) are now treated as transient. They retry with exponential backoff — honoring a `Retry-After` delta-seconds or HTTP-date header, clamped to the existing 30s cap — and, once retries are exhausted, route through the configured `onUnavailable` policy like 5xx do, instead of throwing a raw `SnapApiError` with no retry and no policy routing. A quota-exhausted `429` (body code `quota_exceeded`) stays a hard `SnapApiError`: it is not retried and is not masked by `warn-and-skip`/`fallback-local`, so users still learn the quota is spent. Permanent 4xx behavior is unchanged.
14
+ - `@snapdrift/adapter-report-md`: report markdown now escapes every interpolated string field. `makeMarkdown` escapes route ids, capture-gap reasons, error messages, viewports, and diff-image paths (the PR-comment surface already escaped). `escapeMarkdown` additionally escapes backslashes first, parentheses, emphasis characters (`*`, `_`, `~`, `#`, `!`), `@` (mentions), collapses `\r?\n` to a space (a raw newline inside a table cell ends the row), and neutralizes GFM autolinks by escaping `://` — a hostile capture target could otherwise inject markdown links or break tables via error messages that quote page content. Escaped bare URLs render identically but are no longer clickable. **Output change:** `escapeMarkdown` is re-exported publicly (`lib/pr-comment.mjs`, the package index), so external callers see the new output; values destined for code spans should use the new `formatCodeSpan` (backslash escapes render literally inside code spans). Diff-image destinations are now angle-bracketed with `%`/`<`/`>`/`&` percent-encoded, so paths with spaces survive CommonMark link parsing.
15
+ - `@snapdrift/manifest`: `checkCaptureProfileCompatibility` no longer compares the `platform` block when both profiles are `snap-batch` (cloud-rendered). Snap-batch pixels render cloud-side, so the submitting runner's OS metadata says nothing about the images — without this, each GitHub runner image refresh would mark every stored snap-batch baseline `incompatible_capture` and force a mass re-baselining unrelated to real drift. Rendering-relevant fields (engine, browser, playwrightVersion, locale, timezone, settings) are still compared, and `snapdrift-local` captures keep the strict platform comparison.
16
+ - `@snapdrift/adapter-fs`: `SNAPDRIFT_CAPTURE_CONCURRENCY` is now one run-wide limit shared across all viewport groups, instead of a per-group limit. Per-group limiters multiplied effective concurrency by the number of distinct viewports (e.g. 4 viewports × default 5 = 20 concurrent contexts on one shared chromium), an OOM/timeout risk on small runners. Multi-viewport captures may be slightly slower at the same setting; the documented limit is now the actual maximum.
17
+
18
+ - `@snapdrift/compare-core`: a pixel that is fully transparent on both sides (alpha `0` on each side) is now visually equal regardless of its hidden RGB, in local comparison policy v1 and v2. This covers the strict comparators (`compareBuffers`, `compareWithIgnoreRegions`, `generateDiffImage`), v1 coordinate overlap, v2 exact row fingerprints and Myers verification, v2 offset-run paired rows and displacement evidence, and offset highlighting. One-sided transparent pixels still count as changed in strict, coordinate, and v2 exact-row comparisons, and an opacity change between alpha `0` and nonzero alpha still counts; the pre-existing v2 offset blank-padding rule is unchanged. The change lowers `differentPixels` only for overlapping alpha-0 pairs whose hidden RGB differs; no config, schema, or policy version changed. Hosted Snap `provider: "snap"` diffs reuse `@snapdrift/compare-core` pinned at `1.8.0` in `i2Dev-com/snap`, so hosted parity requires a Snap dependency bump and redeploy there (tracked in #224). Closes #201.
19
+
20
+ - `lib/snap-provider.mjs` / `lib/cli.mjs`: `resolveGitRef` now prefers `GITHUB_HEAD_REF` over `GITHUB_REF_NAME` for branch attribution — on `pull_request` events `GITHUB_REF_NAME` is `<pr>/merge`, which misattributed baselines published from PR-context workflows. The CLI `diff` command now checks the baseline `results.json`/`manifest.json` on disk and reports the documented baseline-unavailable outcome (with a message) instead of recapturing locally and failing with a raw `ENOENT`; `diffWithPolicy` documents its inference as a path-string check only. Local-capture JSON parse failures inside the Snap local-upload path now name the unreadable file and suggest recapturing. A body-read timeout that fires after the attempt deadline no longer reports the request limit as elapsed time. The `comparisonPolicy` doc comment now describes the real fail-closed behavior (policy always sent, server acknowledgment required), and the locally-guaranteed dead truthiness guards are removed.
21
+ - `@snapdrift/adapter-fs`: `stageArtifacts` preserves screenshot subdirectory structure instead of flattening, so same-named PNGs from different subdirectories can no longer silently overwrite each other in the uploaded bundle; symlinked PNGs are staged (stat-resolved) instead of dropped. Local capture records a per-route `Browser launch failed` error and still writes `results.json`/`manifest.json` when `chromium.launch` rejects (previously the most common CI capture failure left no artifacts at all), writes artifacts before `browser.close()`, and logs (rather than propagates) a close failure. Snap batch capture fails fast on a terminal `failed` poll status instead of spinning until the 5-minute timeout, bounds concurrent screenshot downloads (`downloadConcurrency` option, default `SNAPDRIFT_CAPTURE_CONCURRENCY`), and reads PNG dimensions header-only instead of fully decoding every image. `resolveImagePath` refuses manifest image paths that resolve outside the run directory. Capture retries are skipped for deterministic HTTP failures (4xx except 408/429).
22
+ - `@snapdrift/manifest`: `normalizedViewportIdentity` preserves `deviceScaleFactor`/`isMobile`/`hasTouch` on custom viewports (a custom 1440×900 viewport at DSF 3 previously tested as identical to the desktop preset, letting 3×-different-sized images compare instead of reporting `incompatible_capture`). `validateManifest` no longer mutates its input when defaulting `schemaVersion`, rejects a `generatedAt` that fails `Date.parse`, and validates device-characteristic flags on custom viewports. `isLocalBaseUrl` recognizes loopback spellings URL canonicalization hides (`127.1`, `0177.0.0.1`, IPv4-mapped IPv6 loopback `::ffff:127.0.0.1`), which were previously sent to Snap Cloud as if remote.
23
+ - `@snapdrift/compare-core`: the offset-align preflight early-exit bound is documented and guarded by a regression test: the mid-scan mean divides by the full planned sample count, so a costly early row cannot reject a pair whose remaining sampled rows align — the bound only triggers rejection the final sampled-mean verdict would reach anyway.
24
+ - `@snapdrift/adapter-report-md`: the HTML report renders numeric summary fields (dimensions, pixel counts) through a finite-number guard — summaries can arrive from downloaded CI artifacts, so malformed fields no longer interpolate markup into the HTML. The package's `@snapdrift/manifest` floor is raised to `^1.8.0` to match the API it consumes.
25
+ - Actions: baseline and pr-diff artifact uploads use `if-no-files-found: error` (an empty artifact was previously uploaded with a warning and later misread by the resolver). `actions/resolve-baseline` resolves its lib modules against the action checkout without the redundant `GITHUB_WORKSPACE` prefix. The pr-diff enforce step consumes the compare step's `summary_path` output instead of a hardcoded `qa-artifacts` path, so a repo-level output override no longer enforces against a stale or missing summary. The comment action guards an unset `summary-path` input with a warning instead of a `TypeError` from `existsSync(undefined)`.
26
+ - CI (`publish.yml`): new `npm audit --omit=dev --audit-level=high` step runs before install, and the npm upgrade for trusted publishing is pinned to the exact `11.5.1` version.
27
+ - CLI/lib: `openFile` reports a non-zero opener exit status (previously silent). `upsertPullRequestReportComment` sorts unparseable comment timestamps to the end deterministically instead of letting `NaN` pick an arbitrary comment to update. `init --from-snap-action` no longer overwrites an existing `MIGRATION_NOTES.md`.
28
+
29
+ ### Removals
30
+
31
+ - **`SnapProvider.fetchLatestBaseline()` and `SnapProvider.checkBaselineExists()` are removed**, along with the `fetchLatestBaseline` member of the `VisualProvider` interface (both `@snapdrift/manifest` types and the root re-export), the `LocalProvider.fetchLatestBaseline` not-implemented stub, and the `fetchLatestBaseline` capability from `PROVIDER_CAPABILITIES`/`providerSupports`. None had a production caller: baseline resolution goes through the `actions/resolve-baseline` artifact download, and the by-SHA lookup belonged to migration workflows retired with `migrate-baselines`. `#resolveLatestBaselineId` (the internal `baselines/latest` GET used by `capture()`/`diff()`) is unchanged. The `snapdrift baseline` command and both wrapper actions are unaffected. Add-back is a small, isolated change if a hosted baseline-store consumer appears.
32
+ - **`lib/resolve-baseline-paths.mjs` is removed**, including its `./lib/resolve-baseline-paths.mjs` exports-map entry and test. It was superseded by the path computation inside `actions/resolve-baseline` itself and had no importer.
33
+
34
+ ## 0.18.0 - 2026-11-02
35
+
36
+ Workspace releases this version:
37
+
38
+ - `@snapdrift/adapter-fs` 1.8.0 — local capture now requires Playwright `^1.64.1`, which installs Chromium `156.0.8078.4` (revision `1248`) instead of Chromium `153.0.8010.12` (revision `1243`). Existing local baselines captured with Playwright 1.63.x persist a different `captureProfile.browserRevision` / `playwrightVersion`, so comparison reports `incompatible_capture` and does not compare pixels until the baseline is refreshed in the upgraded environment. Pixel rendering may still be byte-identical; the capture-profile contract has no version tolerance.
39
+
40
+ `@snapdrift/manifest` stays 1.8.0, `@snapdrift/compare-core` stays 1.8.0, and `@snapdrift/adapter-report-md` stays 1.4.0. Root floors move to `@snapdrift/adapter-fs ^1.8.0` and `playwright ^1.64.1`; the previous `adapter-fs` 1.7.2 and the Playwright 1.63 floor do not satisfy them. The dispatcher pins `3831623`, the #216 merge commit on `main`, which includes the newest commit touching `actions/` (`1cbf629`, the shell-only Playwright install).
41
+
42
+ ### Changes
43
+
44
+ - Local capture raises the Playwright floor to `^1.64.1`; installs now provision Chromium `156.0.8078.4` (revision `1248`). GitHub Actions keep the shell-only Chromium install (`playwright install --with-deps --only-shell chromium`).
45
+ - **Baseline refresh required:** local baselines captured under the previous Playwright floor (`1.63.x`, Chromium `153.0.8010.12`, revision `1243`) are incompatible until recaptured. A real local-capture verification on a deterministic page produced byte-identical desktop (1440×900) and mobile (1170×2955) PNGs across both versions, but diffing the old baseline against a 1.64.1 current capture returned `incomplete` with two `incompatible_capture` errors (reason: `captureProfile.browserRevision differs between baseline and current capture`); recapturing on 1.64.1 then diffing returned `clean` with `captureCompatibility: verified`.
46
+
47
+ ### Documentation
48
+
49
+ - `docs/local-cli.md` now states the upgrade path explicitly: refresh every local baseline in the upgraded Playwright/Chromium environment before expecting PR diffs to compare pixels, even when rendering is byte-identical.
50
+ - Existing guidance on refreshing or acknowledging local baselines remains unchanged; `report-only` keeps incompatibility visible and never forces pixel comparison.
4
51
 
5
52
  ## 0.17.0 - 2026-10-28
6
53
 
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.17.0
51
+ uses: ranacseruet/snapdrift@v0.18.1
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.17.0
63
+ uses: ranacseruet/snapdrift@v0.18.1
64
64
  with:
65
65
  mode: pr-diff
66
66
  github-token: ${{ secrets.GITHUB_TOKEN }}
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@2d93a5499daf6e0273ed11da69b364d33dc8d4d2 # v0.17.0
158
+ uses: ranacseruet/snapdrift/actions/baseline@2ac911f8def2b8df61f015837d01555f04b68c99 # v0.18.1
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@2d93a5499daf6e0273ed11da69b364d33dc8d4d2 # v0.17.0
168
+ uses: ranacseruet/snapdrift/actions/pr-diff@2ac911f8def2b8df61f015837d01555f04b68c99 # v0.18.1
169
169
  with:
170
170
  github-token: ${{ inputs.github-token }}
171
171
  repo-config-path: ${{ inputs.repo-config-path }}
@@ -235,5 +235,7 @@ runs:
235
235
  with:
236
236
  name: ${{ steps.config.outputs.artifact_name }}
237
237
  retention-days: ${{ inputs.artifact-retention-days }}
238
- if-no-files-found: warn
238
+ # A baseline upload that contains no files would publish an artifact
239
+ # the resolver later "finds" but cannot use; fail instead of warn.
240
+ if-no-files-found: error
239
241
  path: ${{ steps.stage.outputs.bundle_dir }}
@@ -75,6 +75,12 @@ runs:
75
75
  const { pathToFileURL } = require('url');
76
76
 
77
77
  const summaryPath = process.env.VISUAL_DIFF_SUMMARY_PATH;
78
+ if (!summaryPath) {
79
+ // existsSync(undefined) throws a TypeError; an unset summary-path
80
+ // input means there is no report to post, not a crash.
81
+ core.warning('SnapDrift summary-path input was not set; skipping PR comment.');
82
+ return;
83
+ }
78
84
  if (!fs.existsSync(summaryPath)) {
79
85
  core.warning(`SnapDrift summary not found at ${summaryPath}`);
80
86
  return;
@@ -584,7 +584,10 @@ runs:
584
584
  with:
585
585
  name: ${{ steps.context.outputs.artifact_name }}
586
586
  retention-days: ${{ inputs.artifact-retention-days }}
587
- if-no-files-found: warn
587
+ # A drift upload that contains no files means staging ran but produced
588
+ # nothing; downstream consumers would download an empty artifact and
589
+ # misread it as "no drift". Fail instead of warn.
590
+ if-no-files-found: error
588
591
  path: ${{ steps.stage.outputs.bundle_dir }}
589
592
 
590
593
  - if: always()
@@ -687,6 +690,13 @@ runs:
687
690
  # has nothing to evaluate — `warn-and-skip` means "do not fail my build".
688
691
  - if: steps.scope.outputs.should_run == 'true' && steps.capture.outputs.provider != 'skipped' && steps.compare.outputs.skipped != 'true' && (steps.baseline.outputs.found == 'true' || steps.capture.outputs.provider == 'snap')
689
692
  shell: bash
693
+ env:
694
+ # Consume the compare step's summary_path output rather than hardcoding
695
+ # the default drift directory: a repo-level workingDirectory or
696
+ # outDir override moves the summary, and the hardcoded path then read
697
+ # a stale file (or failed with ENOENT) while the real summary said
698
+ # something different.
699
+ SUMMARY_JSON_PATH: ${{ steps.compare.outputs.summary_path }}
690
700
  run: |
691
701
  node --input-type=module <<'EOF'
692
702
  import fs from 'node:fs/promises';
@@ -695,7 +705,7 @@ runs:
695
705
 
696
706
  const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/compare-results.mjs');
697
707
  const { shouldFailDriftCheck, formatDriftFailureMessage } = await import(pathToFileURL(modulePath));
698
- const summaryModulePath = path.resolve('qa-artifacts/snapdrift/drift/current/summary.json');
708
+ const summaryModulePath = path.resolve(process.env.SUMMARY_JSON_PATH);
699
709
  const summary = JSON.parse(await fs.readFile(summaryModulePath, 'utf8'));
700
710
 
701
711
  if (shouldFailDriftCheck(summary)) {
@@ -84,8 +84,13 @@ runs:
84
84
  const { pathToFileURL } = require('url');
85
85
 
86
86
  const repository = process.env.INPUT_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`;
87
- const modulePath = path.resolve(process.env.GITHUB_WORKSPACE, process.env.GITHUB_ACTION_PATH, '..', '..', 'lib', 'snapdrift-config.mjs');
88
- const resolverModulePath = path.resolve(process.env.GITHUB_WORKSPACE, process.env.GITHUB_ACTION_PATH, '..', '..', 'lib', 'resolve-baseline-artifact.mjs');
87
+ // Resolve against the action's own checkout, not GITHUB_WORKSPACE:
88
+ // this composite action may be consumed from a repo whose working
89
+ // directory does not contain the snapdrift checkout, and
90
+ // GITHUB_ACTION_PATH already points inside the action.
91
+ const actionRoot = path.resolve(process.env.GITHUB_ACTION_PATH, '..', '..');
92
+ const modulePath = path.resolve(actionRoot, 'lib', 'snapdrift-config.mjs');
93
+ const resolverModulePath = path.resolve(actionRoot, 'lib', 'resolve-baseline-artifact.mjs');
89
94
  const { loadSnapdriftConfig } = await import(pathToFileURL(modulePath));
90
95
  const { resolveBaselineArtifact } = await import(pathToFileURL(resolverModulePath));
91
96
  let artifactName = process.env.INPUT_ARTIFACT_NAME || '';
package/docs/contracts.md CHANGED
@@ -191,7 +191,7 @@ and comparison policy v1. The profile records:
191
191
  |:------|:--------------------|
192
192
  | `engine`, `engineVersion` | `{ "name": "snapdrift-local", "version": "<adapter-fs version>" }`; `engineVersion` equals `engine.version` |
193
193
  | `browser`, `browserRevision`, `playwrightVersion` | `chromium`, the running browser's `browser.version()` string, and the installed Playwright version |
194
- | `platform` | `name`, `architecture`, `release`, `version` from Node's OS APIs |
194
+ | `platform` | `name`, `architecture`, `release`, `version` from Node's OS APIs. Compared strictly for `snapdrift-local` captures (rendering happens on the submitting machine); ignored for `snap-batch` captures, whose pixels are rendered cloud-side, so GitHub runner image refreshes do not invalidate stored baselines |
195
195
  | `locale`, `timezone` | `en-US`, `UTC` |
196
196
  | `settings.screenshot` | `fullPage: true`, `animations: "disabled"`, `caret: "hide"`, `scale: "device"`, `omitBackground: false`, `type: "png"` |
197
197
  | `settings.readiness` | `waitUntil: "load"`, `settleDelayMs: 300` |
@@ -297,8 +297,8 @@ Policy v2 comparison metadata additionally includes `policyVersion: 2`,
297
297
  `mode`, and, for aligned results, `alignmentStrategy`. An aligned result has
298
298
  `mode: "vertical-aligned"` and a compact `rowMapping` describing matched,
299
299
  changed, inserted, and deleted row runs. `alignmentStrategy: "exact"` records
300
- the Myers fingerprint path: matched rows are byte-identical and `tolerance` is
301
- absent. `alignmentStrategy: "offset"` records the piecewise vertical-offset
300
+ the Myers fingerprint path: matched rows are visually identical (fully
301
+ transparent pairs may differ in hidden RGB) and `tolerance` is absent. `alignmentStrategy: "offset"` records the piecewise vertical-offset
302
302
  fallback: `tolerance` is the maximum per-channel delta that stays unhighlighted
303
303
  on stable offset-run rows (`STRUCTURED_DELTA`, 32). Local-mismatch residual
304
304
  bands between stable runs instead highlight deltas above `NOISE_DELTA` (2), so
@@ -345,11 +345,11 @@ Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
345
345
  - Mismatch ratio is `different_pixels / total_pixels`
346
346
  - `diff.threshold` applies per screenshot
347
347
  - Missing captures are counted separately from drift signals
348
- - Comparison policy v1 `{ "version": 1, "threshold": number }` is deprecated but remains the compatibility default. 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.
349
- - Comparison policy v2 `{ "version": 2, "threshold": number }` is opt-in. When source widths match and no ignore regions are requested, rows are fingerprinted and aligned with a bounded Myers sequence diff. If that search hits its edit cap, long vertical-offset runs are mapped instead. Inside a run, a row is compared one pixel above or below the recorded current row only when the row's own recorded-offset ink cost exceeds `LOCAL_MISMATCH_COST` (0.5) and a ±1 neighbor matches; a stable row with a smaller residual keeps its own offset, so its nominal current row is still compared instead of being consumed without comparison. `currentStart` still consumes each current row once, so that neighbor is not a separate insertion or deletion. When the compared row differs, the segment includes `comparedOffset` of `-1` or `1`: output row `i` was compared to current row `currentStart + i + comparedOffset`. On stable offset-run rows, a pixel whose red, green, blue, or alpha channel differs by 32 or less stays unhighlighted, so rendering noise and soft edges remain matched. A local-mismatch residual band between stable runs is stricter: a pixel there is highlighted when any channel differs by more than `NOISE_DELTA` (2). A short cluster of such larger channel deltas is an orange edit. A wide band of those pixels is a local mismatch: it is highlighted in place and does not discard the rest of the page. Unmatched baseline rows are red deletions. Current rows with no baseline partner are green insertions. `matched` on this path means the row was mapped and fell below this highlight rule. Exact fingerprint matches remain byte-identical. Aligned metadata records which strategy applied: exact Myers results carry `alignmentStrategy: "exact"` and no `tolerance`; offset-run results carry `alignmentStrategy: "offset"` and the stable-run `tolerance: 32`, while residual local-mismatch bands are not covered by `tolerance`. An unmatched row that also occurs on the opposite side is ambiguous only when its repeat count on either side stays below `MIN_STRUCTURAL_ROW_REPEATS` (16): such a row could be moved content. Ambiguous exact alignment first tries the offset-run mapping; when that mapping succeeds, the result is `vertical-aligned` with `alignmentStrategy: "offset"`, and only an unsuccessful mapping falls back to v1 coordinate comparison as `ambiguous`. A row that repeats at least 16 times on either side (page background, card padding, borders) is structural and does not count: where it lands inside a run of identical rows makes no visible difference. Offset-run plans treat blank structural padding as matched rather than painting it green or red: a one-sided row carries content only when at least `CONTENT_INK_MIN` (16) full-width ink pixels are present. A one-sided content row that duplicates a claimed row in the other image within `STRUCTURAL_DUPLICATE_COST` (0.05 row ink cost) at an offset within `STRUCTURAL_DUPLICATE_WINDOW` (3) of the neighboring stable runs is a compensating rendering band, not a new or deleted line, and stays matched. An unresolved band at a shift boundary splits into changed-ink rows and padding: a baseline ink row pairs with the current row at the preceding stable offset when that row carries ink and is free, otherwise with its best evidence offset when that offset sits within the duplicate window of the neighboring runs, otherwise it is a red deletion. Leading blank rows stay with the preceding offset only when every candidate current row is free; trailing blank rows follow the last paired content offset when free, otherwise they are the deleted region's blank tail. A candidate current row already claimed by a stable run is never a free partner. Inside a paired row, a mismatch set with fewer than `CONTENT_INK_MIN` mismatched pixels is a localized rendering displacement (a glyph or badge edge drawn a few rows off its text) only when every mismatch has both background evidence — the baseline pixel exists in a claimed current row — and foreground evidence — the current pixel exists in a claimed baseline row — at one common non-zero relative offset within the duplicate window; inserted current rows and deleted baseline rows count as claimed rows on their respective sides. A mismatch with only one side's duplicate, such as a new small mark on a uniform background whose old background has a neighboring current copy but whose foreground exists nowhere in the baseline, is content and stays orange. A contiguous displaced run is suppressed only when it borders matched rows or blank structural one-sided rows; a run bordering unexplained mismatched content or content-carrying one-sided rows stays painted as part of that edit. A mismatch set reaching `CONTENT_INK_MIN` is a structured glyph or region recolor and stays orange. Width changes, ignore regions, resource limits, and ambiguous exact-row matches whose offset-run mapping also fails fall back to v1 coordinate comparison and record the fallback reason; coordinate-fallback metadata carries neither `alignmentStrategy` nor `tolerance`. This is a raster row alignment aid, not DOM or arbitrary component matching.
348
+ - Comparison policy v1 `{ "version": 1, "threshold": number }` is deprecated but remains the compatibility default. Images are top-left aligned on a max-dimension union canvas with no scaling. Overlap pixels are compared, and a two-sided overlap pixel is equal when both alphas are 0 regardless of hidden RGB. One-sided pixels count as changed (including transparent pixels), and empty union corners do not enter the denominator.
349
+ - Comparison policy v2 `{ "version": 2, "threshold": number }` is opt-in. When source widths match and no ignore regions are requested, rows are fingerprinted and aligned with a bounded Myers sequence diff. Fingerprinting and Myers verification treat a pixel that is fully transparent on both sides as zero RGBA regardless of hidden RGB. If that search hits its edit cap, long vertical-offset runs are mapped instead. Inside a run, a row is compared one pixel above or below the recorded current row only when the row's own recorded-offset ink cost exceeds `LOCAL_MISMATCH_COST` (0.5) and a ±1 neighbor matches; a stable row with a smaller residual keeps its own offset, so its nominal current row is still compared instead of being consumed without comparison. `currentStart` still consumes each current row once, so that neighbor is not a separate insertion or deletion. When the compared row differs, the segment includes `comparedOffset` of `-1` or `1`: output row `i` was compared to current row `currentStart + i + comparedOffset`. On stable offset-run rows, a pixel transparent on both sides is equal regardless of hidden RGB, and a pixel whose red, green, blue, or alpha channel differs by 32 or less stays unhighlighted, so rendering noise and soft edges remain matched. A local-mismatch residual band between stable runs is stricter: a pixel there is highlighted when any channel differs by more than `NOISE_DELTA` (2), except that a pair transparent on both sides remains equal regardless of hidden RGB. A short cluster of such larger channel deltas is an orange edit. A wide band of those pixels is a local mismatch: it is highlighted in place and does not discard the rest of the page. Unmatched baseline rows are red deletions. Current rows with no baseline partner are green insertions. `matched` on this path means the row was mapped and fell below this highlight rule. Exact fingerprint matches remain visually identical; a fully transparent pair may differ in hidden RGB. Aligned metadata records which strategy applied: exact Myers results carry `alignmentStrategy: "exact"` and no `tolerance`; offset-run results carry `alignmentStrategy: "offset"` and the stable-run `tolerance: 32`, while residual local-mismatch bands are not covered by `tolerance`. An unmatched row that also occurs on the opposite side is ambiguous only when its repeat count on either side stays below `MIN_STRUCTURAL_ROW_REPEATS` (16): such a row could be moved content. Ambiguous exact alignment first tries the offset-run mapping; when that mapping succeeds, the result is `vertical-aligned` with `alignmentStrategy: "offset"`, and only an unsuccessful mapping falls back to v1 coordinate comparison as `ambiguous`. A row that repeats at least 16 times on either side (page background, card padding, borders) is structural and does not count: where it lands inside a run of identical rows makes no visible difference. Offset-run plans treat blank structural padding as matched rather than painting it green or red: a one-sided row carries content only when at least `CONTENT_INK_MIN` (16) full-width ink pixels are present. A one-sided content row that duplicates a claimed row in the other image within `STRUCTURAL_DUPLICATE_COST` (0.05 row ink cost) at an offset within `STRUCTURAL_DUPLICATE_WINDOW` (3) of the neighboring stable runs is a compensating rendering band, not a new or deleted line, and stays matched. An unresolved band at a shift boundary splits into changed-ink rows and padding: a baseline ink row pairs with the current row at the preceding stable offset when that row carries ink and is free, otherwise with its best evidence offset when that offset sits within the duplicate window of the neighboring runs, otherwise it is a red deletion. Leading blank rows stay with the preceding offset only when every candidate current row is free; trailing blank rows follow the last paired content offset when free, otherwise they are the deleted region's blank tail. A candidate current row already claimed by a stable run is never a free partner. Inside a paired row, a mismatch set with fewer than `CONTENT_INK_MIN` mismatched pixels is a localized rendering displacement (a glyph or badge edge drawn a few rows off its text) only when every mismatch has both background evidence — the baseline pixel exists in a claimed current row — and foreground evidence — the current pixel exists in a claimed baseline row — at one common non-zero relative offset within the duplicate window; inserted current rows and deleted baseline rows count as claimed rows on their respective sides. A mismatch with only one side's duplicate, such as a new small mark on a uniform background whose old background has a neighboring current copy but whose foreground exists nowhere in the baseline, is content and stays orange. A contiguous displaced run is suppressed only when it borders matched rows or blank structural one-sided rows; a run bordering unexplained mismatched content or content-carrying one-sided rows stays painted as part of that edit. A mismatch set reaching `CONTENT_INK_MIN` is a structured glyph or region recolor and stays orange. Width changes, ignore regions, resource limits, and ambiguous exact-row matches whose offset-run mapping also fails fall back to v1 coordinate comparison and record the fallback reason; coordinate-fallback metadata carries neither `alignmentStrategy` nor `tolerance`. This is a raster row alignment aid, not DOM or arbitrary component matching.
350
350
  - Both policies preserve the explicit dimension-change signal: a source height/width change remains a changed route even when the aligned mismatch ratio is at or below the threshold. Empty output corners do not enter the denominator.
351
351
  - Local diff images are rendered with a semantic palette: orange = changed pixels, green = added or inserted pixels, red = removed or deleted pixels. 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.
352
- - `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.
352
+ - `dimensionChanges[]` remains part of the summary contract but is empty for local comparisons in v1. Strict same-dimension comparison (`compareBuffers`, `compareWithIgnoreRegions`, `generateDiffImage`) remains available to direct `@snapdrift/compare-core`/`comparePngs` callers that pass no policy; its pixel equality uses the same two-sided alpha-0 rule as v1.
353
353
  - 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.
354
354
  - `diff.mode` controls enforcement, not summary generation
355
355
 
@@ -461,7 +461,7 @@ that matches the product behavior. Do not resize or crop screenshots, or lower
461
461
  | Navigation timeout | 30000ms |
462
462
  | Settle delay | 300ms |
463
463
  | Screenshot animations | `disabled` (Playwright finishes/cancels CSS animations before capture) |
464
- | Capture concurrency | 5 routes per viewport (overridable via `SNAPDRIFT_CAPTURE_CONCURRENCY`)|
464
+ | Capture concurrency | 5 routes in total across all viewport groups (overridable via `SNAPDRIFT_CAPTURE_CONCURRENCY`)|
465
465
 
466
466
  Local Playwright captures use a fresh browser context for every route attempt,
467
467
  including retries. Cookies and browser storage are not shared between routes,
@@ -735,7 +735,7 @@ These are for custom orchestration only. Wrapper actions set them automatically.
735
735
  | `SNAPDRIFT_BASELINE_ARTIFACT_NAME` | `compare-results.mjs` | Baseline artifact label to embed in the report |
736
736
  | `SNAPDRIFT_BASELINE_SOURCE_SHA` | `compare-results.mjs` | Baseline source SHA to embed in the report |
737
737
  | `SNAPDRIFT_ENFORCE_OUTCOME` | `compare-results.mjs` | Set to `0` to disable enforcement in direct CLI usage |
738
- | `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. |
738
+ | `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture-routes.mjs` | Max concurrent route captures across all viewport groups combined (positive integer, default `5`). Each local attempt uses a fresh browser context. Set to `1` to fully serialize captures without sharing storage. |
739
739
 
740
740
  ## PR comment markdown shape
741
741
 
@@ -8,7 +8,7 @@ Examples use the public release tag for readability. If your organization requir
8
8
 
9
9
  - SnapDrift provisions Node 22 internally.
10
10
  - The consumer app can use any stack as long as it can be started and reached at `baseUrl`.
11
- - The runner must be Ubuntu-compatible because Playwright Chromium is installed with system dependencies.
11
+ - The runner must be pinned to a stable Ubuntu-compatible image (for example `ubuntu-24.04`) because Playwright Chromium is installed with system dependencies and local capture profiles fingerprint the OS. Avoid a moving `ubuntu-latest` label between baseline and PR captures.
12
12
  - The runner must have outbound network access to fetch Playwright Chromium.
13
13
 
14
14
  Your repository still owns:
@@ -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.17.0
49
+ uses: ranacseruet/snapdrift@v0.18.1
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.17.0
74
+ uses: ranacseruet/snapdrift@v0.18.1
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.17.0` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.17.0` |
90
- | `ranacseruet/snapdrift@v0.17.0` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.17.0` |
89
+ | `ranacseruet/snapdrift@v0.18.1` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.18.1` |
90
+ | `ranacseruet/snapdrift@v0.18.1` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.18.1` |
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
 
@@ -112,7 +112,7 @@ permissions:
112
112
 
113
113
  jobs:
114
114
  snapdrift:
115
- runs-on: ubuntu-latest
115
+ runs-on: ubuntu-24.04
116
116
  steps:
117
117
  - uses: actions/checkout@v4
118
118
  - uses: actions/setup-node@v4
@@ -130,7 +130,7 @@ jobs:
130
130
  done
131
131
 
132
132
  - name: SnapDrift Report
133
- uses: ranacseruet/snapdrift@v0.17.0
133
+ uses: ranacseruet/snapdrift@v0.18.1
134
134
  with:
135
135
  mode: pr-diff
136
136
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -154,7 +154,7 @@ permissions:
154
154
 
155
155
  jobs:
156
156
  snapdrift:
157
- runs-on: ubuntu-latest
157
+ runs-on: ubuntu-24.04
158
158
  steps:
159
159
  - uses: actions/checkout@v4
160
160
  - uses: actions/setup-python@v5
@@ -170,7 +170,7 @@ jobs:
170
170
  done
171
171
 
172
172
  - name: SnapDrift Report
173
- uses: ranacseruet/snapdrift@v0.17.0
173
+ uses: ranacseruet/snapdrift@v0.18.1
174
174
  with:
175
175
  mode: pr-diff
176
176
  github-token: ${{ secrets.GITHUB_TOKEN }}
package/docs/local-cli.md CHANGED
@@ -214,6 +214,13 @@ normalized viewport. A mismatch produces an
214
214
  signal. Matching profiles can still have route-identity errors:
215
215
  `summary.captureCompatibility.status` describes profiles only.
216
216
 
217
+ SnapDrift 0.18.0 raises the Playwright floor to 1.64.1, which installs Chromium
218
+ 156 (`156.0.8078.4`, revision 1248) instead of Chromium 153 (`153.0.8010.12`,
219
+ revision 1243). Every local baseline captured with Playwright 1.63.x is
220
+ incompatible until refreshed, even when the rendered pixels are byte-identical;
221
+ the profile check has no version tolerance. Refresh on the upgraded environment
222
+ before expecting PR diffs to compare pixels.
223
+
217
224
  To refresh, serve the intended accepted application state using the same capture
218
225
  environment and route config as future diffs. With `provider: "local"`, run:
219
226
 
@@ -372,7 +379,7 @@ differ; `fail-on-changes` and `strict` still enforce it.
372
379
 
373
380
  | Variable | Applies to | Description |
374
381
  |:---------|:-----------|:------------|
375
- | `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. |
382
+ | `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture`, `diff` | Max concurrent route operations (positive integer, default `5`): route captures in total across all viewport groups during `capture`, and per-route resolve/compare during `diff`. Each local capture attempt uses isolated browser storage. Set to `1` to fully serialize captures, without sharing storage. |
376
383
  | `SNAPDRIFT_CONFIG_PATH` | `capture`, `diff`, `migrate-baselines` | Override the config file path. Equivalent to `--config`. |
377
384
  | `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. |
378
385
 
@@ -35,7 +35,7 @@ jobs:
35
35
  concurrency:
36
36
  group: snapdrift-baseline-${{ github.repository }}-${{ github.ref }}
37
37
  cancel-in-progress: false
38
- runs-on: ubuntu-latest
38
+ runs-on: ubuntu-24.04
39
39
 
40
40
  steps:
41
41
  - uses: actions/checkout@v4
@@ -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.17.0
85
+ uses: ranacseruet/snapdrift@v0.18.1
86
86
  with:
87
87
  mode: baseline
88
88
  repo-config-path: .github/snapdrift.json
package/lib/cli.mjs CHANGED
@@ -50,7 +50,8 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
50
50
  * --from-snap-action <path> Snap action workflow YAML path (init command)
51
51
  *
52
52
  * Values may be given inline as `--flag=value` (so values beginning with a hyphen,
53
- * e.g. `--routes=-mobile`, work) or after the `--` end-of-options marker. Unknown
53
+ * e.g. `--routes=-mobile`, work) or after the `--` end-of-options marker (where
54
+ * known value flags are still honored, so `-- --routes -mobile` works). Unknown
54
55
  * flags and missing option values are rejected with a non-zero error; invalid
55
56
  * *values* for --to/--from remain silently ignored (validated downstream).
56
57
  *
@@ -131,7 +132,11 @@ export function parseArgs(argv) {
131
132
  continue;
132
133
  }
133
134
 
134
- // End-of-options: after `--`, tokens are values/positionals, never flags.
135
+ // After `--`, tokens are still matched against known value flags so a
136
+ // hyphenated value can be passed as `-- --routes -mobile`. The inline
137
+ // comment below previously claimed tokens become positionals, which the
138
+ // code (and tests) never did — this comment now describes the actual
139
+ // contract.
135
140
  if (endOfOptions) {
136
141
  if (VALUE_FLAGS.has(raw)) {
137
142
  const value = args[i + 1];
@@ -230,16 +235,22 @@ export function printSummary(summary) {
230
235
  }
231
236
 
232
237
  /**
233
- * Open a file with the OS default viewer.
238
+ * Open a file with the OS default viewer. Exported for testing.
234
239
  *
235
240
  * @param {string} filePath
236
241
  * @returns {void}
237
242
  */
238
- function openFile(filePath) {
243
+ export function openFile(filePath) {
239
244
  const { cmd, args } = buildOpenCommand(filePath);
240
245
  const result = spawnSync(cmd, args);
241
246
  if (result.error) {
242
247
  process.stderr.write(`Could not open file: ${filePath}\n`);
248
+ } else if (result.status !== 0) {
249
+ // A non-zero exit (e.g. xdg-open without a registered handler) previously
250
+ // passed silently; report it so the user knows the report was not opened.
251
+ process.stderr.write(
252
+ `Could not open file: ${filePath} (${cmd} exited with status ${result.status})\n`
253
+ );
243
254
  }
244
255
  }
245
256
 
@@ -452,11 +463,23 @@ export async function runDiffCommand(opts) {
452
463
  localScreenshots: captured.localScreenshots,
453
464
  captureOptions,
454
465
  diffOptions,
466
+ // Explicit, because the policy's inference only checks that a baseline
467
+ // path string was supplied — a stale baselineDir without results.json
468
+ // must produce the documented baseline-unavailable outcome, not a raw
469
+ // ENOENT from the local diff engine.
470
+ baselineAvailable: await Promise.all([
471
+ fs.access(baselineResultsPath),
472
+ fs.access(baselineManifestPath)
473
+ ]).then(() => true, () => false),
455
474
  onSkip: () =>
456
475
  process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n'),
457
476
  onFallback: () => process.stdout.write('Falling back to local provider for the diff...\n'),
458
477
  onRecapture: () =>
459
- process.stdout.write('Recapturing locally so the local diff has screenshots to compare...\n')
478
+ process.stdout.write('Recapturing locally so the local diff has screenshots to compare...\n'),
479
+ onBaselineUnavailable: () =>
480
+ process.stdout.write(
481
+ `No baseline found at ${baselineResultsPath}; the local fallback has nothing to diff.\n`
482
+ )
460
483
  });
461
484
 
462
485
  if (diffed.outcome !== 'diffed') {
@@ -120,6 +120,15 @@ export async function upsertPullRequestReportComment({ github, owner, repo, issu
120
120
  .sort((left, right) => {
121
121
  const leftTime = new Date(left.updated_at || left.created_at).getTime();
122
122
  const rightTime = new Date(right.updated_at || right.created_at).getTime();
123
+ // NaN (unparseable timestamps) makes the comparator's contract
124
+ // unreliable: subtracting NaN yields NaN, and V8 treats an unsorted
125
+ // NaN result as "keep order", which would pick an arbitrary comment
126
+ // to update. Send non-finite timestamps to the end deterministically.
127
+ const leftFinite = Number.isFinite(leftTime);
128
+ const rightFinite = Number.isFinite(rightTime);
129
+ if (!leftFinite && !rightFinite) return 0;
130
+ if (!leftFinite) return 1;
131
+ if (!rightFinite) return -1;
123
132
  return rightTime - leftTime;
124
133
  });
125
134
  const existing = matchingComments[0];
@@ -242,13 +242,19 @@ export async function runInitFromAction(actionYamlPath) {
242
242
 
243
243
  // Write snapdrift.json
244
244
  await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
245
+ process.stdout.write(`Created ${configPath}\n`);
245
246
 
246
- // Write MIGRATION_NOTES.md
247
+ // Write MIGRATION_NOTES.md, but never overwrite an existing one: unlike
248
+ // the generated config, the notes file is meant to be edited by the user
249
+ // during migration, and re-running init must not silently clobber it.
247
250
  const notesContent = generateMigrationNotes(warnings, actionYamlPath);
248
- await fs.writeFile(notesPath, notesContent);
249
-
250
- process.stdout.write(`Created ${configPath}\n`);
251
- process.stdout.write(`Created ${notesPath}\n`);
251
+ const notesExist = await fs.access(notesPath).then(() => true, () => false);
252
+ if (notesExist) {
253
+ process.stdout.write(`Kept existing ${notesPath} (not overwritten)\n`);
254
+ } else {
255
+ await fs.writeFile(notesPath, notesContent);
256
+ process.stdout.write(`Created ${notesPath}\n`);
257
+ }
252
258
  if (warnings.length > 0) {
253
259
  process.stdout.write(`${warnings.length} warning(s)/note(s) — review ${notesPath}\n`);
254
260
  }
@@ -157,7 +157,10 @@ export async function captureWithPolicy(options) {
157
157
  *
158
158
  * 1. A baseline must exist on disk. Without one the local diff throws on a
159
159
  * missing results file, so we report `baseline-unavailable` and let the
160
- * caller write the documented missing-baseline summary instead.
160
+ * caller write the documented missing-baseline summary instead. Callers
161
+ * should pass `baselineAvailable` explicitly (the CLI checks the file);
162
+ * the fallback inference only tests that `diffOptions.baselineResultsPath`
163
+ * is a non-empty string, which is weaker than existence on disk.
161
164
  * 2. The current capture must have local screenshots. A remote Snap capture
162
165
  * does not, so we recapture locally first rather than diffing a manifest of
163
166
  * zero-dimension placeholders.
@@ -1,4 +1,4 @@
1
1
  // @ts-check
2
2
  // Re-exports from packages — this module is a thin shim.
3
3
 
4
- export { buildReportCommentBody, PR_COMMENT_MARKER, PR_COMMENT_MARKERS, escapeMarkdown } from '@snapdrift/adapter-report-md';
4
+ export { buildReportCommentBody, PR_COMMENT_MARKER, PR_COMMENT_MARKERS, escapeMarkdown, formatCodeSpan } from '@snapdrift/adapter-report-md';
package/lib/provider.mjs CHANGED
@@ -19,8 +19,6 @@ import {
19
19
  /** @typedef {import('../types/visual-diff-types').ProviderDiffResult} DiffResult */
20
20
  /** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineOptions} PublishBaselineOptions */
21
21
  /** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineResult} PublishBaselineResult */
22
- /** @typedef {import('../types/visual-diff-types').ProviderFetchBaselineOptions} FetchBaselineOptions */
23
- /** @typedef {import('../types/visual-diff-types').ProviderBaselineData} BaselineData */
24
22
  /** @typedef {import('../types/visual-diff-types').SnapConfig} SnapConfig */
25
23
  /** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
26
24
 
@@ -56,19 +54,6 @@ export class LocalProvider {
56
54
  return stageArtifacts({ artifactType: 'baseline', ...stageOpts });
57
55
  }
58
56
 
59
- /**
60
- * @param {FetchBaselineOptions} _options
61
- * @returns {Promise<never>}
62
- * Guard with providerSupports(providerName, 'fetchLatestBaseline') before calling.
63
- * @throws {Error} Not yet implemented for LocalProvider.
64
- * Use the pr-diff action for baseline resolution, or wait for a future release.
65
- */
66
- async fetchLatestBaseline(_options) {
67
- throw new Error(
68
- 'LocalProvider.fetchLatestBaseline is not yet implemented. ' + 'Use the pr-diff action for baseline resolution, or wait for a future release.'
69
- );
70
- }
71
-
72
57
  /**
73
58
  * Build a PR comment body from a diff summary.
74
59
  * LocalProvider omits the dashboard link (no dashboard exists).
@@ -83,8 +68,8 @@ export class LocalProvider {
83
68
  }
84
69
 
85
70
  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 })
71
+ local: Object.freeze({ capture: true, diff: true, publishBaseline: true, buildCommentBody: true }),
72
+ snap: Object.freeze({ capture: true, diff: true, publishBaseline: true, buildCommentBody: true })
88
73
  });
89
74
 
90
75
  /**
@@ -28,8 +28,6 @@ import {
28
28
  /** @typedef {import('../types/visual-diff-types').ProviderDiffResult} DiffResult */
29
29
  /** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineOptions} PublishBaselineOptions */
30
30
  /** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineResult} PublishBaselineResult */
31
- /** @typedef {import('../types/visual-diff-types').ProviderFetchBaselineOptions} FetchBaselineOptions */
32
- /** @typedef {import('../types/visual-diff-types').ProviderBaselineData} BaselineData */
33
31
  /** @typedef {import('../types/visual-diff-types').SnapConfig} SnapConfig */
34
32
  /** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
35
33
  /** @typedef {import('../types/visual-diff-types').ComparisonPolicy} ComparisonPolicy */
@@ -42,6 +40,28 @@ const MAX_RETRIES = 3;
42
40
  const INITIAL_RETRY_DELAY_MS = 1000;
43
41
  const RETRY_MULTIPLIER = 2;
44
42
  const RETRY_DELAY_CAP_MS = 30000;
43
+ /**
44
+ * Transient 4xx statuses that are retryable and routed through the
45
+ * `onUnavailable` availability policy instead of failing outright. 429
46
+ * (rate limited) and 408 (request timeout) describe a temporary server-side
47
+ * condition, not a permanent client error, so they get the same retry +
48
+ * availability semantics as 5xx.
49
+ * @type {Set<number>}
50
+ */
51
+ const TRANSIENT_RETRYABLE_4XX = new Set([408, 429]);
52
+ /**
53
+ * Snap reports exhausted account quota as a 429 carrying a quota body code
54
+ * (e.g. `quota_exceeded`). Unlike a rate limit, exhausted quota does not
55
+ * recover by waiting, so those 429s must stay hard, non-retryable
56
+ * `SnapApiError`s instead of being retried and then masked by the
57
+ * availability policy (warn-and-skip would exit 0 and never tell the user
58
+ * the quota is spent).
59
+ * @param {SnapApiError} error
60
+ * @returns {boolean}
61
+ */
62
+ function isQuotaExhaustedError(error) {
63
+ return /quota/i.test(error.code || '');
64
+ }
45
65
  const JSON_REQUEST_TIMEOUT_MS = 30 * 1000;
46
66
  const BINARY_REQUEST_TIMEOUT_MS = 120 * 1000;
47
67
  const POLL_INTERVAL_MS = 2000;
@@ -328,10 +348,12 @@ function assertComparisonPolicyAcknowledged(actual, expected, context) {
328
348
  }
329
349
 
330
350
  /**
331
- * Updated SnapDrift clients explicitly opt into the union-canvas comparison.
332
- * Keep the config override for callers that want the policy visible in their
333
- * config, but derive it from the existing diff threshold by default so a
334
- * normal v1 client does not need to repeat that value.
351
+ * The comparison policy is fail-closed by default: every client sends a policy
352
+ * derived from the existing `diff.threshold` (or the explicit
353
+ * `diff.comparisonPolicy` config override), and requires the Snap server to
354
+ * acknowledge it. A server that does not echo the policy back raises
355
+ * `SnapUpgradeRequiredError` for every operation — an unacknowledging server
356
+ * cannot silently get different comparison semantics.
335
357
  *
336
358
  * @param {VisualRegressionConfig} config
337
359
  * @returns {ComparisonPolicy}
@@ -639,7 +661,11 @@ function gitOutput(args) {
639
661
  export function resolveGitRef() {
640
662
  const branch = gitOutput(['rev-parse', '--abbrev-ref', 'HEAD']);
641
663
  return {
642
- refBranch: process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || (branch && branch !== 'HEAD' ? branch : undefined) || 'main',
664
+ // Prefer GITHUB_HEAD_REF: on pull_request events GITHUB_REF_NAME is
665
+ // "<pr>/merge", which would misattribute the baseline branch in the
666
+ // dashboard and exported metadata. On push events GITHUB_HEAD_REF is
667
+ // unset, so GITHUB_REF_NAME (the pushed branch) applies.
668
+ refBranch: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || (branch && branch !== 'HEAD' ? branch : undefined) || 'main',
643
669
  refSha: process.env.GITHUB_SHA || gitOutput(['rev-parse', 'HEAD']) || 'unknown'
644
670
  };
645
671
  }
@@ -930,14 +956,12 @@ export class SnapProvider {
930
956
  expectedCaptures
931
957
  },
932
958
  ...runContext,
933
- ...(comparisonPolicy ? { comparisonPolicy } : {})
959
+ comparisonPolicy
934
960
  },
935
961
  idempotencyKey,
936
962
  operation
937
963
  );
938
- if (comparisonPolicy) {
939
- assertComparisonPolicyAcknowledged(runResponse?.comparisonPolicy, comparisonPolicy, 'Snap run creation');
940
- }
964
+ assertComparisonPolicyAcknowledged(runResponse?.comparisonPolicy, comparisonPolicy, 'Snap run creation');
941
965
 
942
966
  // Submit each route as a capture with its own idempotency key
943
967
  for (const route of routes) {
@@ -1181,44 +1205,6 @@ export class SnapProvider {
1181
1205
  );
1182
1206
  }
1183
1207
 
1184
- /**
1185
- * Fetch the latest baseline from Snap.
1186
- *
1187
- * @param {FetchBaselineOptions} _options
1188
- * @returns {Promise<BaselineData | null>}
1189
- */
1190
- async fetchLatestBaseline(_options) {
1191
- const operation = this.#createOperationDeadline('baseline lookup');
1192
- try {
1193
- const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`, undefined, undefined, operation);
1194
-
1195
- const runDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
1196
- const resultsPath = path.join(runDir, 'results.json');
1197
- const manifestPath = path.join(runDir, 'manifest.json');
1198
-
1199
- if (baseline.results) {
1200
- await fs.writeFile(resultsPath, JSON.stringify(baseline.results, null, 2));
1201
- }
1202
- if (baseline.manifest) {
1203
- await fs.writeFile(manifestPath, JSON.stringify(baseline.manifest, null, 2));
1204
- }
1205
-
1206
- return {
1207
- resultsPath,
1208
- manifestPath,
1209
- runDir,
1210
- screenshotsDir: path.join(runDir, 'screenshots'),
1211
- artifactName: baseline.artifactName || 'snap-baseline',
1212
- headSha: baseline.headSha || ''
1213
- };
1214
- } catch (error) {
1215
- if (error instanceof SnapApiError && error.status === 404) {
1216
- return null;
1217
- }
1218
- throw error;
1219
- }
1220
- }
1221
-
1222
1208
  // ---------------------------------------------------------------------------
1223
1209
  // PR comment
1224
1210
  // ---------------------------------------------------------------------------
@@ -1238,42 +1224,23 @@ export class SnapProvider {
1238
1224
 
1239
1225
  // ---------------------------------------------------------------------------
1240
1226
  // Migration methods
1227
+ //
1228
+ // Removed:
1229
+ // - `checkBaselineExists()` in 0.8.x — no production caller: the migration
1230
+ // workflows that queried baselines by SHA were retired with
1231
+ // `migrate-baselines`, and baseline resolution goes through the
1232
+ // `actions/resolve-baseline` artifact download instead of the API.
1233
+ // - `migrateBaselineFromLocal()` in 0.7.0 — it POSTed a pre-built local
1234
+ // bundle (`manifest`/`results`/`screenshots` as objects) to the baselines
1235
+ // API, but the server never read those fields — the PNGs were never stored
1236
+ // and the manifest referenced local filenames rather than Snap object
1237
+ // keys, so the call could only ever produce an unexportable baseline. Snap
1238
+ // now rejects that body with `400 unsupported_baseline_body`
1239
+ // (i2Dev-com/snap#653). Use `publishBaseline()` after `capture()` — or the
1240
+ // `snapdrift baseline` command, which wires both together. See
1241
+ // ranacseruet/snapdrift#106.
1241
1242
  // ---------------------------------------------------------------------------
1242
1243
 
1243
- /**
1244
- * Check whether a baseline already exists for a given commit SHA.
1245
- *
1246
- * @param {string} headSha
1247
- * @returns {Promise<object | null>} Baseline data if found, null on 404
1248
- */
1249
- async checkBaselineExists(headSha) {
1250
- const operation = this.#createOperationDeadline('baseline existence check');
1251
- try {
1252
- const baseline = await this.#request(
1253
- 'GET',
1254
- `/v1/visual/projects/${this.#projectId}/baselines/latest?sha=${encodeURIComponent(headSha)}`,
1255
- undefined,
1256
- undefined,
1257
- operation
1258
- );
1259
- return baseline;
1260
- } catch (error) {
1261
- if (error instanceof SnapApiError && error.status === 404) {
1262
- return null;
1263
- }
1264
- throw error;
1265
- }
1266
- }
1267
-
1268
- // `migrateBaselineFromLocal()` was removed in 0.7.0. It POSTed a pre-built
1269
- // local bundle (`manifest`/`results`/`screenshots` as objects) to the
1270
- // baselines API, but the server never read those fields — the PNGs were never
1271
- // stored and the manifest referenced local filenames rather than Snap object
1272
- // keys, so the call could only ever produce an unexportable baseline. Snap now
1273
- // rejects that body with `400 unsupported_baseline_body` (i2Dev-com/snap#653).
1274
- // Use `publishBaseline()` after `capture()` — or the `snapdrift baseline`
1275
- // command, which wires both together. See ranacseruet/snapdrift#106.
1276
-
1277
1244
  /**
1278
1245
  * Export baselines from Snap for local import.
1279
1246
  * Used by `snapdrift migrate-baselines --to local --from snap`.
@@ -1605,8 +1572,30 @@ export class SnapProvider {
1605
1572
  return error;
1606
1573
  }
1607
1574
 
1608
- /** @param {number} attempt @returns {number} */
1609
- #retryDelay(attempt) {
1575
+ /**
1576
+ * Compute the delay before the next retry attempt.
1577
+ *
1578
+ * When the server supplies a `Retry-After` header, that value is honored and
1579
+ * clamped to the retry delay cap: delta-seconds are used as-is (including a
1580
+ * deliberate immediate retry for `Retry-After: 0`), and RFC 9110 HTTP-date
1581
+ * values are converted to a delay relative to the current clock. When the
1582
+ * header is absent or malformed, exponential backoff applies.
1583
+ *
1584
+ * @param {number} attempt
1585
+ * @param {string|number|null|undefined} [retryAfterHeader]
1586
+ * @returns {number}
1587
+ */
1588
+ #retryDelay(attempt, retryAfterHeader) {
1589
+ if (retryAfterHeader !== undefined && retryAfterHeader !== null && retryAfterHeader !== '') {
1590
+ const retryAfterSeconds = Number(retryAfterHeader);
1591
+ if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) {
1592
+ return Math.min(retryAfterSeconds * 1000, RETRY_DELAY_CAP_MS);
1593
+ }
1594
+ const retryAfterDate = Date.parse(String(retryAfterHeader));
1595
+ if (!Number.isNaN(retryAfterDate)) {
1596
+ return Math.min(Math.max(0, retryAfterDate - this.#nowFn()), RETRY_DELAY_CAP_MS);
1597
+ }
1598
+ }
1610
1599
  return Math.min(INITIAL_RETRY_DELAY_MS * RETRY_MULTIPLIER ** (attempt - 1), RETRY_DELAY_CAP_MS);
1611
1600
  }
1612
1601
 
@@ -1620,8 +1609,10 @@ export class SnapProvider {
1620
1609
  /**
1621
1610
  * Read and classify an HTTP error response under the current deadline.
1622
1611
  *
1623
- * A received 4xx remains non-retryable even when its diagnostic body cannot
1624
- * be read. Retryable 5xx body failures are returned to the transport loop so
1612
+ * Most received 4xx remain non-retryable even when their diagnostic body
1613
+ * cannot be read; transient statuses (429, 408) are retried by the caller
1614
+ * and routed to the availability policy once retries are exhausted.
1615
+ * Retryable 5xx body failures are returned to the transport loop so
1625
1616
  * they retain the normal availability semantics.
1626
1617
  *
1627
1618
  * @param {any} response
@@ -1647,13 +1638,19 @@ export class SnapProvider {
1647
1638
  }
1648
1639
 
1649
1640
  let detail = '';
1641
+ let code;
1650
1642
  try {
1651
1643
  const parsed = JSON.parse(errorBody);
1652
- detail = parsed?.error || parsed?.message || '';
1644
+ if (typeof parsed?.error === 'string') {
1645
+ code = parsed.error;
1646
+ detail = parsed.error;
1647
+ } else if (typeof parsed?.message === 'string') {
1648
+ detail = parsed.message;
1649
+ }
1653
1650
  } catch {
1654
1651
  /* use default message */
1655
1652
  }
1656
- return new SnapApiError(response.status, detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`, requestPath);
1653
+ return new SnapApiError(response.status, detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`, requestPath, code);
1657
1654
  }
1658
1655
 
1659
1656
  /**
@@ -1681,7 +1678,15 @@ export class SnapProvider {
1681
1678
  if (attemptRemaining <= 0) {
1682
1679
  controller.abort();
1683
1680
  cancelResponseBody(response);
1684
- throw this.#requestTimeoutError(requestPath, requestLimitMs, operation, requestLimitMs);
1681
+ // The attempt deadline was already exhausted by the fetch phase, so no
1682
+ // honest elapsed time is attributable to this body read. Say that,
1683
+ // rather than reporting the request limit as if it had elapsed here.
1684
+ const timeoutError = new SnapUnavailableError(
1685
+ `Snap ${operation.label} request ${requestPath} attempt deadline elapsed before the response body could be read ` +
1686
+ `(request limit ${requestLimitMs}ms; operation limit ${OPERATION_LIMIT_TEXT})`
1687
+ );
1688
+ timeoutError.name = 'SnapTransportTimeoutError';
1689
+ throw timeoutError;
1685
1690
  }
1686
1691
  const remaining = Math.min(attemptRemaining, operationRemaining);
1687
1692
  return runWithTimeout(
@@ -1782,18 +1787,36 @@ export class SnapProvider {
1782
1787
  return await options.decode(response, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
1783
1788
  }
1784
1789
 
1785
- // 4xx — non-retryable when the diagnostic body is available. A body
1786
- // read timeout still preserves the received HTTP status, rather than
1787
- // turning an already-classified API response into an availability retry.
1790
+ // 4xx — non-retryable when the diagnostic body is available, except for
1791
+ // transient statuses (429 rate limit, 408 request timeout) which retry
1792
+ // with backoff and, once retries are exhausted, fall through to the
1793
+ // onUnavailable availability policy like 5xx do. A quota-exhausted 429
1794
+ // is permanent and stays a hard SnapApiError so the user learns the
1795
+ // quota is spent. A body read timeout still preserves the received
1796
+ // HTTP status, rather than turning an already-classified API response
1797
+ // into an availability retry.
1788
1798
  if (response.status >= 400 && response.status < 500) {
1789
- throw await this.#readApiError(response, options.requestPath, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
1799
+ const apiError = await this.#readApiError(response, options.requestPath, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
1800
+ if (TRANSIENT_RETRYABLE_4XX.has(apiError.status) && !isQuotaExhaustedError(apiError)) {
1801
+ if (attempt < MAX_RETRIES) {
1802
+ cancelResponseBody(response);
1803
+ await this.#sleepWithinOperation(
1804
+ this.#retryDelay(attempt, response.headers?.get?.('retry-after')),
1805
+ options.operation,
1806
+ options.requestPath
1807
+ );
1808
+ continue;
1809
+ }
1810
+ return this.#handleUnavailable(apiError);
1811
+ }
1812
+ throw apiError;
1790
1813
  }
1791
1814
 
1792
1815
  // 5xx — retry with backoff unless this is the last attempt. Cancel
1793
1816
  // discarded response bodies so a slow server cannot retain a stream.
1794
1817
  if (attempt < MAX_RETRIES) {
1795
1818
  cancelResponseBody(response);
1796
- await this.#sleepWithinOperation(this.#retryDelay(attempt), options.operation, options.requestPath);
1819
+ await this.#sleepWithinOperation(this.#retryDelay(attempt, response.headers?.get?.('retry-after')), options.operation, options.requestPath);
1797
1820
  continue;
1798
1821
  }
1799
1822
 
@@ -1917,8 +1940,28 @@ export class SnapProvider {
1917
1940
  outDir: options.outDir
1918
1941
  });
1919
1942
 
1920
- const localResults = JSON.parse(await fs.readFile(localCapture.resultsPath, 'utf-8'));
1921
- const manifest = JSON.parse(await fs.readFile(localCapture.manifestPath, 'utf-8'));
1943
+ /** @type {any} */
1944
+ let localResults;
1945
+ try {
1946
+ localResults = JSON.parse(await fs.readFile(localCapture.resultsPath, 'utf-8'));
1947
+ } catch (error) {
1948
+ throw new Error(
1949
+ `Local capture results are unreadable at "${localCapture.resultsPath}"; recapture locally before uploading to Snap. ` +
1950
+ `${error instanceof Error ? error.message : String(error)}`,
1951
+ { cause: error }
1952
+ );
1953
+ }
1954
+ /** @type {any} */
1955
+ let manifest;
1956
+ try {
1957
+ manifest = JSON.parse(await fs.readFile(localCapture.manifestPath, 'utf-8'));
1958
+ } catch (error) {
1959
+ throw new Error(
1960
+ `Local capture manifest is unreadable at "${localCapture.manifestPath}"; recapture locally before uploading to Snap. ` +
1961
+ `${error instanceof Error ? error.message : String(error)}`,
1962
+ { cause: error }
1963
+ );
1964
+ }
1922
1965
  // Route ids are unique per the v1 schema (one viewport per route), so keying
1923
1966
  // the manifest by id is an unambiguous join against the configured routes.
1924
1967
  const manifestById = new Map((manifest.screenshots || []).map((entry) => [entry.id, entry]));
@@ -2450,17 +2493,21 @@ export class SnapApiError extends Error {
2450
2493
  status;
2451
2494
  /** @type {string} */
2452
2495
  path;
2496
+ /** Machine-readable error code from the API diagnostic body, when present. @type {string | undefined} */
2497
+ code;
2453
2498
 
2454
2499
  /**
2455
2500
  * @param {number} status
2456
2501
  * @param {string} message
2457
2502
  * @param {string} path
2503
+ * @param {string | undefined} [code]
2458
2504
  */
2459
- constructor(status, message, path) {
2505
+ constructor(status, message, path, code) {
2460
2506
  super(message);
2461
2507
  this.name = 'SnapApiError';
2462
2508
  this.status = status;
2463
2509
  this.path = path;
2510
+ this.code = code;
2464
2511
  }
2465
2512
  }
2466
2513
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snapdrift",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SnapDrift captures, compares, and reports UI drift for Playwright-based app checks.",
@@ -85,7 +85,6 @@
85
85
  "./lib/snap-provider.mjs": "./lib/snap-provider.mjs",
86
86
  "./lib/migrate-baselines.mjs": "./lib/migrate-baselines.mjs",
87
87
  "./lib/init-from-action.mjs": "./lib/init-from-action.mjs",
88
- "./lib/resolve-baseline-paths.mjs": "./lib/resolve-baseline-paths.mjs",
89
88
  "./lib/resolve-baseline-artifact.mjs": "./lib/resolve-baseline-artifact.mjs"
90
89
  },
91
90
  "files": [
@@ -103,12 +102,15 @@
103
102
  "registry": "https://registry.npmjs.org"
104
103
  },
105
104
  "dependencies": {
106
- "@snapdrift/adapter-fs": "^1.7.2",
105
+ "@snapdrift/adapter-fs": "^1.8.0",
107
106
  "@snapdrift/adapter-report-md": "^1.4.0",
108
- "@snapdrift/compare-core": "^1.8.0",
107
+ "@snapdrift/compare-core": "^1.8.1",
109
108
  "@snapdrift/manifest": "^1.8.0",
110
109
  "js-yaml": "^5.4.0",
111
- "playwright": "^1.59.1",
110
+ "playwright": "^1.64.1",
112
111
  "pngjs": "^7.0.0"
112
+ },
113
+ "overrides": {
114
+ "brace-expansion@5": "^5.0.12"
113
115
  }
114
116
  }
@@ -257,8 +257,6 @@ export type {
257
257
  ProviderDiffResult,
258
258
  ProviderPublishBaselineOptions,
259
259
  ProviderPublishBaselineResult,
260
- ProviderFetchBaselineOptions,
261
- ProviderBaselineData,
262
260
  ProviderCommentMeta,
263
261
  VisualProvider,
264
262
  SnapConfig
@@ -1,40 +0,0 @@
1
- // @ts-check
2
-
3
- import path from 'node:path';
4
-
5
- /**
6
- * @typedef {{
7
- * runDir: string,
8
- * resultsFile: string,
9
- * manifestFile: string,
10
- * screenshotsDir: string,
11
- * }} BaselinePaths
12
- */
13
-
14
- /**
15
- * @typedef {{
16
- * workspace: string,
17
- * downloadPath?: string,
18
- * }} ResolveBaselinePathsParams
19
- */
20
-
21
- const DEFAULT_DOWNLOAD_PATH = 'snapdrift-baseline-download';
22
-
23
- /**
24
- * Resolve the baseline download paths for the resolve-baseline composite action.
25
- *
26
- * `downloadPath` may be absolute (used directly) or relative to the Actions
27
- * workspace (prefixed with `workspace`). Returns the run dir plus the results,
28
- * manifest, and screenshots paths derived from it.
29
- *
30
- * @param {ResolveBaselinePathsParams} params
31
- * @returns {BaselinePaths}
32
- */
33
- export function resolveBaselinePaths({ workspace, downloadPath }) {
34
- const rel = downloadPath || DEFAULT_DOWNLOAD_PATH;
35
- const runDir = path.isAbsolute(rel) ? rel : path.join(workspace, rel);
36
- const resultsFile = path.join(runDir, 'results.json');
37
- const manifestFile = path.join(runDir, 'manifest.json');
38
- const screenshotsDir = path.join(path.dirname(manifestFile), 'screenshots');
39
- return { runDir, resultsFile, manifestFile, screenshotsDir };
40
- }