snapdrift 0.8.1 → 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,21 @@ 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;
46
+ const MAX_STABLE_INCOMPLETE_POLLS = 30;
47
+ const BASELINE_RUN_SUCCESS_STATUS = 'new';
48
+ const BASELINE_CAPTURE_SUCCESS_STATUS = 'new';
49
+ const TERMINAL_CAPTURE_STATUSES = new Set(['new', 'diffed', 'error']);
35
50
 
36
51
  // Snap reports a run as "new" when it has no baseline to compare against (first
37
52
  // capture for a project, or any baseline-publish run that intentionally omits a
@@ -39,6 +54,372 @@ const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
39
54
  // client must stop polling on it just like pass/fail/error.
40
55
  const TERMINAL_RUN_STATUSES = new Set(['pass', 'fail', 'error', 'new']);
41
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
+
132
+ /** @param {string} detail @returns {Error} */
133
+ function completeBaselineError(detail) {
134
+ return new Error(`Cannot publish a complete Snap baseline: ${detail}`);
135
+ }
136
+
137
+ /** @param {string} detail @returns {Error} */
138
+ function hostedDiffError(detail) {
139
+ return new Error(`Cannot compare a Snap visual diff: ${detail}`);
140
+ }
141
+
142
+ /**
143
+ * @param {import('../types/visual-diff-types').VisualViewport} viewport
144
+ * @returns {{ width: number, height: number, deviceScaleFactor?: number, isMobile?: boolean, hasTouch?: boolean }}
145
+ */
146
+ function resolveViewportDescriptor(viewport) {
147
+ return typeof viewport === 'string'
148
+ ? (VIEWPORT_PRESETS[viewport] ?? { width: 1280, height: 720 })
149
+ : viewport;
150
+ }
151
+
152
+ /** @param {VisualRegressionConfig['routes'][number]} route */
153
+ function expectedCaptureIdentity(route) {
154
+ return {
155
+ routeId: route.id,
156
+ routePath: route.path,
157
+ viewportDescriptorJson: JSON.stringify(resolveViewportDescriptor(route.viewport))
158
+ };
159
+ }
160
+
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) {
168
+ let descriptor;
169
+ try {
170
+ descriptor = typeof value === 'string' ? JSON.parse(value) : value;
171
+ } catch {
172
+ throw errorFactory(`${context} has malformed viewportDescriptorJson.`);
173
+ }
174
+ if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor) ||
175
+ !Number.isFinite(descriptor.width) || !Number.isFinite(descriptor.height)) {
176
+ throw errorFactory(`${context} has an invalid viewport descriptor.`);
177
+ }
178
+ return JSON.stringify(Object.fromEntries(
179
+ Object.entries(descriptor).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
180
+ ));
181
+ }
182
+
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) {
189
+ if (typeof capture.routeId !== 'string' || !capture.routeId) {
190
+ throw errorFactory(`${context} has no routeId.`);
191
+ }
192
+ return `${capture.routeId}\u0000${normalizeViewportDescriptor(capture.viewportDescriptorJson, context, errorFactory)}`;
193
+ }
194
+
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) {
202
+ if (!Array.isArray(value) || value.some((id) => typeof id !== 'string' || !id)) {
203
+ throw errorFactory(`results metadata has no valid ${field}; capture again with SnapDrift 0.8.2 or newer.`);
204
+ }
205
+ const ids = new Set(value);
206
+ if (ids.size !== value.length) {
207
+ throw errorFactory(`results metadata ${field} contains duplicate route ids.`);
208
+ }
209
+ return ids;
210
+ }
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
+
306
+ /** @param {string[]} configuredRouteIds @param {string[]} selectedRouteIds */
307
+ function assertCompleteHostedBaselineSelection(configuredRouteIds, selectedRouteIds) {
308
+ const selected = new Set(selectedRouteIds);
309
+ const missing = configuredRouteIds.filter((id) => !selected.has(id));
310
+ if (missing.length === 0 && selected.size === configuredRouteIds.length) return;
311
+ throw new Error(
312
+ `Hosted Snap baseline publication requires all ${configuredRouteIds.length} configured route(s), ` +
313
+ `but only ${selected.size} were selected. Remove route-ids/--routes scoping ` +
314
+ `(missing: ${missing.join(', ') || 'unknown'}). Scoped runs remain supported for PR diffs ` +
315
+ 'and local-provider baselines.'
316
+ );
317
+ }
318
+
319
+ /**
320
+ * @param {import('../types/visual-diff-types').SnapRunMetadata} metadata
321
+ * @param {JsonObject} run
322
+ * @param {string} projectId
323
+ */
324
+ function validateCompleteBaselineRun(metadata, run, projectId) {
325
+ if (metadata.projectId !== projectId) {
326
+ throw completeBaselineError(`source run belongs to project "${metadata.projectId || 'unknown'}", not "${projectId}".`);
327
+ }
328
+ if (metadata.purpose !== 'baseline') {
329
+ throw completeBaselineError('source results were not recorded as a baseline capture.');
330
+ }
331
+ if (typeof metadata.refBranch !== 'string' || !metadata.refBranch ||
332
+ typeof metadata.refSha !== 'string' || !metadata.refSha) {
333
+ throw completeBaselineError('source results have no resolved ref; capture again with SnapDrift 0.8.2 or newer.');
334
+ }
335
+ if (
336
+ typeof metadata.publicationSequence !== 'number' ||
337
+ !Number.isSafeInteger(metadata.publicationSequence) ||
338
+ metadata.publicationSequence <= 0
339
+ ) {
340
+ throw completeBaselineError(
341
+ 'source results have no valid publication sequence; capture again with SnapDrift 0.8.2 or newer.'
342
+ );
343
+ }
344
+ if (typeof metadata.publicationWorkflowRef !== 'string' || !metadata.publicationWorkflowRef.trim()) {
345
+ throw completeBaselineError(
346
+ 'source results have no publication workflow identity; capture again with SnapDrift 0.8.2 or newer.'
347
+ );
348
+ }
349
+
350
+ const configured = uniqueRouteIdSet(metadata.configuredRouteIds, 'configuredRouteIds', completeBaselineError);
351
+ const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds', completeBaselineError);
352
+ assertCompleteHostedBaselineSelection([...configured], [...selected]);
353
+ if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length !== configured.size) {
354
+ throw completeBaselineError(`expected capture metadata does not match ${configured.size} configured route(s).`);
355
+ }
356
+
357
+ const expectedRoutes = new Set();
358
+ const expectedByIdentity = new Map();
359
+ for (const expected of metadata.expectedCaptures) {
360
+ if (!expected || typeof expected !== 'object' || typeof expected.routePath !== 'string' ||
361
+ !configured.has(expected.routeId) || expectedRoutes.has(expected.routeId)) {
362
+ throw completeBaselineError(`expected capture metadata has duplicate or extra route "${expected?.routeId || 'unknown'}".`);
363
+ }
364
+ expectedRoutes.add(expected.routeId);
365
+ expectedByIdentity.set(
366
+ captureIdentityKey(expected, `expected capture "${expected.routeId}"`, completeBaselineError),
367
+ expected
368
+ );
369
+ }
370
+
371
+ if (!Array.isArray(run.captures)) throw completeBaselineError('source run has no captures array.');
372
+ if (run.captures.length !== expectedByIdentity.size) {
373
+ throw completeBaselineError(`source run has ${run.captures.length} capture(s), expected ${expectedByIdentity.size}.`);
374
+ }
375
+
376
+ const seen = new Set();
377
+ const routes = run.captures.map((capture) => {
378
+ if (!capture || typeof capture !== 'object') {
379
+ throw completeBaselineError('source run contains a malformed capture.');
380
+ }
381
+ const identity = captureIdentityKey(
382
+ capture,
383
+ `source capture "${capture.routeId || capture.id || 'unknown'}"`,
384
+ completeBaselineError
385
+ );
386
+ if (seen.has(identity)) {
387
+ throw completeBaselineError(`duplicate source route/viewport identity for "${capture.routeId}".`);
388
+ }
389
+ seen.add(identity);
390
+ const expected = expectedByIdentity.get(identity);
391
+ if (!expected) {
392
+ throw completeBaselineError(`source run contains an unexpected route/viewport capture for "${capture.routeId}".`);
393
+ }
394
+ if (capture.routePath !== expected.routePath) {
395
+ throw completeBaselineError(`source route "${capture.routeId}" used an unexpected path.`);
396
+ }
397
+ if (capture.status !== BASELINE_CAPTURE_SUCCESS_STATUS) {
398
+ throw completeBaselineError(`source capture "${capture.routeId}" ended with status "${capture.status || 'unknown'}".`);
399
+ }
400
+ if (typeof capture.currentObjectKey !== 'string' || !capture.currentObjectKey) {
401
+ throw completeBaselineError(`source capture "${capture.routeId}" has no currentObjectKey.`);
402
+ }
403
+ return {
404
+ routeId: expected.routeId,
405
+ routePath: expected.routePath,
406
+ viewportDescriptorJson: expected.viewportDescriptorJson,
407
+ objectKey: capture.currentObjectKey
408
+ };
409
+ });
410
+
411
+ return routes.sort((left, right) => {
412
+ const leftKey = captureIdentityKey(left, `route "${left.routeId}"`, completeBaselineError);
413
+ const rightKey = captureIdentityKey(right, `route "${right.routeId}"`, completeBaselineError);
414
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
415
+ });
416
+ }
417
+
418
+ /** @param {string} runId */
419
+ function baselineIdForRun(runId) {
420
+ return `bsl_${crypto.createHash('sha256').update(runId).digest('hex').slice(0, 24)}`;
421
+ }
422
+
42
423
  /**
43
424
  * @param {string | undefined} baseUrl
44
425
  * @returns {boolean}
@@ -114,6 +495,33 @@ export function resolveGitRef() {
114
495
  };
115
496
  }
116
497
 
498
+ function resolveHostedBaselinePublicationRef() {
499
+ const gitRef = resolveGitRef();
500
+ const refBranch = process.env.GITHUB_REF_NAME || gitRef.refBranch;
501
+ const refSha = process.env.GITHUB_SHA || gitRef.refSha;
502
+ const publicationWorkflowRef =
503
+ process.env.SNAPDRIFT_PUBLICATION_WORKFLOW_REF || process.env.GITHUB_WORKFLOW_REF;
504
+ const sequenceValue = process.env.SNAPDRIFT_PUBLICATION_SEQUENCE || process.env.GITHUB_RUN_NUMBER;
505
+ const publicationSequence = Number(sequenceValue);
506
+ if (
507
+ !refBranch ||
508
+ !refSha ||
509
+ !/^[0-9a-f]{40}$/i.test(refSha) ||
510
+ !publicationWorkflowRef ||
511
+ !Number.isSafeInteger(publicationSequence) ||
512
+ publicationSequence <= 0
513
+ ) {
514
+ throw new Error(
515
+ 'Hosted Snap baseline publication requires a branch, a resolved 40-character commit SHA, ' +
516
+ 'a publication workflow identity, and a positive publication sequence. GitHub Actions ' +
517
+ 'provides these as GITHUB_REF_NAME/GITHUB_SHA/GITHUB_WORKFLOW_REF/GITHUB_RUN_NUMBER; ' +
518
+ 'other CI systems may use SNAPDRIFT_PUBLICATION_WORKFLOW_REF and ' +
519
+ 'SNAPDRIFT_PUBLICATION_SEQUENCE.'
520
+ );
521
+ }
522
+ return { refBranch, refSha, publicationWorkflowRef, publicationSequence };
523
+ }
524
+
117
525
  /**
118
526
  * Read the package version from package.json.
119
527
  * @returns {string}
@@ -131,23 +539,6 @@ function getPackageVersion() {
131
539
  const TAR_BLOCK_SIZE = 512;
132
540
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
133
541
 
134
- /**
135
- * Strips characters that could cause path traversal or produce invalid
136
- * filenames when a route id is used as a screenshot filename. Mirrors the
137
- * sanitisation applied by `@snapdrift/adapter-fs` during local capture so
138
- * imported baselines land on the same filenames a local capture would produce.
139
- *
140
- * @param {string} id
141
- * @returns {string}
142
- */
143
- function sanitizeRouteId(id) {
144
- return id
145
- .replace(/\.\./g, '_')
146
- .replace(/[/\\]/g, '_')
147
- // eslint-disable-next-line no-control-regex
148
- .replace(/[\x00-\x1f\x7f]/g, '');
149
- }
150
-
151
542
  /**
152
543
  * Parse a plain ustar-style tar archive (512-byte headers, bodies padded to
153
544
  * 512-byte blocks, terminated by zero blocks) into a map of entry name →
@@ -252,22 +643,6 @@ function resolveExportEngine(captureProfile) {
252
643
  return { name: 'snap-hosted', version: 'unknown' };
253
644
  }
254
645
 
255
- /**
256
- * Extract a human-readable detail string from a Snap API error response.
257
- *
258
- * @param {{ text: () => Promise<string> }} response
259
- * @returns {Promise<string>}
260
- */
261
- async function readErrorDetail(response) {
262
- const errorBody = await response.text().catch(() => '');
263
- try {
264
- const parsed = JSON.parse(errorBody);
265
- return parsed.error || parsed.message || '';
266
- } catch {
267
- return '';
268
- }
269
- }
270
-
271
646
  /**
272
647
  * SnapProvider — VisualProvider that delegates capture + diff to Snap's
273
648
  * hosted /v1/visual/* API.
@@ -279,8 +654,10 @@ export class SnapProvider {
279
654
  #snapConfig;
280
655
  /** @type {typeof globalThis.fetch} */
281
656
  #fetchFn;
282
- /** @type {(ms: number) => Promise<void>} */
657
+ /** @type {(ms: number, signal?: AbortSignal) => Promise<void>} */
283
658
  #sleepFn;
659
+ /** @type {() => number} */
660
+ #nowFn;
284
661
  /** @type {(options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>} */
285
662
  #localCaptureFn;
286
663
  /** @type {string} */
@@ -294,14 +671,27 @@ export class SnapProvider {
294
671
  * @param {SnapConfig} snapConfig — validated snap section from config
295
672
  * @param {{
296
673
  * fetchFn?: typeof globalThis.fetch,
297
- * sleepFn?: (ms: number) => Promise<void>,
674
+ * sleepFn?: (ms: number, signal?: AbortSignal) => Promise<void>,
675
+ * nowFn?: () => number,
298
676
  * localCaptureFn?: (options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>
299
677
  * }} [options]
300
678
  */
301
679
  constructor(snapConfig, options = {}) {
302
680
  this.#snapConfig = snapConfig;
303
681
  this.#fetchFn = options.fetchFn ?? globalThis.fetch;
304
- 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;
305
695
  this.#localCaptureFn = options.localCaptureFn ?? runBaselineCapture;
306
696
  this.#apiUrl = (snapConfig.apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
307
697
  this.#apiKey = this.#resolveApiKey(snapConfig);
@@ -329,6 +719,17 @@ export class SnapProvider {
329
719
  : splitCommaList(process.env.SNAPDRIFT_ROUTE_IDS);
330
720
  const { routes, selectedRouteIds } = selectConfiguredRoutes(config, requestedRouteIds);
331
721
  const purpose = options.purpose ?? 'diff';
722
+ const configuredRouteIds = config.routes.map((route) => route.id);
723
+ const expectedCaptures = routes.map(expectedCaptureIdentity);
724
+
725
+ if (purpose === 'baseline') {
726
+ assertCompleteHostedBaselineSelection(configuredRouteIds, selectedRouteIds);
727
+ }
728
+
729
+ const baselineRef = purpose === 'baseline' ? resolveHostedBaselinePublicationRef() : undefined;
730
+ const runContext = baselineRef
731
+ ? { branch: baselineRef.refBranch, prHeadSha: baselineRef.refSha }
732
+ : this.#gitRunContext();
332
733
 
333
734
  if (isLocalBaseUrl(config.baseUrl)) {
334
735
  return this.#captureLocalAndUpload({
@@ -338,12 +739,17 @@ export class SnapProvider {
338
739
  config,
339
740
  routes,
340
741
  selectedRouteIds,
341
- purpose
742
+ purpose,
743
+ configuredRouteIds,
744
+ expectedCaptures,
745
+ runContext,
746
+ baselineRef
342
747
  });
343
748
  }
344
749
 
345
750
  const idempotencyKey = this.#generateIdempotencyKey();
346
751
  const runId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
752
+ const operation = this.#createOperationDeadline('capture');
347
753
 
348
754
  // For a diff run, resolve the latest accepted baseline so the Snap backend
349
755
  // can attach it to this run's captures. Without a baselineId the server has
@@ -355,7 +761,7 @@ export class SnapProvider {
355
761
  // Attaching one would make the backend diff the fresh captures against the
356
762
  // prior baseline — wasteful at best, and on a dimension mismatch it errors
357
763
  // the run and fails the publish.
358
- const baselineId = purpose === 'baseline' ? null : await this.#resolveLatestBaselineId();
764
+ const baselineId = purpose === 'diff' ? await this.#resolveLatestBaselineId(operation) : null;
359
765
 
360
766
  // We intentionally do NOT send captureProfileJson on run creation. SnapDrift
361
767
  // submits routes for server-side rendering, so the render environment
@@ -373,23 +779,33 @@ export class SnapProvider {
373
779
  ...(baselineId ? { baselineId } : {}),
374
780
  // A baseline run must not be diffed. Suppress the server's
375
781
  // auto-resolve-by-branch so no prior baseline is attached.
376
- ...(purpose === 'baseline' ? { skipBaselineResolution: true } : {}),
377
- ...this.#gitRunContext(),
378
- }, idempotencyKey);
782
+ ...(purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
783
+ capturePlan: {
784
+ purpose: purpose === 'baseline' ? 'baseline' : 'diff',
785
+ ...(baselineRef
786
+ ? {
787
+ publicationWorkflowRef: baselineRef.publicationWorkflowRef,
788
+ publicationSequence: baselineRef.publicationSequence
789
+ }
790
+ : {}),
791
+ configuredRouteIds,
792
+ selectedRouteIds,
793
+ expectedCaptures
794
+ },
795
+ ...runContext,
796
+ }, idempotencyKey, operation);
379
797
 
380
798
  // Submit each route as a capture with its own idempotency key
381
799
  for (const route of routes) {
382
800
  const captureKey = this.#generateIdempotencyKey();
383
801
  const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
384
- const viewportDescriptor = typeof route.viewport === 'string'
385
- ? (VIEWPORT_PRESETS[route.viewport] ?? { width: 1280, height: 720 })
386
- : route.viewport;
802
+ const viewportDescriptor = resolveViewportDescriptor(route.viewport);
387
803
  await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
388
804
  id: captureId,
389
805
  routeId: route.id,
390
806
  routePath: route.path,
391
807
  viewportDescriptorJson: JSON.stringify(viewportDescriptor),
392
- }, captureKey);
808
+ }, captureKey, operation);
393
809
  }
394
810
 
395
811
  // Write run metadata to a temp file so diff() can thread the run_id
@@ -402,7 +818,17 @@ export class SnapProvider {
402
818
  const resultsPath = path.join(outDir, 'results.json');
403
819
  const manifestPath = path.join(outDir, 'manifest.json');
404
820
 
405
- const runMetadata = { runId, projectId: this.#projectId, startedAt: new Date().toISOString() };
821
+ /** @type {import('../types/visual-diff-types').SnapRunMetadata} */
822
+ const runMetadata = {
823
+ runId,
824
+ projectId: this.#projectId,
825
+ purpose,
826
+ ...(baselineRef ? baselineRef : {}),
827
+ startedAt: new Date().toISOString(),
828
+ configuredRouteIds,
829
+ selectedRouteIds,
830
+ expectedCaptures
831
+ };
406
832
  await Promise.all([
407
833
  fs.writeFile(resultsPath, JSON.stringify(runMetadata, null, 2)),
408
834
  fs.writeFile(manifestPath, JSON.stringify({
@@ -412,7 +838,7 @@ export class SnapProvider {
412
838
  id: r.id,
413
839
  path: r.path,
414
840
  viewport: r.viewport,
415
- imagePath: `screenshots/${r.id}.png`,
841
+ imagePath: `screenshots/${sanitizeRouteId(r.id)}.png`,
416
842
  width: 0,
417
843
  height: 0
418
844
  }))
@@ -440,14 +866,37 @@ export class SnapProvider {
440
866
  throw new Error('SnapProvider.diff requires currentResultsPath from a prior capture() call.');
441
867
  }
442
868
 
443
- const metadata = JSON.parse(await fs.readFile(currentResultsPath, 'utf-8'));
444
- 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);
445
889
 
446
890
  // Poll until the run reaches a terminal state
447
- 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
+ }
448
898
 
449
- const { config } = await loadSnapdriftConfig(options.configPath);
450
- const summary = this.#mapRunToSummary(run, config);
899
+ const summary = this.#mapRunToSummary(run, config, diffPlan);
451
900
 
452
901
  return {
453
902
  summary,
@@ -473,6 +922,7 @@ export class SnapProvider {
473
922
  // Snap-native baseline: poll the run that capture() submitted, then
474
923
  // create a baseline from the rendered S3 keys.
475
924
  if (resultsPath) {
925
+ /** @type {import('../types/visual-diff-types').SnapRunMetadata | undefined} */
476
926
  let metadata;
477
927
  /** @type {unknown} */
478
928
  let readError;
@@ -482,7 +932,7 @@ export class SnapProvider {
482
932
  readError = error;
483
933
  }
484
934
 
485
- if (!metadata?.runId) {
935
+ if (typeof metadata?.runId !== 'string' || !metadata.runId) {
486
936
  // Previously this fell through to a "legacy" branch that POSTed
487
937
  // `manifest`/`results` as objects. Snap has never read those fields, so
488
938
  // the request could only ever fail — with an opaque 500 before, and with
@@ -500,60 +950,62 @@ export class SnapProvider {
500
950
  {
501
951
  const { runId } = metadata;
502
952
  const idempotencyKey = `baseline-${runId}`;
953
+ const operation = this.#createOperationDeadline('baseline publication');
503
954
 
504
- // Poll until the run reaches a terminal state (renders + auto-diffs finish)
505
- const run = await this.#pollRun(runId);
506
-
507
- if (run.status === 'error') {
508
- throw new Error(
509
- `Snap baseline run ${runId} ended with status "error" — cannot publish baseline. ` +
510
- `Check the snap dashboard for capture error details.`
955
+ if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length === 0) {
956
+ throw completeBaselineError(
957
+ 'results metadata has no expected captures; capture again with SnapDrift 0.8.2 or newer.'
511
958
  );
512
959
  }
513
960
 
514
- const captures = run.captures ?? [];
515
- const rendered = captures.filter((c) => c.currentObjectKey);
961
+ // Older Snap servers could mark a baseline run terminal after the first
962
+ // submitted capture. Keep polling until the complete planned capture set
963
+ // is present and terminal so a mixed-version rollout remains fail-closed.
964
+ const run = await this.#pollRun(runId, metadata.expectedCaptures.length, undefined, operation);
516
965
 
517
- if (rendered.length === 0) {
966
+ if (run.id && run.id !== runId) {
518
967
  throw new Error(
519
- `Snap baseline run ${runId} completed but no captures produced a screenshot ` +
520
- `(${captures.length} capture(s) found, 0 rendered). Cannot publish an empty baseline.`
968
+ `Cannot publish a complete Snap baseline: requested source run ${runId}, but Snap returned ${run.id}.`
521
969
  );
522
970
  }
523
971
 
524
- if (rendered.length < captures.length) {
525
- const failed = captures.length - rendered.length;
526
- process.stderr.write(
527
- `[SnapDrift] Warning: ${failed} of ${captures.length} capture(s) did not produce ` +
528
- `a screenshot and will be excluded from the baseline.\n`
972
+ // Hosted baseline runs intentionally use `new` because they omit a
973
+ // baseline comparison. The server accepts a wider terminal set for
974
+ // legacy/intentional workflows, but this client is stricter: any other
975
+ // terminal status means the baseline capture did not establish fresh
976
+ // ground truth.
977
+ if (run.status !== BASELINE_RUN_SUCCESS_STATUS) {
978
+ throw new Error(
979
+ `Snap baseline run ${runId} ended with status "${run.status || 'unknown'}"; ` +
980
+ `complete baseline publication requires "${BASELINE_RUN_SUCCESS_STATUS}". ` +
981
+ 'Check the Snap dashboard for capture error details.'
529
982
  );
530
983
  }
531
984
 
532
- // Build a manifest from the rendered captures
533
- const routes = rendered.map((c) => ({
534
- routeId: c.routeId,
535
- routePath: c.routePath,
536
- viewportDescriptorJson: c.viewportDescriptorJson,
537
- objectKey: c.currentObjectKey
538
- }));
985
+ const routes = validateCompleteBaselineRun(metadata, run, this.#projectId);
539
986
 
540
987
  const manifestJson = JSON.stringify({
541
988
  schemaVersion: 1,
542
989
  sourceRunId: runId,
990
+ publicationWorkflowRef: metadata.publicationWorkflowRef,
543
991
  routes
544
992
  });
545
993
 
546
- const baselineId = `bsl_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
994
+ const baselineId = baselineIdForRun(runId);
547
995
  await this.#request(
548
996
  'POST',
549
997
  `/v1/visual/projects/${this.#projectId}/baselines`,
550
998
  {
551
999
  id: baselineId,
552
- ...resolveGitRef(),
1000
+ refBranch: metadata.refBranch,
1001
+ refSha: metadata.refSha,
1002
+ publicationMode: 'complete',
1003
+ sourceRunId: runId,
553
1004
  manifestJson,
554
1005
  captureProfileJson: JSON.stringify(this.#buildCaptureProfile())
555
1006
  },
556
- idempotencyKey
1007
+ idempotencyKey,
1008
+ operation
557
1009
  );
558
1010
 
559
1011
  const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
@@ -576,8 +1028,9 @@ export class SnapProvider {
576
1028
  * @returns {Promise<BaselineData | null>}
577
1029
  */
578
1030
  async fetchLatestBaseline(_options) {
1031
+ const operation = this.#createOperationDeadline('baseline lookup');
579
1032
  try {
580
- 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);
581
1034
 
582
1035
  const runDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
583
1036
  const resultsPath = path.join(runDir, 'results.json');
@@ -614,7 +1067,7 @@ export class SnapProvider {
614
1067
  * Build a PR comment body from a diff summary.
615
1068
  * SnapProvider includes the dashboard URL from the summary when available.
616
1069
  *
617
- * @param {Record<string, unknown>} summary
1070
+ * @param {import('@snapdrift/manifest').VisualReportSummary} summary
618
1071
  * @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
619
1072
  * @returns {string}
620
1073
  */
@@ -634,8 +1087,9 @@ export class SnapProvider {
634
1087
  * @returns {Promise<object | null>} Baseline data if found, null on 404
635
1088
  */
636
1089
  async checkBaselineExists(headSha) {
1090
+ const operation = this.#createOperationDeadline('baseline existence check');
637
1091
  try {
638
- 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);
639
1093
  return baseline;
640
1094
  } catch (error) {
641
1095
  if (error instanceof SnapApiError && error.status === 404) {
@@ -678,9 +1132,10 @@ export class SnapProvider {
678
1132
  */
679
1133
  async exportBaselines(_options) {
680
1134
  const exportPath = `/v1/visual/projects/${this.#projectId}/export`;
1135
+ const operation = this.#createOperationDeadline('baseline export');
681
1136
  let archive;
682
1137
  try {
683
- archive = await this.#requestBinary('GET', exportPath);
1138
+ archive = await this.#requestBinary('GET', exportPath, operation);
684
1139
  } catch (error) {
685
1140
  if (error instanceof SnapApiError) {
686
1141
  if (error.status === 403) {
@@ -750,6 +1205,20 @@ export class SnapProvider {
750
1205
  (baseline.objects || []).map((object) => [object.sourceKey, object.archivePath])
751
1206
  );
752
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
+
753
1222
  /** @type {Array<{ filename: string, data: Buffer }>} */
754
1223
  const screenshots = [];
755
1224
  /** @type {JsonObject[]} */
@@ -780,7 +1249,7 @@ export class SnapProvider {
780
1249
  ? { width: descriptor.width, height: descriptor.height }
781
1250
  : { width: dimensions.width, height: dimensions.height };
782
1251
 
783
- const extension = path.extname(archivePath) || '.png';
1252
+ const extension = typeof archivePath === 'string' ? path.extname(archivePath) || '.png' : '.png';
784
1253
  const filename = `${sanitizeRouteId(route.routeId)}${extension}`;
785
1254
  screenshots.push({ filename, data: Buffer.from(bytes) });
786
1255
  manifestEntries.push({
@@ -912,9 +1381,9 @@ export class SnapProvider {
912
1381
  *
913
1382
  * @returns {Promise<string | null>}
914
1383
  */
915
- async #resolveLatestBaselineId() {
1384
+ async #resolveLatestBaselineId(operation = this.#createOperationDeadline('baseline lookup')) {
916
1385
  try {
917
- 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);
918
1387
  return baseline?.id ?? null;
919
1388
  } catch (error) {
920
1389
  if (error instanceof SnapApiError && error.status === 404) {
@@ -944,6 +1413,374 @@ export class SnapProvider {
944
1413
  return crypto.randomUUID();
945
1414
  }
946
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
+
947
1784
  /**
948
1785
  * @param {{
949
1786
  * configPath?: string,
@@ -952,7 +1789,11 @@ export class SnapProvider {
952
1789
  * config: VisualRegressionConfig,
953
1790
  * routes: VisualRegressionConfig['routes'],
954
1791
  * selectedRouteIds: string[],
955
- * purpose: 'baseline' | 'diff'
1792
+ * purpose: 'baseline' | 'capture' | 'diff',
1793
+ * configuredRouteIds: string[],
1794
+ * expectedCaptures: import('../types/visual-diff-types').SnapExpectedCaptureIdentity[],
1795
+ * runContext: { branch?: string, prHeadSha?: string },
1796
+ * baselineRef?: { refBranch: string, refSha: string, publicationWorkflowRef: string, publicationSequence: number }
956
1797
  * }} options
957
1798
  * @returns {Promise<CaptureResult>}
958
1799
  */
@@ -981,11 +1822,12 @@ export class SnapProvider {
981
1822
  });
982
1823
 
983
1824
  const runId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
1825
+ const operation = this.#createOperationDeadline('capture');
984
1826
  // A baseline run establishes new ground truth and must not be diffed, so we
985
1827
  // omit the baselineId. Captures then settle to "new" server-side (no
986
1828
  // comparison), and publishBaseline harvests their object keys. A diff run
987
1829
  // attaches the latest baseline so the backend can compare against it.
988
- const baselineId = options.purpose === 'baseline' ? null : await this.#resolveLatestBaselineId();
1830
+ const baselineId = options.purpose === 'diff' ? await this.#resolveLatestBaselineId(operation) : null;
989
1831
 
990
1832
  await this.#request('POST', `/v1/visual/projects/${this.#projectId}/runs`, {
991
1833
  id: runId,
@@ -994,15 +1836,25 @@ export class SnapProvider {
994
1836
  ...(baselineId ? { baselineId } : {}),
995
1837
  // A baseline run must not be diffed. Suppress the server's
996
1838
  // auto-resolve-by-branch so no prior baseline is attached.
997
- ...(options.purpose === 'baseline' ? { skipBaselineResolution: true } : {}),
998
- ...this.#gitRunContext()
999
- }, this.#generateIdempotencyKey());
1839
+ ...(options.purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
1840
+ capturePlan: {
1841
+ purpose: options.purpose === 'baseline' ? 'baseline' : 'diff',
1842
+ ...(options.baselineRef
1843
+ ? {
1844
+ publicationWorkflowRef: options.baselineRef.publicationWorkflowRef,
1845
+ publicationSequence: options.baselineRef.publicationSequence
1846
+ }
1847
+ : {}),
1848
+ configuredRouteIds: options.configuredRouteIds,
1849
+ selectedRouteIds: options.selectedRouteIds,
1850
+ expectedCaptures: options.expectedCaptures
1851
+ },
1852
+ ...options.runContext
1853
+ }, this.#generateIdempotencyKey(), operation);
1000
1854
 
1001
1855
  for (const { route, manifestEntry } of uploads) {
1002
1856
  const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
1003
- const viewportDescriptor = typeof route.viewport === 'string'
1004
- ? (VIEWPORT_PRESETS[route.viewport] ?? { width: 1280, height: 720 })
1005
- : route.viewport;
1857
+ const viewportDescriptor = resolveViewportDescriptor(route.viewport);
1006
1858
 
1007
1859
  await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
1008
1860
  id: captureId,
@@ -1014,7 +1866,7 @@ export class SnapProvider {
1014
1866
  // worker's queue — without it the worker would try to render baseUrl
1015
1867
  // (a client-only/loopback address it can't reach) and error the run.
1016
1868
  localCapture: true,
1017
- }, this.#generateIdempotencyKey());
1869
+ }, this.#generateIdempotencyKey(), operation);
1018
1870
 
1019
1871
  const imagePath = path.resolve(localCapture.screenshotsRoot, manifestEntry.imagePath);
1020
1872
  const imageBytes = await fs.readFile(imagePath);
@@ -1023,7 +1875,7 @@ export class SnapProvider {
1023
1875
  imageBase64: imageBytes.toString('base64'),
1024
1876
  width: manifestEntry.width,
1025
1877
  height: manifestEntry.height
1026
- }, this.#generateIdempotencyKey());
1878
+ }, this.#generateIdempotencyKey(), operation);
1027
1879
  }
1028
1880
 
1029
1881
  const snapResults = {
@@ -1032,6 +1884,11 @@ export class SnapProvider {
1032
1884
  captureMode: 'local-upload',
1033
1885
  runId,
1034
1886
  projectId: this.#projectId,
1887
+ purpose: options.purpose,
1888
+ ...(options.baselineRef ? options.baselineRef : {}),
1889
+ configuredRouteIds: options.configuredRouteIds,
1890
+ selectedRouteIds: options.selectedRouteIds,
1891
+ expectedCaptures: options.expectedCaptures,
1035
1892
  snapStartedAt: new Date().toISOString()
1036
1893
  };
1037
1894
  await fs.writeFile(localCapture.resultsPath, JSON.stringify(snapResults, null, 2));
@@ -1039,149 +1896,6 @@ export class SnapProvider {
1039
1896
  return localCapture;
1040
1897
  }
1041
1898
 
1042
- /**
1043
- * Make an HTTP request to the Snap API with retry logic.
1044
- *
1045
- * @param {string} method
1046
- * @param {string} path
1047
- * @param {object} [body]
1048
- * @param {string} [idempotencyKey]
1049
- * @returns {Promise<any>} Parsed JSON response
1050
- */
1051
- async #request(method, path, body, idempotencyKey) {
1052
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
1053
- try {
1054
- const headers = {
1055
- 'Authorization': `Bearer ${this.#apiKey}`,
1056
- 'Content-Type': 'application/json',
1057
- 'Accept': 'application/json'
1058
- };
1059
- if (idempotencyKey && method === 'POST') {
1060
- headers['Idempotency-Key'] = idempotencyKey;
1061
- }
1062
-
1063
- const url = `${this.#apiUrl}${path}`;
1064
- const response = await this.#fetchFn(url, {
1065
- method,
1066
- headers,
1067
- body: body ? JSON.stringify(body) : undefined
1068
- });
1069
-
1070
- if (response.ok) {
1071
- const text = await response.text();
1072
- return text ? JSON.parse(text) : {};
1073
- }
1074
-
1075
- // 4xx — never retry
1076
- if (response.status >= 400 && response.status < 500) {
1077
- const errorBody = await response.text().catch(() => '');
1078
- let detail = '';
1079
- try {
1080
- const parsed = JSON.parse(errorBody);
1081
- detail = parsed.error || parsed.message || '';
1082
- } catch { /* use default message */ }
1083
- const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
1084
- throw new SnapApiError(response.status, errorMessage, path);
1085
- }
1086
-
1087
- // 5xx — retry with backoff (unless this is the last attempt)
1088
- if (attempt < MAX_RETRIES) {
1089
- const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
1090
- await this.#sleepFn(delay);
1091
- continue;
1092
- }
1093
-
1094
- // Exhausted retries
1095
- const errorBody = await response.text().catch(() => '');
1096
- let detail = '';
1097
- try {
1098
- const parsed = JSON.parse(errorBody);
1099
- detail = parsed.error || parsed.message || '';
1100
- } catch { /* use default message */ }
1101
- const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
1102
- return this.#handleUnavailable(new SnapApiError(response.status, errorMessage, path));
1103
-
1104
- } catch (error) {
1105
- if (error instanceof SnapApiError) {
1106
- throw error;
1107
- }
1108
-
1109
- // Network error — retry
1110
- if (attempt < MAX_RETRIES) {
1111
- const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
1112
- await this.#sleepFn(delay);
1113
- continue;
1114
- }
1115
-
1116
- return this.#handleUnavailable(new SnapUnavailableError(error instanceof Error ? error.message : String(error)));
1117
- }
1118
- }
1119
-
1120
- // Unreachable, but satisfies the type checker
1121
- return this.#handleUnavailable(new SnapUnavailableError('Max retries exceeded'));
1122
- }
1123
-
1124
- /**
1125
- * Make an HTTP request to the Snap API that returns a binary body (e.g. the
1126
- * export tar). Mirrors #request's retry and error semantics — 4xx never
1127
- * retries and throws SnapApiError, 5xx/network errors retry with backoff and
1128
- * consult onUnavailable when exhausted — but returns the raw bytes instead
1129
- * of parsed JSON.
1130
- *
1131
- * @param {string} method
1132
- * @param {string} path
1133
- * @returns {Promise<any>} Raw response bytes as a Buffer
1134
- */
1135
- async #requestBinary(method, path) {
1136
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
1137
- try {
1138
- const response = await this.#fetchFn(`${this.#apiUrl}${path}`, {
1139
- method,
1140
- headers: { 'Authorization': `Bearer ${this.#apiKey}` }
1141
- });
1142
-
1143
- if (response.ok) {
1144
- return Buffer.from(await response.arrayBuffer());
1145
- }
1146
-
1147
- // 4xx — never retry
1148
- if (response.status >= 400 && response.status < 500) {
1149
- const detail = await readErrorDetail(response);
1150
- const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
1151
- throw new SnapApiError(response.status, errorMessage, path);
1152
- }
1153
-
1154
- // 5xx — retry with backoff (unless this is the last attempt)
1155
- if (attempt < MAX_RETRIES) {
1156
- const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
1157
- await this.#sleepFn(delay);
1158
- continue;
1159
- }
1160
-
1161
- // Exhausted retries
1162
- const detail = await readErrorDetail(response);
1163
- const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
1164
- return this.#handleUnavailable(new SnapApiError(response.status, errorMessage, path));
1165
- } catch (error) {
1166
- if (error instanceof SnapApiError) {
1167
- throw error;
1168
- }
1169
-
1170
- // Network error — retry
1171
- if (attempt < MAX_RETRIES) {
1172
- const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
1173
- await this.#sleepFn(delay);
1174
- continue;
1175
- }
1176
-
1177
- return this.#handleUnavailable(new SnapUnavailableError(error instanceof Error ? error.message : String(error)));
1178
- }
1179
- }
1180
-
1181
- // Unreachable, but satisfies the type checker
1182
- return this.#handleUnavailable(new SnapUnavailableError('Max retries exceeded'));
1183
- }
1184
-
1185
1899
  /**
1186
1900
  * Handle unavailable behavior based on onUnavailable config.
1187
1901
  * @param {Error} error
@@ -1210,33 +1924,125 @@ export class SnapProvider {
1210
1924
  /**
1211
1925
  * Poll a run until it reaches a terminal state.
1212
1926
  * @param {string} runId
1927
+ * @param {number} [expectedCaptureCount]
1928
+ * @param {(detail: string) => Error} [errorFactory]
1929
+ * @param {OperationDeadline} [operation]
1213
1930
  * @returns {Promise<JsonObject>}
1214
1931
  */
1215
- async #pollRun(runId) {
1216
- const startTime = Date.now();
1217
-
1218
- while (Date.now() - startTime < MAX_POLL_DURATION_MS) {
1219
- const run = await this.#request('GET', `/v1/visual/runs/${runId}`);
1932
+ async #pollRun(
1933
+ runId,
1934
+ expectedCaptureCount,
1935
+ errorFactory = (detail) => new Error(detail),
1936
+ operation = this.#createOperationDeadline('run polling')
1937
+ ) {
1938
+ let incompleteTerminalSignature;
1939
+ let stableIncompleteTerminalPolls = 0;
1940
+ let incompleteSignature;
1941
+ let stableIncompletePolls = 0;
1942
+
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
+ }
1949
+ const captures = Array.isArray(run.captures) ? run.captures : [];
1950
+ const completeCaptureSet =
1951
+ expectedCaptureCount === undefined ||
1952
+ (captures.length >= expectedCaptureCount &&
1953
+ captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status)));
1954
+
1955
+ if (expectedCaptureCount !== undefined && !completeCaptureSet) {
1956
+ const signature = JSON.stringify(captures.map((capture) => [
1957
+ capture?.id,
1958
+ capture?.routeId,
1959
+ capture?.status,
1960
+ capture?.currentObjectKey
1961
+ ]).sort((left, right) => {
1962
+ const leftJson = JSON.stringify(left);
1963
+ const rightJson = JSON.stringify(right);
1964
+ return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
1965
+ }));
1966
+ if (signature === incompleteSignature) stableIncompletePolls += 1;
1967
+ else {
1968
+ incompleteSignature = signature;
1969
+ stableIncompletePolls = 0;
1970
+ }
1971
+ // A server-side run can remain rendering forever after a cancelled
1972
+ // worker. Do not spend the full ten-minute poll budget on a capture set
1973
+ // that has made no progress for roughly one minute.
1974
+ if (stableIncompletePolls >= MAX_STABLE_INCOMPLETE_POLLS) return run;
1975
+ } else {
1976
+ incompleteSignature = undefined;
1977
+ stableIncompletePolls = 0;
1978
+ }
1220
1979
 
1221
1980
  if (TERMINAL_RUN_STATUSES.has(run.status)) {
1222
- return run;
1981
+ if (expectedCaptureCount === undefined || run.status === 'error') {
1982
+ return run;
1983
+ }
1984
+
1985
+ if (completeCaptureSet) return run;
1986
+ // An older server can report a terminal run after only an early subset
1987
+ // has settled. Keep polling for the full capture set until the normal
1988
+ // ten-minute deadline; ordinary renders routinely exceed a few seconds.
1989
+ if (
1990
+ captures.length < expectedCaptureCount &&
1991
+ captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status))
1992
+ ) {
1993
+ const signature = JSON.stringify(captures.map((capture) => [
1994
+ capture?.id,
1995
+ capture?.routeId,
1996
+ capture?.status
1997
+ ]).sort((left, right) => {
1998
+ const leftJson = JSON.stringify(left);
1999
+ const rightJson = JSON.stringify(right);
2000
+ return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
2001
+ }));
2002
+ if (signature === incompleteTerminalSignature) stableIncompleteTerminalPolls += 1;
2003
+ else {
2004
+ incompleteTerminalSignature = signature;
2005
+ stableIncompleteTerminalPolls = 0;
2006
+ }
2007
+ // A stable terminal subset cannot make render progress. Return it for
2008
+ // precise fail-closed count validation instead of waiting ten minutes.
2009
+ if (stableIncompleteTerminalPolls >= 2) return run;
2010
+ } else {
2011
+ incompleteTerminalSignature = undefined;
2012
+ stableIncompleteTerminalPolls = 0;
2013
+ }
1223
2014
  }
1224
2015
 
1225
- 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
+ }
1226
2023
  }
1227
2024
 
1228
- 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
+ ));
1229
2029
  }
1230
2030
 
1231
2031
  /**
1232
2032
  * Map a Snap run result to a VisualDiffSummary.
1233
2033
  * @param {JsonObject} run
1234
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
1235
2041
  * @returns {import('../types/visual-diff-types').VisualDiffSummary}
1236
2042
  */
1237
- #mapRunToSummary(run, config) {
1238
- const captures = run.captures || [];
1239
- const totalScreenshots = captures.length;
2043
+ #mapRunToSummary(run, config, diffPlan) {
2044
+ const captures = Array.isArray(run.captures) ? run.captures : [];
2045
+ const totalScreenshots = diffPlan.expectedCaptures.length;
1240
2046
  let matchedScreenshots = 0;
1241
2047
  let changedScreenshots = 0;
1242
2048
  let missingInBaseline = 0;
@@ -1254,72 +2060,209 @@ export class SnapProvider {
1254
2060
  /** @type {import('../types/visual-diff-types').VisualDiffErrorItem[]} */
1255
2061
  const errors = [];
1256
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();
1257
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
+
1258
2150
  if (capture.status === 'error') {
1259
- errors.push({
1260
- id: capture.routeId || capture.id,
1261
- path: capture.routePath,
1262
- viewport: capture.viewport,
1263
- status: 'error',
1264
- message: capture.errorDetails?.message || capture.errorCode || 'Unknown error'
1265
- });
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
+ );
1266
2177
  continue;
1267
2178
  }
1268
2179
 
1269
2180
  if (capture.status === 'new') {
1270
- // The backend rendered this capture but had no baseline to diff against
1271
- // (first run for the route). Surface it as a missing baseline rather than
1272
- // a silent match — there is nothing to compare yet.
2181
+ // The backend rendered this capture but had no baseline to diff against.
1273
2182
  missingInBaseline++;
1274
2183
  missing.push({
1275
- id: capture.routeId || capture.id,
2184
+ id: expected.routeId,
1276
2185
  reason: 'no baseline capture found on Snap',
1277
- path: capture.routePath,
1278
- viewport: capture.viewport,
2186
+ path: expected.routePath,
2187
+ viewport: expected.viewport,
1279
2188
  location: 'baseline'
1280
2189
  });
1281
2190
  continue;
1282
2191
  }
1283
2192
 
1284
- if (capture.status === 'diffed') {
1285
- if (!capture.baselineObjectKey) {
1286
- // No baseline was attached to this capture: the backend short-circuits
1287
- // to "diffed" with no comparison data (no diffPct / diff image). Surface
1288
- // it as a missing baseline instead of silently counting it as a match.
1289
- missingInBaseline++;
1290
- missing.push({
1291
- id: capture.routeId || capture.id,
1292
- reason: 'no baseline capture found on Snap',
1293
- path: capture.routePath,
1294
- viewport: capture.viewport,
1295
- location: 'baseline'
1296
- });
1297
- } else if (capture.diffPct != null && capture.diffPct > config.diff.threshold) {
1298
- comparedWithBaseline++;
1299
- changedScreenshots++;
1300
- changed.push({
1301
- id: capture.routeId || capture.id,
1302
- path: capture.routePath || '',
1303
- viewport: capture.viewport,
1304
- baselineImagePath: capture.baselineObjectKey || '',
1305
- currentImagePath: capture.currentObjectKey || '',
1306
- width: capture.width || 0,
1307
- height: capture.height || 0,
1308
- differentPixels: capture.diffPixels || 0,
1309
- totalPixels: (capture.width || 0) * (capture.height || 0),
1310
- mismatchRatio: capture.diffPct,
1311
- status: 'changed'
1312
- });
1313
- } else {
1314
- comparedWithBaseline++;
1315
- if (capture.diffPct === 0) {
1316
- exactZeroMatches++;
1317
- }
1318
- 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++;
1319
2249
  }
2250
+ matchedScreenshots++;
1320
2251
  }
1321
2252
  }
1322
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
+
1323
2266
  // False-negative guard: when a diff run compared every route against a real
1324
2267
  // baseline yet every single comparison came back pixel-identical (0% drift),
1325
2268
  // the most likely explanation is that the client captured a stale or wrong
@@ -1346,7 +2289,7 @@ export class SnapProvider {
1346
2289
  finishedAt: run.finishedAt || new Date().toISOString(),
1347
2290
  completed: true,
1348
2291
  status: run.status === 'pass' ? 'clean' : run.status === 'fail' ? 'changes-detected' : 'incomplete',
1349
- selectedRoutes: captures.map((c) => c.routeId || c.id),
2292
+ selectedRoutes: diffPlan.selectedRouteIds,
1350
2293
  baselineManifestPath: '',
1351
2294
  currentManifestPath: '',
1352
2295
  diffMode: config.diff.mode,