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 +9 -1
- package/docs/contracts.md +5 -4
- package/docs/integration-guide.md +1 -1
- package/lib/snap-provider.mjs +372 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
|
276
|
-
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
package/lib/snap-provider.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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}
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|