snapdrift 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 */
@@ -24,14 +32,17 @@ import { selectConfiguredRoutes, splitCommaList, determineDriftStatus, VIEWPORT_
24
32
  /** @typedef {import('../types/visual-diff-types').SnapConfig} SnapConfig */
25
33
  /** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
26
34
  /** @typedef {import('../types/visual-diff-types').JsonObject} JsonObject */
35
+ /** @typedef {{ deadline: number, label: string }} OperationDeadline */
27
36
 
28
37
  const DEFAULT_API_URL = 'https://snap.i2dev.com';
29
38
  const MAX_RETRIES = 3;
30
39
  const INITIAL_RETRY_DELAY_MS = 1000;
31
40
  const RETRY_MULTIPLIER = 2;
32
- const MAX_RETRY_TOTAL_MS = 30000;
41
+ const RETRY_DELAY_CAP_MS = 30000;
42
+ const JSON_REQUEST_TIMEOUT_MS = 30 * 1000;
43
+ const BINARY_REQUEST_TIMEOUT_MS = 120 * 1000;
33
44
  const POLL_INTERVAL_MS = 2000;
34
- const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
45
+ const MAX_OPERATION_DURATION_MS = 10 * 60 * 1000;
35
46
  const MAX_STABLE_INCOMPLETE_POLLS = 30;
36
47
  const BASELINE_RUN_SUCCESS_STATUS = 'new';
37
48
  const BASELINE_CAPTURE_SUCCESS_STATUS = 'new';
@@ -43,11 +54,91 @@ const TERMINAL_CAPTURE_STATUSES = new Set(['new', 'diffed', 'error']);
43
54
  // client must stop polling on it just like pass/fail/error.
44
55
  const TERMINAL_RUN_STATUSES = new Set(['pass', 'fail', 'error', 'new']);
45
56
 
57
+ /** @param {number} milliseconds @returns {string} */
58
+ function formatDuration(milliseconds) {
59
+ const seconds = Math.round(milliseconds / 1000);
60
+ if (seconds % 60 === 0) return `${seconds / 60} minutes`;
61
+ return `${seconds} seconds`;
62
+ }
63
+
64
+ const OPERATION_LIMIT_TEXT = formatDuration(MAX_OPERATION_DURATION_MS);
65
+
66
+ /** @returns {number} A monotonic timestamp in milliseconds. */
67
+ function monotonicNow() {
68
+ return typeof globalThis.performance?.now === 'function'
69
+ ? globalThis.performance.now()
70
+ : Date.now();
71
+ }
72
+
73
+ /**
74
+ * Run an asynchronous operation with a timer that also settles when the
75
+ * underlying promise ignores cancellation. The promise remains observed after
76
+ * the timeout, so a late rejection cannot become an unhandled rejection.
77
+ *
78
+ * @template T
79
+ * @param {() => Promise<T> | T} task
80
+ * @param {number} timeoutMs
81
+ * @param {() => void} onTimeout
82
+ * @param {() => Error} timeoutError
83
+ * @returns {Promise<T>}
84
+ */
85
+ function runWithTimeout(task, timeoutMs, onTimeout, timeoutError) {
86
+ return new Promise((resolve, reject) => {
87
+ let settled = false;
88
+ let taskPromise;
89
+ try {
90
+ taskPromise = Promise.resolve(task());
91
+ } catch (error) {
92
+ reject(error);
93
+ return;
94
+ }
95
+ const timer = setTimeout(() => {
96
+ if (settled) return;
97
+ settled = true;
98
+ try {
99
+ onTimeout();
100
+ } catch {
101
+ // Abort/cancel is best effort; the timeout still settles the caller.
102
+ }
103
+ reject(timeoutError());
104
+ }, Math.max(0, timeoutMs));
105
+
106
+ taskPromise
107
+ .then((value) => {
108
+ if (settled) {
109
+ if (value && typeof value === 'object') cancelResponseBody(value);
110
+ return;
111
+ }
112
+ settled = true;
113
+ clearTimeout(timer);
114
+ resolve(value);
115
+ }, (error) => {
116
+ if (settled) return;
117
+ settled = true;
118
+ clearTimeout(timer);
119
+ reject(error);
120
+ });
121
+ });
122
+ }
123
+
124
+ /** @param {any} response */
125
+ function cancelResponseBody(response) {
126
+ const body = response && typeof response === 'object' ? response.body : undefined;
127
+ if (body && typeof body.cancel === 'function') {
128
+ Promise.resolve(body.cancel()).catch(() => {});
129
+ }
130
+ }
131
+
46
132
  /** @param {string} detail @returns {Error} */
47
133
  function completeBaselineError(detail) {
48
134
  return new Error(`Cannot publish a complete Snap baseline: ${detail}`);
49
135
  }
50
136
 
137
+ /** @param {string} detail @returns {Error} */
138
+ function hostedDiffError(detail) {
139
+ return new Error(`Cannot compare a Snap visual diff: ${detail}`);
140
+ }
141
+
51
142
  /**
52
143
  * @param {import('../types/visual-diff-types').VisualViewport} viewport
53
144
  * @returns {{ width: number, height: number, deviceScaleFactor?: number, isMobile?: boolean, hasTouch?: boolean }}
@@ -67,43 +158,151 @@ function expectedCaptureIdentity(route) {
67
158
  };
68
159
  }
69
160
 
70
- /** @param {unknown} value @param {string} context @returns {string} */
71
- function normalizeViewportDescriptor(value, context) {
161
+ /**
162
+ * @param {unknown} value
163
+ * @param {string} context
164
+ * @param {(detail: string) => Error} errorFactory
165
+ * @returns {string}
166
+ */
167
+ function normalizeViewportDescriptor(value, context, errorFactory) {
72
168
  let descriptor;
73
169
  try {
74
170
  descriptor = typeof value === 'string' ? JSON.parse(value) : value;
75
171
  } catch {
76
- throw completeBaselineError(`${context} has malformed viewportDescriptorJson.`);
172
+ throw errorFactory(`${context} has malformed viewportDescriptorJson.`);
77
173
  }
78
174
  if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor) ||
79
175
  !Number.isFinite(descriptor.width) || !Number.isFinite(descriptor.height)) {
80
- throw completeBaselineError(`${context} has an invalid viewport descriptor.`);
176
+ throw errorFactory(`${context} has an invalid viewport descriptor.`);
81
177
  }
82
178
  return JSON.stringify(Object.fromEntries(
83
179
  Object.entries(descriptor).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
84
180
  ));
85
181
  }
86
182
 
87
- /** @param {{ routeId?: unknown, viewportDescriptorJson?: unknown }} capture @param {string} context */
88
- function captureIdentityKey(capture, context) {
183
+ /**
184
+ * @param {{ routeId?: unknown, viewportDescriptorJson?: unknown }} capture
185
+ * @param {string} context
186
+ * @param {(detail: string) => Error} errorFactory
187
+ */
188
+ function captureIdentityKey(capture, context, errorFactory) {
89
189
  if (typeof capture.routeId !== 'string' || !capture.routeId) {
90
- throw completeBaselineError(`${context} has no routeId.`);
190
+ throw errorFactory(`${context} has no routeId.`);
91
191
  }
92
- return `${capture.routeId}\u0000${normalizeViewportDescriptor(capture.viewportDescriptorJson, context)}`;
192
+ return `${capture.routeId}\u0000${normalizeViewportDescriptor(capture.viewportDescriptorJson, context, errorFactory)}`;
93
193
  }
94
194
 
95
- /** @param {unknown} value @param {string} field @returns {Set<string>} */
96
- function uniqueRouteIdSet(value, field) {
195
+ /**
196
+ * @param {unknown} value
197
+ * @param {string} field
198
+ * @param {(detail: string) => Error} errorFactory
199
+ * @returns {Set<string>}
200
+ */
201
+ function uniqueRouteIdSet(value, field, errorFactory) {
97
202
  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.`);
203
+ throw errorFactory(`results metadata has no valid ${field}; capture again with SnapDrift 0.8.2 or newer.`);
99
204
  }
100
205
  const ids = new Set(value);
101
206
  if (ids.size !== value.length) {
102
- throw completeBaselineError(`results metadata ${field} contains duplicate route ids.`);
207
+ throw errorFactory(`results metadata ${field} contains duplicate route ids.`);
103
208
  }
104
209
  return ids;
105
210
  }
106
211
 
212
+ /**
213
+ * Validate the immutable capture plan written by SnapProvider.capture().
214
+ *
215
+ * Diff runs are allowed to be scoped, so this deliberately validates the
216
+ * expected set against selectedRouteIds rather than requiring every configured
217
+ * route as hosted baseline publication does.
218
+ *
219
+ * @param {JsonObject} metadata
220
+ * @param {string} projectId
221
+ * @returns {{
222
+ * runId: string,
223
+ * selectedRouteIds: string[],
224
+ * expectedCaptures: Array<{ routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
225
+ * expectedByIdentity: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
226
+ * expectedByRouteId: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>
227
+ * }}
228
+ */
229
+ function validateHostedDiffMetadata(metadata, projectId) {
230
+ if (!metadata || typeof metadata !== 'object') {
231
+ throw hostedDiffError('capture results metadata is not an object; capture again with SnapDrift 0.8.2 or newer.');
232
+ }
233
+ if (typeof metadata.runId !== 'string' || !metadata.runId.trim()) {
234
+ throw hostedDiffError('capture results metadata has no run id; capture again with SnapDrift 0.8.2 or newer.');
235
+ }
236
+ if (metadata.projectId !== projectId) {
237
+ throw hostedDiffError(
238
+ `capture results belong to project "${metadata.projectId || 'unknown'}", not "${projectId}".`
239
+ );
240
+ }
241
+
242
+ if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length === 0) {
243
+ throw hostedDiffError(
244
+ 'capture results have no expected capture identities; recapture with SnapDrift 0.8.2 or newer.'
245
+ );
246
+ }
247
+ if (metadata.purpose !== 'diff') {
248
+ throw hostedDiffError(
249
+ `capture results have purpose "${metadata.purpose || 'unknown'}", not "diff"; ` +
250
+ 'recapture with SnapDrift 0.8.2 or newer.'
251
+ );
252
+ }
253
+ const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds', hostedDiffError);
254
+ if (selected.size === 0) {
255
+ throw hostedDiffError('capture results selected no routes.');
256
+ }
257
+ if (metadata.expectedCaptures.length !== selected.size) {
258
+ throw hostedDiffError(
259
+ `capture results describe ${metadata.expectedCaptures.length} expected capture(s) for ${selected.size} selected route(s).`
260
+ );
261
+ }
262
+
263
+ const expectedRouteIds = new Set();
264
+ const expectedByIdentity = new Map();
265
+ const expectedByRouteId = new Map();
266
+ for (const [index, expected] of metadata.expectedCaptures.entries()) {
267
+ if (!expected || typeof expected !== 'object' ||
268
+ typeof expected.routeId !== 'string' || !expected.routeId ||
269
+ typeof expected.routePath !== 'string' || !expected.routePath ||
270
+ !selected.has(expected.routeId)) {
271
+ throw hostedDiffError(`expected capture metadata at index ${index} is invalid or not selected.`);
272
+ }
273
+ if (expectedRouteIds.has(expected.routeId)) {
274
+ throw hostedDiffError(`expected capture metadata contains duplicate route "${expected.routeId}".`);
275
+ }
276
+ expectedRouteIds.add(expected.routeId);
277
+ const viewportDescriptorJson = normalizeViewportDescriptor(
278
+ expected.viewportDescriptorJson,
279
+ `expected capture "${expected.routeId}"`,
280
+ hostedDiffError
281
+ );
282
+ const viewportDescriptor = JSON.parse(viewportDescriptorJson);
283
+ const expectedCapture = {
284
+ routeId: expected.routeId,
285
+ routePath: expected.routePath,
286
+ viewportDescriptorJson,
287
+ viewport: { width: viewportDescriptor.width, height: viewportDescriptor.height }
288
+ };
289
+ const identity = captureIdentityKey(expectedCapture, `expected capture "${expected.routeId}"`, hostedDiffError);
290
+ if (expectedByIdentity.has(identity)) {
291
+ throw hostedDiffError(`expected capture metadata contains duplicate route/viewport identity for "${expected.routeId}".`);
292
+ }
293
+ expectedByIdentity.set(identity, expectedCapture);
294
+ expectedByRouteId.set(expected.routeId, expectedCapture);
295
+ }
296
+
297
+ return {
298
+ runId: metadata.runId,
299
+ selectedRouteIds: [...selected],
300
+ expectedCaptures: [...expectedByIdentity.values()],
301
+ expectedByIdentity,
302
+ expectedByRouteId
303
+ };
304
+ }
305
+
107
306
  /** @param {string[]} configuredRouteIds @param {string[]} selectedRouteIds */
108
307
  function assertCompleteHostedBaselineSelection(configuredRouteIds, selectedRouteIds) {
109
308
  const selected = new Set(selectedRouteIds);
@@ -148,8 +347,8 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
148
347
  );
149
348
  }
150
349
 
151
- const configured = uniqueRouteIdSet(metadata.configuredRouteIds, 'configuredRouteIds');
152
- const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds');
350
+ const configured = uniqueRouteIdSet(metadata.configuredRouteIds, 'configuredRouteIds', completeBaselineError);
351
+ const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds', completeBaselineError);
153
352
  assertCompleteHostedBaselineSelection([...configured], [...selected]);
154
353
  if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length !== configured.size) {
155
354
  throw completeBaselineError(`expected capture metadata does not match ${configured.size} configured route(s).`);
@@ -164,7 +363,7 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
164
363
  }
165
364
  expectedRoutes.add(expected.routeId);
166
365
  expectedByIdentity.set(
167
- captureIdentityKey(expected, `expected capture "${expected.routeId}"`),
366
+ captureIdentityKey(expected, `expected capture "${expected.routeId}"`, completeBaselineError),
168
367
  expected
169
368
  );
170
369
  }
@@ -179,7 +378,11 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
179
378
  if (!capture || typeof capture !== 'object') {
180
379
  throw completeBaselineError('source run contains a malformed capture.');
181
380
  }
182
- const identity = captureIdentityKey(capture, `source capture "${capture.routeId || capture.id || 'unknown'}"`);
381
+ const identity = captureIdentityKey(
382
+ capture,
383
+ `source capture "${capture.routeId || capture.id || 'unknown'}"`,
384
+ completeBaselineError
385
+ );
183
386
  if (seen.has(identity)) {
184
387
  throw completeBaselineError(`duplicate source route/viewport identity for "${capture.routeId}".`);
185
388
  }
@@ -206,8 +409,8 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
206
409
  });
207
410
 
208
411
  return routes.sort((left, right) => {
209
- const leftKey = captureIdentityKey(left, `route "${left.routeId}"`);
210
- const rightKey = captureIdentityKey(right, `route "${right.routeId}"`);
412
+ const leftKey = captureIdentityKey(left, `route "${left.routeId}"`, completeBaselineError);
413
+ const rightKey = captureIdentityKey(right, `route "${right.routeId}"`, completeBaselineError);
211
414
  return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
212
415
  });
213
416
  }
@@ -336,23 +539,6 @@ function getPackageVersion() {
336
539
  const TAR_BLOCK_SIZE = 512;
337
540
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
338
541
 
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
542
  /**
357
543
  * Parse a plain ustar-style tar archive (512-byte headers, bodies padded to
358
544
  * 512-byte blocks, terminated by zero blocks) into a map of entry name →
@@ -457,22 +643,6 @@ function resolveExportEngine(captureProfile) {
457
643
  return { name: 'snap-hosted', version: 'unknown' };
458
644
  }
459
645
 
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
646
  /**
477
647
  * SnapProvider — VisualProvider that delegates capture + diff to Snap's
478
648
  * hosted /v1/visual/* API.
@@ -484,8 +654,10 @@ export class SnapProvider {
484
654
  #snapConfig;
485
655
  /** @type {typeof globalThis.fetch} */
486
656
  #fetchFn;
487
- /** @type {(ms: number) => Promise<void>} */
657
+ /** @type {(ms: number, signal?: AbortSignal) => Promise<void>} */
488
658
  #sleepFn;
659
+ /** @type {() => number} */
660
+ #nowFn;
489
661
  /** @type {(options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>} */
490
662
  #localCaptureFn;
491
663
  /** @type {string} */
@@ -499,14 +671,27 @@ export class SnapProvider {
499
671
  * @param {SnapConfig} snapConfig — validated snap section from config
500
672
  * @param {{
501
673
  * fetchFn?: typeof globalThis.fetch,
502
- * sleepFn?: (ms: number) => Promise<void>,
674
+ * sleepFn?: (ms: number, signal?: AbortSignal) => Promise<void>,
675
+ * nowFn?: () => number,
503
676
  * localCaptureFn?: (options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>
504
677
  * }} [options]
505
678
  */
506
679
  constructor(snapConfig, options = {}) {
507
680
  this.#snapConfig = snapConfig;
508
681
  this.#fetchFn = options.fetchFn ?? globalThis.fetch;
509
- this.#sleepFn = options.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
682
+ this.#sleepFn = options.sleepFn ?? ((ms, signal) => new Promise((resolve, reject) => {
683
+ const timer = setTimeout(() => {
684
+ signal?.removeEventListener('abort', onAbort);
685
+ resolve();
686
+ }, ms);
687
+ const onAbort = () => {
688
+ clearTimeout(timer);
689
+ signal?.removeEventListener('abort', onAbort);
690
+ reject(signal?.reason ?? new Error('Sleep aborted.'));
691
+ };
692
+ signal?.addEventListener('abort', onAbort, { once: true });
693
+ }));
694
+ this.#nowFn = options.nowFn ?? monotonicNow;
510
695
  this.#localCaptureFn = options.localCaptureFn ?? runBaselineCapture;
511
696
  this.#apiUrl = (snapConfig.apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
512
697
  this.#apiKey = this.#resolveApiKey(snapConfig);
@@ -564,6 +749,7 @@ export class SnapProvider {
564
749
 
565
750
  const idempotencyKey = this.#generateIdempotencyKey();
566
751
  const runId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
752
+ const operation = this.#createOperationDeadline('capture');
567
753
 
568
754
  // For a diff run, resolve the latest accepted baseline so the Snap backend
569
755
  // can attach it to this run's captures. Without a baselineId the server has
@@ -575,7 +761,7 @@ export class SnapProvider {
575
761
  // Attaching one would make the backend diff the fresh captures against the
576
762
  // prior baseline — wasteful at best, and on a dimension mismatch it errors
577
763
  // the run and fails the publish.
578
- const baselineId = purpose === 'diff' ? await this.#resolveLatestBaselineId() : null;
764
+ const baselineId = purpose === 'diff' ? await this.#resolveLatestBaselineId(operation) : null;
579
765
 
580
766
  // We intentionally do NOT send captureProfileJson on run creation. SnapDrift
581
767
  // submits routes for server-side rendering, so the render environment
@@ -607,7 +793,7 @@ export class SnapProvider {
607
793
  expectedCaptures
608
794
  },
609
795
  ...runContext,
610
- }, idempotencyKey);
796
+ }, idempotencyKey, operation);
611
797
 
612
798
  // Submit each route as a capture with its own idempotency key
613
799
  for (const route of routes) {
@@ -619,7 +805,7 @@ export class SnapProvider {
619
805
  routeId: route.id,
620
806
  routePath: route.path,
621
807
  viewportDescriptorJson: JSON.stringify(viewportDescriptor),
622
- }, captureKey);
808
+ }, captureKey, operation);
623
809
  }
624
810
 
625
811
  // Write run metadata to a temp file so diff() can thread the run_id
@@ -652,7 +838,7 @@ export class SnapProvider {
652
838
  id: r.id,
653
839
  path: r.path,
654
840
  viewport: r.viewport,
655
- imagePath: `screenshots/${r.id}.png`,
841
+ imagePath: `screenshots/${sanitizeRouteId(r.id)}.png`,
656
842
  width: 0,
657
843
  height: 0
658
844
  }))
@@ -680,14 +866,37 @@ export class SnapProvider {
680
866
  throw new Error('SnapProvider.diff requires currentResultsPath from a prior capture() call.');
681
867
  }
682
868
 
683
- const metadata = JSON.parse(await fs.readFile(currentResultsPath, 'utf-8'));
684
- const runId = metadata.runId;
869
+ let metadata;
870
+ let metadataSource;
871
+ try {
872
+ metadataSource = await fs.readFile(currentResultsPath, 'utf-8');
873
+ } catch (error) {
874
+ throw hostedDiffError(
875
+ `could not read capture results metadata at "${currentResultsPath}". ` +
876
+ `${error instanceof Error ? error.message : String(error)}`
877
+ );
878
+ }
879
+ try {
880
+ metadata = JSON.parse(metadataSource);
881
+ } catch (error) {
882
+ throw hostedDiffError(
883
+ `capture results metadata is not valid JSON; recapture with SnapDrift 0.8.2 or newer. ` +
884
+ `${error instanceof Error ? error.message : String(error)}`
885
+ );
886
+ }
887
+ const { config } = await loadSnapdriftConfig(options.configPath);
888
+ const diffPlan = validateHostedDiffMetadata(metadata, this.#projectId);
685
889
 
686
890
  // Poll until the run reaches a terminal state
687
- const run = await this.#pollRun(runId);
891
+ const operation = this.#createOperationDeadline('diff');
892
+ const run = await this.#pollRun(diffPlan.runId, diffPlan.expectedCaptures.length, hostedDiffError, operation);
893
+ if (!run || typeof run !== 'object' || run.id !== diffPlan.runId) {
894
+ throw hostedDiffError(
895
+ `Snap returned run "${run?.id || 'unknown'}" for requested run "${diffPlan.runId}".`
896
+ );
897
+ }
688
898
 
689
- const { config } = await loadSnapdriftConfig(options.configPath);
690
- const summary = this.#mapRunToSummary(run, config);
899
+ const summary = this.#mapRunToSummary(run, config, diffPlan);
691
900
 
692
901
  return {
693
902
  summary,
@@ -741,6 +950,7 @@ export class SnapProvider {
741
950
  {
742
951
  const { runId } = metadata;
743
952
  const idempotencyKey = `baseline-${runId}`;
953
+ const operation = this.#createOperationDeadline('baseline publication');
744
954
 
745
955
  if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length === 0) {
746
956
  throw completeBaselineError(
@@ -751,7 +961,7 @@ export class SnapProvider {
751
961
  // Older Snap servers could mark a baseline run terminal after the first
752
962
  // submitted capture. Keep polling until the complete planned capture set
753
963
  // is present and terminal so a mixed-version rollout remains fail-closed.
754
- const run = await this.#pollRun(runId, metadata.expectedCaptures.length);
964
+ const run = await this.#pollRun(runId, metadata.expectedCaptures.length, undefined, operation);
755
965
 
756
966
  if (run.id && run.id !== runId) {
757
967
  throw new Error(
@@ -794,7 +1004,8 @@ export class SnapProvider {
794
1004
  manifestJson,
795
1005
  captureProfileJson: JSON.stringify(this.#buildCaptureProfile())
796
1006
  },
797
- idempotencyKey
1007
+ idempotencyKey,
1008
+ operation
798
1009
  );
799
1010
 
800
1011
  const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
@@ -817,8 +1028,9 @@ export class SnapProvider {
817
1028
  * @returns {Promise<BaselineData | null>}
818
1029
  */
819
1030
  async fetchLatestBaseline(_options) {
1031
+ const operation = this.#createOperationDeadline('baseline lookup');
820
1032
  try {
821
- const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`);
1033
+ const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`, undefined, undefined, operation);
822
1034
 
823
1035
  const runDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
824
1036
  const resultsPath = path.join(runDir, 'results.json');
@@ -855,7 +1067,7 @@ export class SnapProvider {
855
1067
  * Build a PR comment body from a diff summary.
856
1068
  * SnapProvider includes the dashboard URL from the summary when available.
857
1069
  *
858
- * @param {Record<string, unknown>} summary
1070
+ * @param {import('@snapdrift/manifest').VisualReportSummary} summary
859
1071
  * @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
860
1072
  * @returns {string}
861
1073
  */
@@ -875,8 +1087,9 @@ export class SnapProvider {
875
1087
  * @returns {Promise<object | null>} Baseline data if found, null on 404
876
1088
  */
877
1089
  async checkBaselineExists(headSha) {
1090
+ const operation = this.#createOperationDeadline('baseline existence check');
878
1091
  try {
879
- const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest?sha=${encodeURIComponent(headSha)}`);
1092
+ const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest?sha=${encodeURIComponent(headSha)}`, undefined, undefined, operation);
880
1093
  return baseline;
881
1094
  } catch (error) {
882
1095
  if (error instanceof SnapApiError && error.status === 404) {
@@ -919,9 +1132,10 @@ export class SnapProvider {
919
1132
  */
920
1133
  async exportBaselines(_options) {
921
1134
  const exportPath = `/v1/visual/projects/${this.#projectId}/export`;
1135
+ const operation = this.#createOperationDeadline('baseline export');
922
1136
  let archive;
923
1137
  try {
924
- archive = await this.#requestBinary('GET', exportPath);
1138
+ archive = await this.#requestBinary('GET', exportPath, operation);
925
1139
  } catch (error) {
926
1140
  if (error instanceof SnapApiError) {
927
1141
  if (error.status === 403) {
@@ -991,6 +1205,20 @@ export class SnapProvider {
991
1205
  (baseline.objects || []).map((object) => [object.sourceKey, object.archivePath])
992
1206
  );
993
1207
 
1208
+ const sourceLabel = `Snap baseline "${baseline.id}" source manifest`;
1209
+ /** @type {Map<string, unknown[]>} */
1210
+ const routeIdsByExtension = new Map();
1211
+ for (const route of routes) {
1212
+ const archivePath = route && route.objectKey ? archivePathByKey.get(route.objectKey) : undefined;
1213
+ const extension = typeof archivePath === 'string' ? path.extname(archivePath) || '.png' : '.png';
1214
+ const routeIds = routeIdsByExtension.get(extension) || [];
1215
+ routeIds.push(route?.routeId);
1216
+ routeIdsByExtension.set(extension, routeIds);
1217
+ }
1218
+ for (const [extension, routeIds] of routeIdsByExtension) {
1219
+ assertUniqueRouteIdFilenames(routeIds, sourceLabel, extension);
1220
+ }
1221
+
994
1222
  /** @type {Array<{ filename: string, data: Buffer }>} */
995
1223
  const screenshots = [];
996
1224
  /** @type {JsonObject[]} */
@@ -1021,7 +1249,7 @@ export class SnapProvider {
1021
1249
  ? { width: descriptor.width, height: descriptor.height }
1022
1250
  : { width: dimensions.width, height: dimensions.height };
1023
1251
 
1024
- const extension = path.extname(archivePath) || '.png';
1252
+ const extension = typeof archivePath === 'string' ? path.extname(archivePath) || '.png' : '.png';
1025
1253
  const filename = `${sanitizeRouteId(route.routeId)}${extension}`;
1026
1254
  screenshots.push({ filename, data: Buffer.from(bytes) });
1027
1255
  manifestEntries.push({
@@ -1153,9 +1381,9 @@ export class SnapProvider {
1153
1381
  *
1154
1382
  * @returns {Promise<string | null>}
1155
1383
  */
1156
- async #resolveLatestBaselineId() {
1384
+ async #resolveLatestBaselineId(operation = this.#createOperationDeadline('baseline lookup')) {
1157
1385
  try {
1158
- const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`);
1386
+ const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`, undefined, undefined, operation);
1159
1387
  return baseline?.id ?? null;
1160
1388
  } catch (error) {
1161
1389
  if (error instanceof SnapApiError && error.status === 404) {
@@ -1185,6 +1413,374 @@ export class SnapProvider {
1185
1413
  return crypto.randomUUID();
1186
1414
  }
1187
1415
 
1416
+ /** @param {string} [label] @returns {OperationDeadline} */
1417
+ #createOperationDeadline(label = 'Snap operation') {
1418
+ return { deadline: this.#nowFn() + MAX_OPERATION_DURATION_MS, label };
1419
+ }
1420
+
1421
+ /** @param {OperationDeadline} operation @returns {number} */
1422
+ #remainingOperationMs(operation) {
1423
+ return operation.deadline - this.#nowFn();
1424
+ }
1425
+
1426
+ /**
1427
+ * @param {string} requestPath
1428
+ * @param {OperationDeadline} operation
1429
+ * @returns {SnapUnavailableError}
1430
+ */
1431
+ #operationDeadlineError(requestPath, operation) {
1432
+ return new SnapUnavailableError(
1433
+ `Snap ${operation.label} operation deadline exceeded while requesting ${requestPath}; ` +
1434
+ `the ${OPERATION_LIMIT_TEXT} Snap operation limit was reached`
1435
+ );
1436
+ }
1437
+
1438
+ /**
1439
+ * @param {string} requestPath
1440
+ * @param {number} timeoutMs
1441
+ * @param {OperationDeadline} operation
1442
+ * @param {number} requestLimitMs
1443
+ * @returns {Error}
1444
+ */
1445
+ #requestTimeoutError(requestPath, timeoutMs, operation, requestLimitMs) {
1446
+ const error = new Error(
1447
+ `Snap ${operation.label} request ${requestPath} timed out after ` +
1448
+ `${Math.max(0, Math.round(timeoutMs))}ms (request limit ${requestLimitMs}ms; ` +
1449
+ `operation limit ${OPERATION_LIMIT_TEXT})`
1450
+ );
1451
+ error.name = 'SnapTransportTimeoutError';
1452
+ return error;
1453
+ }
1454
+
1455
+ /** @param {number} attempt @returns {number} */
1456
+ #retryDelay(attempt) {
1457
+ return Math.min(
1458
+ INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)),
1459
+ RETRY_DELAY_CAP_MS
1460
+ );
1461
+ }
1462
+
1463
+ /** @param {string} requestPath @param {number} delayMs @param {OperationDeadline} operation @returns {SnapUnavailableError} */
1464
+ #sleepTimeoutError(requestPath, delayMs, operation) {
1465
+ return new SnapUnavailableError(
1466
+ `Snap ${operation.label} wait for ${requestPath} timed out after ${delayMs}ms ` +
1467
+ `before the ${OPERATION_LIMIT_TEXT} operation deadline`
1468
+ );
1469
+ }
1470
+
1471
+ /**
1472
+ * Read and classify an HTTP error response under the current deadline.
1473
+ *
1474
+ * A received 4xx remains non-retryable even when its diagnostic body cannot
1475
+ * be read. Retryable 5xx body failures are returned to the transport loop so
1476
+ * they retain the normal availability semantics.
1477
+ *
1478
+ * @param {any} response
1479
+ * @param {string} requestPath
1480
+ * @param {AbortController} controller
1481
+ * @param {number} attemptDeadline
1482
+ * @param {OperationDeadline} operation
1483
+ * @param {number} requestLimitMs
1484
+ * @returns {Promise<SnapApiError>}
1485
+ */
1486
+ async #readApiError(response, requestPath, controller, attemptDeadline, operation, requestLimitMs) {
1487
+ let errorBody;
1488
+ try {
1489
+ errorBody = await this.#readResponseBody(
1490
+ response,
1491
+ requestPath,
1492
+ controller,
1493
+ attemptDeadline,
1494
+ operation,
1495
+ requestLimitMs,
1496
+ () => response.text()
1497
+ );
1498
+ } catch (error) {
1499
+ if (response.status < 400 || response.status >= 500) throw error;
1500
+ const detail = error instanceof Error ? error.message : String(error);
1501
+ const reason = error instanceof SnapUnavailableError || error?.name === 'SnapTransportTimeoutError'
1502
+ ? `diagnostic body timed out (${detail})`
1503
+ : `diagnostic body unavailable (${detail})`;
1504
+ return new SnapApiError(
1505
+ response.status,
1506
+ `Snap API ${response.status}: ${reason}`,
1507
+ requestPath
1508
+ );
1509
+ }
1510
+
1511
+ let detail = '';
1512
+ try {
1513
+ const parsed = JSON.parse(errorBody);
1514
+ detail = parsed?.error || parsed?.message || '';
1515
+ } catch { /* use default message */ }
1516
+ return new SnapApiError(
1517
+ response.status,
1518
+ detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`,
1519
+ requestPath
1520
+ );
1521
+ }
1522
+
1523
+ /**
1524
+ * Read a response body under the current attempt and operation deadlines.
1525
+ *
1526
+ * @template T
1527
+ * @param {any} response
1528
+ * @param {string} requestPath
1529
+ * @param {AbortController} controller
1530
+ * @param {number} attemptDeadline
1531
+ * @param {OperationDeadline} operation
1532
+ * @param {number} requestLimitMs
1533
+ * @param {() => Promise<T>} reader
1534
+ * @returns {Promise<T>}
1535
+ */
1536
+ async #readResponseBody(response, requestPath, controller, attemptDeadline, operation, requestLimitMs, reader) {
1537
+ const now = this.#nowFn();
1538
+ const attemptRemaining = attemptDeadline - now;
1539
+ const operationRemaining = this.#remainingOperationMs(operation);
1540
+ if (operationRemaining <= 0) {
1541
+ controller.abort();
1542
+ cancelResponseBody(response);
1543
+ throw this.#operationDeadlineError(requestPath, operation);
1544
+ }
1545
+ if (attemptRemaining <= 0) {
1546
+ controller.abort();
1547
+ cancelResponseBody(response);
1548
+ throw this.#requestTimeoutError(requestPath, requestLimitMs, operation, requestLimitMs);
1549
+ }
1550
+ const remaining = Math.min(attemptRemaining, operationRemaining);
1551
+ return runWithTimeout(
1552
+ reader,
1553
+ remaining,
1554
+ () => {
1555
+ controller.abort();
1556
+ cancelResponseBody(response);
1557
+ },
1558
+ () => this.#remainingOperationMs(operation) <= 0
1559
+ ? this.#operationDeadlineError(requestPath, operation)
1560
+ : this.#requestTimeoutError(requestPath, remaining, operation, requestLimitMs)
1561
+ );
1562
+ }
1563
+
1564
+ /**
1565
+ * Sleep for a retry or poll interval without exceeding the operation budget.
1566
+ *
1567
+ * @param {number} delayMs
1568
+ * @param {OperationDeadline} operation
1569
+ * @param {string} requestPath
1570
+ * @returns {Promise<void>}
1571
+ */
1572
+ async #sleepWithinOperation(delayMs, operation, requestPath) {
1573
+ const remaining = this.#remainingOperationMs(operation);
1574
+ if (remaining <= 0) {
1575
+ throw this.#operationDeadlineError(requestPath, operation);
1576
+ }
1577
+ const delay = Math.min(delayMs, remaining);
1578
+ const controller = new AbortController();
1579
+ await runWithTimeout(
1580
+ () => this.#sleepFn(delay, controller.signal),
1581
+ // Keep a one-millisecond watchdog margin so a sleep timer scheduled by
1582
+ // an async injected sleepFn can settle before the watchdog at the same
1583
+ // nominal deadline. The signal aborts the default timer on timeout.
1584
+ delay + 1,
1585
+ () => controller.abort(),
1586
+ () => this.#sleepTimeoutError(requestPath, delay, operation)
1587
+ );
1588
+ }
1589
+
1590
+ /**
1591
+ * Shared JSON/binary transport with retries and bounded request bodies.
1592
+ *
1593
+ * @param {{
1594
+ * method: string,
1595
+ * requestPath: string,
1596
+ * body?: object,
1597
+ * idempotencyKey?: string,
1598
+ * contentType?: string,
1599
+ * accept?: string,
1600
+ * operation: OperationDeadline,
1601
+ * requestTimeoutMs: number,
1602
+ * decode: (response: any, controller: AbortController, attemptDeadline: number, operation: OperationDeadline, requestLimitMs: number) => Promise<any>
1603
+ * }} options
1604
+ * @returns {Promise<any>}
1605
+ */
1606
+ async #requestTransport(options) {
1607
+ const serializedBody = options.body ? JSON.stringify(options.body) : undefined;
1608
+ const requestHeaders = {
1609
+ 'Authorization': `Bearer ${this.#apiKey}`
1610
+ };
1611
+ if (options.accept) {
1612
+ requestHeaders['Accept'] = options.accept;
1613
+ }
1614
+ if (options.contentType) {
1615
+ requestHeaders['Content-Type'] = options.contentType;
1616
+ }
1617
+ if (options.idempotencyKey && options.method === 'POST') {
1618
+ requestHeaders['Idempotency-Key'] = options.idempotencyKey;
1619
+ }
1620
+
1621
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
1622
+ const remaining = this.#remainingOperationMs(options.operation);
1623
+ if (remaining <= 0) {
1624
+ return this.#handleUnavailable(this.#operationDeadlineError(options.requestPath, options.operation));
1625
+ }
1626
+
1627
+ const attemptBudget = Math.min(options.requestTimeoutMs, remaining);
1628
+ const attemptDeadline = this.#nowFn() + attemptBudget;
1629
+ const controller = new AbortController();
1630
+ try {
1631
+ const response = await runWithTimeout(
1632
+ () => this.#fetchFn(`${this.#apiUrl}${options.requestPath}`, {
1633
+ method: options.method,
1634
+ headers: { ...requestHeaders },
1635
+ body: serializedBody,
1636
+ signal: controller.signal
1637
+ }),
1638
+ attemptBudget,
1639
+ () => controller.abort(),
1640
+ () => this.#requestTimeoutError(options.requestPath, attemptBudget, options.operation, options.requestTimeoutMs)
1641
+ );
1642
+
1643
+ if (response.ok) {
1644
+ return await options.decode(response, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
1645
+ }
1646
+
1647
+ // 4xx — non-retryable when the diagnostic body is available. A body
1648
+ // read timeout still preserves the received HTTP status, rather than
1649
+ // turning an already-classified API response into an availability retry.
1650
+ if (response.status >= 400 && response.status < 500) {
1651
+ throw await this.#readApiError(
1652
+ response,
1653
+ options.requestPath,
1654
+ controller,
1655
+ attemptDeadline,
1656
+ options.operation,
1657
+ options.requestTimeoutMs
1658
+ );
1659
+ }
1660
+
1661
+ // 5xx — retry with backoff unless this is the last attempt. Cancel
1662
+ // discarded response bodies so a slow server cannot retain a stream.
1663
+ if (attempt < MAX_RETRIES) {
1664
+ cancelResponseBody(response);
1665
+ await this.#sleepWithinOperation(this.#retryDelay(attempt), options.operation, options.requestPath);
1666
+ continue;
1667
+ }
1668
+
1669
+ throw await this.#readApiError(
1670
+ response,
1671
+ options.requestPath,
1672
+ controller,
1673
+ attemptDeadline,
1674
+ options.operation,
1675
+ options.requestTimeoutMs
1676
+ );
1677
+ } catch (error) {
1678
+ if (error instanceof SnapSkipError || error instanceof SnapFallbackError) {
1679
+ throw error;
1680
+ }
1681
+ if (error instanceof SnapApiError && error.status >= 400 && error.status < 500) {
1682
+ throw error;
1683
+ }
1684
+ if (error instanceof SnapApiError && error.status >= 500) {
1685
+ return this.#handleUnavailable(error);
1686
+ }
1687
+ if (error instanceof SnapUnavailableError) {
1688
+ return this.#handleUnavailable(error);
1689
+ }
1690
+
1691
+ if (this.#remainingOperationMs(options.operation) <= 0) {
1692
+ return this.#handleUnavailable(this.#operationDeadlineError(options.requestPath, options.operation));
1693
+ }
1694
+
1695
+ if (attempt < MAX_RETRIES) {
1696
+ try {
1697
+ await this.#sleepWithinOperation(this.#retryDelay(attempt), options.operation, options.requestPath);
1698
+ } catch (retryError) {
1699
+ return this.#handleUnavailable(retryError instanceof Error
1700
+ ? retryError
1701
+ : new SnapUnavailableError(String(retryError)));
1702
+ }
1703
+ continue;
1704
+ }
1705
+
1706
+ const detail = error instanceof Error ? error.message : String(error);
1707
+ return this.#handleUnavailable(
1708
+ new SnapUnavailableError(
1709
+ `Snap request ${options.requestPath} failed after ${MAX_RETRIES} attempts: ${detail}`
1710
+ )
1711
+ );
1712
+ } finally {
1713
+ controller.abort();
1714
+ }
1715
+ }
1716
+
1717
+ return this.#handleUnavailable(this.#operationDeadlineError(options.requestPath, options.operation));
1718
+ }
1719
+
1720
+ /**
1721
+ * Make a JSON request with retry and operation deadlines.
1722
+ *
1723
+ * @param {string} method
1724
+ * @param {string} requestPath
1725
+ * @param {object} [body]
1726
+ * @param {string} [idempotencyKey]
1727
+ * @param {OperationDeadline} [operation]
1728
+ * @returns {Promise<any>}
1729
+ */
1730
+ async #request(method, requestPath, body, idempotencyKey, operation = this.#createOperationDeadline('Snap request')) {
1731
+ return this.#requestTransport({
1732
+ method,
1733
+ requestPath,
1734
+ body,
1735
+ idempotencyKey,
1736
+ contentType: 'application/json',
1737
+ accept: 'application/json',
1738
+ operation,
1739
+ requestTimeoutMs: JSON_REQUEST_TIMEOUT_MS,
1740
+ decode: async (response, controller, attemptDeadline, requestOperation, requestLimitMs) => {
1741
+ const text = await this.#readResponseBody(
1742
+ response,
1743
+ requestPath,
1744
+ controller,
1745
+ attemptDeadline,
1746
+ requestOperation,
1747
+ requestLimitMs,
1748
+ () => response.text()
1749
+ );
1750
+ return text ? JSON.parse(text) : {};
1751
+ }
1752
+ });
1753
+ }
1754
+
1755
+ /**
1756
+ * Make a binary request with retry and operation deadlines.
1757
+ *
1758
+ * @param {string} method
1759
+ * @param {string} requestPath
1760
+ * @param {OperationDeadline} [operation]
1761
+ * @returns {Promise<Buffer>}
1762
+ */
1763
+ async #requestBinary(method, requestPath, operation = this.#createOperationDeadline('binary export')) {
1764
+ return this.#requestTransport({
1765
+ method,
1766
+ requestPath,
1767
+ operation,
1768
+ requestTimeoutMs: BINARY_REQUEST_TIMEOUT_MS,
1769
+ decode: async (response, controller, attemptDeadline, requestOperation, requestLimitMs) => {
1770
+ const bytes = await this.#readResponseBody(
1771
+ response,
1772
+ requestPath,
1773
+ controller,
1774
+ attemptDeadline,
1775
+ requestOperation,
1776
+ requestLimitMs,
1777
+ () => response.arrayBuffer()
1778
+ );
1779
+ return Buffer.from(bytes);
1780
+ }
1781
+ });
1782
+ }
1783
+
1188
1784
  /**
1189
1785
  * @param {{
1190
1786
  * configPath?: string,
@@ -1226,11 +1822,12 @@ export class SnapProvider {
1226
1822
  });
1227
1823
 
1228
1824
  const runId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
1825
+ const operation = this.#createOperationDeadline('capture');
1229
1826
  // A baseline run establishes new ground truth and must not be diffed, so we
1230
1827
  // omit the baselineId. Captures then settle to "new" server-side (no
1231
1828
  // comparison), and publishBaseline harvests their object keys. A diff run
1232
1829
  // attaches the latest baseline so the backend can compare against it.
1233
- const baselineId = options.purpose === 'diff' ? await this.#resolveLatestBaselineId() : null;
1830
+ const baselineId = options.purpose === 'diff' ? await this.#resolveLatestBaselineId(operation) : null;
1234
1831
 
1235
1832
  await this.#request('POST', `/v1/visual/projects/${this.#projectId}/runs`, {
1236
1833
  id: runId,
@@ -1253,7 +1850,7 @@ export class SnapProvider {
1253
1850
  expectedCaptures: options.expectedCaptures
1254
1851
  },
1255
1852
  ...options.runContext
1256
- }, this.#generateIdempotencyKey());
1853
+ }, this.#generateIdempotencyKey(), operation);
1257
1854
 
1258
1855
  for (const { route, manifestEntry } of uploads) {
1259
1856
  const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
@@ -1269,7 +1866,7 @@ export class SnapProvider {
1269
1866
  // worker's queue — without it the worker would try to render baseUrl
1270
1867
  // (a client-only/loopback address it can't reach) and error the run.
1271
1868
  localCapture: true,
1272
- }, this.#generateIdempotencyKey());
1869
+ }, this.#generateIdempotencyKey(), operation);
1273
1870
 
1274
1871
  const imagePath = path.resolve(localCapture.screenshotsRoot, manifestEntry.imagePath);
1275
1872
  const imageBytes = await fs.readFile(imagePath);
@@ -1278,7 +1875,7 @@ export class SnapProvider {
1278
1875
  imageBase64: imageBytes.toString('base64'),
1279
1876
  width: manifestEntry.width,
1280
1877
  height: manifestEntry.height
1281
- }, this.#generateIdempotencyKey());
1878
+ }, this.#generateIdempotencyKey(), operation);
1282
1879
  }
1283
1880
 
1284
1881
  const snapResults = {
@@ -1299,149 +1896,6 @@ export class SnapProvider {
1299
1896
  return localCapture;
1300
1897
  }
1301
1898
 
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
1899
  /**
1446
1900
  * Handle unavailable behavior based on onUnavailable config.
1447
1901
  * @param {Error} error
@@ -1471,17 +1925,27 @@ export class SnapProvider {
1471
1925
  * Poll a run until it reaches a terminal state.
1472
1926
  * @param {string} runId
1473
1927
  * @param {number} [expectedCaptureCount]
1928
+ * @param {(detail: string) => Error} [errorFactory]
1929
+ * @param {OperationDeadline} [operation]
1474
1930
  * @returns {Promise<JsonObject>}
1475
1931
  */
1476
- async #pollRun(runId, expectedCaptureCount) {
1477
- const startTime = Date.now();
1932
+ async #pollRun(
1933
+ runId,
1934
+ expectedCaptureCount,
1935
+ errorFactory = (detail) => new Error(detail),
1936
+ operation = this.#createOperationDeadline('run polling')
1937
+ ) {
1478
1938
  let incompleteTerminalSignature;
1479
1939
  let stableIncompleteTerminalPolls = 0;
1480
1940
  let incompleteSignature;
1481
1941
  let stableIncompletePolls = 0;
1482
1942
 
1483
- while (Date.now() - startTime < MAX_POLL_DURATION_MS) {
1484
- const run = await this.#request('GET', `/v1/visual/runs/${runId}`);
1943
+ while (this.#remainingOperationMs(operation) > 0) {
1944
+ const requestPath = `/v1/visual/runs/${runId}`;
1945
+ const run = await this.#request('GET', requestPath, undefined, undefined, operation);
1946
+ if (!run || typeof run !== 'object' || Array.isArray(run)) {
1947
+ throw errorFactory(`Snap returned a malformed run response for requested run "${runId}".`);
1948
+ }
1485
1949
  const captures = Array.isArray(run.captures) ? run.captures : [];
1486
1950
  const completeCaptureSet =
1487
1951
  expectedCaptureCount === undefined ||
@@ -1549,21 +2013,36 @@ export class SnapProvider {
1549
2013
  }
1550
2014
  }
1551
2015
 
1552
- await this.#sleepFn(POLL_INTERVAL_MS);
2016
+ try {
2017
+ await this.#sleepWithinOperation(POLL_INTERVAL_MS, operation, requestPath);
2018
+ } catch (error) {
2019
+ return this.#handleUnavailable(error instanceof Error
2020
+ ? error
2021
+ : new SnapUnavailableError(String(error)));
2022
+ }
1553
2023
  }
1554
2024
 
1555
- throw new Error(`Snap run ${runId} did not complete within 10 minutes.`);
2025
+ return this.#handleUnavailable(new SnapUnavailableError(
2026
+ `Snap ${operation.label} run ${runId} did not complete within ${OPERATION_LIMIT_TEXT}; ` +
2027
+ 'the Snap operation deadline was reached while polling'
2028
+ ));
1556
2029
  }
1557
2030
 
1558
2031
  /**
1559
2032
  * Map a Snap run result to a VisualDiffSummary.
1560
2033
  * @param {JsonObject} run
1561
2034
  * @param {VisualRegressionConfig} config
2035
+ * @param {{
2036
+ * selectedRouteIds: string[],
2037
+ * expectedCaptures: Array<{ routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
2038
+ * expectedByIdentity: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
2039
+ * expectedByRouteId: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>
2040
+ * }} diffPlan
1562
2041
  * @returns {import('../types/visual-diff-types').VisualDiffSummary}
1563
2042
  */
1564
- #mapRunToSummary(run, config) {
1565
- const captures = run.captures || [];
1566
- const totalScreenshots = captures.length;
2043
+ #mapRunToSummary(run, config, diffPlan) {
2044
+ const captures = Array.isArray(run.captures) ? run.captures : [];
2045
+ const totalScreenshots = diffPlan.expectedCaptures.length;
1567
2046
  let matchedScreenshots = 0;
1568
2047
  let changedScreenshots = 0;
1569
2048
  let missingInBaseline = 0;
@@ -1581,72 +2060,209 @@ export class SnapProvider {
1581
2060
  /** @type {import('../types/visual-diff-types').VisualDiffErrorItem[]} */
1582
2061
  const errors = [];
1583
2062
 
2063
+ /**
2064
+ * @param {string} id
2065
+ * @param {string | undefined} path
2066
+ * @param {import('../types/visual-diff-types').VisualViewport | undefined} viewport
2067
+ * @param {string} message
2068
+ */
2069
+ const addError = (id, path, viewport, message) => {
2070
+ errors.push({ id, path, viewport, status: 'error', message });
2071
+ };
2072
+
2073
+ if (!Array.isArray(run.captures)) {
2074
+ addError(`run:${run.id}`, undefined, undefined, 'Snap run returned no valid captures array.');
2075
+ }
2076
+
2077
+ if (run.status === 'error') {
2078
+ addError(
2079
+ `run:${run.id}`,
2080
+ undefined,
2081
+ undefined,
2082
+ run.errorDetails?.message || run.errorCode || run.message || 'Snap run ended with status "error".'
2083
+ );
2084
+ }
2085
+
2086
+ const seenIdentities = new Set();
2087
+ const coveredExpectedRoutes = new Set();
1584
2088
  for (const capture of captures) {
2089
+ if (!capture || typeof capture !== 'object' || Array.isArray(capture)) {
2090
+ addError('unknown', undefined, undefined, 'Snap run returned a malformed capture.');
2091
+ continue;
2092
+ }
2093
+
2094
+ if (typeof capture.routeId === 'string' && diffPlan.expectedByRouteId.has(capture.routeId)) {
2095
+ coveredExpectedRoutes.add(capture.routeId);
2096
+ }
2097
+
2098
+ let identity;
2099
+ try {
2100
+ identity = captureIdentityKey(
2101
+ capture,
2102
+ `source capture "${capture.routeId || capture.id || 'unknown'}"`,
2103
+ hostedDiffError
2104
+ );
2105
+ } catch (error) {
2106
+ const malformedRouteId = typeof capture.routeId === 'string' && capture.routeId
2107
+ ? capture.routeId
2108
+ : 'unknown';
2109
+ const expected = diffPlan.expectedByRouteId.get(malformedRouteId);
2110
+ addError(
2111
+ malformedRouteId,
2112
+ typeof capture.routePath === 'string' ? capture.routePath : expected?.routePath,
2113
+ expected?.viewport ?? capture.viewport,
2114
+ error instanceof Error ? error.message : String(error)
2115
+ );
2116
+ continue;
2117
+ }
2118
+
2119
+ const expected = diffPlan.expectedByIdentity.get(identity);
2120
+ if (!expected) {
2121
+ addError(
2122
+ capture.routeId,
2123
+ capture.routePath,
2124
+ capture.viewport,
2125
+ `Snap run returned an unexpected route/viewport capture for "${capture.routeId}".`
2126
+ );
2127
+ continue;
2128
+ }
2129
+ if (seenIdentities.has(identity)) {
2130
+ addError(
2131
+ expected.routeId,
2132
+ expected.routePath,
2133
+ expected.viewport,
2134
+ `Snap run returned a duplicate route/viewport capture for "${expected.routeId}".`
2135
+ );
2136
+ continue;
2137
+ }
2138
+ seenIdentities.add(identity);
2139
+
2140
+ if (capture.routePath !== expected.routePath) {
2141
+ addError(
2142
+ expected.routeId,
2143
+ capture.routePath,
2144
+ expected.viewport,
2145
+ `Snap run returned an unexpected path for "${expected.routeId}".`
2146
+ );
2147
+ continue;
2148
+ }
2149
+
1585
2150
  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
- });
2151
+ addError(
2152
+ expected.routeId,
2153
+ expected.routePath,
2154
+ expected.viewport,
2155
+ capture.errorDetails?.message || capture.errorCode || 'Snap capture ended with status "error".'
2156
+ );
2157
+ continue;
2158
+ }
2159
+
2160
+ if (capture.status !== 'new' && capture.status !== 'diffed') {
2161
+ addError(
2162
+ expected.routeId,
2163
+ expected.routePath,
2164
+ expected.viewport,
2165
+ `Snap capture "${expected.routeId}" ended with unknown status "${capture.status || 'unknown'}".`
2166
+ );
2167
+ continue;
2168
+ }
2169
+
2170
+ if (typeof capture.currentObjectKey !== 'string' || !capture.currentObjectKey) {
2171
+ addError(
2172
+ expected.routeId,
2173
+ expected.routePath,
2174
+ expected.viewport,
2175
+ `Snap capture "${expected.routeId}" has no currentObjectKey.`
2176
+ );
1593
2177
  continue;
1594
2178
  }
1595
2179
 
1596
2180
  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.
2181
+ // The backend rendered this capture but had no baseline to diff against.
1600
2182
  missingInBaseline++;
1601
2183
  missing.push({
1602
- id: capture.routeId || capture.id,
2184
+ id: expected.routeId,
1603
2185
  reason: 'no baseline capture found on Snap',
1604
- path: capture.routePath,
1605
- viewport: capture.viewport,
2186
+ path: expected.routePath,
2187
+ viewport: expected.viewport,
1606
2188
  location: 'baseline'
1607
2189
  });
1608
2190
  continue;
1609
2191
  }
1610
2192
 
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++;
2193
+ if (capture.baselineObjectKey !== undefined &&
2194
+ capture.baselineObjectKey !== null &&
2195
+ typeof capture.baselineObjectKey !== 'string') {
2196
+ addError(
2197
+ expected.routeId,
2198
+ expected.routePath,
2199
+ expected.viewport,
2200
+ `Snap capture "${expected.routeId}" has an invalid baselineObjectKey.`
2201
+ );
2202
+ continue;
2203
+ }
2204
+
2205
+ if (!capture.baselineObjectKey) {
2206
+ // No baseline was attached to this capture: the backend short-circuits
2207
+ // to "diffed" with no comparison data.
2208
+ missingInBaseline++;
2209
+ missing.push({
2210
+ id: expected.routeId,
2211
+ reason: 'no baseline capture found on Snap',
2212
+ path: expected.routePath,
2213
+ viewport: expected.viewport,
2214
+ location: 'baseline'
2215
+ });
2216
+ continue;
2217
+ }
2218
+
2219
+ if (!Number.isFinite(capture.diffPct) || capture.diffPct < 0 || capture.diffPct > 1) {
2220
+ addError(
2221
+ expected.routeId,
2222
+ expected.routePath,
2223
+ expected.viewport,
2224
+ `Snap capture "${expected.routeId}" has an invalid diffPct.`
2225
+ );
2226
+ continue;
2227
+ }
2228
+
2229
+ if (capture.diffPct > config.diff.threshold) {
2230
+ comparedWithBaseline++;
2231
+ changedScreenshots++;
2232
+ changed.push({
2233
+ id: expected.routeId,
2234
+ path: expected.routePath,
2235
+ viewport: expected.viewport,
2236
+ baselineImagePath: capture.baselineObjectKey,
2237
+ currentImagePath: capture.currentObjectKey,
2238
+ width: capture.width || 0,
2239
+ height: capture.height || 0,
2240
+ differentPixels: capture.diffPixels || 0,
2241
+ totalPixels: (capture.width || 0) * (capture.height || 0),
2242
+ mismatchRatio: capture.diffPct,
2243
+ status: 'changed'
2244
+ });
2245
+ } else {
2246
+ comparedWithBaseline++;
2247
+ if (capture.diffPct === 0) {
2248
+ exactZeroMatches++;
1646
2249
  }
2250
+ matchedScreenshots++;
1647
2251
  }
1648
2252
  }
1649
2253
 
2254
+ for (const expected of diffPlan.expectedByRouteId.values()) {
2255
+ if (coveredExpectedRoutes.has(expected.routeId)) continue;
2256
+ missingInCurrent++;
2257
+ missing.push({
2258
+ id: expected.routeId,
2259
+ reason: 'capture missing from Snap run',
2260
+ path: expected.routePath,
2261
+ viewport: expected.viewport,
2262
+ location: 'current'
2263
+ });
2264
+ }
2265
+
1650
2266
  // False-negative guard: when a diff run compared every route against a real
1651
2267
  // baseline yet every single comparison came back pixel-identical (0% drift),
1652
2268
  // the most likely explanation is that the client captured a stale or wrong
@@ -1673,7 +2289,7 @@ export class SnapProvider {
1673
2289
  finishedAt: run.finishedAt || new Date().toISOString(),
1674
2290
  completed: true,
1675
2291
  status: run.status === 'pass' ? 'clean' : run.status === 'fail' ? 'changes-detected' : 'incomplete',
1676
- selectedRoutes: captures.map((c) => c.routeId || c.id),
2292
+ selectedRoutes: diffPlan.selectedRouteIds,
1677
2293
  baselineManifestPath: '',
1678
2294
  currentManifestPath: '',
1679
2295
  diffMode: config.diff.mode,