snapdrift 0.5.1 → 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,14 @@
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.
4
12
 
5
13
  ## 0.5.1 - 2026-07-14
6
14
 
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
 
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snapdrift",
3
- "version": "0.5.1",
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.",