snapdrift 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -1,6 +1,25 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.6.0 - 2026-07-17
4
+
5
+ ### Features
6
+
7
+ - **`migrate-baselines --to local --from snap` is now functional** — `SnapProvider.exportBaselines` downloads the project's export archive from Snap's `GET /v1/visual/projects/:id/export` endpoint (requires an API key with the `visual:export` scope), parses the tar in pure JS, selects the most recent accepted baseline, and maps it back to the local baseline layout (`results.json`, `manifest.json`, `screenshots/<routeId>.png`). The imported engine name is passed through truthfully — baselines rendered by Snap's hosted worker report their own engine (falling back to `snap-hosted`) and require `--accept-cross-engine` to import, while baselines originally published by SnapDrift report `snapdrift-local` and import cleanly. Clear errors cover a missing `visual:export` scope (403), an unknown project (404), oversized exports (413), projects with no accepted baselines, and legacy baselines that predate manifest tracking. Previously this command failed with a "Snap export endpoint is not yet available" stub error.
8
+
9
+ ### Fixes
10
+
11
+ - **Reject error pages during capture** — `runBaselineCapture` now checks the HTTP status of each route navigation and fails the route if it resolves to a 4xx/5xx. `page.goto` resolves even on a 404/500 (the error body still loads), so previously a broken route — wrong slug, misconfigured `baseUrl`, or an outage — would silently screenshot its error page and upload it as a valid baseline. The failure flows into the existing retry-then-fail path, so a genuinely broken capture now fails loudly instead of poisoning the baseline. Complements the 0% -drift heuristic guard from #93 with a direct status check. Redirects (3xx) still pass, since Playwright follows them and reports the final status; `null` responses (non-navigation schemes) are treated as fine.
12
+
13
+ ## 0.5.1 - 2026-07-14
14
+
15
+ ### Fixes
16
+
17
+ - **Stale-capture false-negative guard (#93)** — capture logs now print the fully resolved target URL per route (`... -> https://.../path`), so a `baseUrl` pointing at production instead of the PR preview is visible at a glance. Additionally, `SnapProvider.diff` now emits a warning when a diff run compared more than one route against a real baseline yet every comparison came back pixel-identical (0% drift) — the tell-tale signature of capturing a stale or wrong page rather than a genuine "no change". The guard only warns (never fails), since a PR with no visual change legitimately produces the same shape.
18
+
19
+ ### Dependencies
20
+
21
+ - Updated `js-yaml` from 4.1.0 to 5.2.1, including ESM import compatibility changes.
22
+ - Updated the pinned `actions/checkout` action from v6.0.3 to v7.0.0.
4
23
 
5
24
  ## 0.5.0 - 2026-06-08
6
25
 
package/docs/contracts.md CHANGED
@@ -272,12 +272,13 @@ snapdrift migrate-baselines --to snap [--config <path>] [--baseline-dir <dir>]
272
272
  snapdrift migrate-baselines --to local --from snap [--accept-cross-engine] [--config <path>] [--baseline-dir <dir>]
273
273
  ```
274
274
 
275
- - Downloads baselines from Snap's export endpoint.
276
- - Writes `results.json`, `manifest.json`, and `screenshots/*.png` to the local baseline directory.
275
+ - Downloads the project's export archive from Snap's export endpoint (`GET /v1/visual/projects/:id/export`, a tar containing every accepted baseline's screenshots, capture profile, and source manifest). Requires an API key with the `visual:export` scope.
276
+ - Imports the **most recent accepted baseline** (by `createdAt`) from the archive; older accepted baselines in the export are ignored.
277
+ - Writes `results.json`, `manifest.json`, and `screenshots/*.png` to the local baseline directory. Screenshot filenames are derived from the baseline's route ids (`screenshots/<routeId>.png`), matching what a local capture would produce.
277
278
  - Writes `.migration-metadata.json` next to the baseline recording `source`, `migratedAt`, and the engine that produced the export.
278
- - Without `--accept-cross-engine`: hard-errors if the exported manifest's engine name is not `snapdrift-local`.
279
+ - Without `--accept-cross-engine`: hard-errors if the exported baseline's engine name is not `snapdrift-local` (e.g. `snap-hosted` for baselines rendered by Snap's worker).
279
280
  - With `--accept-cross-engine`: overrides the engine name to `snapdrift-local` in the imported manifest (visual differences may occur when the captures came from a different engine).
280
- - Requires the Snap export endpoint to be available; if it is not yet wired up, the command fails with an actionable message instead of producing a partial baseline.
281
+ - Fails with an actionable message — instead of producing a partial baseline — when the project has no accepted baselines, when the selected baseline predates manifest tracking (no source manifest recorded at publish time), when the key lacks the `visual:export` scope (403), or when the export exceeds Snap's size caps (413).
281
282
 
282
283
  ### init
283
284
 
@@ -227,7 +227,7 @@ Conversely, downloading a Snap baseline back to a local directory (useful for re
227
227
  snapdrift migrate-baselines --to local --from snap
228
228
  ```
229
229
 
230
- The reverse direction requires the Snap export endpoint to be available; if it isn't, the command fails with an actionable error. See the [Contracts reference](contracts.md#migration-commands) for the full flag set, the engine-compatibility check, and the metadata file written for idempotency.
230
+ The reverse direction downloads the project's export archive from Snap (`GET /v1/visual/projects/:id/export`, which requires an API key with the `visual:export` scope) and imports the most recent accepted baseline into the local baseline directory. See the [Contracts reference](contracts.md#migration-commands) for the full flag set, the engine-compatibility check, and the metadata file written for idempotency.
231
231
 
232
232
  If you're adopting SnapDrift as a replacement for the upstream `snap/github-action`, the `init` codemod translates the workflow YAML and emits a `MIGRATION_NOTES.md` with everything that couldn't be auto-translated:
233
233
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import yaml from 'js-yaml';
5
+ import * as yaml from 'js-yaml';
6
6
 
7
7
  /** @typedef {import('../types/visual-diff-types').InitWarning} InitWarning */
8
8
 
@@ -9,7 +9,7 @@ import path from 'node:path';
9
9
 
10
10
  import { makeMarkdown, buildReportCommentBody } from '@snapdrift/adapter-report-md';
11
11
  import { loadSnapdriftConfig, runBaselineCapture } from '@snapdrift/adapter-fs';
12
- import { selectConfiguredRoutes, splitCommaList, determineDriftStatus, VIEWPORT_PRESETS } from '@snapdrift/manifest';
12
+ import { selectConfiguredRoutes, splitCommaList, determineDriftStatus, VIEWPORT_PRESETS, viewportHash } from '@snapdrift/manifest';
13
13
 
14
14
  /** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
15
15
  /** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
@@ -85,6 +85,146 @@ function getPackageVersion() {
85
85
  }
86
86
  }
87
87
 
88
+ const TAR_BLOCK_SIZE = 512;
89
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
90
+
91
+ /**
92
+ * Strips characters that could cause path traversal or produce invalid
93
+ * filenames when a route id is used as a screenshot filename. Mirrors the
94
+ * sanitisation applied by `@snapdrift/adapter-fs` during local capture so
95
+ * imported baselines land on the same filenames a local capture would produce.
96
+ *
97
+ * @param {string} id
98
+ * @returns {string}
99
+ */
100
+ function sanitizeRouteId(id) {
101
+ return id
102
+ .replace(/\.\./g, '_')
103
+ .replace(/[/\\]/g, '_')
104
+ // eslint-disable-next-line no-control-regex
105
+ .replace(/[\x00-\x1f\x7f]/g, '');
106
+ }
107
+
108
+ /**
109
+ * Parse a plain ustar-style tar archive (512-byte headers, bodies padded to
110
+ * 512-byte blocks, terminated by zero blocks) into a map of entry name →
111
+ * entry bytes. This is the exact layout Snap's export endpoint produces;
112
+ * long-name extensions (GNU/pax) are not needed and not supported.
113
+ *
114
+ * @param {Buffer} archive
115
+ * @returns {Map<string, Buffer>}
116
+ */
117
+ function parseTarEntries(archive) {
118
+ /** @type {Map<string, Buffer>} */
119
+ const entries = new Map();
120
+ let offset = 0;
121
+
122
+ while (offset + TAR_BLOCK_SIZE <= archive.length) {
123
+ const header = archive.subarray(offset, offset + TAR_BLOCK_SIZE);
124
+ if (header.every((byte) => byte === 0)) {
125
+ break; // end-of-archive marker
126
+ }
127
+
128
+ const nameField = header.subarray(0, 100);
129
+ const nameEnd = nameField.indexOf(0);
130
+ const name = nameField.toString('utf-8', 0, nameEnd === -1 ? 100 : nameEnd);
131
+ const size = parseInt(header.toString('ascii', 124, 136).replace(/\0/g, '').trim(), 8);
132
+
133
+ if (!name || !Number.isFinite(size) || size < 0) {
134
+ throw new Error(`Snap export archive is corrupt: invalid tar header at offset ${offset}.`);
135
+ }
136
+
137
+ const bodyStart = offset + TAR_BLOCK_SIZE;
138
+ if (bodyStart + size > archive.length) {
139
+ throw new Error(`Snap export archive is truncated: entry "${name}" extends past the end of the archive.`);
140
+ }
141
+
142
+ entries.set(name, archive.subarray(bodyStart, bodyStart + size));
143
+ offset = bodyStart + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
144
+ }
145
+
146
+ return entries;
147
+ }
148
+
149
+ /**
150
+ * Read the pixel dimensions from a PNG's IHDR chunk. Returns null when the
151
+ * bytes are not a PNG (e.g. a JPEG export) or are too short.
152
+ *
153
+ * @param {Buffer} bytes
154
+ * @returns {{ width: number, height: number } | null}
155
+ */
156
+ function readPngDimensions(bytes) {
157
+ if (bytes.length < 24 || !bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
158
+ return null;
159
+ }
160
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
161
+ }
162
+
163
+ /**
164
+ * @param {string | undefined} json
165
+ * @returns {any}
166
+ */
167
+ function safeJsonParse(json) {
168
+ if (!json) return null;
169
+ try {
170
+ return JSON.parse(json);
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Sort key for picking the newest baseline. Snap serialises createdAt as an
178
+ * ISO string, but tolerate epoch numbers and treat unparsable values as
179
+ * oldest so a legacy row never shadows a well-formed one.
180
+ *
181
+ * @param {{ createdAt?: unknown }} baseline
182
+ * @returns {number}
183
+ */
184
+ function baselineTimestamp(baseline) {
185
+ const createdAt = baseline?.createdAt;
186
+ if (typeof createdAt === 'number') return createdAt;
187
+ const parsed = Date.parse(typeof createdAt === 'string' ? createdAt : '');
188
+ return Number.isNaN(parsed) ? -Infinity : parsed;
189
+ }
190
+
191
+ /**
192
+ * Derive the capture engine from a baseline's capture profile. A baseline
193
+ * published by SnapDrift records `{ engine: { name: "snapdrift-local" } }`;
194
+ * a baseline rendered by Snap's hosted worker records Snap's own engine.
195
+ * When nothing is recorded (legacy hosted baselines export `{}`), fall back
196
+ * to "snap-hosted" so the CLI's cross-engine flow engages truthfully.
197
+ *
198
+ * @param {any} captureProfile
199
+ * @returns {{ name: string, version: string }}
200
+ */
201
+ function resolveExportEngine(captureProfile) {
202
+ const engine = captureProfile?.engine;
203
+ if (engine && typeof engine.name === 'string' && engine.name) {
204
+ return {
205
+ name: engine.name,
206
+ version: typeof engine.version === 'string' && engine.version ? engine.version : 'unknown'
207
+ };
208
+ }
209
+ return { name: 'snap-hosted', version: 'unknown' };
210
+ }
211
+
212
+ /**
213
+ * Extract a human-readable detail string from a Snap API error response.
214
+ *
215
+ * @param {{ text: () => Promise<string> }} response
216
+ * @returns {Promise<string>}
217
+ */
218
+ async function readErrorDetail(response) {
219
+ const errorBody = await response.text().catch(() => '');
220
+ try {
221
+ const parsed = JSON.parse(errorBody);
222
+ return parsed.error || parsed.message || '';
223
+ } catch {
224
+ return '';
225
+ }
226
+ }
227
+
88
228
  /**
89
229
  * SnapProvider — VisualProvider that delegates capture + diff to Snap's
90
230
  * hosted /v1/visual/* API.
@@ -512,21 +652,185 @@ export class SnapProvider {
512
652
  * Export baselines from Snap for local import.
513
653
  * Used by `snapdrift migrate-baselines --to local --from snap`.
514
654
  *
515
- * @param {{ tag?: string }} _options
655
+ * Downloads the project's export archive from
656
+ * `GET /v1/visual/projects/:id/export` (a plain ustar tar containing a
657
+ * `manifest.json`, per-baseline screenshot images, and per-baseline
658
+ * capture profiles; requires an API key with the `visual:export` scope),
659
+ * selects the most recent accepted baseline, and maps it back to the
660
+ * local baseline layout (`results.json` + `manifest.json` +
661
+ * `screenshots/<routeId>.png`) that `LocalProvider` reads.
662
+ *
663
+ * @param {{ tag?: string }} [_options]
516
664
  * @returns {Promise<{
517
665
  * results: object,
518
666
  * manifest: object,
519
667
  * screenshots: Array<{ filename: string, data: Buffer }>,
520
668
  * engine: { name: string, version: string }
521
669
  * }>}
522
- * @throws {Error} The Snap export endpoint is not yet available.
670
+ * @throws {Error} When the project has no accepted baselines, the selected
671
+ * baseline predates manifest tracking, or the export request fails.
523
672
  */
524
673
  async exportBaselines(_options) {
525
- throw new Error(
526
- 'The Snap export endpoint is not yet available. ' +
527
- 'This command will be functional once the Snap API adds GET /v1/visual/projects/:id/export. ' +
528
- 'See Snap repo issue #482 for tracking.'
674
+ const exportPath = `/v1/visual/projects/${this.#projectId}/export`;
675
+ let archive;
676
+ try {
677
+ archive = await this.#requestBinary('GET', exportPath);
678
+ } catch (error) {
679
+ if (error instanceof SnapApiError) {
680
+ if (error.status === 403) {
681
+ throw new Error(
682
+ `Snap refused the export (403): the API key does not have the "visual:export" scope. ` +
683
+ `Issue a key with visual:export access for project "${this.#projectId}" and retry.`,
684
+ { cause: error }
685
+ );
686
+ }
687
+ if (error.status === 404) {
688
+ throw new Error(
689
+ `Snap project "${this.#projectId}" was not found (404). ` +
690
+ `Check snap.projectId in snapdrift.json (or GITHUB_REPOSITORY when projectId is "auto").`,
691
+ { cause: error }
692
+ );
693
+ }
694
+ if (error.status === 413) {
695
+ throw new Error(
696
+ `Snap export for project "${this.#projectId}" is too large (413): ${error.message}. ` +
697
+ `The export endpoint caps the number of accepted baselines and total archive bytes.`,
698
+ { cause: error }
699
+ );
700
+ }
701
+ }
702
+ throw error;
703
+ }
704
+
705
+ const entries = parseTarEntries(archive);
706
+ const manifestBytes = entries.get('manifest.json');
707
+ if (!manifestBytes) {
708
+ throw new Error('Snap export archive is missing manifest.json.');
709
+ }
710
+ const exportManifest = JSON.parse(manifestBytes.toString('utf-8'));
711
+ const baselines = Array.isArray(exportManifest.baselines) ? exportManifest.baselines : [];
712
+ if (baselines.length === 0) {
713
+ throw new Error(
714
+ `Snap project "${this.#projectId}" has no accepted baselines to export. ` +
715
+ `Publish a baseline first (snapdrift baseline with provider: "snap").`
716
+ );
717
+ }
718
+
719
+ // Most recent accepted baseline wins; `>=` makes the later array entry win
720
+ // a createdAt tie (the server appends newer rows last).
721
+ let baseline = baselines[0];
722
+ for (const candidate of baselines.slice(1)) {
723
+ if (baselineTimestamp(candidate) >= baselineTimestamp(baseline)) {
724
+ baseline = candidate;
725
+ }
726
+ }
727
+
728
+ if (!baseline.sourceManifest) {
729
+ throw new Error(
730
+ `Snap baseline "${baseline.id}" predates manifest tracking (no source manifest was recorded ` +
731
+ `at publish time) and cannot be migrated. Publish a fresh baseline with a current SnapDrift ` +
732
+ `version, then re-run the migration.`
733
+ );
734
+ }
735
+ const routes = Array.isArray(baseline.sourceManifest.routes) ? baseline.sourceManifest.routes : null;
736
+ if (!routes) {
737
+ throw new Error(
738
+ `Snap baseline "${baseline.id}" has an unrecognised source manifest format ` +
739
+ `(expected a "routes" array) and cannot be migrated.`
740
+ );
741
+ }
742
+
743
+ const archivePathByKey = new Map(
744
+ (baseline.objects || []).map((object) => [object.sourceKey, object.archivePath])
529
745
  );
746
+
747
+ /** @type {Array<{ filename: string, data: Buffer }>} */
748
+ const screenshots = [];
749
+ /** @type {Array<object>} */
750
+ const manifestEntries = [];
751
+
752
+ for (const route of routes) {
753
+ const archivePath = route.objectKey ? archivePathByKey.get(route.objectKey) : undefined;
754
+ const bytes = archivePath ? entries.get(archivePath) : undefined;
755
+ if (!bytes) {
756
+ process.stderr.write(
757
+ `[SnapDrift] Warning: Snap export has no image for route "${route.routeId}" ` +
758
+ `in baseline ${baseline.id}. Skipping the route.\n`
759
+ );
760
+ continue;
761
+ }
762
+
763
+ const descriptor = safeJsonParse(route.viewportDescriptorJson) ?? {};
764
+ const hasDescriptorDims = typeof descriptor.width === 'number' && typeof descriptor.height === 'number';
765
+ // Map the viewport descriptor back to a preset name when it matches one
766
+ // exactly; otherwise keep custom dimensions. This round-trips with the
767
+ // descriptor expansion capture() applies on the way up.
768
+ const presetName = hasDescriptorDims ? viewportHash(descriptor) : null;
769
+ const dimensions = readPngDimensions(bytes)
770
+ ?? (hasDescriptorDims ? { width: descriptor.width, height: descriptor.height } : { width: 1, height: 1 });
771
+ const viewport = presetName && VIEWPORT_PRESETS[presetName]
772
+ ? presetName
773
+ : hasDescriptorDims
774
+ ? { width: descriptor.width, height: descriptor.height }
775
+ : { width: dimensions.width, height: dimensions.height };
776
+
777
+ const extension = path.extname(archivePath) || '.png';
778
+ const filename = `${sanitizeRouteId(route.routeId)}${extension}`;
779
+ screenshots.push({ filename, data: Buffer.from(bytes) });
780
+ manifestEntries.push({
781
+ id: route.routeId,
782
+ path: route.routePath ?? '',
783
+ viewport,
784
+ imagePath: `screenshots/${filename}`,
785
+ width: dimensions.width,
786
+ height: dimensions.height
787
+ });
788
+ }
789
+
790
+ if (screenshots.length === 0) {
791
+ throw new Error(
792
+ `Snap baseline "${baseline.id}" contained no exportable screenshots — ` +
793
+ `the export archive had no image bytes for any of its routes.`
794
+ );
795
+ }
796
+
797
+ const captureProfile = safeJsonParse(
798
+ entries.get(`${baseline.id}/capture_profile.json`)?.toString('utf-8')
799
+ ) ?? {};
800
+ const engine = resolveExportEngine(captureProfile);
801
+
802
+ const generatedAt = new Date().toISOString();
803
+ const manifest = {
804
+ schemaVersion: 1,
805
+ generatedAt,
806
+ baseUrl: typeof baseline.sourceManifest.baseUrl === 'string' ? baseline.sourceManifest.baseUrl : '',
807
+ screenshots: manifestEntries,
808
+ captureProfile: { ...captureProfile, engine }
809
+ };
810
+
811
+ const results = {
812
+ startedAt: typeof baseline.createdAt === 'string' ? baseline.createdAt : generatedAt,
813
+ suite: 'snap-export',
814
+ provider: 'snap',
815
+ projectId: this.#projectId,
816
+ baselineId: baseline.id,
817
+ refBranch: baseline.refBranch,
818
+ // readLocalBaselines() picks this up as the commit SHA if the baseline is
819
+ // ever migrated back to Snap, keeping the idempotency key stable.
820
+ headSha: baseline.refSha,
821
+ routes: manifestEntries.map((entry) => ({
822
+ id: entry.id,
823
+ path: entry.path,
824
+ viewport: entry.viewport,
825
+ status: 'passed',
826
+ durationMs: 0,
827
+ imagePath: entry.imagePath,
828
+ width: entry.width,
829
+ height: entry.height
830
+ }))
831
+ };
832
+
833
+ return { results, manifest, screenshots, engine };
530
834
  }
531
835
 
532
836
  // ---------------------------------------------------------------------------
@@ -811,6 +1115,67 @@ export class SnapProvider {
811
1115
  return this.#handleUnavailable(new SnapUnavailableError('Max retries exceeded'));
812
1116
  }
813
1117
 
1118
+ /**
1119
+ * Make an HTTP request to the Snap API that returns a binary body (e.g. the
1120
+ * export tar). Mirrors #request's retry and error semantics — 4xx never
1121
+ * retries and throws SnapApiError, 5xx/network errors retry with backoff and
1122
+ * consult onUnavailable when exhausted — but returns the raw bytes instead
1123
+ * of parsed JSON.
1124
+ *
1125
+ * @param {string} method
1126
+ * @param {string} path
1127
+ * @returns {Promise<any>} Raw response bytes as a Buffer
1128
+ */
1129
+ async #requestBinary(method, path) {
1130
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
1131
+ try {
1132
+ const response = await this.#fetchFn(`${this.#apiUrl}${path}`, {
1133
+ method,
1134
+ headers: { 'Authorization': `Bearer ${this.#apiKey}` }
1135
+ });
1136
+
1137
+ if (response.ok) {
1138
+ return Buffer.from(await response.arrayBuffer());
1139
+ }
1140
+
1141
+ // 4xx — never retry
1142
+ if (response.status >= 400 && response.status < 500) {
1143
+ const detail = await readErrorDetail(response);
1144
+ const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
1145
+ throw new SnapApiError(response.status, errorMessage, path);
1146
+ }
1147
+
1148
+ // 5xx — retry with backoff (unless this is the last attempt)
1149
+ if (attempt < MAX_RETRIES) {
1150
+ const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
1151
+ await this.#sleepFn(delay);
1152
+ continue;
1153
+ }
1154
+
1155
+ // Exhausted retries
1156
+ const detail = await readErrorDetail(response);
1157
+ const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
1158
+ return this.#handleUnavailable(new SnapApiError(response.status, errorMessage, path));
1159
+ } catch (error) {
1160
+ if (error instanceof SnapApiError) {
1161
+ throw error;
1162
+ }
1163
+
1164
+ // Network error — retry
1165
+ if (attempt < MAX_RETRIES) {
1166
+ const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
1167
+ await this.#sleepFn(delay);
1168
+ continue;
1169
+ }
1170
+
1171
+ return this.#handleUnavailable(new SnapUnavailableError(error instanceof Error ? error.message : String(error)));
1172
+ }
1173
+ }
1174
+
1175
+ // Unreachable, but satisfies the type checker
1176
+ return this.#handleUnavailable(new SnapUnavailableError('Max retries exceeded'));
1177
+ }
1178
+
814
1179
  /**
815
1180
  * Handle unavailable behavior based on onUnavailable config.
816
1181
  * @param {Error} error
@@ -870,6 +1235,11 @@ export class SnapProvider {
870
1235
  let changedScreenshots = 0;
871
1236
  let missingInBaseline = 0;
872
1237
  let missingInCurrent = 0;
1238
+ // Track real comparisons (a baseline was attached) and how many came back
1239
+ // pixel-identical (0% drift) so we can flag the "captured a stale/wrong
1240
+ // page" pattern below. See issue #93.
1241
+ let comparedWithBaseline = 0;
1242
+ let exactZeroMatches = 0;
873
1243
 
874
1244
  /** @type {import('../types/visual-diff-types').VisualDiffChangedItem[]} */
875
1245
  const changed = [];
@@ -919,6 +1289,7 @@ export class SnapProvider {
919
1289
  location: 'baseline'
920
1290
  });
921
1291
  } else if (capture.diffPct != null && capture.diffPct > config.diff.threshold) {
1292
+ comparedWithBaseline++;
922
1293
  changedScreenshots++;
923
1294
  changed.push({
924
1295
  id: capture.routeId || capture.id,
@@ -934,11 +1305,35 @@ export class SnapProvider {
934
1305
  status: 'changed'
935
1306
  });
936
1307
  } else {
1308
+ comparedWithBaseline++;
1309
+ if (capture.diffPct === 0) {
1310
+ exactZeroMatches++;
1311
+ }
937
1312
  matchedScreenshots++;
938
1313
  }
939
1314
  }
940
1315
  }
941
1316
 
1317
+ // False-negative guard: when a diff run compared every route against a real
1318
+ // baseline yet every single comparison came back pixel-identical (0% drift),
1319
+ // the most likely explanation is that the client captured a stale or wrong
1320
+ // page (baseUrl pointing at production instead of the PR preview, the
1321
+ // preview not being deployed yet, or a caching layer) rather than a genuine
1322
+ // "nothing changed". Warn so a real regression isn't silently missed. We
1323
+ // only warn (never fail): a PR with no visual change legitimately produces
1324
+ // this same shape. We require more than one compared route — a single
1325
+ // pixel-identical route is indistinguishable from an ordinary clean diff,
1326
+ // whereas several independent routes all landing at exactly 0% is the
1327
+ // tell-tale signature of a single stale source page. See issue #93.
1328
+ if (comparedWithBaseline > 1 && exactZeroMatches === comparedWithBaseline) {
1329
+ process.stderr.write(
1330
+ `[SnapDrift] Warning: all ${comparedWithBaseline} compared route(s) were pixel-identical ` +
1331
+ `to the baseline (0% drift). If you expected a visual change, the captured page may be ` +
1332
+ `stale or wrong — verify the resolved baseUrl points at the PR preview and that the ` +
1333
+ `preview was deployed before capture ran. See issue #93.\n`
1334
+ );
1335
+ }
1336
+
942
1337
  /** @type {import('../types/visual-diff-types').VisualDiffSummary} */
943
1338
  const summary = {
944
1339
  startedAt: run.startedAt || new Date().toISOString(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snapdrift",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SnapDrift captures, compares, and reports UI drift for Playwright-based app checks.",
@@ -41,7 +41,7 @@
41
41
  "@eslint/js": "^10.0.1",
42
42
  "@types/jest": "^30.0.0",
43
43
  "@types/js-yaml": "^4.0.9",
44
- "@types/node": "^25.3.5",
44
+ "@types/node": "^26.1.0",
45
45
  "@types/pngjs": "^6.0.5",
46
46
  "eslint": "^10.2.0",
47
47
  "globals": "^17.4.0",
@@ -95,11 +95,11 @@
95
95
  "registry": "https://registry.npmjs.org"
96
96
  },
97
97
  "dependencies": {
98
- "@snapdrift/adapter-fs": "^1.0.0",
98
+ "@snapdrift/adapter-fs": "^1.0.1",
99
99
  "@snapdrift/adapter-report-md": "^1.1.0",
100
100
  "@snapdrift/compare-core": "^1.0.0",
101
101
  "@snapdrift/manifest": "^1.2.0",
102
- "js-yaml": "^4.1.0",
102
+ "js-yaml": "^5.2.1",
103
103
  "playwright": "^1.59.1",
104
104
  "pngjs": "^7.0.0"
105
105
  }