snapdrift 0.8.2 → 0.10.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.
@@ -10,7 +10,15 @@ import path from 'node:path';
10
10
 
11
11
  import { makeMarkdown, buildReportCommentBody } from '@snapdrift/adapter-report-md';
12
12
  import { loadSnapdriftConfig, runBaselineCapture } from '@snapdrift/adapter-fs';
13
- import { selectConfiguredRoutes, splitCommaList, determineDriftStatus, VIEWPORT_PRESETS, viewportHash } from '@snapdrift/manifest';
13
+ import {
14
+ selectConfiguredRoutes,
15
+ splitCommaList,
16
+ determineDriftStatus,
17
+ VIEWPORT_PRESETS,
18
+ viewportHash,
19
+ assertUniqueRouteIdFilenames,
20
+ sanitizeRouteId
21
+ } from '@snapdrift/manifest';
14
22
 
15
23
  /** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
16
24
  /** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
@@ -23,16 +31,22 @@ import { selectConfiguredRoutes, splitCommaList, determineDriftStatus, VIEWPORT_
23
31
  /** @typedef {import('../types/visual-diff-types').ProviderBaselineData} BaselineData */
24
32
  /** @typedef {import('../types/visual-diff-types').SnapConfig} SnapConfig */
25
33
  /** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
34
+ /** @typedef {import('../types/visual-diff-types').ComparisonPolicy} ComparisonPolicy */
35
+ /** @typedef {import('../types/visual-diff-types').ComparisonMetadata} ComparisonMetadata */
26
36
  /** @typedef {import('../types/visual-diff-types').JsonObject} JsonObject */
37
+ /** @typedef {{ deadline: number, label: string }} OperationDeadline */
27
38
 
28
39
  const DEFAULT_API_URL = 'https://snap.i2dev.com';
29
40
  const MAX_RETRIES = 3;
30
41
  const INITIAL_RETRY_DELAY_MS = 1000;
31
42
  const RETRY_MULTIPLIER = 2;
32
- const MAX_RETRY_TOTAL_MS = 30000;
43
+ const RETRY_DELAY_CAP_MS = 30000;
44
+ const JSON_REQUEST_TIMEOUT_MS = 30 * 1000;
45
+ const BINARY_REQUEST_TIMEOUT_MS = 120 * 1000;
33
46
  const POLL_INTERVAL_MS = 2000;
34
- const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
47
+ const MAX_OPERATION_DURATION_MS = 10 * 60 * 1000;
35
48
  const MAX_STABLE_INCOMPLETE_POLLS = 30;
49
+ const MAX_COMPARISON_PIXELS = 32 * 1024 * 1024;
36
50
  const BASELINE_RUN_SUCCESS_STATUS = 'new';
37
51
  const BASELINE_CAPTURE_SUCCESS_STATUS = 'new';
38
52
  const TERMINAL_CAPTURE_STATUSES = new Set(['new', 'diffed', 'error']);
@@ -43,19 +57,201 @@ const TERMINAL_CAPTURE_STATUSES = new Set(['new', 'diffed', 'error']);
43
57
  // client must stop polling on it just like pass/fail/error.
44
58
  const TERMINAL_RUN_STATUSES = new Set(['pass', 'fail', 'error', 'new']);
45
59
 
60
+ /** @param {number} milliseconds @returns {string} */
61
+ function formatDuration(milliseconds) {
62
+ const seconds = Math.round(milliseconds / 1000);
63
+ if (seconds % 60 === 0) return `${seconds / 60} minutes`;
64
+ return `${seconds} seconds`;
65
+ }
66
+
67
+ const OPERATION_LIMIT_TEXT = formatDuration(MAX_OPERATION_DURATION_MS);
68
+
69
+ /** @returns {number} A monotonic timestamp in milliseconds. */
70
+ function monotonicNow() {
71
+ return typeof globalThis.performance?.now === 'function' ? globalThis.performance.now() : Date.now();
72
+ }
73
+
74
+ /**
75
+ * Run an asynchronous operation with a timer that also settles when the
76
+ * underlying promise ignores cancellation. The promise remains observed after
77
+ * the timeout, so a late rejection cannot become an unhandled rejection.
78
+ *
79
+ * @template T
80
+ * @param {() => Promise<T> | T} task
81
+ * @param {number} timeoutMs
82
+ * @param {() => void} onTimeout
83
+ * @param {() => Error} timeoutError
84
+ * @returns {Promise<T>}
85
+ */
86
+ function runWithTimeout(task, timeoutMs, onTimeout, timeoutError) {
87
+ return new Promise((resolve, reject) => {
88
+ let settled = false;
89
+ let taskPromise;
90
+ try {
91
+ taskPromise = Promise.resolve(task());
92
+ } catch (error) {
93
+ reject(error);
94
+ return;
95
+ }
96
+ const timer = setTimeout(
97
+ () => {
98
+ if (settled) return;
99
+ settled = true;
100
+ try {
101
+ onTimeout();
102
+ } catch {
103
+ // Abort/cancel is best effort; the timeout still settles the caller.
104
+ }
105
+ reject(timeoutError());
106
+ },
107
+ Math.max(0, timeoutMs)
108
+ );
109
+
110
+ taskPromise.then(
111
+ (value) => {
112
+ if (settled) {
113
+ if (value && typeof value === 'object') cancelResponseBody(value);
114
+ return;
115
+ }
116
+ settled = true;
117
+ clearTimeout(timer);
118
+ resolve(value);
119
+ },
120
+ (error) => {
121
+ if (settled) return;
122
+ settled = true;
123
+ clearTimeout(timer);
124
+ reject(error);
125
+ }
126
+ );
127
+ });
128
+ }
129
+
130
+ /** @param {any} response */
131
+ function cancelResponseBody(response) {
132
+ const body = response && typeof response === 'object' ? response.body : undefined;
133
+ if (body && typeof body.cancel === 'function') {
134
+ Promise.resolve(body.cancel()).catch(() => {});
135
+ }
136
+ }
137
+
46
138
  /** @param {string} detail @returns {Error} */
47
139
  function completeBaselineError(detail) {
48
140
  return new Error(`Cannot publish a complete Snap baseline: ${detail}`);
49
141
  }
50
142
 
143
+ /** @param {string} detail @returns {Error} */
144
+ function hostedDiffError(detail) {
145
+ return new Error(`Cannot compare a Snap visual diff: ${detail}`);
146
+ }
147
+
148
+ /** @param {unknown} value @returns {value is ComparisonPolicy} */
149
+ function isComparisonPolicy(value) {
150
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
151
+ const policy = /** @type {Record<string, any>} */ (value);
152
+ return policy.version === 1 && typeof policy.threshold === 'number' && Number.isFinite(policy.threshold) && policy.threshold >= 0 && policy.threshold <= 1;
153
+ }
154
+
155
+ /** @param {unknown} value @returns {value is ComparisonMetadata} */
156
+ function isComparisonMetadata(value) {
157
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
158
+ const comparison = /** @type {Record<string, any>} */ (value);
159
+ const dimensions = [comparison.baseline, comparison.current, comparison.canvas];
160
+ if (
161
+ dimensions.some(
162
+ (dimension) =>
163
+ !dimension ||
164
+ typeof dimension !== 'object' ||
165
+ !Number.isInteger(dimension.width) ||
166
+ dimension.width < 0 ||
167
+ !Number.isInteger(dimension.height) ||
168
+ dimension.height < 0
169
+ )
170
+ ) {
171
+ return false;
172
+ }
173
+ const [baseline, current, canvas] = dimensions;
174
+ return (
175
+ typeof comparison.dimensionsChanged === 'boolean' &&
176
+ comparison.dimensionsChanged === (baseline.width !== current.width || baseline.height !== current.height) &&
177
+ canvas.width === Math.max(baseline.width, current.width) &&
178
+ canvas.height === Math.max(baseline.height, current.height) &&
179
+ Number.isInteger(comparison.totalPixels) &&
180
+ comparison.totalPixels >= 0 &&
181
+ comparison.totalPixels <= canvas.width * canvas.height &&
182
+ canvas.width * canvas.height <= MAX_COMPARISON_PIXELS
183
+ );
184
+ }
185
+
186
+ /**
187
+ * @param {any} capture
188
+ * @param {ComparisonMetadata} comparison
189
+ * @param {number} expectedThreshold
190
+ * @returns {boolean}
191
+ */
192
+ function hasCompleteComparisonResult(capture, comparison, expectedThreshold) {
193
+ if (
194
+ !Number.isFinite(capture.diffPct) ||
195
+ capture.diffPct < 0 ||
196
+ capture.diffPct > 1 ||
197
+ !Number.isInteger(capture.diffPixels) ||
198
+ capture.diffPixels < 0 ||
199
+ capture.diffPixels > comparison.totalPixels ||
200
+ typeof capture.diffObjectKey !== 'string' ||
201
+ capture.diffObjectKey.trim().length === 0 ||
202
+ capture.thresholdUsed !== expectedThreshold
203
+ ) {
204
+ return false;
205
+ }
206
+
207
+ const expectedRatio = comparison.totalPixels === 0 ? 0 : capture.diffPixels / comparison.totalPixels;
208
+ return Math.abs(capture.diffPct - expectedRatio) <= 1e-12;
209
+ }
210
+
211
+ /** @param {any} capture @returns {ComparisonMetadata | undefined} */
212
+ function comparisonFromCapture(capture) {
213
+ const candidate =
214
+ capture?.comparison ??
215
+ (() => {
216
+ if (typeof capture?.comparisonJson !== 'string') return undefined;
217
+ try {
218
+ return JSON.parse(capture.comparisonJson);
219
+ } catch {
220
+ return undefined;
221
+ }
222
+ })();
223
+ return isComparisonMetadata(candidate) ? candidate : undefined;
224
+ }
225
+
226
+ /** @param {unknown} actual @param {ComparisonPolicy} expected @param {string} context */
227
+ function assertComparisonPolicyAcknowledged(actual, expected, context) {
228
+ if (!isComparisonPolicy(actual) || actual.threshold !== expected.threshold) {
229
+ throw new SnapUpgradeRequiredError(
230
+ `${context} did not acknowledge comparisonPolicy v1 with threshold ${expected.threshold}. ` +
231
+ 'Upgrade the Snap server before enabling unequal-dimension comparisons.'
232
+ );
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Updated SnapDrift clients explicitly opt into the union-canvas comparison.
238
+ * Keep the config override for callers that want the policy visible in their
239
+ * config, but derive it from the existing diff threshold by default so a
240
+ * normal v1 client does not need to repeat that value.
241
+ *
242
+ * @param {VisualRegressionConfig} config
243
+ * @returns {ComparisonPolicy}
244
+ */
245
+ function resolveComparisonPolicy(config) {
246
+ return config.diff.comparisonPolicy ? { ...config.diff.comparisonPolicy } : { version: 1, threshold: config.diff.threshold };
247
+ }
248
+
51
249
  /**
52
250
  * @param {import('../types/visual-diff-types').VisualViewport} viewport
53
251
  * @returns {{ width: number, height: number, deviceScaleFactor?: number, isMobile?: boolean, hasTouch?: boolean }}
54
252
  */
55
253
  function resolveViewportDescriptor(viewport) {
56
- return typeof viewport === 'string'
57
- ? (VIEWPORT_PRESETS[viewport] ?? { width: 1280, height: 720 })
58
- : viewport;
254
+ return typeof viewport === 'string' ? (VIEWPORT_PRESETS[viewport] ?? { width: 1280, height: 720 }) : viewport;
59
255
  }
60
256
 
61
257
  /** @param {VisualRegressionConfig['routes'][number]} route */
@@ -67,43 +263,140 @@ function expectedCaptureIdentity(route) {
67
263
  };
68
264
  }
69
265
 
70
- /** @param {unknown} value @param {string} context @returns {string} */
71
- function normalizeViewportDescriptor(value, context) {
266
+ /**
267
+ * @param {unknown} value
268
+ * @param {string} context
269
+ * @param {(detail: string) => Error} errorFactory
270
+ * @returns {string}
271
+ */
272
+ function normalizeViewportDescriptor(value, context, errorFactory) {
72
273
  let descriptor;
73
274
  try {
74
275
  descriptor = typeof value === 'string' ? JSON.parse(value) : value;
75
276
  } catch {
76
- throw completeBaselineError(`${context} has malformed viewportDescriptorJson.`);
277
+ throw errorFactory(`${context} has malformed viewportDescriptorJson.`);
77
278
  }
78
- if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor) ||
79
- !Number.isFinite(descriptor.width) || !Number.isFinite(descriptor.height)) {
80
- throw completeBaselineError(`${context} has an invalid viewport descriptor.`);
279
+ if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor) || !Number.isFinite(descriptor.width) || !Number.isFinite(descriptor.height)) {
280
+ throw errorFactory(`${context} has an invalid viewport descriptor.`);
81
281
  }
82
- return JSON.stringify(Object.fromEntries(
83
- Object.entries(descriptor).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
84
- ));
282
+ return JSON.stringify(Object.fromEntries(Object.entries(descriptor).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))));
85
283
  }
86
284
 
87
- /** @param {{ routeId?: unknown, viewportDescriptorJson?: unknown }} capture @param {string} context */
88
- function captureIdentityKey(capture, context) {
285
+ /**
286
+ * @param {{ routeId?: unknown, viewportDescriptorJson?: unknown }} capture
287
+ * @param {string} context
288
+ * @param {(detail: string) => Error} errorFactory
289
+ */
290
+ function captureIdentityKey(capture, context, errorFactory) {
89
291
  if (typeof capture.routeId !== 'string' || !capture.routeId) {
90
- throw completeBaselineError(`${context} has no routeId.`);
292
+ throw errorFactory(`${context} has no routeId.`);
91
293
  }
92
- return `${capture.routeId}\u0000${normalizeViewportDescriptor(capture.viewportDescriptorJson, context)}`;
294
+ return `${capture.routeId}\u0000${normalizeViewportDescriptor(capture.viewportDescriptorJson, context, errorFactory)}`;
93
295
  }
94
296
 
95
- /** @param {unknown} value @param {string} field @returns {Set<string>} */
96
- function uniqueRouteIdSet(value, field) {
297
+ /**
298
+ * @param {unknown} value
299
+ * @param {string} field
300
+ * @param {(detail: string) => Error} errorFactory
301
+ * @returns {Set<string>}
302
+ */
303
+ function uniqueRouteIdSet(value, field, errorFactory) {
97
304
  if (!Array.isArray(value) || value.some((id) => typeof id !== 'string' || !id)) {
98
- throw completeBaselineError(`results metadata has no valid ${field}; capture again with SnapDrift 0.8.2 or newer.`);
305
+ throw errorFactory(`results metadata has no valid ${field}; capture again with SnapDrift 0.8.2 or newer.`);
99
306
  }
100
307
  const ids = new Set(value);
101
308
  if (ids.size !== value.length) {
102
- throw completeBaselineError(`results metadata ${field} contains duplicate route ids.`);
309
+ throw errorFactory(`results metadata ${field} contains duplicate route ids.`);
103
310
  }
104
311
  return ids;
105
312
  }
106
313
 
314
+ /**
315
+ * Validate the immutable capture plan written by SnapProvider.capture().
316
+ *
317
+ * Diff runs are allowed to be scoped, so this deliberately validates the
318
+ * expected set against selectedRouteIds rather than requiring every configured
319
+ * route as hosted baseline publication does.
320
+ *
321
+ * @param {JsonObject} metadata
322
+ * @param {string} projectId
323
+ * @returns {{
324
+ * runId: string,
325
+ * selectedRouteIds: string[],
326
+ * expectedCaptures: Array<{ routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
327
+ * expectedByIdentity: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
328
+ * expectedByRouteId: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>
329
+ * }}
330
+ */
331
+ function validateHostedDiffMetadata(metadata, projectId) {
332
+ if (!metadata || typeof metadata !== 'object') {
333
+ throw hostedDiffError('capture results metadata is not an object; capture again with SnapDrift 0.8.2 or newer.');
334
+ }
335
+ if (typeof metadata.runId !== 'string' || !metadata.runId.trim()) {
336
+ throw hostedDiffError('capture results metadata has no run id; capture again with SnapDrift 0.8.2 or newer.');
337
+ }
338
+ if (metadata.projectId !== projectId) {
339
+ throw hostedDiffError(`capture results belong to project "${metadata.projectId || 'unknown'}", not "${projectId}".`);
340
+ }
341
+
342
+ if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length === 0) {
343
+ throw hostedDiffError('capture results have no expected capture identities; recapture with SnapDrift 0.8.2 or newer.');
344
+ }
345
+ if (metadata.purpose !== 'diff') {
346
+ throw hostedDiffError(`capture results have purpose "${metadata.purpose || 'unknown'}", not "diff"; ` + 'recapture with SnapDrift 0.8.2 or newer.');
347
+ }
348
+ const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds', hostedDiffError);
349
+ if (selected.size === 0) {
350
+ throw hostedDiffError('capture results selected no routes.');
351
+ }
352
+ if (metadata.expectedCaptures.length !== selected.size) {
353
+ throw hostedDiffError(`capture results describe ${metadata.expectedCaptures.length} expected capture(s) for ${selected.size} selected route(s).`);
354
+ }
355
+
356
+ const expectedRouteIds = new Set();
357
+ const expectedByIdentity = new Map();
358
+ const expectedByRouteId = new Map();
359
+ for (const [index, expected] of metadata.expectedCaptures.entries()) {
360
+ if (
361
+ !expected ||
362
+ typeof expected !== 'object' ||
363
+ typeof expected.routeId !== 'string' ||
364
+ !expected.routeId ||
365
+ typeof expected.routePath !== 'string' ||
366
+ !expected.routePath ||
367
+ !selected.has(expected.routeId)
368
+ ) {
369
+ throw hostedDiffError(`expected capture metadata at index ${index} is invalid or not selected.`);
370
+ }
371
+ if (expectedRouteIds.has(expected.routeId)) {
372
+ throw hostedDiffError(`expected capture metadata contains duplicate route "${expected.routeId}".`);
373
+ }
374
+ expectedRouteIds.add(expected.routeId);
375
+ const viewportDescriptorJson = normalizeViewportDescriptor(expected.viewportDescriptorJson, `expected capture "${expected.routeId}"`, hostedDiffError);
376
+ const viewportDescriptor = JSON.parse(viewportDescriptorJson);
377
+ const expectedCapture = {
378
+ routeId: expected.routeId,
379
+ routePath: expected.routePath,
380
+ viewportDescriptorJson,
381
+ viewport: { width: viewportDescriptor.width, height: viewportDescriptor.height }
382
+ };
383
+ const identity = captureIdentityKey(expectedCapture, `expected capture "${expected.routeId}"`, hostedDiffError);
384
+ if (expectedByIdentity.has(identity)) {
385
+ throw hostedDiffError(`expected capture metadata contains duplicate route/viewport identity for "${expected.routeId}".`);
386
+ }
387
+ expectedByIdentity.set(identity, expectedCapture);
388
+ expectedByRouteId.set(expected.routeId, expectedCapture);
389
+ }
390
+
391
+ return {
392
+ runId: metadata.runId,
393
+ selectedRouteIds: [...selected],
394
+ expectedCaptures: [...expectedByIdentity.values()],
395
+ expectedByIdentity,
396
+ expectedByRouteId
397
+ };
398
+ }
399
+
107
400
  /** @param {string[]} configuredRouteIds @param {string[]} selectedRouteIds */
108
401
  function assertCompleteHostedBaselineSelection(configuredRouteIds, selectedRouteIds) {
109
402
  const selected = new Set(selectedRouteIds);
@@ -111,9 +404,9 @@ function assertCompleteHostedBaselineSelection(configuredRouteIds, selectedRoute
111
404
  if (missing.length === 0 && selected.size === configuredRouteIds.length) return;
112
405
  throw new Error(
113
406
  `Hosted Snap baseline publication requires all ${configuredRouteIds.length} configured route(s), ` +
114
- `but only ${selected.size} were selected. Remove route-ids/--routes scoping ` +
115
- `(missing: ${missing.join(', ') || 'unknown'}). Scoped runs remain supported for PR diffs ` +
116
- 'and local-provider baselines.'
407
+ `but only ${selected.size} were selected. Remove route-ids/--routes scoping ` +
408
+ `(missing: ${missing.join(', ') || 'unknown'}). Scoped runs remain supported for PR diffs ` +
409
+ 'and local-provider baselines.'
117
410
  );
118
411
  }
119
412
 
@@ -129,27 +422,18 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
129
422
  if (metadata.purpose !== 'baseline') {
130
423
  throw completeBaselineError('source results were not recorded as a baseline capture.');
131
424
  }
132
- if (typeof metadata.refBranch !== 'string' || !metadata.refBranch ||
133
- typeof metadata.refSha !== 'string' || !metadata.refSha) {
425
+ if (typeof metadata.refBranch !== 'string' || !metadata.refBranch || typeof metadata.refSha !== 'string' || !metadata.refSha) {
134
426
  throw completeBaselineError('source results have no resolved ref; capture again with SnapDrift 0.8.2 or newer.');
135
427
  }
136
- if (
137
- typeof metadata.publicationSequence !== 'number' ||
138
- !Number.isSafeInteger(metadata.publicationSequence) ||
139
- metadata.publicationSequence <= 0
140
- ) {
141
- throw completeBaselineError(
142
- 'source results have no valid publication sequence; capture again with SnapDrift 0.8.2 or newer.'
143
- );
428
+ if (typeof metadata.publicationSequence !== 'number' || !Number.isSafeInteger(metadata.publicationSequence) || metadata.publicationSequence <= 0) {
429
+ throw completeBaselineError('source results have no valid publication sequence; capture again with SnapDrift 0.8.2 or newer.');
144
430
  }
145
431
  if (typeof metadata.publicationWorkflowRef !== 'string' || !metadata.publicationWorkflowRef.trim()) {
146
- throw completeBaselineError(
147
- 'source results have no publication workflow identity; capture again with SnapDrift 0.8.2 or newer.'
148
- );
432
+ throw completeBaselineError('source results have no publication workflow identity; capture again with SnapDrift 0.8.2 or newer.');
149
433
  }
150
434
 
151
- const configured = uniqueRouteIdSet(metadata.configuredRouteIds, 'configuredRouteIds');
152
- const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds');
435
+ const configured = uniqueRouteIdSet(metadata.configuredRouteIds, 'configuredRouteIds', completeBaselineError);
436
+ const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds', completeBaselineError);
153
437
  assertCompleteHostedBaselineSelection([...configured], [...selected]);
154
438
  if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length !== configured.size) {
155
439
  throw completeBaselineError(`expected capture metadata does not match ${configured.size} configured route(s).`);
@@ -158,15 +442,17 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
158
442
  const expectedRoutes = new Set();
159
443
  const expectedByIdentity = new Map();
160
444
  for (const expected of metadata.expectedCaptures) {
161
- if (!expected || typeof expected !== 'object' || typeof expected.routePath !== 'string' ||
162
- !configured.has(expected.routeId) || expectedRoutes.has(expected.routeId)) {
445
+ if (
446
+ !expected ||
447
+ typeof expected !== 'object' ||
448
+ typeof expected.routePath !== 'string' ||
449
+ !configured.has(expected.routeId) ||
450
+ expectedRoutes.has(expected.routeId)
451
+ ) {
163
452
  throw completeBaselineError(`expected capture metadata has duplicate or extra route "${expected?.routeId || 'unknown'}".`);
164
453
  }
165
454
  expectedRoutes.add(expected.routeId);
166
- expectedByIdentity.set(
167
- captureIdentityKey(expected, `expected capture "${expected.routeId}"`),
168
- expected
169
- );
455
+ expectedByIdentity.set(captureIdentityKey(expected, `expected capture "${expected.routeId}"`, completeBaselineError), expected);
170
456
  }
171
457
 
172
458
  if (!Array.isArray(run.captures)) throw completeBaselineError('source run has no captures array.');
@@ -179,7 +465,7 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
179
465
  if (!capture || typeof capture !== 'object') {
180
466
  throw completeBaselineError('source run contains a malformed capture.');
181
467
  }
182
- const identity = captureIdentityKey(capture, `source capture "${capture.routeId || capture.id || 'unknown'}"`);
468
+ const identity = captureIdentityKey(capture, `source capture "${capture.routeId || capture.id || 'unknown'}"`, completeBaselineError);
183
469
  if (seen.has(identity)) {
184
470
  throw completeBaselineError(`duplicate source route/viewport identity for "${capture.routeId}".`);
185
471
  }
@@ -206,8 +492,8 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
206
492
  });
207
493
 
208
494
  return routes.sort((left, right) => {
209
- const leftKey = captureIdentityKey(left, `route "${left.routeId}"`);
210
- const rightKey = captureIdentityKey(right, `route "${right.routeId}"`);
495
+ const leftKey = captureIdentityKey(left, `route "${left.routeId}"`, completeBaselineError);
496
+ const rightKey = captureIdentityKey(right, `route "${right.routeId}"`, completeBaselineError);
211
497
  return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
212
498
  });
213
499
  }
@@ -283,11 +569,7 @@ function gitOutput(args) {
283
569
  export function resolveGitRef() {
284
570
  const branch = gitOutput(['rev-parse', '--abbrev-ref', 'HEAD']);
285
571
  return {
286
- refBranch:
287
- process.env.GITHUB_REF_NAME ||
288
- process.env.GITHUB_HEAD_REF ||
289
- (branch && branch !== 'HEAD' ? branch : undefined) ||
290
- 'main',
572
+ refBranch: process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || (branch && branch !== 'HEAD' ? branch : undefined) || 'main',
291
573
  refSha: process.env.GITHUB_SHA || gitOutput(['rev-parse', 'HEAD']) || 'unknown'
292
574
  };
293
575
  }
@@ -296,8 +578,7 @@ function resolveHostedBaselinePublicationRef() {
296
578
  const gitRef = resolveGitRef();
297
579
  const refBranch = process.env.GITHUB_REF_NAME || gitRef.refBranch;
298
580
  const refSha = process.env.GITHUB_SHA || gitRef.refSha;
299
- const publicationWorkflowRef =
300
- process.env.SNAPDRIFT_PUBLICATION_WORKFLOW_REF || process.env.GITHUB_WORKFLOW_REF;
581
+ const publicationWorkflowRef = process.env.SNAPDRIFT_PUBLICATION_WORKFLOW_REF || process.env.GITHUB_WORKFLOW_REF;
301
582
  const sequenceValue = process.env.SNAPDRIFT_PUBLICATION_SEQUENCE || process.env.GITHUB_RUN_NUMBER;
302
583
  const publicationSequence = Number(sequenceValue);
303
584
  if (
@@ -310,10 +591,10 @@ function resolveHostedBaselinePublicationRef() {
310
591
  ) {
311
592
  throw new Error(
312
593
  'Hosted Snap baseline publication requires a branch, a resolved 40-character commit SHA, ' +
313
- 'a publication workflow identity, and a positive publication sequence. GitHub Actions ' +
314
- 'provides these as GITHUB_REF_NAME/GITHUB_SHA/GITHUB_WORKFLOW_REF/GITHUB_RUN_NUMBER; ' +
315
- 'other CI systems may use SNAPDRIFT_PUBLICATION_WORKFLOW_REF and ' +
316
- 'SNAPDRIFT_PUBLICATION_SEQUENCE.'
594
+ 'a publication workflow identity, and a positive publication sequence. GitHub Actions ' +
595
+ 'provides these as GITHUB_REF_NAME/GITHUB_SHA/GITHUB_WORKFLOW_REF/GITHUB_RUN_NUMBER; ' +
596
+ 'other CI systems may use SNAPDRIFT_PUBLICATION_WORKFLOW_REF and ' +
597
+ 'SNAPDRIFT_PUBLICATION_SEQUENCE.'
317
598
  );
318
599
  }
319
600
  return { refBranch, refSha, publicationWorkflowRef, publicationSequence };
@@ -336,23 +617,6 @@ function getPackageVersion() {
336
617
  const TAR_BLOCK_SIZE = 512;
337
618
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
338
619
 
339
- /**
340
- * Strips characters that could cause path traversal or produce invalid
341
- * filenames when a route id is used as a screenshot filename. Mirrors the
342
- * sanitisation applied by `@snapdrift/adapter-fs` during local capture so
343
- * imported baselines land on the same filenames a local capture would produce.
344
- *
345
- * @param {string} id
346
- * @returns {string}
347
- */
348
- function sanitizeRouteId(id) {
349
- return id
350
- .replace(/\.\./g, '_')
351
- .replace(/[/\\]/g, '_')
352
- // eslint-disable-next-line no-control-regex
353
- .replace(/[\x00-\x1f\x7f]/g, '');
354
- }
355
-
356
620
  /**
357
621
  * Parse a plain ustar-style tar archive (512-byte headers, bodies padded to
358
622
  * 512-byte blocks, terminated by zero blocks) into a map of entry name →
@@ -457,22 +721,6 @@ function resolveExportEngine(captureProfile) {
457
721
  return { name: 'snap-hosted', version: 'unknown' };
458
722
  }
459
723
 
460
- /**
461
- * Extract a human-readable detail string from a Snap API error response.
462
- *
463
- * @param {{ text: () => Promise<string> }} response
464
- * @returns {Promise<string>}
465
- */
466
- async function readErrorDetail(response) {
467
- const errorBody = await response.text().catch(() => '');
468
- try {
469
- const parsed = JSON.parse(errorBody);
470
- return parsed.error || parsed.message || '';
471
- } catch {
472
- return '';
473
- }
474
- }
475
-
476
724
  /**
477
725
  * SnapProvider — VisualProvider that delegates capture + diff to Snap's
478
726
  * hosted /v1/visual/* API.
@@ -484,8 +732,10 @@ export class SnapProvider {
484
732
  #snapConfig;
485
733
  /** @type {typeof globalThis.fetch} */
486
734
  #fetchFn;
487
- /** @type {(ms: number) => Promise<void>} */
735
+ /** @type {(ms: number, signal?: AbortSignal) => Promise<void>} */
488
736
  #sleepFn;
737
+ /** @type {() => number} */
738
+ #nowFn;
489
739
  /** @type {(options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>} */
490
740
  #localCaptureFn;
491
741
  /** @type {string} */
@@ -499,14 +749,30 @@ export class SnapProvider {
499
749
  * @param {SnapConfig} snapConfig — validated snap section from config
500
750
  * @param {{
501
751
  * fetchFn?: typeof globalThis.fetch,
502
- * sleepFn?: (ms: number) => Promise<void>,
752
+ * sleepFn?: (ms: number, signal?: AbortSignal) => Promise<void>,
753
+ * nowFn?: () => number,
503
754
  * localCaptureFn?: (options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>
504
755
  * }} [options]
505
756
  */
506
757
  constructor(snapConfig, options = {}) {
507
758
  this.#snapConfig = snapConfig;
508
759
  this.#fetchFn = options.fetchFn ?? globalThis.fetch;
509
- this.#sleepFn = options.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
760
+ this.#sleepFn =
761
+ options.sleepFn ??
762
+ ((ms, signal) =>
763
+ new Promise((resolve, reject) => {
764
+ const timer = setTimeout(() => {
765
+ signal?.removeEventListener('abort', onAbort);
766
+ resolve();
767
+ }, ms);
768
+ const onAbort = () => {
769
+ clearTimeout(timer);
770
+ signal?.removeEventListener('abort', onAbort);
771
+ reject(signal?.reason ?? new Error('Sleep aborted.'));
772
+ };
773
+ signal?.addEventListener('abort', onAbort, { once: true });
774
+ }));
775
+ this.#nowFn = options.nowFn ?? monotonicNow;
510
776
  this.#localCaptureFn = options.localCaptureFn ?? runBaselineCapture;
511
777
  this.#apiUrl = (snapConfig.apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
512
778
  this.#apiKey = this.#resolveApiKey(snapConfig);
@@ -529,9 +795,7 @@ export class SnapProvider {
529
795
  */
530
796
  async capture(options) {
531
797
  const { config } = await loadSnapdriftConfig(options.configPath);
532
- const requestedRouteIds = options.routeIds
533
- ? [...options.routeIds]
534
- : splitCommaList(process.env.SNAPDRIFT_ROUTE_IDS);
798
+ const requestedRouteIds = options.routeIds ? [...options.routeIds] : splitCommaList(process.env.SNAPDRIFT_ROUTE_IDS);
535
799
  const { routes, selectedRouteIds } = selectConfiguredRoutes(config, requestedRouteIds);
536
800
  const purpose = options.purpose ?? 'diff';
537
801
  const configuredRouteIds = config.routes.map((route) => route.id);
@@ -542,9 +806,7 @@ export class SnapProvider {
542
806
  }
543
807
 
544
808
  const baselineRef = purpose === 'baseline' ? resolveHostedBaselinePublicationRef() : undefined;
545
- const runContext = baselineRef
546
- ? { branch: baselineRef.refBranch, prHeadSha: baselineRef.refSha }
547
- : this.#gitRunContext();
809
+ const runContext = baselineRef ? { branch: baselineRef.refBranch, prHeadSha: baselineRef.refSha } : this.#gitRunContext();
548
810
 
549
811
  if (isLocalBaseUrl(config.baseUrl)) {
550
812
  return this.#captureLocalAndUpload({
@@ -558,12 +820,14 @@ export class SnapProvider {
558
820
  configuredRouteIds,
559
821
  expectedCaptures,
560
822
  runContext,
561
- baselineRef
823
+ baselineRef,
824
+ comparisonPolicy: resolveComparisonPolicy(config)
562
825
  });
563
826
  }
564
827
 
565
828
  const idempotencyKey = this.#generateIdempotencyKey();
566
829
  const runId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
830
+ const operation = this.#createOperationDeadline('capture');
567
831
 
568
832
  // For a diff run, resolve the latest accepted baseline so the Snap backend
569
833
  // can attach it to this run's captures. Without a baselineId the server has
@@ -575,7 +839,7 @@ export class SnapProvider {
575
839
  // Attaching one would make the backend diff the fresh captures against the
576
840
  // prior baseline — wasteful at best, and on a dimension mismatch it errors
577
841
  // the run and fails the publish.
578
- const baselineId = purpose === 'diff' ? await this.#resolveLatestBaselineId() : null;
842
+ const baselineId = purpose === 'diff' ? await this.#resolveLatestBaselineId(operation) : null;
579
843
 
580
844
  // We intentionally do NOT send captureProfileJson on run creation. SnapDrift
581
845
  // submits routes for server-side rendering, so the render environment
@@ -586,46 +850,61 @@ export class SnapProvider {
586
850
  // profile does not populate — which crashes the server with a 500. The
587
851
  // render worker already defaults locale/timezone/viewport when no profile
588
852
  // is present, so omitting it here is behaviour-preserving.
589
- await this.#request('POST', `/v1/visual/projects/${this.#projectId}/runs`, {
590
- id: runId,
591
- baseUrl: config.baseUrl,
592
- trigger: 'ci',
593
- ...(baselineId ? { baselineId } : {}),
594
- // A baseline run must not be diffed. Suppress the server's
595
- // auto-resolve-by-branch so no prior baseline is attached.
596
- ...(purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
597
- capturePlan: {
598
- purpose: purpose === 'baseline' ? 'baseline' : 'diff',
599
- ...(baselineRef
600
- ? {
601
- publicationWorkflowRef: baselineRef.publicationWorkflowRef,
602
- publicationSequence: baselineRef.publicationSequence
603
- }
604
- : {}),
605
- configuredRouteIds,
606
- selectedRouteIds,
607
- expectedCaptures
853
+ const comparisonPolicy = resolveComparisonPolicy(config);
854
+ const runResponse = await this.#request(
855
+ 'POST',
856
+ `/v1/visual/projects/${this.#projectId}/runs`,
857
+ {
858
+ id: runId,
859
+ baseUrl: config.baseUrl,
860
+ trigger: 'ci',
861
+ ...(baselineId ? { baselineId } : {}),
862
+ // A baseline run must not be diffed. Suppress the server's
863
+ // auto-resolve-by-branch so no prior baseline is attached.
864
+ ...(purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
865
+ capturePlan: {
866
+ purpose: purpose === 'baseline' ? 'baseline' : 'diff',
867
+ ...(baselineRef
868
+ ? {
869
+ publicationWorkflowRef: baselineRef.publicationWorkflowRef,
870
+ publicationSequence: baselineRef.publicationSequence
871
+ }
872
+ : {}),
873
+ configuredRouteIds,
874
+ selectedRouteIds,
875
+ expectedCaptures
876
+ },
877
+ ...runContext,
878
+ ...(comparisonPolicy ? { comparisonPolicy } : {})
608
879
  },
609
- ...runContext,
610
- }, idempotencyKey);
880
+ idempotencyKey,
881
+ operation
882
+ );
883
+ if (comparisonPolicy) {
884
+ assertComparisonPolicyAcknowledged(runResponse?.comparisonPolicy, comparisonPolicy, 'Snap run creation');
885
+ }
611
886
 
612
887
  // Submit each route as a capture with its own idempotency key
613
888
  for (const route of routes) {
614
889
  const captureKey = this.#generateIdempotencyKey();
615
890
  const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
616
891
  const viewportDescriptor = resolveViewportDescriptor(route.viewport);
617
- await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
618
- id: captureId,
619
- routeId: route.id,
620
- routePath: route.path,
621
- viewportDescriptorJson: JSON.stringify(viewportDescriptor),
622
- }, captureKey);
892
+ await this.#request(
893
+ 'POST',
894
+ `/v1/visual/runs/${runId}/captures`,
895
+ {
896
+ id: captureId,
897
+ routeId: route.id,
898
+ routePath: route.path,
899
+ viewportDescriptorJson: JSON.stringify(viewportDescriptor)
900
+ },
901
+ captureKey,
902
+ operation
903
+ );
623
904
  }
624
905
 
625
906
  // Write run metadata to a temp file so diff() can thread the run_id
626
- const outDir = options.outDir
627
- ? path.resolve(options.outDir)
628
- : await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-'));
907
+ const outDir = options.outDir ? path.resolve(options.outDir) : await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-'));
629
908
 
630
909
  await fs.mkdir(outDir, { recursive: true });
631
910
 
@@ -637,6 +916,7 @@ export class SnapProvider {
637
916
  runId,
638
917
  projectId: this.#projectId,
639
918
  purpose,
919
+ ...(comparisonPolicy ? { comparisonPolicy } : {}),
640
920
  ...(baselineRef ? baselineRef : {}),
641
921
  startedAt: new Date().toISOString(),
642
922
  configuredRouteIds,
@@ -645,18 +925,25 @@ export class SnapProvider {
645
925
  };
646
926
  await Promise.all([
647
927
  fs.writeFile(resultsPath, JSON.stringify(runMetadata, null, 2)),
648
- fs.writeFile(manifestPath, JSON.stringify({
649
- generatedAt: new Date().toISOString(),
650
- baseUrl: config.baseUrl,
651
- screenshots: routes.map((r) => ({
652
- id: r.id,
653
- path: r.path,
654
- viewport: r.viewport,
655
- imagePath: `screenshots/${r.id}.png`,
656
- width: 0,
657
- height: 0
658
- }))
659
- }, null, 2))
928
+ fs.writeFile(
929
+ manifestPath,
930
+ JSON.stringify(
931
+ {
932
+ generatedAt: new Date().toISOString(),
933
+ baseUrl: config.baseUrl,
934
+ screenshots: routes.map((r) => ({
935
+ id: r.id,
936
+ path: r.path,
937
+ viewport: r.viewport,
938
+ imagePath: `screenshots/${sanitizeRouteId(r.id)}.png`,
939
+ width: 0,
940
+ height: 0
941
+ }))
942
+ },
943
+ null,
944
+ 2
945
+ )
946
+ )
660
947
  ]);
661
948
 
662
949
  return {
@@ -680,14 +967,45 @@ export class SnapProvider {
680
967
  throw new Error('SnapProvider.diff requires currentResultsPath from a prior capture() call.');
681
968
  }
682
969
 
683
- const metadata = JSON.parse(await fs.readFile(currentResultsPath, 'utf-8'));
684
- const runId = metadata.runId;
970
+ let metadata;
971
+ let metadataSource;
972
+ try {
973
+ metadataSource = await fs.readFile(currentResultsPath, 'utf-8');
974
+ } catch (error) {
975
+ throw hostedDiffError(
976
+ `could not read capture results metadata at "${currentResultsPath}". ` + `${error instanceof Error ? error.message : String(error)}`
977
+ );
978
+ }
979
+ try {
980
+ metadata = JSON.parse(metadataSource);
981
+ } catch (error) {
982
+ throw hostedDiffError(
983
+ `capture results metadata is not valid JSON; recapture with SnapDrift 0.8.2 or newer. ` + `${error instanceof Error ? error.message : String(error)}`
984
+ );
985
+ }
986
+ const { config } = await loadSnapdriftConfig(options.configPath);
987
+ const diffPlan = validateHostedDiffMetadata(metadata, this.#projectId);
988
+ /** @type {ComparisonPolicy | undefined} */
989
+ let comparisonPolicy;
990
+ if (metadata.comparisonPolicy !== undefined) {
991
+ if (!isComparisonPolicy(metadata.comparisonPolicy)) {
992
+ throw hostedDiffError('capture results metadata has an invalid comparison policy; recapture with the updated SnapDrift client.');
993
+ }
994
+ comparisonPolicy = { ...metadata.comparisonPolicy };
995
+ }
685
996
 
686
997
  // Poll until the run reaches a terminal state
687
- const run = await this.#pollRun(runId);
998
+ const operation = this.#createOperationDeadline('diff');
999
+ const run = await this.#pollRun(diffPlan.runId, diffPlan.expectedCaptures.length, hostedDiffError, operation);
1000
+ if (!run || typeof run !== 'object' || run.id !== diffPlan.runId) {
1001
+ throw hostedDiffError(`Snap returned run "${run?.id || 'unknown'}" for requested run "${diffPlan.runId}".`);
1002
+ }
688
1003
 
689
- const { config } = await loadSnapdriftConfig(options.configPath);
690
- const summary = this.#mapRunToSummary(run, config);
1004
+ if (comparisonPolicy) {
1005
+ assertComparisonPolicyAcknowledged(run.comparisonPolicy, comparisonPolicy, 'Snap run retrieval');
1006
+ }
1007
+
1008
+ const summary = this.#mapRunToSummary(run, config, diffPlan, comparisonPolicy);
691
1009
 
692
1010
  return {
693
1011
  summary,
@@ -707,8 +1025,7 @@ export class SnapProvider {
707
1025
  * @returns {Promise<PublishBaselineResult>}
708
1026
  */
709
1027
  async publishBaseline(options) {
710
- const resultsPath = options.resultsPath
711
- ?? (options.bundleDir ? path.join(path.resolve(options.bundleDir), 'results.json') : undefined);
1028
+ const resultsPath = options.resultsPath ?? (options.bundleDir ? path.join(path.resolve(options.bundleDir), 'results.json') : undefined);
712
1029
 
713
1030
  // Snap-native baseline: poll the run that capture() submitted, then
714
1031
  // create a baseline from the rendered S3 keys.
@@ -732,31 +1049,28 @@ export class SnapProvider {
732
1049
  // and surfacing its body-shape error. See #109.
733
1050
  throw new Error(
734
1051
  `Cannot publish a Snap baseline: no run id found in ${resultsPath}. ` +
735
- 'A baseline is created from the object keys of a completed Snap run, so the capture step ' +
736
- 'must have recorded one. Run `snapdrift baseline` (or actions/baseline) with ' +
737
- `provider "snap".${readError ? ` Could not read that file: ${readError instanceof Error ? readError.message : String(readError)}` : ''}`
1052
+ 'A baseline is created from the object keys of a completed Snap run, so the capture step ' +
1053
+ 'must have recorded one. Run `snapdrift baseline` (or actions/baseline) with ' +
1054
+ `provider "snap".${readError ? ` Could not read that file: ${readError instanceof Error ? readError.message : String(readError)}` : ''}`
738
1055
  );
739
1056
  }
740
1057
 
741
1058
  {
742
1059
  const { runId } = metadata;
743
1060
  const idempotencyKey = `baseline-${runId}`;
1061
+ const operation = this.#createOperationDeadline('baseline publication');
744
1062
 
745
1063
  if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length === 0) {
746
- throw completeBaselineError(
747
- 'results metadata has no expected captures; capture again with SnapDrift 0.8.2 or newer.'
748
- );
1064
+ throw completeBaselineError('results metadata has no expected captures; capture again with SnapDrift 0.8.2 or newer.');
749
1065
  }
750
1066
 
751
1067
  // Older Snap servers could mark a baseline run terminal after the first
752
1068
  // submitted capture. Keep polling until the complete planned capture set
753
1069
  // is present and terminal so a mixed-version rollout remains fail-closed.
754
- const run = await this.#pollRun(runId, metadata.expectedCaptures.length);
1070
+ const run = await this.#pollRun(runId, metadata.expectedCaptures.length, undefined, operation);
755
1071
 
756
1072
  if (run.id && run.id !== runId) {
757
- throw new Error(
758
- `Cannot publish a complete Snap baseline: requested source run ${runId}, but Snap returned ${run.id}.`
759
- );
1073
+ throw new Error(`Cannot publish a complete Snap baseline: requested source run ${runId}, but Snap returned ${run.id}.`);
760
1074
  }
761
1075
 
762
1076
  // Hosted baseline runs intentionally use `new` because they omit a
@@ -767,8 +1081,8 @@ export class SnapProvider {
767
1081
  if (run.status !== BASELINE_RUN_SUCCESS_STATUS) {
768
1082
  throw new Error(
769
1083
  `Snap baseline run ${runId} ended with status "${run.status || 'unknown'}"; ` +
770
- `complete baseline publication requires "${BASELINE_RUN_SUCCESS_STATUS}". ` +
771
- 'Check the Snap dashboard for capture error details.'
1084
+ `complete baseline publication requires "${BASELINE_RUN_SUCCESS_STATUS}". ` +
1085
+ 'Check the Snap dashboard for capture error details.'
772
1086
  );
773
1087
  }
774
1088
 
@@ -794,7 +1108,8 @@ export class SnapProvider {
794
1108
  manifestJson,
795
1109
  captureProfileJson: JSON.stringify(this.#buildCaptureProfile())
796
1110
  },
797
- idempotencyKey
1111
+ idempotencyKey,
1112
+ operation
798
1113
  );
799
1114
 
800
1115
  const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
@@ -805,8 +1120,8 @@ export class SnapProvider {
805
1120
  // No resultsPath and no bundleDir: there is no run to build a baseline from.
806
1121
  throw new Error(
807
1122
  'Cannot publish a Snap baseline: neither resultsPath nor bundleDir was provided, so there ' +
808
- 'is no Snap run to harvest object keys from. Run `snapdrift baseline` (or actions/baseline) ' +
809
- 'with provider "snap", which captures first and passes the run results through.'
1123
+ 'is no Snap run to harvest object keys from. Run `snapdrift baseline` (or actions/baseline) ' +
1124
+ 'with provider "snap", which captures first and passes the run results through.'
810
1125
  );
811
1126
  }
812
1127
 
@@ -817,8 +1132,9 @@ export class SnapProvider {
817
1132
  * @returns {Promise<BaselineData | null>}
818
1133
  */
819
1134
  async fetchLatestBaseline(_options) {
1135
+ const operation = this.#createOperationDeadline('baseline lookup');
820
1136
  try {
821
- const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`);
1137
+ const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`, undefined, undefined, operation);
822
1138
 
823
1139
  const runDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
824
1140
  const resultsPath = path.join(runDir, 'results.json');
@@ -855,7 +1171,7 @@ export class SnapProvider {
855
1171
  * Build a PR comment body from a diff summary.
856
1172
  * SnapProvider includes the dashboard URL from the summary when available.
857
1173
  *
858
- * @param {Record<string, unknown>} summary
1174
+ * @param {import('@snapdrift/manifest').VisualReportSummary} summary
859
1175
  * @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
860
1176
  * @returns {string}
861
1177
  */
@@ -875,8 +1191,15 @@ export class SnapProvider {
875
1191
  * @returns {Promise<object | null>} Baseline data if found, null on 404
876
1192
  */
877
1193
  async checkBaselineExists(headSha) {
1194
+ const operation = this.#createOperationDeadline('baseline existence check');
878
1195
  try {
879
- const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest?sha=${encodeURIComponent(headSha)}`);
1196
+ const baseline = await this.#request(
1197
+ 'GET',
1198
+ `/v1/visual/projects/${this.#projectId}/baselines/latest?sha=${encodeURIComponent(headSha)}`,
1199
+ undefined,
1200
+ undefined,
1201
+ operation
1202
+ );
880
1203
  return baseline;
881
1204
  } catch (error) {
882
1205
  if (error instanceof SnapApiError && error.status === 404) {
@@ -919,29 +1242,30 @@ export class SnapProvider {
919
1242
  */
920
1243
  async exportBaselines(_options) {
921
1244
  const exportPath = `/v1/visual/projects/${this.#projectId}/export`;
1245
+ const operation = this.#createOperationDeadline('baseline export');
922
1246
  let archive;
923
1247
  try {
924
- archive = await this.#requestBinary('GET', exportPath);
1248
+ archive = await this.#requestBinary('GET', exportPath, operation);
925
1249
  } catch (error) {
926
1250
  if (error instanceof SnapApiError) {
927
1251
  if (error.status === 403) {
928
1252
  throw new Error(
929
1253
  `Snap refused the export (403): the API key does not have the "visual:export" scope. ` +
930
- `Issue a key with visual:export access for project "${this.#projectId}" and retry.`,
1254
+ `Issue a key with visual:export access for project "${this.#projectId}" and retry.`,
931
1255
  { cause: error }
932
1256
  );
933
1257
  }
934
1258
  if (error.status === 404) {
935
1259
  throw new Error(
936
1260
  `Snap project "${this.#projectId}" was not found (404). ` +
937
- `Check snap.projectId in snapdrift.json (or GITHUB_REPOSITORY when projectId is "auto").`,
1261
+ `Check snap.projectId in snapdrift.json (or GITHUB_REPOSITORY when projectId is "auto").`,
938
1262
  { cause: error }
939
1263
  );
940
1264
  }
941
1265
  if (error.status === 413) {
942
1266
  throw new Error(
943
1267
  `Snap export for project "${this.#projectId}" is too large (413): ${error.message}. ` +
944
- `The export endpoint caps the number of accepted baselines and total archive bytes.`,
1268
+ `The export endpoint caps the number of accepted baselines and total archive bytes.`,
945
1269
  { cause: error }
946
1270
  );
947
1271
  }
@@ -958,8 +1282,7 @@ export class SnapProvider {
958
1282
  const baselines = Array.isArray(exportManifest.baselines) ? exportManifest.baselines : [];
959
1283
  if (baselines.length === 0) {
960
1284
  throw new Error(
961
- `Snap project "${this.#projectId}" has no accepted baselines to export. ` +
962
- `Publish a baseline first (snapdrift baseline with provider: "snap").`
1285
+ `Snap project "${this.#projectId}" has no accepted baselines to export. ` + `Publish a baseline first (snapdrift baseline with provider: "snap").`
963
1286
  );
964
1287
  }
965
1288
 
@@ -975,21 +1298,30 @@ export class SnapProvider {
975
1298
  if (!baseline.sourceManifest) {
976
1299
  throw new Error(
977
1300
  `Snap baseline "${baseline.id}" predates manifest tracking (no source manifest was recorded ` +
978
- `at publish time) and cannot be migrated. Publish a fresh baseline with a current SnapDrift ` +
979
- `version, then re-run the migration.`
1301
+ `at publish time) and cannot be migrated. Publish a fresh baseline with a current SnapDrift ` +
1302
+ `version, then re-run the migration.`
980
1303
  );
981
1304
  }
982
1305
  const routes = Array.isArray(baseline.sourceManifest.routes) ? baseline.sourceManifest.routes : null;
983
1306
  if (!routes) {
984
- throw new Error(
985
- `Snap baseline "${baseline.id}" has an unrecognised source manifest format ` +
986
- `(expected a "routes" array) and cannot be migrated.`
987
- );
1307
+ throw new Error(`Snap baseline "${baseline.id}" has an unrecognised source manifest format ` + `(expected a "routes" array) and cannot be migrated.`);
988
1308
  }
989
1309
 
990
- const archivePathByKey = new Map(
991
- (baseline.objects || []).map((object) => [object.sourceKey, object.archivePath])
992
- );
1310
+ const archivePathByKey = new Map((baseline.objects || []).map((object) => [object.sourceKey, object.archivePath]));
1311
+
1312
+ const sourceLabel = `Snap baseline "${baseline.id}" source manifest`;
1313
+ /** @type {Map<string, unknown[]>} */
1314
+ const routeIdsByExtension = new Map();
1315
+ for (const route of routes) {
1316
+ const archivePath = route && route.objectKey ? archivePathByKey.get(route.objectKey) : undefined;
1317
+ const extension = typeof archivePath === 'string' ? path.extname(archivePath) || '.png' : '.png';
1318
+ const routeIds = routeIdsByExtension.get(extension) || [];
1319
+ routeIds.push(route?.routeId);
1320
+ routeIdsByExtension.set(extension, routeIds);
1321
+ }
1322
+ for (const [extension, routeIds] of routeIdsByExtension) {
1323
+ assertUniqueRouteIdFilenames(routeIds, sourceLabel, extension);
1324
+ }
993
1325
 
994
1326
  /** @type {Array<{ filename: string, data: Buffer }>} */
995
1327
  const screenshots = [];
@@ -1001,8 +1333,7 @@ export class SnapProvider {
1001
1333
  const bytes = archivePath ? entries.get(archivePath) : undefined;
1002
1334
  if (!bytes) {
1003
1335
  process.stderr.write(
1004
- `[SnapDrift] Warning: Snap export has no image for route "${route.routeId}" ` +
1005
- `in baseline ${baseline.id}. Skipping the route.\n`
1336
+ `[SnapDrift] Warning: Snap export has no image for route "${route.routeId}" ` + `in baseline ${baseline.id}. Skipping the route.\n`
1006
1337
  );
1007
1338
  continue;
1008
1339
  }
@@ -1013,15 +1344,15 @@ export class SnapProvider {
1013
1344
  // exactly; otherwise keep custom dimensions. This round-trips with the
1014
1345
  // descriptor expansion capture() applies on the way up.
1015
1346
  const presetName = hasDescriptorDims ? viewportHash(descriptor) : null;
1016
- const dimensions = readPngDimensions(bytes)
1017
- ?? (hasDescriptorDims ? { width: descriptor.width, height: descriptor.height } : { width: 1, height: 1 });
1018
- const viewport = presetName && VIEWPORT_PRESETS[presetName]
1019
- ? presetName
1020
- : hasDescriptorDims
1021
- ? { width: descriptor.width, height: descriptor.height }
1022
- : { width: dimensions.width, height: dimensions.height };
1023
-
1024
- const extension = path.extname(archivePath) || '.png';
1347
+ const dimensions = readPngDimensions(bytes) ?? (hasDescriptorDims ? { width: descriptor.width, height: descriptor.height } : { width: 1, height: 1 });
1348
+ const viewport =
1349
+ presetName && VIEWPORT_PRESETS[presetName]
1350
+ ? presetName
1351
+ : hasDescriptorDims
1352
+ ? { width: descriptor.width, height: descriptor.height }
1353
+ : { width: dimensions.width, height: dimensions.height };
1354
+
1355
+ const extension = typeof archivePath === 'string' ? path.extname(archivePath) || '.png' : '.png';
1025
1356
  const filename = `${sanitizeRouteId(route.routeId)}${extension}`;
1026
1357
  screenshots.push({ filename, data: Buffer.from(bytes) });
1027
1358
  manifestEntries.push({
@@ -1035,15 +1366,10 @@ export class SnapProvider {
1035
1366
  }
1036
1367
 
1037
1368
  if (screenshots.length === 0) {
1038
- throw new Error(
1039
- `Snap baseline "${baseline.id}" contained no exportable screenshots — ` +
1040
- `the export archive had no image bytes for any of its routes.`
1041
- );
1369
+ throw new Error(`Snap baseline "${baseline.id}" contained no exportable screenshots — ` + `the export archive had no image bytes for any of its routes.`);
1042
1370
  }
1043
1371
 
1044
- const captureProfile = safeJsonParse(
1045
- entries.get(`${baseline.id}/capture_profile.json`)?.toString('utf-8')
1046
- ) ?? {};
1372
+ const captureProfile = safeJsonParse(entries.get(`${baseline.id}/capture_profile.json`)?.toString('utf-8')) ?? {};
1047
1373
  const engine = resolveExportEngine(captureProfile);
1048
1374
 
1049
1375
  const generatedAt = new Date().toISOString();
@@ -1124,7 +1450,7 @@ export class SnapProvider {
1124
1450
  if (!repo) {
1125
1451
  throw new Error(
1126
1452
  'Snap project ID is set to "auto" but GITHUB_REPOSITORY is not set. ' +
1127
- 'Set the GITHUB_REPOSITORY environment variable or provide an explicit snap.projectId.'
1453
+ 'Set the GITHUB_REPOSITORY environment variable or provide an explicit snap.projectId.'
1128
1454
  );
1129
1455
  }
1130
1456
  return repoSlugToProjectId(repo);
@@ -1153,9 +1479,9 @@ export class SnapProvider {
1153
1479
  *
1154
1480
  * @returns {Promise<string | null>}
1155
1481
  */
1156
- async #resolveLatestBaselineId() {
1482
+ async #resolveLatestBaselineId(operation = this.#createOperationDeadline('baseline lookup')) {
1157
1483
  try {
1158
- const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`);
1484
+ const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`, undefined, undefined, operation);
1159
1485
  return baseline?.id ?? null;
1160
1486
  } catch (error) {
1161
1487
  if (error instanceof SnapApiError && error.status === 404) {
@@ -1185,6 +1511,324 @@ export class SnapProvider {
1185
1511
  return crypto.randomUUID();
1186
1512
  }
1187
1513
 
1514
+ /** @param {string} [label] @returns {OperationDeadline} */
1515
+ #createOperationDeadline(label = 'Snap operation') {
1516
+ return { deadline: this.#nowFn() + MAX_OPERATION_DURATION_MS, label };
1517
+ }
1518
+
1519
+ /** @param {OperationDeadline} operation @returns {number} */
1520
+ #remainingOperationMs(operation) {
1521
+ return operation.deadline - this.#nowFn();
1522
+ }
1523
+
1524
+ /**
1525
+ * @param {string} requestPath
1526
+ * @param {OperationDeadline} operation
1527
+ * @returns {SnapUnavailableError}
1528
+ */
1529
+ #operationDeadlineError(requestPath, operation) {
1530
+ return new SnapUnavailableError(
1531
+ `Snap ${operation.label} operation deadline exceeded while requesting ${requestPath}; ` + `the ${OPERATION_LIMIT_TEXT} Snap operation limit was reached`
1532
+ );
1533
+ }
1534
+
1535
+ /**
1536
+ * @param {string} requestPath
1537
+ * @param {number} timeoutMs
1538
+ * @param {OperationDeadline} operation
1539
+ * @param {number} requestLimitMs
1540
+ * @returns {Error}
1541
+ */
1542
+ #requestTimeoutError(requestPath, timeoutMs, operation, requestLimitMs) {
1543
+ const error = new Error(
1544
+ `Snap ${operation.label} request ${requestPath} timed out after ` +
1545
+ `${Math.max(0, Math.round(timeoutMs))}ms (request limit ${requestLimitMs}ms; ` +
1546
+ `operation limit ${OPERATION_LIMIT_TEXT})`
1547
+ );
1548
+ error.name = 'SnapTransportTimeoutError';
1549
+ return error;
1550
+ }
1551
+
1552
+ /** @param {number} attempt @returns {number} */
1553
+ #retryDelay(attempt) {
1554
+ return Math.min(INITIAL_RETRY_DELAY_MS * RETRY_MULTIPLIER ** (attempt - 1), RETRY_DELAY_CAP_MS);
1555
+ }
1556
+
1557
+ /** @param {string} requestPath @param {number} delayMs @param {OperationDeadline} operation @returns {SnapUnavailableError} */
1558
+ #sleepTimeoutError(requestPath, delayMs, operation) {
1559
+ return new SnapUnavailableError(
1560
+ `Snap ${operation.label} wait for ${requestPath} timed out after ${delayMs}ms ` + `before the ${OPERATION_LIMIT_TEXT} operation deadline`
1561
+ );
1562
+ }
1563
+
1564
+ /**
1565
+ * Read and classify an HTTP error response under the current deadline.
1566
+ *
1567
+ * A received 4xx remains non-retryable even when its diagnostic body cannot
1568
+ * be read. Retryable 5xx body failures are returned to the transport loop so
1569
+ * they retain the normal availability semantics.
1570
+ *
1571
+ * @param {any} response
1572
+ * @param {string} requestPath
1573
+ * @param {AbortController} controller
1574
+ * @param {number} attemptDeadline
1575
+ * @param {OperationDeadline} operation
1576
+ * @param {number} requestLimitMs
1577
+ * @returns {Promise<SnapApiError>}
1578
+ */
1579
+ async #readApiError(response, requestPath, controller, attemptDeadline, operation, requestLimitMs) {
1580
+ let errorBody;
1581
+ try {
1582
+ errorBody = await this.#readResponseBody(response, requestPath, controller, attemptDeadline, operation, requestLimitMs, () => response.text());
1583
+ } catch (error) {
1584
+ if (response.status < 400 || response.status >= 500) throw error;
1585
+ const detail = error instanceof Error ? error.message : String(error);
1586
+ const reason =
1587
+ error instanceof SnapUnavailableError || error?.name === 'SnapTransportTimeoutError'
1588
+ ? `diagnostic body timed out (${detail})`
1589
+ : `diagnostic body unavailable (${detail})`;
1590
+ return new SnapApiError(response.status, `Snap API ${response.status}: ${reason}`, requestPath);
1591
+ }
1592
+
1593
+ let detail = '';
1594
+ try {
1595
+ const parsed = JSON.parse(errorBody);
1596
+ detail = parsed?.error || parsed?.message || '';
1597
+ } catch {
1598
+ /* use default message */
1599
+ }
1600
+ return new SnapApiError(response.status, detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`, requestPath);
1601
+ }
1602
+
1603
+ /**
1604
+ * Read a response body under the current attempt and operation deadlines.
1605
+ *
1606
+ * @template T
1607
+ * @param {any} response
1608
+ * @param {string} requestPath
1609
+ * @param {AbortController} controller
1610
+ * @param {number} attemptDeadline
1611
+ * @param {OperationDeadline} operation
1612
+ * @param {number} requestLimitMs
1613
+ * @param {() => Promise<T>} reader
1614
+ * @returns {Promise<T>}
1615
+ */
1616
+ async #readResponseBody(response, requestPath, controller, attemptDeadline, operation, requestLimitMs, reader) {
1617
+ const now = this.#nowFn();
1618
+ const attemptRemaining = attemptDeadline - now;
1619
+ const operationRemaining = this.#remainingOperationMs(operation);
1620
+ if (operationRemaining <= 0) {
1621
+ controller.abort();
1622
+ cancelResponseBody(response);
1623
+ throw this.#operationDeadlineError(requestPath, operation);
1624
+ }
1625
+ if (attemptRemaining <= 0) {
1626
+ controller.abort();
1627
+ cancelResponseBody(response);
1628
+ throw this.#requestTimeoutError(requestPath, requestLimitMs, operation, requestLimitMs);
1629
+ }
1630
+ const remaining = Math.min(attemptRemaining, operationRemaining);
1631
+ return runWithTimeout(
1632
+ reader,
1633
+ remaining,
1634
+ () => {
1635
+ controller.abort();
1636
+ cancelResponseBody(response);
1637
+ },
1638
+ () =>
1639
+ this.#remainingOperationMs(operation) <= 0
1640
+ ? this.#operationDeadlineError(requestPath, operation)
1641
+ : this.#requestTimeoutError(requestPath, remaining, operation, requestLimitMs)
1642
+ );
1643
+ }
1644
+
1645
+ /**
1646
+ * Sleep for a retry or poll interval without exceeding the operation budget.
1647
+ *
1648
+ * @param {number} delayMs
1649
+ * @param {OperationDeadline} operation
1650
+ * @param {string} requestPath
1651
+ * @returns {Promise<void>}
1652
+ */
1653
+ async #sleepWithinOperation(delayMs, operation, requestPath) {
1654
+ const remaining = this.#remainingOperationMs(operation);
1655
+ if (remaining <= 0) {
1656
+ throw this.#operationDeadlineError(requestPath, operation);
1657
+ }
1658
+ const delay = Math.min(delayMs, remaining);
1659
+ const controller = new AbortController();
1660
+ await runWithTimeout(
1661
+ () => this.#sleepFn(delay, controller.signal),
1662
+ // Keep a one-millisecond watchdog margin so a sleep timer scheduled by
1663
+ // an async injected sleepFn can settle before the watchdog at the same
1664
+ // nominal deadline. The signal aborts the default timer on timeout.
1665
+ delay + 1,
1666
+ () => controller.abort(),
1667
+ () => this.#sleepTimeoutError(requestPath, delay, operation)
1668
+ );
1669
+ }
1670
+
1671
+ /**
1672
+ * Shared JSON/binary transport with retries and bounded request bodies.
1673
+ *
1674
+ * @param {{
1675
+ * method: string,
1676
+ * requestPath: string,
1677
+ * body?: object,
1678
+ * idempotencyKey?: string,
1679
+ * contentType?: string,
1680
+ * accept?: string,
1681
+ * operation: OperationDeadline,
1682
+ * requestTimeoutMs: number,
1683
+ * decode: (response: any, controller: AbortController, attemptDeadline: number, operation: OperationDeadline, requestLimitMs: number) => Promise<any>
1684
+ * }} options
1685
+ * @returns {Promise<any>}
1686
+ */
1687
+ async #requestTransport(options) {
1688
+ const serializedBody = options.body ? JSON.stringify(options.body) : undefined;
1689
+ const requestHeaders = {
1690
+ Authorization: `Bearer ${this.#apiKey}`
1691
+ };
1692
+ if (options.accept) {
1693
+ requestHeaders['Accept'] = options.accept;
1694
+ }
1695
+ if (options.contentType) {
1696
+ requestHeaders['Content-Type'] = options.contentType;
1697
+ }
1698
+ if (options.idempotencyKey && options.method === 'POST') {
1699
+ requestHeaders['Idempotency-Key'] = options.idempotencyKey;
1700
+ }
1701
+
1702
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
1703
+ const remaining = this.#remainingOperationMs(options.operation);
1704
+ if (remaining <= 0) {
1705
+ return this.#handleUnavailable(this.#operationDeadlineError(options.requestPath, options.operation));
1706
+ }
1707
+
1708
+ const attemptBudget = Math.min(options.requestTimeoutMs, remaining);
1709
+ const attemptDeadline = this.#nowFn() + attemptBudget;
1710
+ const controller = new AbortController();
1711
+ try {
1712
+ const response = await runWithTimeout(
1713
+ () =>
1714
+ this.#fetchFn(`${this.#apiUrl}${options.requestPath}`, {
1715
+ method: options.method,
1716
+ headers: { ...requestHeaders },
1717
+ body: serializedBody,
1718
+ signal: controller.signal
1719
+ }),
1720
+ attemptBudget,
1721
+ () => controller.abort(),
1722
+ () => this.#requestTimeoutError(options.requestPath, attemptBudget, options.operation, options.requestTimeoutMs)
1723
+ );
1724
+
1725
+ if (response.ok) {
1726
+ return await options.decode(response, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
1727
+ }
1728
+
1729
+ // 4xx — non-retryable when the diagnostic body is available. A body
1730
+ // read timeout still preserves the received HTTP status, rather than
1731
+ // turning an already-classified API response into an availability retry.
1732
+ if (response.status >= 400 && response.status < 500) {
1733
+ throw await this.#readApiError(response, options.requestPath, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
1734
+ }
1735
+
1736
+ // 5xx — retry with backoff unless this is the last attempt. Cancel
1737
+ // discarded response bodies so a slow server cannot retain a stream.
1738
+ if (attempt < MAX_RETRIES) {
1739
+ cancelResponseBody(response);
1740
+ await this.#sleepWithinOperation(this.#retryDelay(attempt), options.operation, options.requestPath);
1741
+ continue;
1742
+ }
1743
+
1744
+ throw await this.#readApiError(response, options.requestPath, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
1745
+ } catch (error) {
1746
+ if (error instanceof SnapSkipError || error instanceof SnapFallbackError) {
1747
+ throw error;
1748
+ }
1749
+ if (error instanceof SnapApiError && error.status >= 400 && error.status < 500) {
1750
+ throw error;
1751
+ }
1752
+ if (error instanceof SnapApiError && error.status >= 500) {
1753
+ return this.#handleUnavailable(error);
1754
+ }
1755
+ if (error instanceof SnapUnavailableError) {
1756
+ return this.#handleUnavailable(error);
1757
+ }
1758
+
1759
+ if (this.#remainingOperationMs(options.operation) <= 0) {
1760
+ return this.#handleUnavailable(this.#operationDeadlineError(options.requestPath, options.operation));
1761
+ }
1762
+
1763
+ if (attempt < MAX_RETRIES) {
1764
+ try {
1765
+ await this.#sleepWithinOperation(this.#retryDelay(attempt), options.operation, options.requestPath);
1766
+ } catch (retryError) {
1767
+ return this.#handleUnavailable(retryError instanceof Error ? retryError : new SnapUnavailableError(String(retryError)));
1768
+ }
1769
+ continue;
1770
+ }
1771
+
1772
+ const detail = error instanceof Error ? error.message : String(error);
1773
+ return this.#handleUnavailable(new SnapUnavailableError(`Snap request ${options.requestPath} failed after ${MAX_RETRIES} attempts: ${detail}`));
1774
+ } finally {
1775
+ controller.abort();
1776
+ }
1777
+ }
1778
+
1779
+ return this.#handleUnavailable(this.#operationDeadlineError(options.requestPath, options.operation));
1780
+ }
1781
+
1782
+ /**
1783
+ * Make a JSON request with retry and operation deadlines.
1784
+ *
1785
+ * @param {string} method
1786
+ * @param {string} requestPath
1787
+ * @param {object} [body]
1788
+ * @param {string} [idempotencyKey]
1789
+ * @param {OperationDeadline} [operation]
1790
+ * @returns {Promise<any>}
1791
+ */
1792
+ async #request(method, requestPath, body, idempotencyKey, operation = this.#createOperationDeadline('Snap request')) {
1793
+ return this.#requestTransport({
1794
+ method,
1795
+ requestPath,
1796
+ body,
1797
+ idempotencyKey,
1798
+ contentType: 'application/json',
1799
+ accept: 'application/json',
1800
+ operation,
1801
+ requestTimeoutMs: JSON_REQUEST_TIMEOUT_MS,
1802
+ decode: async (response, controller, attemptDeadline, requestOperation, requestLimitMs) => {
1803
+ const text = await this.#readResponseBody(response, requestPath, controller, attemptDeadline, requestOperation, requestLimitMs, () => response.text());
1804
+ return text ? JSON.parse(text) : {};
1805
+ }
1806
+ });
1807
+ }
1808
+
1809
+ /**
1810
+ * Make a binary request with retry and operation deadlines.
1811
+ *
1812
+ * @param {string} method
1813
+ * @param {string} requestPath
1814
+ * @param {OperationDeadline} [operation]
1815
+ * @returns {Promise<Buffer>}
1816
+ */
1817
+ async #requestBinary(method, requestPath, operation = this.#createOperationDeadline('binary export')) {
1818
+ return this.#requestTransport({
1819
+ method,
1820
+ requestPath,
1821
+ operation,
1822
+ requestTimeoutMs: BINARY_REQUEST_TIMEOUT_MS,
1823
+ decode: async (response, controller, attemptDeadline, requestOperation, requestLimitMs) => {
1824
+ const bytes = await this.#readResponseBody(response, requestPath, controller, attemptDeadline, requestOperation, requestLimitMs, () =>
1825
+ response.arrayBuffer()
1826
+ );
1827
+ return Buffer.from(bytes);
1828
+ }
1829
+ });
1830
+ }
1831
+
1188
1832
  /**
1189
1833
  * @param {{
1190
1834
  * configPath?: string,
@@ -1195,10 +1839,11 @@ export class SnapProvider {
1195
1839
  * selectedRouteIds: string[],
1196
1840
  * purpose: 'baseline' | 'capture' | 'diff',
1197
1841
  * configuredRouteIds: string[],
1198
- * expectedCaptures: import('../types/visual-diff-types').SnapExpectedCaptureIdentity[],
1199
- * runContext: { branch?: string, prHeadSha?: string },
1200
- * baselineRef?: { refBranch: string, refSha: string, publicationWorkflowRef: string, publicationSequence: number }
1201
- * }} options
1842
+ * expectedCaptures: import('../types/visual-diff-types').SnapExpectedCaptureIdentity[],
1843
+ * runContext: { branch?: string, prHeadSha?: string },
1844
+ * baselineRef?: { refBranch: string, refSha: string, publicationWorkflowRef: string, publicationSequence: number },
1845
+ * comparisonPolicy?: ComparisonPolicy
1846
+ * }} options
1202
1847
  * @returns {Promise<CaptureResult>}
1203
1848
  */
1204
1849
  async #captureLocalAndUpload(options) {
@@ -1226,59 +1871,82 @@ export class SnapProvider {
1226
1871
  });
1227
1872
 
1228
1873
  const runId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
1874
+ const operation = this.#createOperationDeadline('capture');
1229
1875
  // A baseline run establishes new ground truth and must not be diffed, so we
1230
1876
  // omit the baselineId. Captures then settle to "new" server-side (no
1231
1877
  // comparison), and publishBaseline harvests their object keys. A diff run
1232
1878
  // attaches the latest baseline so the backend can compare against it.
1233
- const baselineId = options.purpose === 'diff' ? await this.#resolveLatestBaselineId() : null;
1234
-
1235
- await this.#request('POST', `/v1/visual/projects/${this.#projectId}/runs`, {
1236
- id: runId,
1237
- baseUrl: options.config.baseUrl,
1238
- trigger: 'ci',
1239
- ...(baselineId ? { baselineId } : {}),
1240
- // A baseline run must not be diffed. Suppress the server's
1241
- // auto-resolve-by-branch so no prior baseline is attached.
1242
- ...(options.purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
1243
- capturePlan: {
1244
- purpose: options.purpose === 'baseline' ? 'baseline' : 'diff',
1245
- ...(options.baselineRef
1246
- ? {
1247
- publicationWorkflowRef: options.baselineRef.publicationWorkflowRef,
1248
- publicationSequence: options.baselineRef.publicationSequence
1249
- }
1250
- : {}),
1251
- configuredRouteIds: options.configuredRouteIds,
1252
- selectedRouteIds: options.selectedRouteIds,
1253
- expectedCaptures: options.expectedCaptures
1879
+ const baselineId = options.purpose === 'diff' ? await this.#resolveLatestBaselineId(operation) : null;
1880
+
1881
+ const runResponse = await this.#request(
1882
+ 'POST',
1883
+ `/v1/visual/projects/${this.#projectId}/runs`,
1884
+ {
1885
+ id: runId,
1886
+ baseUrl: options.config.baseUrl,
1887
+ trigger: 'ci',
1888
+ ...(baselineId ? { baselineId } : {}),
1889
+ // A baseline run must not be diffed. Suppress the server's
1890
+ // auto-resolve-by-branch so no prior baseline is attached.
1891
+ ...(options.purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
1892
+ capturePlan: {
1893
+ purpose: options.purpose === 'baseline' ? 'baseline' : 'diff',
1894
+ ...(options.baselineRef
1895
+ ? {
1896
+ publicationWorkflowRef: options.baselineRef.publicationWorkflowRef,
1897
+ publicationSequence: options.baselineRef.publicationSequence
1898
+ }
1899
+ : {}),
1900
+ configuredRouteIds: options.configuredRouteIds,
1901
+ selectedRouteIds: options.selectedRouteIds,
1902
+ expectedCaptures: options.expectedCaptures
1903
+ },
1904
+ ...options.runContext,
1905
+ ...(options.comparisonPolicy ? { comparisonPolicy: options.comparisonPolicy } : {})
1254
1906
  },
1255
- ...options.runContext
1256
- }, this.#generateIdempotencyKey());
1907
+ this.#generateIdempotencyKey(),
1908
+ operation
1909
+ );
1910
+ if (options.comparisonPolicy) {
1911
+ assertComparisonPolicyAcknowledged(runResponse?.comparisonPolicy, options.comparisonPolicy, 'Snap local-upload run creation');
1912
+ }
1257
1913
 
1258
1914
  for (const { route, manifestEntry } of uploads) {
1259
1915
  const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
1260
1916
  const viewportDescriptor = resolveViewportDescriptor(route.viewport);
1261
1917
 
1262
- await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
1263
- id: captureId,
1264
- routeId: route.id,
1265
- routePath: route.path,
1266
- viewportDescriptorJson: JSON.stringify(viewportDescriptor),
1267
- // The screenshot is captured locally and uploaded below, not rendered by
1268
- // Snap. This flag tells the backend to keep the capture out of the render
1269
- // worker's queue — without it the worker would try to render baseUrl
1270
- // (a client-only/loopback address it can't reach) and error the run.
1271
- localCapture: true,
1272
- }, this.#generateIdempotencyKey());
1918
+ await this.#request(
1919
+ 'POST',
1920
+ `/v1/visual/runs/${runId}/captures`,
1921
+ {
1922
+ id: captureId,
1923
+ routeId: route.id,
1924
+ routePath: route.path,
1925
+ viewportDescriptorJson: JSON.stringify(viewportDescriptor),
1926
+ // The screenshot is captured locally and uploaded below, not rendered by
1927
+ // Snap. This flag tells the backend to keep the capture out of the render
1928
+ // worker's queue — without it the worker would try to render baseUrl
1929
+ // (a client-only/loopback address it can't reach) and error the run.
1930
+ localCapture: true
1931
+ },
1932
+ this.#generateIdempotencyKey(),
1933
+ operation
1934
+ );
1273
1935
 
1274
1936
  const imagePath = path.resolve(localCapture.screenshotsRoot, manifestEntry.imagePath);
1275
1937
  const imageBytes = await fs.readFile(imagePath);
1276
1938
 
1277
- await this.#request('POST', `/v1/visual/captures/${captureId}/local-result`, {
1278
- imageBase64: imageBytes.toString('base64'),
1279
- width: manifestEntry.width,
1280
- height: manifestEntry.height
1281
- }, this.#generateIdempotencyKey());
1939
+ await this.#request(
1940
+ 'POST',
1941
+ `/v1/visual/captures/${captureId}/local-result`,
1942
+ {
1943
+ imageBase64: imageBytes.toString('base64'),
1944
+ width: manifestEntry.width,
1945
+ height: manifestEntry.height
1946
+ },
1947
+ this.#generateIdempotencyKey(),
1948
+ operation
1949
+ );
1282
1950
  }
1283
1951
 
1284
1952
  const snapResults = {
@@ -1288,6 +1956,7 @@ export class SnapProvider {
1288
1956
  runId,
1289
1957
  projectId: this.#projectId,
1290
1958
  purpose: options.purpose,
1959
+ ...(options.comparisonPolicy ? { comparisonPolicy: options.comparisonPolicy } : {}),
1291
1960
  ...(options.baselineRef ? options.baselineRef : {}),
1292
1961
  configuredRouteIds: options.configuredRouteIds,
1293
1962
  selectedRouteIds: options.selectedRouteIds,
@@ -1299,149 +1968,6 @@ export class SnapProvider {
1299
1968
  return localCapture;
1300
1969
  }
1301
1970
 
1302
- /**
1303
- * Make an HTTP request to the Snap API with retry logic.
1304
- *
1305
- * @param {string} method
1306
- * @param {string} path
1307
- * @param {object} [body]
1308
- * @param {string} [idempotencyKey]
1309
- * @returns {Promise<any>} Parsed JSON response
1310
- */
1311
- async #request(method, path, body, idempotencyKey) {
1312
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
1313
- try {
1314
- const headers = {
1315
- 'Authorization': `Bearer ${this.#apiKey}`,
1316
- 'Content-Type': 'application/json',
1317
- 'Accept': 'application/json'
1318
- };
1319
- if (idempotencyKey && method === 'POST') {
1320
- headers['Idempotency-Key'] = idempotencyKey;
1321
- }
1322
-
1323
- const url = `${this.#apiUrl}${path}`;
1324
- const response = await this.#fetchFn(url, {
1325
- method,
1326
- headers,
1327
- body: body ? JSON.stringify(body) : undefined
1328
- });
1329
-
1330
- if (response.ok) {
1331
- const text = await response.text();
1332
- return text ? JSON.parse(text) : {};
1333
- }
1334
-
1335
- // 4xx — never retry
1336
- if (response.status >= 400 && response.status < 500) {
1337
- const errorBody = await response.text().catch(() => '');
1338
- let detail = '';
1339
- try {
1340
- const parsed = JSON.parse(errorBody);
1341
- detail = parsed.error || parsed.message || '';
1342
- } catch { /* use default message */ }
1343
- const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
1344
- throw new SnapApiError(response.status, errorMessage, path);
1345
- }
1346
-
1347
- // 5xx — retry with backoff (unless this is the last attempt)
1348
- if (attempt < MAX_RETRIES) {
1349
- const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
1350
- await this.#sleepFn(delay);
1351
- continue;
1352
- }
1353
-
1354
- // Exhausted retries
1355
- const errorBody = await response.text().catch(() => '');
1356
- let detail = '';
1357
- try {
1358
- const parsed = JSON.parse(errorBody);
1359
- detail = parsed.error || parsed.message || '';
1360
- } catch { /* use default message */ }
1361
- const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
1362
- return this.#handleUnavailable(new SnapApiError(response.status, errorMessage, path));
1363
-
1364
- } catch (error) {
1365
- if (error instanceof SnapApiError) {
1366
- throw error;
1367
- }
1368
-
1369
- // Network error — retry
1370
- if (attempt < MAX_RETRIES) {
1371
- const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
1372
- await this.#sleepFn(delay);
1373
- continue;
1374
- }
1375
-
1376
- return this.#handleUnavailable(new SnapUnavailableError(error instanceof Error ? error.message : String(error)));
1377
- }
1378
- }
1379
-
1380
- // Unreachable, but satisfies the type checker
1381
- return this.#handleUnavailable(new SnapUnavailableError('Max retries exceeded'));
1382
- }
1383
-
1384
- /**
1385
- * Make an HTTP request to the Snap API that returns a binary body (e.g. the
1386
- * export tar). Mirrors #request's retry and error semantics — 4xx never
1387
- * retries and throws SnapApiError, 5xx/network errors retry with backoff and
1388
- * consult onUnavailable when exhausted — but returns the raw bytes instead
1389
- * of parsed JSON.
1390
- *
1391
- * @param {string} method
1392
- * @param {string} path
1393
- * @returns {Promise<any>} Raw response bytes as a Buffer
1394
- */
1395
- async #requestBinary(method, path) {
1396
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
1397
- try {
1398
- const response = await this.#fetchFn(`${this.#apiUrl}${path}`, {
1399
- method,
1400
- headers: { 'Authorization': `Bearer ${this.#apiKey}` }
1401
- });
1402
-
1403
- if (response.ok) {
1404
- return Buffer.from(await response.arrayBuffer());
1405
- }
1406
-
1407
- // 4xx — never retry
1408
- if (response.status >= 400 && response.status < 500) {
1409
- const detail = await readErrorDetail(response);
1410
- const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
1411
- throw new SnapApiError(response.status, errorMessage, path);
1412
- }
1413
-
1414
- // 5xx — retry with backoff (unless this is the last attempt)
1415
- if (attempt < MAX_RETRIES) {
1416
- const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
1417
- await this.#sleepFn(delay);
1418
- continue;
1419
- }
1420
-
1421
- // Exhausted retries
1422
- const detail = await readErrorDetail(response);
1423
- const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
1424
- return this.#handleUnavailable(new SnapApiError(response.status, errorMessage, path));
1425
- } catch (error) {
1426
- if (error instanceof SnapApiError) {
1427
- throw error;
1428
- }
1429
-
1430
- // Network error — retry
1431
- if (attempt < MAX_RETRIES) {
1432
- const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
1433
- await this.#sleepFn(delay);
1434
- continue;
1435
- }
1436
-
1437
- return this.#handleUnavailable(new SnapUnavailableError(error instanceof Error ? error.message : String(error)));
1438
- }
1439
- }
1440
-
1441
- // Unreachable, but satisfies the type checker
1442
- return this.#handleUnavailable(new SnapUnavailableError('Max retries exceeded'));
1443
- }
1444
-
1445
1971
  /**
1446
1972
  * Handle unavailable behavior based on onUnavailable config.
1447
1973
  * @param {Error} error
@@ -1471,34 +1997,37 @@ export class SnapProvider {
1471
1997
  * Poll a run until it reaches a terminal state.
1472
1998
  * @param {string} runId
1473
1999
  * @param {number} [expectedCaptureCount]
2000
+ * @param {(detail: string) => Error} [errorFactory]
2001
+ * @param {OperationDeadline} [operation]
1474
2002
  * @returns {Promise<JsonObject>}
1475
2003
  */
1476
- async #pollRun(runId, expectedCaptureCount) {
1477
- const startTime = Date.now();
2004
+ async #pollRun(runId, expectedCaptureCount, errorFactory = (detail) => new Error(detail), operation = this.#createOperationDeadline('run polling')) {
1478
2005
  let incompleteTerminalSignature;
1479
2006
  let stableIncompleteTerminalPolls = 0;
1480
2007
  let incompleteSignature;
1481
2008
  let stableIncompletePolls = 0;
1482
2009
 
1483
- while (Date.now() - startTime < MAX_POLL_DURATION_MS) {
1484
- const run = await this.#request('GET', `/v1/visual/runs/${runId}`);
2010
+ while (this.#remainingOperationMs(operation) > 0) {
2011
+ const requestPath = `/v1/visual/runs/${runId}`;
2012
+ const run = await this.#request('GET', requestPath, undefined, undefined, operation);
2013
+ if (!run || typeof run !== 'object' || Array.isArray(run)) {
2014
+ throw errorFactory(`Snap returned a malformed run response for requested run "${runId}".`);
2015
+ }
1485
2016
  const captures = Array.isArray(run.captures) ? run.captures : [];
1486
2017
  const completeCaptureSet =
1487
2018
  expectedCaptureCount === undefined ||
1488
- (captures.length >= expectedCaptureCount &&
1489
- captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status)));
2019
+ (captures.length >= expectedCaptureCount && captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status)));
1490
2020
 
1491
2021
  if (expectedCaptureCount !== undefined && !completeCaptureSet) {
1492
- const signature = JSON.stringify(captures.map((capture) => [
1493
- capture?.id,
1494
- capture?.routeId,
1495
- capture?.status,
1496
- capture?.currentObjectKey
1497
- ]).sort((left, right) => {
1498
- const leftJson = JSON.stringify(left);
1499
- const rightJson = JSON.stringify(right);
1500
- return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
1501
- }));
2022
+ const signature = JSON.stringify(
2023
+ captures
2024
+ .map((capture) => [capture?.id, capture?.routeId, capture?.status, capture?.currentObjectKey])
2025
+ .sort((left, right) => {
2026
+ const leftJson = JSON.stringify(left);
2027
+ const rightJson = JSON.stringify(right);
2028
+ return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
2029
+ })
2030
+ );
1502
2031
  if (signature === incompleteSignature) stableIncompletePolls += 1;
1503
2032
  else {
1504
2033
  incompleteSignature = signature;
@@ -1522,19 +2051,16 @@ export class SnapProvider {
1522
2051
  // An older server can report a terminal run after only an early subset
1523
2052
  // has settled. Keep polling for the full capture set until the normal
1524
2053
  // ten-minute deadline; ordinary renders routinely exceed a few seconds.
1525
- if (
1526
- captures.length < expectedCaptureCount &&
1527
- captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status))
1528
- ) {
1529
- const signature = JSON.stringify(captures.map((capture) => [
1530
- capture?.id,
1531
- capture?.routeId,
1532
- capture?.status
1533
- ]).sort((left, right) => {
1534
- const leftJson = JSON.stringify(left);
1535
- const rightJson = JSON.stringify(right);
1536
- return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
1537
- }));
2054
+ if (captures.length < expectedCaptureCount && captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status))) {
2055
+ const signature = JSON.stringify(
2056
+ captures
2057
+ .map((capture) => [capture?.id, capture?.routeId, capture?.status])
2058
+ .sort((left, right) => {
2059
+ const leftJson = JSON.stringify(left);
2060
+ const rightJson = JSON.stringify(right);
2061
+ return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
2062
+ })
2063
+ );
1538
2064
  if (signature === incompleteTerminalSignature) stableIncompleteTerminalPolls += 1;
1539
2065
  else {
1540
2066
  incompleteTerminalSignature = signature;
@@ -1549,21 +2075,37 @@ export class SnapProvider {
1549
2075
  }
1550
2076
  }
1551
2077
 
1552
- await this.#sleepFn(POLL_INTERVAL_MS);
2078
+ try {
2079
+ await this.#sleepWithinOperation(POLL_INTERVAL_MS, operation, requestPath);
2080
+ } catch (error) {
2081
+ return this.#handleUnavailable(error instanceof Error ? error : new SnapUnavailableError(String(error)));
2082
+ }
1553
2083
  }
1554
2084
 
1555
- throw new Error(`Snap run ${runId} did not complete within 10 minutes.`);
2085
+ return this.#handleUnavailable(
2086
+ new SnapUnavailableError(
2087
+ `Snap ${operation.label} run ${runId} did not complete within ${OPERATION_LIMIT_TEXT}; ` + 'the Snap operation deadline was reached while polling'
2088
+ )
2089
+ );
1556
2090
  }
1557
2091
 
1558
2092
  /**
1559
2093
  * Map a Snap run result to a VisualDiffSummary.
1560
2094
  * @param {JsonObject} run
1561
2095
  * @param {VisualRegressionConfig} config
2096
+ * @param {{
2097
+ * selectedRouteIds: string[],
2098
+ * expectedCaptures: Array<{ routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
2099
+ * expectedByIdentity: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
2100
+ * expectedByRouteId: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>
2101
+ * }} diffPlan
2102
+ * @param {ComparisonPolicy} [comparisonPolicy]
1562
2103
  * @returns {import('../types/visual-diff-types').VisualDiffSummary}
1563
2104
  */
1564
- #mapRunToSummary(run, config) {
1565
- const captures = run.captures || [];
1566
- const totalScreenshots = captures.length;
2105
+ #mapRunToSummary(run, config, diffPlan, comparisonPolicy) {
2106
+ const captures = Array.isArray(run.captures) ? run.captures : [];
2107
+ const comparisonThreshold = comparisonPolicy?.threshold ?? config.diff.threshold;
2108
+ const totalScreenshots = diffPlan.expectedCaptures.length;
1567
2109
  let matchedScreenshots = 0;
1568
2110
  let changedScreenshots = 0;
1569
2111
  let missingInBaseline = 0;
@@ -1581,72 +2123,197 @@ export class SnapProvider {
1581
2123
  /** @type {import('../types/visual-diff-types').VisualDiffErrorItem[]} */
1582
2124
  const errors = [];
1583
2125
 
2126
+ /**
2127
+ * @param {string} id
2128
+ * @param {string | undefined} path
2129
+ * @param {import('../types/visual-diff-types').VisualViewport | undefined} viewport
2130
+ * @param {string} message
2131
+ */
2132
+ const addError = (id, path, viewport, message) => {
2133
+ errors.push({ id, path, viewport, status: 'error', message });
2134
+ };
2135
+
2136
+ if (!Array.isArray(run.captures)) {
2137
+ addError(`run:${run.id}`, undefined, undefined, 'Snap run returned no valid captures array.');
2138
+ }
2139
+
2140
+ if (run.status === 'error') {
2141
+ addError(`run:${run.id}`, undefined, undefined, run.errorDetails?.message || run.errorCode || run.message || 'Snap run ended with status "error".');
2142
+ }
2143
+
2144
+ const seenIdentities = new Set();
2145
+ const coveredExpectedRoutes = new Set();
1584
2146
  for (const capture of captures) {
2147
+ if (!capture || typeof capture !== 'object' || Array.isArray(capture)) {
2148
+ addError('unknown', undefined, undefined, 'Snap run returned a malformed capture.');
2149
+ continue;
2150
+ }
2151
+
2152
+ if (typeof capture.routeId === 'string' && diffPlan.expectedByRouteId.has(capture.routeId)) {
2153
+ coveredExpectedRoutes.add(capture.routeId);
2154
+ }
2155
+
2156
+ let identity;
2157
+ try {
2158
+ identity = captureIdentityKey(capture, `source capture "${capture.routeId || capture.id || 'unknown'}"`, hostedDiffError);
2159
+ } catch (error) {
2160
+ const malformedRouteId = typeof capture.routeId === 'string' && capture.routeId ? capture.routeId : 'unknown';
2161
+ const expected = diffPlan.expectedByRouteId.get(malformedRouteId);
2162
+ addError(
2163
+ malformedRouteId,
2164
+ typeof capture.routePath === 'string' ? capture.routePath : expected?.routePath,
2165
+ expected?.viewport ?? capture.viewport,
2166
+ error instanceof Error ? error.message : String(error)
2167
+ );
2168
+ continue;
2169
+ }
2170
+
2171
+ const expected = diffPlan.expectedByIdentity.get(identity);
2172
+ if (!expected) {
2173
+ addError(capture.routeId, capture.routePath, capture.viewport, `Snap run returned an unexpected route/viewport capture for "${capture.routeId}".`);
2174
+ continue;
2175
+ }
2176
+ if (seenIdentities.has(identity)) {
2177
+ addError(expected.routeId, expected.routePath, expected.viewport, `Snap run returned a duplicate route/viewport capture for "${expected.routeId}".`);
2178
+ continue;
2179
+ }
2180
+ seenIdentities.add(identity);
2181
+
2182
+ if (capture.routePath !== expected.routePath) {
2183
+ addError(expected.routeId, capture.routePath, expected.viewport, `Snap run returned an unexpected path for "${expected.routeId}".`);
2184
+ continue;
2185
+ }
2186
+
1585
2187
  if (capture.status === 'error') {
1586
- errors.push({
1587
- id: capture.routeId || capture.id,
1588
- path: capture.routePath,
1589
- viewport: capture.viewport,
1590
- status: 'error',
1591
- message: capture.errorDetails?.message || capture.errorCode || 'Unknown error'
1592
- });
2188
+ addError(
2189
+ expected.routeId,
2190
+ expected.routePath,
2191
+ expected.viewport,
2192
+ capture.errorDetails?.message || capture.errorCode || 'Snap capture ended with status "error".'
2193
+ );
2194
+ continue;
2195
+ }
2196
+
2197
+ if (capture.status !== 'new' && capture.status !== 'diffed') {
2198
+ addError(
2199
+ expected.routeId,
2200
+ expected.routePath,
2201
+ expected.viewport,
2202
+ `Snap capture "${expected.routeId}" ended with unknown status "${capture.status || 'unknown'}".`
2203
+ );
2204
+ continue;
2205
+ }
2206
+
2207
+ if (typeof capture.currentObjectKey !== 'string' || !capture.currentObjectKey) {
2208
+ addError(expected.routeId, expected.routePath, expected.viewport, `Snap capture "${expected.routeId}" has no currentObjectKey.`);
1593
2209
  continue;
1594
2210
  }
1595
2211
 
1596
2212
  if (capture.status === 'new') {
1597
- // The backend rendered this capture but had no baseline to diff against
1598
- // (first run for the route). Surface it as a missing baseline rather than
1599
- // a silent match — there is nothing to compare yet.
2213
+ // The backend rendered this capture but had no baseline to diff against.
1600
2214
  missingInBaseline++;
1601
2215
  missing.push({
1602
- id: capture.routeId || capture.id,
2216
+ id: expected.routeId,
1603
2217
  reason: 'no baseline capture found on Snap',
1604
- path: capture.routePath,
1605
- viewport: capture.viewport,
2218
+ path: expected.routePath,
2219
+ viewport: expected.viewport,
1606
2220
  location: 'baseline'
1607
2221
  });
1608
2222
  continue;
1609
2223
  }
1610
2224
 
1611
- if (capture.status === 'diffed') {
1612
- if (!capture.baselineObjectKey) {
1613
- // No baseline was attached to this capture: the backend short-circuits
1614
- // to "diffed" with no comparison data (no diffPct / diff image). Surface
1615
- // it as a missing baseline instead of silently counting it as a match.
1616
- missingInBaseline++;
1617
- missing.push({
1618
- id: capture.routeId || capture.id,
1619
- reason: 'no baseline capture found on Snap',
1620
- path: capture.routePath,
1621
- viewport: capture.viewport,
1622
- location: 'baseline'
1623
- });
1624
- } else if (capture.diffPct != null && capture.diffPct > config.diff.threshold) {
1625
- comparedWithBaseline++;
1626
- changedScreenshots++;
1627
- changed.push({
1628
- id: capture.routeId || capture.id,
1629
- path: capture.routePath || '',
1630
- viewport: capture.viewport,
1631
- baselineImagePath: capture.baselineObjectKey || '',
1632
- currentImagePath: capture.currentObjectKey || '',
1633
- width: capture.width || 0,
1634
- height: capture.height || 0,
1635
- differentPixels: capture.diffPixels || 0,
1636
- totalPixels: (capture.width || 0) * (capture.height || 0),
1637
- mismatchRatio: capture.diffPct,
1638
- status: 'changed'
1639
- });
1640
- } else {
1641
- comparedWithBaseline++;
1642
- if (capture.diffPct === 0) {
1643
- exactZeroMatches++;
1644
- }
1645
- matchedScreenshots++;
2225
+ if (capture.baselineObjectKey !== undefined && capture.baselineObjectKey !== null && typeof capture.baselineObjectKey !== 'string') {
2226
+ addError(expected.routeId, expected.routePath, expected.viewport, `Snap capture "${expected.routeId}" has an invalid baselineObjectKey.`);
2227
+ continue;
2228
+ }
2229
+
2230
+ if (!capture.baselineObjectKey) {
2231
+ // No baseline was attached to this capture: the backend short-circuits
2232
+ // to "diffed" with no comparison data.
2233
+ missingInBaseline++;
2234
+ missing.push({
2235
+ id: expected.routeId,
2236
+ reason: 'no baseline capture found on Snap',
2237
+ path: expected.routePath,
2238
+ viewport: expected.viewport,
2239
+ location: 'baseline'
2240
+ });
2241
+ continue;
2242
+ }
2243
+
2244
+ const comparison = comparisonFromCapture(capture);
2245
+ if (comparisonPolicy && !comparison) {
2246
+ addError(
2247
+ expected.routeId,
2248
+ expected.routePath,
2249
+ expected.viewport,
2250
+ `Snap capture "${expected.routeId}" is missing complete comparison metadata; ` +
2251
+ 'upgrade the Snap server and diff Lambda before enabling comparisonPolicy v1.'
2252
+ );
2253
+ continue;
2254
+ }
2255
+
2256
+ if (!Number.isFinite(capture.diffPct) || capture.diffPct < 0 || capture.diffPct > 1) {
2257
+ addError(expected.routeId, expected.routePath, expected.viewport, `Snap capture "${expected.routeId}" has an invalid diffPct.`);
2258
+ continue;
2259
+ }
2260
+
2261
+ if (comparisonPolicy && comparison && !hasCompleteComparisonResult(capture, comparison, comparisonPolicy.threshold)) {
2262
+ addError(
2263
+ expected.routeId,
2264
+ expected.routePath,
2265
+ expected.viewport,
2266
+ `Snap capture "${expected.routeId}" has incomplete or inconsistent comparison statistics.`
2267
+ );
2268
+ continue;
2269
+ }
2270
+
2271
+ if (comparison && (!Number.isInteger(capture.diffPixels) || capture.diffPixels < 0 || capture.diffPixels > comparison.totalPixels)) {
2272
+ addError(expected.routeId, expected.routePath, expected.viewport, `Snap capture "${expected.routeId}" has invalid comparison pixel statistics.`);
2273
+ continue;
2274
+ }
2275
+
2276
+ const legacyWidth = capture.width || 0;
2277
+ const legacyHeight = capture.height || 0;
2278
+ const dimensionsChanged = comparison?.dimensionsChanged === true;
2279
+ if (dimensionsChanged || capture.diffPct > comparisonThreshold) {
2280
+ comparedWithBaseline++;
2281
+ changedScreenshots++;
2282
+ changed.push({
2283
+ id: expected.routeId,
2284
+ path: expected.routePath,
2285
+ viewport: expected.viewport,
2286
+ baselineImagePath: capture.baselineObjectKey,
2287
+ currentImagePath: capture.currentObjectKey,
2288
+ width: comparison?.canvas.width ?? legacyWidth,
2289
+ height: comparison?.canvas.height ?? legacyHeight,
2290
+ differentPixels: capture.diffPixels || 0,
2291
+ totalPixels: comparison?.totalPixels ?? legacyWidth * legacyHeight,
2292
+ mismatchRatio: capture.diffPct,
2293
+ status: 'changed',
2294
+ ...(comparison ? { comparison } : {})
2295
+ });
2296
+ } else {
2297
+ comparedWithBaseline++;
2298
+ if (capture.diffPct === 0) {
2299
+ exactZeroMatches++;
1646
2300
  }
2301
+ matchedScreenshots++;
1647
2302
  }
1648
2303
  }
1649
2304
 
2305
+ for (const expected of diffPlan.expectedByRouteId.values()) {
2306
+ if (coveredExpectedRoutes.has(expected.routeId)) continue;
2307
+ missingInCurrent++;
2308
+ missing.push({
2309
+ id: expected.routeId,
2310
+ reason: 'capture missing from Snap run',
2311
+ path: expected.routePath,
2312
+ viewport: expected.viewport,
2313
+ location: 'current'
2314
+ });
2315
+ }
2316
+
1650
2317
  // False-negative guard: when a diff run compared every route against a real
1651
2318
  // baseline yet every single comparison came back pixel-identical (0% drift),
1652
2319
  // the most likely explanation is that the client captured a stale or wrong
@@ -1661,9 +2328,9 @@ export class SnapProvider {
1661
2328
  if (comparedWithBaseline > 1 && exactZeroMatches === comparedWithBaseline) {
1662
2329
  process.stderr.write(
1663
2330
  `[SnapDrift] Warning: all ${comparedWithBaseline} compared route(s) were pixel-identical ` +
1664
- `to the baseline (0% drift). If you expected a visual change, the captured page may be ` +
1665
- `stale or wrong — verify the resolved baseUrl points at the PR preview and that the ` +
1666
- `preview was deployed before capture ran. See issue #93.\n`
2331
+ `to the baseline (0% drift). If you expected a visual change, the captured page may be ` +
2332
+ `stale or wrong — verify the resolved baseUrl points at the PR preview and that the ` +
2333
+ `preview was deployed before capture ran. See issue #93.\n`
1667
2334
  );
1668
2335
  }
1669
2336
 
@@ -1673,11 +2340,11 @@ export class SnapProvider {
1673
2340
  finishedAt: run.finishedAt || new Date().toISOString(),
1674
2341
  completed: true,
1675
2342
  status: run.status === 'pass' ? 'clean' : run.status === 'fail' ? 'changes-detected' : 'incomplete',
1676
- selectedRoutes: captures.map((c) => c.routeId || c.id),
2343
+ selectedRoutes: diffPlan.selectedRouteIds,
1677
2344
  baselineManifestPath: '',
1678
2345
  currentManifestPath: '',
1679
2346
  diffMode: config.diff.mode,
1680
- threshold: config.diff.threshold,
2347
+ threshold: comparisonThreshold,
1681
2348
  baselineResultsPath: '',
1682
2349
  currentResultsPath: '',
1683
2350
  totalScreenshots,
@@ -1689,6 +2356,7 @@ export class SnapProvider {
1689
2356
  missing,
1690
2357
  errors,
1691
2358
  dimensionChanges: [],
2359
+ ...(comparisonPolicy ? { comparisonPolicy: { ...comparisonPolicy } } : {}),
1692
2360
  dashboardUrl: `${this.#apiUrl}/dashboard/visual/${this.#projectId}/runs/${run.id}`
1693
2361
  };
1694
2362
 
@@ -1719,6 +2387,21 @@ export class SnapApiError extends Error {
1719
2387
  }
1720
2388
  }
1721
2389
 
2390
+ /**
2391
+ * Error raised when an updated client opts into a comparison policy that the
2392
+ * connected Snap server does not acknowledge.
2393
+ */
2394
+ export class SnapUpgradeRequiredError extends Error {
2395
+ /** @type {'comparison_policy_unsupported'} */
2396
+ code = 'comparison_policy_unsupported';
2397
+
2398
+ /** @param {string} message */
2399
+ constructor(message) {
2400
+ super(message);
2401
+ this.name = 'SnapUpgradeRequiredError';
2402
+ }
2403
+ }
2404
+
1722
2405
  /**
1723
2406
  * Error thrown when the Snap API is unreachable after all retries.
1724
2407
  */