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 +48 -1
- package/README.md +2 -2
- package/action.yml +2 -2
- package/actions/baseline/action.yml +3 -1
- package/actions/comment/action.yml +6 -0
- package/actions/pr-diff/action.yml +12 -2
- package/actions/resolve-baseline/action.yml +7 -2
- package/docs/contracts.md +8 -8
- package/docs/integration-guide.md +9 -9
- package/docs/local-cli.md +8 -1
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +2 -2
- package/lib/cli.mjs +28 -5
- package/lib/github-requests.mjs +9 -0
- package/lib/init-from-action.mjs +11 -5
- package/lib/outage-policy.mjs +4 -1
- package/lib/pr-comment.mjs +1 -1
- package/lib/provider.mjs +2 -17
- package/lib/snap-provider.mjs +145 -98
- package/package.json +7 -5
- package/types/visual-diff-types.d.ts +0 -2
- package/lib/resolve-baseline-paths.mjs +0 -40
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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.
|
|
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.
|
|
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@
|
|
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@
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
90
|
-
| `ranacseruet/snapdrift@v0.
|
|
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-
|
|
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.
|
|
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-
|
|
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.
|
|
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
|
|
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-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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') {
|
package/lib/github-requests.mjs
CHANGED
|
@@ -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];
|
package/lib/init-from-action.mjs
CHANGED
|
@@ -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.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
}
|
package/lib/outage-policy.mjs
CHANGED
|
@@ -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.
|
package/lib/pr-comment.mjs
CHANGED
|
@@ -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,
|
|
87
|
-
snap: Object.freeze({ capture: true, diff: true, publishBaseline: 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
|
/**
|
package/lib/snap-provider.mjs
CHANGED
|
@@ -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
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* config,
|
|
334
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
959
|
+
comparisonPolicy
|
|
934
960
|
},
|
|
935
961
|
idempotencyKey,
|
|
936
962
|
operation
|
|
937
963
|
);
|
|
938
|
-
|
|
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
|
-
/**
|
|
1609
|
-
|
|
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
|
-
*
|
|
1624
|
-
* be read
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1786
|
-
//
|
|
1787
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1921
|
-
|
|
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.
|
|
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.
|
|
105
|
+
"@snapdrift/adapter-fs": "^1.8.0",
|
|
107
106
|
"@snapdrift/adapter-report-md": "^1.4.0",
|
|
108
|
-
"@snapdrift/compare-core": "^1.8.
|
|
107
|
+
"@snapdrift/compare-core": "^1.8.1",
|
|
109
108
|
"@snapdrift/manifest": "^1.8.0",
|
|
110
109
|
"js-yaml": "^5.4.0",
|
|
111
|
-
"playwright": "^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
|
}
|
|
@@ -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
|
-
}
|