snapdrift 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/contracts.md CHANGED
@@ -22,7 +22,7 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
22
22
 
23
23
  | Field | Type | Description |
24
24
  |:------|:-----|:------------|
25
- | `id` | `string` | Unique route identifier across runs |
25
+ | `id` | `string` | Unique route identifier across runs. Its sanitized screenshot filename must also be unique across the full configured route set. |
26
26
  | `path` | `string` | URL path appended to `baseUrl` |
27
27
  | `viewport` | `string` or `object` | Preset name (`"desktop"`, `"mobile"`) or a custom object `{ "width": number, "height": number }` |
28
28
  | `changePaths` | `string[]` | Optional prefixes used for changed-file scoping |
@@ -43,6 +43,24 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
43
43
 
44
44
  When `provider: "snap"` is set, the `snap` block is required. Exactly one of `snap.apiKeyEnv` or `snap.apiKey` must be present. `snap.apiKey` accepts `${VAR}` interpolation (for example `"${SNAP_API_KEY}"`); the referenced environment variable must be set at runtime or the config loader throws.
45
45
 
46
+ Changed-file scoping treats a GitHub `renamed` record as both the current
47
+ `filename` and its nonempty `previous_filename`. This keeps a route selected
48
+ when a watched file moves out of its configured `changePaths`, and also detects
49
+ renames into watched or shared paths. Duplicate paths are ignored; other change
50
+ statuses use only their current filename. The `pr-diff` wrapper's explicit
51
+ `route-ids` and `force-run` inputs take precedence over this lookup; the
52
+ standalone `scope` action supports `force-run` and otherwise derives its
53
+ selection from the changed files. If GitHub returns the maximum 3,000 file
54
+ records, both actions run all configured routes with reason
55
+ `changed_files_truncated` because the list may be incomplete.
56
+
57
+ Route ids are sanitized before local captures and Snap baseline exports write
58
+ `screenshots/<route-id>.png`: `..` becomes `_`, path separators become `_`, and
59
+ control characters are removed. Distinct route ids that produce the same
60
+ sanitized filename are rejected before capture starts. Rename one of the route
61
+ ids and recapture the affected baseline; existing screenshot filenames remain
62
+ unchanged for noncolliding ids.
63
+
46
64
  ### Example (local provider)
47
65
 
48
66
  ```json
@@ -101,6 +119,27 @@ The published baseline bundle contains:
101
119
  | `manifest.json` | Screenshot manifest with ids, paths, and dimensions |
102
120
  | `screenshots/*.png` | Captured screenshot images |
103
121
 
122
+ Every manifest entry must use a unique staged screenshot filename (the basename
123
+ of `imagePath`). Duplicate paths or paths that flatten to the same filename are
124
+ rejected before comparison, even when the run selects only one of the affected
125
+ route ids, because they can point two logical screenshots at the same pixels.
126
+ Rename the conflicting route ids and recapture the baseline and current bundle.
127
+
128
+ ### Baseline resolution
129
+
130
+ The `actions/resolve-baseline` action and the `actions/pr-diff` wrapper distinguish three lookup
131
+ outcomes: `found` means a successful search selected a non-expired artifact, `missing` means a
132
+ successful search found no usable artifact, and `error` means GitHub returned an API, network, or
133
+ malformed-response failure. The boolean `found` output remains available for compatibility; use
134
+ `resolution-status` on `resolve-baseline` or `baseline-resolution-status` on `pr-diff` when the
135
+ reason matters. A missing baseline may produce the intentional first-run skipped summary. A lookup
136
+ error fails local comparisons and local Snap fallbacks, so infrastructure failures cannot be
137
+ reported as a clean or intentional missing-baseline result. Healthy hosted Snap diffs may continue
138
+ using their hosted baseline path after a GitHub artifact lookup error.
139
+ The standalone `actions/resolve-baseline` action fails its step for `error`; custom workflows
140
+ should branch on `resolution-status` rather than treating `found: false` as proof that no baseline
141
+ exists.
142
+
104
143
  ## Drift artifact
105
144
 
106
145
  The pull request drift bundle contains:
@@ -328,6 +367,8 @@ A hosted baseline is an authoritative complete snapshot. For `purpose: "baseline
328
367
 
329
368
  The capture `results.json` records `configuredRouteIds`, `selectedRouteIds`, and `expectedCaptures` (route id, path, and viewport descriptor), plus one resolved `refBranch` / `refSha`, publication workflow ref, and publication sequence. The run-creation request sends the same immutable set and sequence as `capturePlan` and sends the ref as `branch` / `prHeadSha`. Before publication, SnapDrift requires the source run to belong to the configured project, settle to terminal `new`, and contain exactly one successful `new` capture with a non-empty `currentObjectKey` for every expected route/viewport identity. It keeps polling while any expected capture remains non-terminal, even when an older server reports the run itself as terminal, but fails fast after a stable incomplete capture set. Failed, missing, duplicate, extra, path-mismatched, or malformed captures abort before the baseline request. If another default-branch baseline wins first, Snap returns `409 baseline_stale_source`; rerun the current baseline job.
330
369
 
370
+ Hosted PR diffs use the same persisted `expectedCaptures` identities. `SnapProvider.diff()` requires `purpose: "diff"` plus a nonempty, unique expected set consistent with `selectedRouteIds`; results written by older SnapDrift versions without that set must be recaptured. It polls until the expected capture count is available, verifies the returned run id, and reconciles every returned capture by route id, path, and normalized viewport descriptor. Missing expected captures, duplicate or unexpected identities, pending or unknown statuses, run or capture errors, missing current objects, and invalid `diffPct` values produce an `incomplete` summary and are never counted as clean matches. A valid comparison requires a current object, a baseline object, and a finite numeric mismatch ratio from 0 through 1. The `diff.mode` setting then controls whether that incomplete summary fails the check.
371
+
331
372
  Publication sends `publicationMode: "complete"` and the source run id both as top-level `sourceRunId` and as `manifest.sourceRunId`. The baseline id is deterministically derived from the run id, and manifest routes are sorted, so a retry sends the same identity and payload. Reusing that id with different persisted fields is rejected; rerun the baseline job to obtain a new source run. `refBranch` / `refSha` come from the persisted capture metadata rather than resolving git a second time. Snap accepts complete publication only from the project's default branch (effective default `main`).
332
373
 
333
374
  ### Local-capture hybrid
@@ -356,9 +397,21 @@ SnapDrift uses a small, stable subset of the Snap API:
356
397
  The Snap HTTP client classifies responses and applies the following rules:
357
398
 
358
399
  - **2xx** — success, return the parsed body.
359
- - **4xx** — non-retryable. The client throws `SnapApiError(status, message, path)` immediately. `onUnavailable` is **not** consulted for 4xx — a 404 from `/baselines/latest` is a "no baseline yet" signal, but a 4xx from `/runs` is a configuration error that retrying won't fix.
360
- - **5xx** — retryable up to 3 attempts with exponential backoff (`1 s` → `2 s` → `4 s`, capped at `30 s` total). If the final attempt still returns 5xx, the client falls through to the `onUnavailable` handler.
361
- - **Network errors** — same retry/backoff behavior as 5xx. After exhaustion, falls through to the `onUnavailable` handler.
400
+ - **4xx** — non-retryable. The client throws `SnapApiError(status, message, path)` immediately. `onUnavailable` is **not** consulted for 4xx — a 404 from `/baselines/latest` is a "no baseline yet" signal, but a 4xx from `/runs` is a configuration error that retrying won't fix. If the diagnostic body itself stalls or is unavailable, the same `SnapApiError` retains the HTTP status and includes that diagnostic failure.
401
+ - **5xx** — retryable up to 3 attempts with exponential backoff (`1 s` → `2 s` → `4 s`, with each delay capped at `30 s`). If the final attempt still returns 5xx, the client falls through to the `onUnavailable` handler.
402
+ - **Network errors and transport timeouts** — same retry/backoff behavior as 5xx. After exhaustion, falls through to the `onUnavailable` handler.
403
+
404
+ Every JSON request attempt has a 30-second limit; binary export attempts have a
405
+ 120-second limit. Headers and response bodies share the applicable attempt
406
+ limit, and each public Snap operation has one 10-minute deadline covering all
407
+ of its requests, retries, backoff, and polling. The client passes an
408
+ `AbortSignal` to fetch and aborts/cancels stalled bodies; injected transports
409
+ that ignore cancellation are still released by the client-side deadline.
410
+ Retry and poll waits are clipped to the remaining operation time, so no new
411
+ request starts after the operation deadline. Deadline exhaustion is treated as
412
+ Snap unavailability and follows `onUnavailable`; received 4xx responses keep
413
+ their immediate non-retryable behavior, and a stalled 4xx diagnostic body
414
+ retains its HTTP status with a timeout diagnostic.
362
415
 
363
416
  `onUnavailable` is consulted once retries are exhausted:
364
417
 
@@ -376,7 +429,7 @@ alike:
376
429
  | Phase | `warn-and-skip` | `fallback-local` |
377
430
  |:------|:----------------|:-----------------|
378
431
  | Capture | Write a skipped `summary.json`/`summary.md` with reason `snap_unavailable`, expose the summary outputs, stage the report, exit 0. | Capture with `LocalProvider` and report the **effective** provider (`local`) so the rest of the pipeline uses local artifacts and the local pixel engine. |
379
- | Diff | Same skipped summary, exit 0. | Diff with `LocalProvider`. A capture that Snap rendered server-side has no PNGs on the runner, so the routes are **recaptured locally first**; the recaptured paths replace the Snap ones in the staged bundle. If no baseline artifact was resolved, the run reports `missing_main_baseline_artifact` instead of crashing the local diff. |
432
+ | Diff | Same skipped summary, exit 0. | Diff with `LocalProvider`. A capture that Snap rendered server-side has no PNGs on the runner, so the routes are **recaptured locally first**; the recaptured paths replace the Snap ones in the staged bundle. If baseline resolution succeeded but no artifact was found, the run reports `missing_main_baseline_artifact`; a lookup error fails instead of entering the missing-baseline path. |
380
433
  | Baseline publish | Skip the publish and exit 0 without an artifact. | Capture locally and stage/upload that bundle, so the run still leaves a usable baseline. |
381
434
 
382
435
  Enforcement of `diff.mode` never runs against a skipped summary — a skipped run
@@ -402,13 +455,17 @@ All four error classes are exported from `lib/provider.mjs` and `lib/snap-provid
402
455
 
403
456
  | Class | Thrown when | Typical handler |
404
457
  |:------|:------------|:----------------|
405
- | `SnapApiError` | A 4xx response was received, or a 5xx/network error was retried to exhaustion. Carries `status` and `path` properties. | Surface the message; do not retry. |
406
- | `SnapUnavailableError` | A network error or 5xx was retried to exhaustion (used internally; usually re-wrapped as `SnapApiError`). | Treat as a temporary outage. |
458
+ | `SnapApiError` | A 4xx response was received, including a response whose diagnostic body stalled. Carries `status` and `path` properties. A final 5xx response also retains its status when it reaches the outage handler. | Surface the message; do not retry. |
459
+ | `SnapUnavailableError` | A retryable network error, transport timeout, or operation deadline was exhausted. In fail mode this may be surfaced directly; skip and fallback modes wrap it in their policy error. | Treat as a temporary outage. |
407
460
  | `SnapFallbackError` | `onUnavailable: "fallback-local"` is set and Snap could not be reached. | Catch and switch to `LocalProvider` for the rest of the pipeline. |
408
461
  | `SnapSkipError` | `onUnavailable: "warn-and-skip"` is set and Snap could not be reached. | Catch and exit cleanly with a skipped summary. |
409
462
 
410
463
  The wrapper actions (`actions/baseline`, `actions/pr-diff`) and the CLI both handle `SnapSkipError` and `SnapFallbackError` through `lib/outage-policy.mjs` (`captureWithPolicy`, `diffWithPolicy`, `publishBaselineWithPolicy`). Custom orchestrations that call `provider.capture()`, `provider.diff()` or `provider.publishBaseline()` directly should use those helpers rather than re-implementing the matrix.
411
464
 
465
+ `SnapTransportTimeoutError` is an internal diagnostic name used while a
466
+ request or response body is being bounded. It is not an additional public
467
+ error class; callers should handle the exported error classes above.
468
+
412
469
  ## Primary entrypoints
413
470
 
414
471
  - `actions/baseline`
@@ -505,3 +562,45 @@ The `VisualProvider` interface requires `buildCommentBody(summary, meta?)`:
505
562
 
506
563
  - **`LocalProvider`** — delegates to `buildReportCommentBody` without a `dashboardUrl`.
507
564
  - **`SnapProvider`** — constructs `dashboardUrl` from `{apiUrl}/projects/{projectId}/runs/{lastRunId}` and passes it through.
565
+
566
+
567
+ ## Workspace TypeScript contracts
568
+
569
+ The four `@snapdrift/*` workspace packages expose their declarations through a
570
+ `types` export condition before the existing JavaScript entrypoint. ESM consumers
571
+ can use Bundler, Node16, or NodeNext module resolution with `strict: true` and
572
+ `skipLibCheck: false`; Node16/NodeNext consumers should use an ESM package or an
573
+ `.mts` entrypoint. The top-level `types` field remains for older tooling.
574
+
575
+ Install `typescript` and `@types/node` in the consuming development environment
576
+ for APIs that use Node buffers. Cross-package declaration dependencies are
577
+ declared by the package that needs them. `npm run check:package-types` packs the
578
+ workspace packages and checks isolated consumers in all three resolution modes,
579
+ including rejected invalid calls. `npm run typecheck` also checks declaration
580
+ bodies without skipping library checks.
581
+
582
+ These guarantees cover the workspace package entrypoints. They do not add type
583
+ entrypoints for the root `snapdrift` package's JavaScript subpaths. Package release
584
+ publication is required before existing registry consumers receive these fixes.
585
+
586
+ Comment renderers accept `VisualReportSummary`: a complete comparison summary or
587
+ a `VisualDriftStatusSummary` with a required status and reason. `buildDriftSummary`
588
+ and `writeDriftSummary` return the latter, preserving their supported non-skipped
589
+ statuses as well as intentional skips. Unrelated object literals are rejected.
590
+ Known viewport presets have complete descriptors; arbitrary string lookups may
591
+ return `undefined` and must be checked by consumers.
592
+
593
+ The packed type gate runs in PR CI and before release publication. It validates
594
+ the candidate workspace tarballs together, not already-published registry
595
+ versions. Release preparation must bump every changed published package and
596
+ raise sibling dependency floors to the versions that contain these type fixes
597
+ before publication; the gate alone does not validate that release-version step.
598
+
599
+ Each workspace package also declares every runtime package it imports. In
600
+ particular, `@snapdrift/adapter-fs` declares `@snapdrift/adapter-report-md`, so
601
+ it can be installed and imported without the SnapDrift repository or a sibling
602
+ workspace masking a missing dependency. `npm run check:package-runtime` packs
603
+ each workspace package, installs it in an isolated temporary consumer together
604
+ with only its declared local workspace dependency closure, and imports its
605
+ public entrypoint. The gate runs in pull-request and release workflows; publish
606
+ the updated package before registry consumers can receive a dependency fix.
@@ -46,7 +46,7 @@ SnapDrift owns route selection, capture, comparison, skipped-report generation,
46
46
 
47
47
  ```yaml
48
48
  - name: SnapDrift Baseline
49
- uses: ranacseruet/snapdrift@v0.8.2
49
+ uses: ranacseruet/snapdrift@v0.9.0
50
50
  with:
51
51
  mode: baseline
52
52
  repo-config-path: .github/snapdrift.json
@@ -71,7 +71,7 @@ Then add SnapDrift after the app is running:
71
71
 
72
72
  ```yaml
73
73
  - name: SnapDrift Report
74
- uses: ranacseruet/snapdrift@v0.8.2
74
+ uses: ranacseruet/snapdrift@v0.9.0
75
75
  with:
76
76
  mode: pr-diff
77
77
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -86,8 +86,8 @@ The wrappers remain published in their own right, and are equivalent to the disp
86
86
 
87
87
  | Entry point | Equivalent to |
88
88
  |-------------|---------------|
89
- | `ranacseruet/snapdrift@v0.8.2` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.8.2` |
90
- | `ranacseruet/snapdrift@v0.8.2` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.8.2` |
89
+ | `ranacseruet/snapdrift@v0.9.0` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.9.0` |
90
+ | `ranacseruet/snapdrift@v0.9.0` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.9.0` |
91
91
 
92
92
  Use the wrappers directly when you want to skip the dispatcher's input indirection, and the lower-level `capture`, `compare`, `scope`, `resolve-baseline`, `stage`, `comment`, and `enforce` actions when you need to orchestrate the stages yourself.
93
93
 
@@ -130,7 +130,7 @@ jobs:
130
130
  done
131
131
 
132
132
  - name: SnapDrift Report
133
- uses: ranacseruet/snapdrift@v0.8.2
133
+ uses: ranacseruet/snapdrift@v0.9.0
134
134
  with:
135
135
  mode: pr-diff
136
136
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -170,7 +170,7 @@ jobs:
170
170
  done
171
171
 
172
172
  - name: SnapDrift Report
173
- uses: ranacseruet/snapdrift@v0.8.2
173
+ uses: ranacseruet/snapdrift@v0.9.0
174
174
  with:
175
175
  mode: pr-diff
176
176
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -188,6 +188,15 @@ jobs:
188
188
  - Upserts the PR report
189
189
  - Enforces `diff.mode` after publication completes
190
190
 
191
+ When the wrapper scopes a pull request from GitHub's changed-file list, a
192
+ renamed file contributes both its new and previous paths. A move out of a
193
+ watched directory therefore still selects the route that owned the old path,
194
+ while a move into a watched or shared path selects the new scope as well.
195
+ Repeated paths are deduplicated, and `route-ids` or `force-run` keep their
196
+ existing precedence. GitHub caps this file listing at 3,000 records; if that
197
+ limit is reached, the actions run all configured routes with scope reason
198
+ `changed_files_truncated` instead of trusting a potentially incomplete list.
199
+
191
200
  ## Useful overrides
192
201
 
193
202
  | Input | Default | Purpose |
@@ -205,7 +214,7 @@ jobs:
205
214
  | `max-changed-rows` | `20` | Max changed-route rows shown in the PR comment before truncation |
206
215
  | `max-error-rows` | `10` | Max error rows shown in the PR comment before truncation |
207
216
 
208
- The `pr-diff` action also exposes outputs you can use in subsequent steps: `should-run`, `scope-reason`, `selected-route-ids`, `baseline-found`, `status`, `summary-path`, `markdown-path`, `artifact-name`, `bundle-dir`. See the wrapper action's `outputs:` block for the canonical list.
217
+ The `pr-diff` action also exposes outputs you can use in subsequent steps: `should-run`, `scope-reason`, `selected-route-ids`, `baseline-found`, `baseline-resolution-status`, `status`, `summary-path`, `markdown-path`, `artifact-name`, `bundle-dir`. `baseline-resolution-status` is `found`, `missing`, or `error`; `missing` is the intentional first-baseline case, while `error` means the GitHub lookup failed and causes local comparisons or local Snap fallbacks to fail. Healthy hosted Snap diffs can continue without the downloaded GitHub artifact. See the wrapper action's `outputs:` block for the canonical list.
209
218
 
210
219
  ## Hosted Snap provider
211
220
 
@@ -234,7 +243,7 @@ Both behaviors apply at every phase of a run — capture, diff, and baseline pub
234
243
 
235
244
  Hosted baseline publication is complete and default-branch-only. SnapDrift rejects scoped baseline capture before creating the run, records the configured/selected route ids and exact route/viewport identities, and publishes only when every expected capture finishes successfully with a stored image. The run records one resolved branch and commit (`branch` / `prHeadSha`) plus a publication workflow ref and monotonic sequence. GitHub Actions provides these automatically; other CI systems may set `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`. Snap uses the sequence for same-workflow ordering and source-run timestamps for cross-workflow freshness. Missing, failed, duplicate, or unexpected captures abort publication without replacing the accepted baseline. If a source run loses the race to a newer candidate, the API returns `409 baseline_stale_source`; an inconsistent manifest/source workflow identity returns `409 baseline_source_run_mismatch` with `details.reason = "workflow_mismatch"`. Workflow-ref rotation itself is supported. This restriction does not apply to non-publishing hosted captures, hosted PR-diff runs, or local-provider baselines.
236
245
 
237
- The Snap API client retries 5xx and network errors with exponential backoff (3 attempts, 1 s → 2 s → 4 s, capped at 30 s). 4xx errors never retry and never fall back.
246
+ The Snap API client retries 5xx, network errors, and transport timeouts with exponential backoff (3 attempts, 1 s → 2 s → 4 s, with each delay capped at 30 s). JSON request attempts are limited to 30 seconds and binary baseline exports to 120 seconds. Each Snap operation has a 10-minute deadline covering its requests, retries, backoff, and polling; stalled headers and response bodies are aborted and cannot keep the workflow waiting indefinitely. Retry and poll waits are clipped to the remaining operation time. Received 4xx errors never retry or fall back; if a 4xx diagnostic body stalls, the response remains a non-retryable error with its HTTP status and timeout diagnostic. When a deadline or other retryable transport failure is exhausted, `onUnavailable` controls fail, warn-and-skip, or fallback-local behavior.
238
247
 
239
248
  When `provider: "snap"` and `baseUrl` points to a local address (localhost, `127.0.0.0/8`, `::1`, or `0.0.0.0`), SnapDrift uses a **local-capture hybrid**: Playwright runs on the runner to render the page, then SnapDrift uploads the resulting screenshots to Snap. This makes it possible to point SnapDrift at a server that only the runner can reach (a typical case) without exposing the server to Snap's render worker.
240
249
 
@@ -267,7 +276,9 @@ The `pr-diff` wrapper composes the following low-level steps. They're still avai
267
276
  - `actions/capture` — capture routes and emit `results.json` + `manifest.json`
268
277
  - `actions/compare` — diff current capture against a baseline
269
278
  - `actions/scope` — decide whether to run and which routes to select from changed files
270
- - `actions/resolve-baseline` — find and download the latest successful baseline artifact
279
+ - `actions/resolve-baseline` — find and download the latest successful baseline artifact; its
280
+ `resolution-status` output distinguishes `found`, `missing`, and `error`; the action fails for
281
+ `error`, while `missing` remains the intentional first-baseline result
271
282
  - `actions/stage` — assemble the baseline or diff bundle for upload
272
283
  - `actions/enforce` — evaluate the summary against `diff.mode` and fail when required
273
284
  - `actions/comment` — upsert a PR comment from a summary (provider-aware)
@@ -313,6 +324,12 @@ Run this job after every successful default-branch build. Hosted publication is
313
324
  **"No non-expired SnapDrift baseline artifact was found"**
314
325
  The baseline workflow has not completed successfully on `main`, or the artifact expired. With the Snap provider, the equivalent situation is a 404 from `/v1/visual/projects/:id/baselines/latest`; `SnapProvider` swallows that 404 and proceeds without a baseline — `onUnavailable` is **not** consulted for this case (a 404 is the legitimate "no baseline yet" signal, not a Snap outage). If you want the PR pipeline to tolerate the first-run case, set `diff.mode: "report-only"`; `onUnavailable: "warn-and-skip"` will not help here.
315
326
 
327
+ **"Unable to resolve the SnapDrift baseline artifact"**
328
+ GitHub failed while listing workflow runs or artifacts. Check the job's `actions: read` permission,
329
+ repository/workflow/branch inputs, and GitHub API availability. Snap hosted diffs can still use the
330
+ hosted comparison path, but local comparisons and `fallback-local` require a successful GitHub
331
+ artifact lookup.
332
+
316
333
  **403 when posting the PR report**
317
334
  Grant `issues: write` and `pull-requests: write` to the job.
318
335
 
@@ -327,3 +344,25 @@ Capture failed before comparison. Confirm the app is fully ready and reachable b
327
344
 
328
345
  **Playwright install runs even though `provider: "snap"`**
329
346
  That's expected for the Snap local-capture hybrid. Playwright runs locally to render the page, then the resulting screenshot is uploaded to Snap. The hybrid kicks in only when `baseUrl` points to a local address; for remote `baseUrl` Snap's render worker captures directly.
347
+
348
+
349
+ ## TypeScript package consumers
350
+
351
+ Import APIs and types from the public `@snapdrift/*` package names. Their export
352
+ maps support Bundler, Node16, and NodeNext resolution; enable `strict: true` and
353
+ `skipLibCheck: false` to validate the contracts in your project. For Node16 or
354
+ NodeNext, use `"type": "module"` or an `.mts` entrypoint. Install `typescript` and
355
+ `@types/node` as development dependencies when using the Node buffer APIs.
356
+
357
+ No TypeScript path aliases or imports into workspace source directories are
358
+ needed. Root `snapdrift/lib/*` subpath typing is separate from these workspace
359
+ contracts. The fixes become available to registry consumers after the packages
360
+ are released and their installed versions are updated.
361
+
362
+ The workspace packages are independently installable runtime packages. Install
363
+ the package you use from the registry and let npm resolve its declared runtime
364
+ dependencies; do not rely on a repository checkout or workspace links. The
365
+ filesystem adapter includes its reporting dependency, so `@snapdrift/adapter-fs`
366
+ can load config, capture, comparison, and report entrypoints from an isolated
367
+ consumer. Release a new adapter version before expecting an existing registry
368
+ installation to include a dependency correction.
@@ -82,7 +82,7 @@ jobs:
82
82
 
83
83
  - name: Publish SnapDrift baseline
84
84
  if: steps.baseline_candidate.outputs.current == 'true'
85
- uses: ranacseruet/snapdrift@v0.8.2
85
+ uses: ranacseruet/snapdrift@v0.9.0
86
86
  with:
87
87
  mode: baseline
88
88
  repo-config-path: .github/snapdrift.json
package/lib/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import { execSync } from 'node:child_process';
3
+ import { spawnSync } from 'node:child_process';
4
4
  import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
 
@@ -49,6 +49,11 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
49
49
  * --accept-cross-engine Allow importing baselines from a different capture engine
50
50
  * --from-snap-action <path> Snap action workflow YAML path (init command)
51
51
  *
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
54
+ * flags and missing option values are rejected with a non-zero error; invalid
55
+ * *values* for --to/--from remain silently ignored (validated downstream).
56
+ *
52
57
  * @param {string[]} argv - process.argv
53
58
  * @returns {CliOptions}
54
59
  */
@@ -71,41 +76,105 @@ export function parseArgs(argv) {
71
76
  /** @type {string | undefined} */
72
77
  let fromSnapAction;
73
78
 
74
- for (let i = 1; i < args.length; i++) {
75
- const arg = args[i];
76
- if (arg === '--open') {
77
- open = true;
78
- } else if (arg === '--config' && i + 1 < args.length) {
79
- configPath = args[++i];
80
- } else if (arg === '--routes' && i + 1 < args.length) {
81
- routes.push(
82
- ...args[++i]
83
- .split(',')
84
- .map((r) => r.trim())
85
- .filter(Boolean)
86
- );
87
- } else if (arg === '--baseline-dir' && i + 1 < args.length) {
88
- baselineDir = path.resolve(args[++i]);
89
- } else if (arg === '--current-dir' && i + 1 < args.length) {
90
- currentDir = path.resolve(args[++i]);
91
- } else if (arg === '--diff-dir' && i + 1 < args.length) {
92
- diffDir = path.resolve(args[++i]);
93
- } else if (arg === '--to' && i + 1 < args.length) {
94
- const toVal = args[++i];
95
- if (toVal === 'snap' || toVal === 'local') {
96
- to = toVal;
97
- }
98
- } else if (arg === '--from' && i + 1 < args.length) {
99
- const fromVal = args[++i];
100
- if (fromVal === 'snap') {
101
- from = fromVal;
102
- }
103
- } else if (arg === '--accept-cross-engine') {
104
- acceptCrossEngine = true;
105
- } else if (arg === '--from-snap-action' && i + 1 < args.length) {
106
- fromSnapAction = args[++i];
107
- }
108
- }
79
+ // Flags that consume the following token as their value. Unknown flags and
80
+ // missing option values are rejected rather than silently ignored, so a typo
81
+ // cannot fall back to the default config/directories. Invalid *values* for
82
+ // --to/--from stay silently ignored to preserve the migrate-baselines contract
83
+ // (their validation happens downstream). Values that begin with a hyphen (e.g.
84
+ // a route id like `-mobile`) may be supplied inline as `--flag=value` or bare.
85
+ const VALUE_FLAGS = new Set([
86
+ '--config',
87
+ '--routes',
88
+ '--baseline-dir',
89
+ '--current-dir',
90
+ '--diff-dir',
91
+ '--to',
92
+ '--from',
93
+ '--from-snap-action'
94
+ ]);
95
+ const BOOLEAN_FLAGS = new Set(['--open', '--accept-cross-engine']);
96
+
97
+ /**
98
+ * Assign a parsed value (from an inline `--flag=value` or a following token)
99
+ * to the option named by `flag`.
100
+ *
101
+ * @param {string} flag
102
+ * @param {string} value
103
+ */
104
+ function assignFlag(flag, value) {
105
+ if (flag === '--routes') {
106
+ routes.push(...value.split(',').map((r) => r.trim()).filter(Boolean));
107
+ } else if (flag === '--config') {
108
+ configPath = value;
109
+ } else if (flag === '--baseline-dir') {
110
+ baselineDir = path.resolve(value);
111
+ } else if (flag === '--current-dir') {
112
+ currentDir = path.resolve(value);
113
+ } else if (flag === '--diff-dir') {
114
+ diffDir = path.resolve(value);
115
+ } else if (flag === '--to') {
116
+ if (value === 'snap' || value === 'local') to = value;
117
+ } else if (flag === '--from') {
118
+ if (value === 'snap') from = value;
119
+ } else if (flag === '--from-snap-action') {
120
+ fromSnapAction = value;
121
+ }
122
+ }
123
+
124
+ let endOfOptions = false;
125
+
126
+ for (let i = 1; i < args.length; i++) {
127
+ const raw = args[i];
128
+
129
+ if (raw === '--') {
130
+ endOfOptions = true;
131
+ continue;
132
+ }
133
+
134
+ // End-of-options: after `--`, tokens are values/positionals, never flags.
135
+ if (endOfOptions) {
136
+ if (VALUE_FLAGS.has(raw)) {
137
+ const value = args[i + 1];
138
+ if (value === undefined) {
139
+ throw new Error(`Missing value for flag ${raw}`);
140
+ }
141
+ assignFlag(raw, value);
142
+ i++; // consume the following token so it is not reprocessed as a flag
143
+ }
144
+ continue;
145
+ }
146
+
147
+ // Inline `--flag=value` form (also handles values that begin with a hyphen).
148
+ const eq = raw.indexOf('=');
149
+ if (raw.startsWith('--') && eq > 0) {
150
+ const flag = raw.slice(0, eq);
151
+ if (!VALUE_FLAGS.has(flag)) {
152
+ throw new Error(`Unknown flag: ${flag}`);
153
+ }
154
+ assignFlag(flag, raw.slice(eq + 1));
155
+ continue;
156
+ }
157
+
158
+ if (BOOLEAN_FLAGS.has(raw)) {
159
+ if (raw === '--open') {
160
+ open = true;
161
+ } else if (raw === '--accept-cross-engine') {
162
+ acceptCrossEngine = true;
163
+ }
164
+ } else if (VALUE_FLAGS.has(raw)) {
165
+ const value = args[i + 1];
166
+ // A hyphenated following token (e.g. a route id like `-mobile`) is a
167
+ // legitimate value; only a missing token, the `--` end-of-options marker,
168
+ // or a following *recognized flag* means the value is absent.
169
+ if (value === undefined || value === '--' || VALUE_FLAGS.has(value) || BOOLEAN_FLAGS.has(value)) {
170
+ throw new Error(`Missing value for flag ${raw}`);
171
+ }
172
+ assignFlag(raw, value);
173
+ i++; // consume the following token so it is not reprocessed as a flag
174
+ } else if (raw.startsWith('-')) {
175
+ throw new Error(`Unknown flag: ${raw}`);
176
+ }
177
+ }
109
178
 
110
179
  return { command, open, configPath, routes, baselineDir, currentDir, diffDir, to, from, acceptCrossEngine, fromSnapAction };
111
180
  }
@@ -163,15 +232,30 @@ export function printSummary(summary) {
163
232
  * @returns {void}
164
233
  */
165
234
  function openFile(filePath) {
166
- const cmd =
167
- process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
168
- try {
169
- execSync(`${cmd} "${filePath}"`);
170
- } catch {
235
+ const { cmd, args } = buildOpenCommand(filePath);
236
+ const result = spawnSync(cmd, args);
237
+ if (result.error) {
171
238
  process.stderr.write(`Could not open file: ${filePath}\n`);
172
239
  }
173
240
  }
174
241
 
242
+ /**
243
+ * Build the platform-correct invocation to open a file with the OS default
244
+ * viewer. The path is passed as a separate argument (never shell-interpolated)
245
+ * so metacharacters in a local path cannot execute commands. On Windows, `start`
246
+ * is a cmd.exe built-in rather than an executable, so spawnSync (which does not
247
+ * use a shell by default) returns ENOENT; open the registered handler through
248
+ * rundll32, a real executable, keeping the path as a separate argument.
249
+ *
250
+ * @param {string} filePath
251
+ * @returns {{ cmd: string, args: string[] }}
252
+ */
253
+ export function buildOpenCommand(filePath, platform = process.platform) {
254
+ if (platform === 'darwin') return { cmd: 'open', args: [filePath] };
255
+ if (platform === 'linux') return { cmd: 'xdg-open', args: [filePath] };
256
+ return { cmd: 'rundll32', args: ['url.dll,FileProtocolHandler', filePath] };
257
+ }
258
+
175
259
  /**
176
260
  * Run the `capture` command — capture screenshots to a local baseline directory.
177
261
  *
@@ -87,19 +87,30 @@ function translateStepToConfig(step) {
87
87
  });
88
88
  }
89
89
 
90
- // Snap provider config (if present in inputs)
91
- /** @type {JsonObject | undefined} */
92
- let snap;
93
- const snapApiUrl = inputs['snap-api-url'] || inputs.snap_api_url;
94
- const snapApiKeyEnv = inputs['snap-api-key-env'] || inputs.snap_api_key_env;
95
- const snapProjectId = inputs['snap-project-id'] || inputs.snap_project_id;
96
-
97
- if (snapApiKeyEnv || snapProjectId) {
98
- snap = {};
99
- if (snapApiUrl) snap.apiUrl = snapApiUrl;
100
- if (snapApiKeyEnv) snap.apiKeyEnv = snapApiKeyEnv;
101
- if (snapProjectId) snap.projectId = snapProjectId;
102
- }
90
+ // Snap provider config (if present in inputs). A hosted Snap provider requires
91
+ // an API-key source; only enable provider: "snap" when one is present. When a
92
+ // project ID exists without an API-key source, keep a valid (local) config and
93
+ // emit a clear migration warning rather than emitting provider: "snap" with no
94
+ // API-key source, which later fails validation.
95
+ /** @type {JsonObject | undefined} */
96
+ let snap;
97
+ const snapApiUrl = inputs['snap-api-url'] || inputs.snap_api_url;
98
+ const snapApiKeyEnv = inputs['snap-api-key-env'] || inputs.snap_api_key_env;
99
+ const snapProjectId = inputs['snap-project-id'] || inputs.snap_project_id;
100
+
101
+ if (snapApiKeyEnv) {
102
+ snap = {};
103
+ if (snapApiUrl) snap.apiUrl = snapApiUrl;
104
+ snap.apiKeyEnv = snapApiKeyEnv;
105
+ if (snapProjectId) snap.projectId = snapProjectId;
106
+ } else if (snapProjectId) {
107
+ warnings.push({
108
+ field: 'snap.projectId',
109
+ originalValue: snapProjectId,
110
+ message: `Snap project ID "${snapProjectId}" is present but no API-key source (snap-api-key-env) was configured. SnapDrift keeps the local provider; add a Snap API key source to enable hosted baselines.`,
111
+ severity: 'warning'
112
+ });
113
+ }
103
114
 
104
115
  const config = {
105
116
  baselineArtifactName: 'snapdrift-baseline',
@@ -41,6 +41,25 @@ export const MISSING_BASELINE_REASON = 'missing_main_baseline_artifact';
41
41
  /** @returns {void} */
42
42
  function noop() {}
43
43
 
44
+ /**
45
+ * @param {unknown} outageError
46
+ * @param {'capture' | 'diff'} phase
47
+ * @param {string | undefined} lookupMessage
48
+ * @returns {Error}
49
+ */
50
+ function createBaselineLookupFallbackError(outageError, phase, lookupMessage) {
51
+ const lookupDetail = typeof lookupMessage === 'string' && lookupMessage.trim().length > 0
52
+ ? ` ${lookupMessage.trim()}`
53
+ : '';
54
+ const outageDetail = outageError instanceof Error && outageError.message
55
+ ? ` Snap fallback reason: ${outageError.message}`
56
+ : '';
57
+ return new Error(
58
+ `Cannot fall back to a local ${phase} because the GitHub baseline lookup failed.${lookupDetail}${outageDetail}`,
59
+ { cause: outageError }
60
+ );
61
+ }
62
+
44
63
  /**
45
64
  * Whether the current capture leaves real PNGs on the runner's filesystem.
46
65
  *
@@ -70,7 +89,9 @@ export function hasLocalScreenshots(providerName, config) {
70
89
  * config?: VisualRegressionConfig,
71
90
  * createLocalProvider?: () => VisualProvider,
72
91
  * onSkip?: (error: SnapSkipError) => void,
73
- * onFallback?: (error: SnapFallbackError) => void
92
+ * onFallback?: (error: SnapFallbackError) => void,
93
+ * baselineResolutionStatus?: string,
94
+ * baselineResolutionMessage?: string
74
95
  * }} options
75
96
  * @returns {Promise<{
76
97
  * outcome: 'captured' | 'skipped',
@@ -100,6 +121,9 @@ export async function captureWithPolicy(options) {
100
121
  }
101
122
  if (error instanceof SnapFallbackError) {
102
123
  onFallback(error);
124
+ if (options.baselineResolutionStatus === 'error') {
125
+ throw createBaselineLookupFallbackError(error, 'capture', options.baselineResolutionMessage);
126
+ }
103
127
  const result = await createLocalProvider().capture(options.captureOptions);
104
128
  // The rest of the pipeline must be told the *effective* provider, not the
105
129
  // configured one — otherwise it rebuilds SnapProvider around local output.
@@ -134,7 +158,9 @@ export async function captureWithPolicy(options) {
134
158
  * onSkip?: (error: SnapSkipError) => void,
135
159
  * onFallback?: (error: SnapFallbackError) => void,
136
160
  * onRecapture?: () => void,
137
- * onBaselineUnavailable?: () => void
161
+ * onBaselineUnavailable?: () => void,
162
+ * baselineResolutionStatus?: string,
163
+ * baselineResolutionMessage?: string
138
164
  * }} options
139
165
  * @returns {Promise<{
140
166
  * outcome: 'diffed' | 'skipped' | 'baseline-unavailable',
@@ -170,9 +196,16 @@ export async function diffWithPolicy(options) {
170
196
  : Boolean(options.diffOptions.baselineResultsPath);
171
197
  if (!baselineAvailable) {
172
198
  onBaselineUnavailable();
199
+ if (options.baselineResolutionStatus === 'error') {
200
+ throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
201
+ }
173
202
  return { outcome: 'baseline-unavailable', providerName: 'local' };
174
203
  }
175
204
 
205
+ if (options.baselineResolutionStatus === 'error') {
206
+ throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
207
+ }
208
+
176
209
  const localProvider = createLocalProvider();
177
210
  let diffOptions = options.diffOptions;
178
211
  /** @type {CaptureResult | undefined} */