snapdrift 0.9.0 → 0.11.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.
@@ -31,6 +31,8 @@ import {
31
31
  /** @typedef {import('../types/visual-diff-types').ProviderBaselineData} BaselineData */
32
32
  /** @typedef {import('../types/visual-diff-types').SnapConfig} SnapConfig */
33
33
  /** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
34
+ /** @typedef {import('../types/visual-diff-types').ComparisonPolicy} ComparisonPolicy */
35
+ /** @typedef {import('../types/visual-diff-types').ComparisonMetadata} ComparisonMetadata */
34
36
  /** @typedef {import('../types/visual-diff-types').JsonObject} JsonObject */
35
37
  /** @typedef {{ deadline: number, label: string }} OperationDeadline */
36
38
 
@@ -44,6 +46,7 @@ const BINARY_REQUEST_TIMEOUT_MS = 120 * 1000;
44
46
  const POLL_INTERVAL_MS = 2000;
45
47
  const MAX_OPERATION_DURATION_MS = 10 * 60 * 1000;
46
48
  const MAX_STABLE_INCOMPLETE_POLLS = 30;
49
+ const MAX_COMPARISON_PIXELS = 32 * 1024 * 1024;
47
50
  const BASELINE_RUN_SUCCESS_STATUS = 'new';
48
51
  const BASELINE_CAPTURE_SUCCESS_STATUS = 'new';
49
52
  const TERMINAL_CAPTURE_STATUSES = new Set(['new', 'diffed', 'error']);
@@ -65,9 +68,7 @@ const OPERATION_LIMIT_TEXT = formatDuration(MAX_OPERATION_DURATION_MS);
65
68
 
66
69
  /** @returns {number} A monotonic timestamp in milliseconds. */
67
70
  function monotonicNow() {
68
- return typeof globalThis.performance?.now === 'function'
69
- ? globalThis.performance.now()
70
- : Date.now();
71
+ return typeof globalThis.performance?.now === 'function' ? globalThis.performance.now() : Date.now();
71
72
  }
72
73
 
73
74
  /**
@@ -92,19 +93,22 @@ function runWithTimeout(task, timeoutMs, onTimeout, timeoutError) {
92
93
  reject(error);
93
94
  return;
94
95
  }
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));
96
+ const timer = setTimeout(
97
+ () => {
98
+ if (settled) return;
99
+ settled = true;
100
+ try {
101
+ onTimeout();
102
+ } catch {
103
+ // Abort/cancel is best effort; the timeout still settles the caller.
104
+ }
105
+ reject(timeoutError());
106
+ },
107
+ Math.max(0, timeoutMs)
108
+ );
105
109
 
106
- taskPromise
107
- .then((value) => {
110
+ taskPromise.then(
111
+ (value) => {
108
112
  if (settled) {
109
113
  if (value && typeof value === 'object') cancelResponseBody(value);
110
114
  return;
@@ -112,12 +116,14 @@ function runWithTimeout(task, timeoutMs, onTimeout, timeoutError) {
112
116
  settled = true;
113
117
  clearTimeout(timer);
114
118
  resolve(value);
115
- }, (error) => {
119
+ },
120
+ (error) => {
116
121
  if (settled) return;
117
122
  settled = true;
118
123
  clearTimeout(timer);
119
124
  reject(error);
120
- });
125
+ }
126
+ );
121
127
  });
122
128
  }
123
129
 
@@ -139,14 +145,113 @@ function hostedDiffError(detail) {
139
145
  return new Error(`Cannot compare a Snap visual diff: ${detail}`);
140
146
  }
141
147
 
148
+ /** @param {unknown} value @returns {value is ComparisonPolicy} */
149
+ function isComparisonPolicy(value) {
150
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
151
+ const policy = /** @type {Record<string, any>} */ (value);
152
+ return policy.version === 1 && typeof policy.threshold === 'number' && Number.isFinite(policy.threshold) && policy.threshold >= 0 && policy.threshold <= 1;
153
+ }
154
+
155
+ /** @param {unknown} value @returns {value is ComparisonMetadata} */
156
+ function isComparisonMetadata(value) {
157
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
158
+ const comparison = /** @type {Record<string, any>} */ (value);
159
+ const dimensions = [comparison.baseline, comparison.current, comparison.canvas];
160
+ if (
161
+ dimensions.some(
162
+ (dimension) =>
163
+ !dimension ||
164
+ typeof dimension !== 'object' ||
165
+ !Number.isInteger(dimension.width) ||
166
+ dimension.width < 0 ||
167
+ !Number.isInteger(dimension.height) ||
168
+ dimension.height < 0
169
+ )
170
+ ) {
171
+ return false;
172
+ }
173
+ const [baseline, current, canvas] = dimensions;
174
+ return (
175
+ typeof comparison.dimensionsChanged === 'boolean' &&
176
+ comparison.dimensionsChanged === (baseline.width !== current.width || baseline.height !== current.height) &&
177
+ canvas.width === Math.max(baseline.width, current.width) &&
178
+ canvas.height === Math.max(baseline.height, current.height) &&
179
+ Number.isInteger(comparison.totalPixels) &&
180
+ comparison.totalPixels >= 0 &&
181
+ comparison.totalPixels <= canvas.width * canvas.height &&
182
+ canvas.width * canvas.height <= MAX_COMPARISON_PIXELS
183
+ );
184
+ }
185
+
186
+ /**
187
+ * @param {any} capture
188
+ * @param {ComparisonMetadata} comparison
189
+ * @param {number} expectedThreshold
190
+ * @returns {boolean}
191
+ */
192
+ function hasCompleteComparisonResult(capture, comparison, expectedThreshold) {
193
+ if (
194
+ !Number.isFinite(capture.diffPct) ||
195
+ capture.diffPct < 0 ||
196
+ capture.diffPct > 1 ||
197
+ !Number.isInteger(capture.diffPixels) ||
198
+ capture.diffPixels < 0 ||
199
+ capture.diffPixels > comparison.totalPixels ||
200
+ typeof capture.diffObjectKey !== 'string' ||
201
+ capture.diffObjectKey.trim().length === 0 ||
202
+ capture.thresholdUsed !== expectedThreshold
203
+ ) {
204
+ return false;
205
+ }
206
+
207
+ const expectedRatio = comparison.totalPixels === 0 ? 0 : capture.diffPixels / comparison.totalPixels;
208
+ return Math.abs(capture.diffPct - expectedRatio) <= 1e-12;
209
+ }
210
+
211
+ /** @param {any} capture @returns {ComparisonMetadata | undefined} */
212
+ function comparisonFromCapture(capture) {
213
+ const candidate =
214
+ capture?.comparison ??
215
+ (() => {
216
+ if (typeof capture?.comparisonJson !== 'string') return undefined;
217
+ try {
218
+ return JSON.parse(capture.comparisonJson);
219
+ } catch {
220
+ return undefined;
221
+ }
222
+ })();
223
+ return isComparisonMetadata(candidate) ? candidate : undefined;
224
+ }
225
+
226
+ /** @param {unknown} actual @param {ComparisonPolicy} expected @param {string} context */
227
+ function assertComparisonPolicyAcknowledged(actual, expected, context) {
228
+ if (!isComparisonPolicy(actual) || actual.threshold !== expected.threshold) {
229
+ throw new SnapUpgradeRequiredError(
230
+ `${context} did not acknowledge comparisonPolicy v1 with threshold ${expected.threshold}. ` +
231
+ 'Upgrade the Snap server before enabling unequal-dimension comparisons.'
232
+ );
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Updated SnapDrift clients explicitly opt into the union-canvas comparison.
238
+ * Keep the config override for callers that want the policy visible in their
239
+ * config, but derive it from the existing diff threshold by default so a
240
+ * normal v1 client does not need to repeat that value.
241
+ *
242
+ * @param {VisualRegressionConfig} config
243
+ * @returns {ComparisonPolicy}
244
+ */
245
+ function resolveComparisonPolicy(config) {
246
+ return config.diff.comparisonPolicy ? { ...config.diff.comparisonPolicy } : { version: 1, threshold: config.diff.threshold };
247
+ }
248
+
142
249
  /**
143
250
  * @param {import('../types/visual-diff-types').VisualViewport} viewport
144
251
  * @returns {{ width: number, height: number, deviceScaleFactor?: number, isMobile?: boolean, hasTouch?: boolean }}
145
252
  */
146
253
  function resolveViewportDescriptor(viewport) {
147
- return typeof viewport === 'string'
148
- ? (VIEWPORT_PRESETS[viewport] ?? { width: 1280, height: 720 })
149
- : viewport;
254
+ return typeof viewport === 'string' ? (VIEWPORT_PRESETS[viewport] ?? { width: 1280, height: 720 }) : viewport;
150
255
  }
151
256
 
152
257
  /** @param {VisualRegressionConfig['routes'][number]} route */
@@ -171,13 +276,10 @@ function normalizeViewportDescriptor(value, context, errorFactory) {
171
276
  } catch {
172
277
  throw errorFactory(`${context} has malformed viewportDescriptorJson.`);
173
278
  }
174
- if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor) ||
175
- !Number.isFinite(descriptor.width) || !Number.isFinite(descriptor.height)) {
279
+ if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor) || !Number.isFinite(descriptor.width) || !Number.isFinite(descriptor.height)) {
176
280
  throw errorFactory(`${context} has an invalid viewport descriptor.`);
177
281
  }
178
- return JSON.stringify(Object.fromEntries(
179
- Object.entries(descriptor).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
180
- ));
282
+ return JSON.stringify(Object.fromEntries(Object.entries(descriptor).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))));
181
283
  }
182
284
 
183
285
  /**
@@ -234,51 +336,43 @@ function validateHostedDiffMetadata(metadata, projectId) {
234
336
  throw hostedDiffError('capture results metadata has no run id; capture again with SnapDrift 0.8.2 or newer.');
235
337
  }
236
338
  if (metadata.projectId !== projectId) {
237
- throw hostedDiffError(
238
- `capture results belong to project "${metadata.projectId || 'unknown'}", not "${projectId}".`
239
- );
339
+ throw hostedDiffError(`capture results belong to project "${metadata.projectId || 'unknown'}", not "${projectId}".`);
240
340
  }
241
341
 
242
342
  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
- );
343
+ throw hostedDiffError('capture results have no expected capture identities; recapture with SnapDrift 0.8.2 or newer.');
246
344
  }
247
345
  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
- );
346
+ throw hostedDiffError(`capture results have purpose "${metadata.purpose || 'unknown'}", not "diff"; ` + 'recapture with SnapDrift 0.8.2 or newer.');
252
347
  }
253
348
  const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds', hostedDiffError);
254
349
  if (selected.size === 0) {
255
350
  throw hostedDiffError('capture results selected no routes.');
256
351
  }
257
352
  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
- );
353
+ throw hostedDiffError(`capture results describe ${metadata.expectedCaptures.length} expected capture(s) for ${selected.size} selected route(s).`);
261
354
  }
262
355
 
263
356
  const expectedRouteIds = new Set();
264
357
  const expectedByIdentity = new Map();
265
358
  const expectedByRouteId = new Map();
266
359
  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)) {
360
+ if (
361
+ !expected ||
362
+ typeof expected !== 'object' ||
363
+ typeof expected.routeId !== 'string' ||
364
+ !expected.routeId ||
365
+ typeof expected.routePath !== 'string' ||
366
+ !expected.routePath ||
367
+ !selected.has(expected.routeId)
368
+ ) {
271
369
  throw hostedDiffError(`expected capture metadata at index ${index} is invalid or not selected.`);
272
370
  }
273
371
  if (expectedRouteIds.has(expected.routeId)) {
274
372
  throw hostedDiffError(`expected capture metadata contains duplicate route "${expected.routeId}".`);
275
373
  }
276
374
  expectedRouteIds.add(expected.routeId);
277
- const viewportDescriptorJson = normalizeViewportDescriptor(
278
- expected.viewportDescriptorJson,
279
- `expected capture "${expected.routeId}"`,
280
- hostedDiffError
281
- );
375
+ const viewportDescriptorJson = normalizeViewportDescriptor(expected.viewportDescriptorJson, `expected capture "${expected.routeId}"`, hostedDiffError);
282
376
  const viewportDescriptor = JSON.parse(viewportDescriptorJson);
283
377
  const expectedCapture = {
284
378
  routeId: expected.routeId,
@@ -310,9 +404,9 @@ function assertCompleteHostedBaselineSelection(configuredRouteIds, selectedRoute
310
404
  if (missing.length === 0 && selected.size === configuredRouteIds.length) return;
311
405
  throw new Error(
312
406
  `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.'
407
+ `but only ${selected.size} were selected. Remove route-ids/--routes scoping ` +
408
+ `(missing: ${missing.join(', ') || 'unknown'}). Scoped runs remain supported for PR diffs ` +
409
+ 'and local-provider baselines.'
316
410
  );
317
411
  }
318
412
 
@@ -328,23 +422,14 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
328
422
  if (metadata.purpose !== 'baseline') {
329
423
  throw completeBaselineError('source results were not recorded as a baseline capture.');
330
424
  }
331
- if (typeof metadata.refBranch !== 'string' || !metadata.refBranch ||
332
- typeof metadata.refSha !== 'string' || !metadata.refSha) {
425
+ if (typeof metadata.refBranch !== 'string' || !metadata.refBranch || typeof metadata.refSha !== 'string' || !metadata.refSha) {
333
426
  throw completeBaselineError('source results have no resolved ref; capture again with SnapDrift 0.8.2 or newer.');
334
427
  }
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
- );
428
+ if (typeof metadata.publicationSequence !== 'number' || !Number.isSafeInteger(metadata.publicationSequence) || metadata.publicationSequence <= 0) {
429
+ throw completeBaselineError('source results have no valid publication sequence; capture again with SnapDrift 0.8.2 or newer.');
343
430
  }
344
431
  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
- );
432
+ throw completeBaselineError('source results have no publication workflow identity; capture again with SnapDrift 0.8.2 or newer.');
348
433
  }
349
434
 
350
435
  const configured = uniqueRouteIdSet(metadata.configuredRouteIds, 'configuredRouteIds', completeBaselineError);
@@ -357,15 +442,17 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
357
442
  const expectedRoutes = new Set();
358
443
  const expectedByIdentity = new Map();
359
444
  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)) {
445
+ if (
446
+ !expected ||
447
+ typeof expected !== 'object' ||
448
+ typeof expected.routePath !== 'string' ||
449
+ !configured.has(expected.routeId) ||
450
+ expectedRoutes.has(expected.routeId)
451
+ ) {
362
452
  throw completeBaselineError(`expected capture metadata has duplicate or extra route "${expected?.routeId || 'unknown'}".`);
363
453
  }
364
454
  expectedRoutes.add(expected.routeId);
365
- expectedByIdentity.set(
366
- captureIdentityKey(expected, `expected capture "${expected.routeId}"`, completeBaselineError),
367
- expected
368
- );
455
+ expectedByIdentity.set(captureIdentityKey(expected, `expected capture "${expected.routeId}"`, completeBaselineError), expected);
369
456
  }
370
457
 
371
458
  if (!Array.isArray(run.captures)) throw completeBaselineError('source run has no captures array.');
@@ -378,11 +465,7 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
378
465
  if (!capture || typeof capture !== 'object') {
379
466
  throw completeBaselineError('source run contains a malformed capture.');
380
467
  }
381
- const identity = captureIdentityKey(
382
- capture,
383
- `source capture "${capture.routeId || capture.id || 'unknown'}"`,
384
- completeBaselineError
385
- );
468
+ const identity = captureIdentityKey(capture, `source capture "${capture.routeId || capture.id || 'unknown'}"`, completeBaselineError);
386
469
  if (seen.has(identity)) {
387
470
  throw completeBaselineError(`duplicate source route/viewport identity for "${capture.routeId}".`);
388
471
  }
@@ -486,11 +569,7 @@ function gitOutput(args) {
486
569
  export function resolveGitRef() {
487
570
  const branch = gitOutput(['rev-parse', '--abbrev-ref', 'HEAD']);
488
571
  return {
489
- refBranch:
490
- process.env.GITHUB_REF_NAME ||
491
- process.env.GITHUB_HEAD_REF ||
492
- (branch && branch !== 'HEAD' ? branch : undefined) ||
493
- 'main',
572
+ refBranch: process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || (branch && branch !== 'HEAD' ? branch : undefined) || 'main',
494
573
  refSha: process.env.GITHUB_SHA || gitOutput(['rev-parse', 'HEAD']) || 'unknown'
495
574
  };
496
575
  }
@@ -499,8 +578,7 @@ function resolveHostedBaselinePublicationRef() {
499
578
  const gitRef = resolveGitRef();
500
579
  const refBranch = process.env.GITHUB_REF_NAME || gitRef.refBranch;
501
580
  const refSha = process.env.GITHUB_SHA || gitRef.refSha;
502
- const publicationWorkflowRef =
503
- process.env.SNAPDRIFT_PUBLICATION_WORKFLOW_REF || process.env.GITHUB_WORKFLOW_REF;
581
+ const publicationWorkflowRef = process.env.SNAPDRIFT_PUBLICATION_WORKFLOW_REF || process.env.GITHUB_WORKFLOW_REF;
504
582
  const sequenceValue = process.env.SNAPDRIFT_PUBLICATION_SEQUENCE || process.env.GITHUB_RUN_NUMBER;
505
583
  const publicationSequence = Number(sequenceValue);
506
584
  if (
@@ -513,10 +591,10 @@ function resolveHostedBaselinePublicationRef() {
513
591
  ) {
514
592
  throw new Error(
515
593
  '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.'
594
+ 'a publication workflow identity, and a positive publication sequence. GitHub Actions ' +
595
+ 'provides these as GITHUB_REF_NAME/GITHUB_SHA/GITHUB_WORKFLOW_REF/GITHUB_RUN_NUMBER; ' +
596
+ 'other CI systems may use SNAPDRIFT_PUBLICATION_WORKFLOW_REF and ' +
597
+ 'SNAPDRIFT_PUBLICATION_SEQUENCE.'
520
598
  );
521
599
  }
522
600
  return { refBranch, refSha, publicationWorkflowRef, publicationSequence };
@@ -679,18 +757,21 @@ export class SnapProvider {
679
757
  constructor(snapConfig, options = {}) {
680
758
  this.#snapConfig = snapConfig;
681
759
  this.#fetchFn = options.fetchFn ?? globalThis.fetch;
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
- }));
760
+ this.#sleepFn =
761
+ options.sleepFn ??
762
+ ((ms, signal) =>
763
+ new Promise((resolve, reject) => {
764
+ const timer = setTimeout(() => {
765
+ signal?.removeEventListener('abort', onAbort);
766
+ resolve();
767
+ }, ms);
768
+ const onAbort = () => {
769
+ clearTimeout(timer);
770
+ signal?.removeEventListener('abort', onAbort);
771
+ reject(signal?.reason ?? new Error('Sleep aborted.'));
772
+ };
773
+ signal?.addEventListener('abort', onAbort, { once: true });
774
+ }));
694
775
  this.#nowFn = options.nowFn ?? monotonicNow;
695
776
  this.#localCaptureFn = options.localCaptureFn ?? runBaselineCapture;
696
777
  this.#apiUrl = (snapConfig.apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
@@ -714,9 +795,7 @@ export class SnapProvider {
714
795
  */
715
796
  async capture(options) {
716
797
  const { config } = await loadSnapdriftConfig(options.configPath);
717
- const requestedRouteIds = options.routeIds
718
- ? [...options.routeIds]
719
- : splitCommaList(process.env.SNAPDRIFT_ROUTE_IDS);
798
+ const requestedRouteIds = options.routeIds ? [...options.routeIds] : splitCommaList(process.env.SNAPDRIFT_ROUTE_IDS);
720
799
  const { routes, selectedRouteIds } = selectConfiguredRoutes(config, requestedRouteIds);
721
800
  const purpose = options.purpose ?? 'diff';
722
801
  const configuredRouteIds = config.routes.map((route) => route.id);
@@ -727,9 +806,7 @@ export class SnapProvider {
727
806
  }
728
807
 
729
808
  const baselineRef = purpose === 'baseline' ? resolveHostedBaselinePublicationRef() : undefined;
730
- const runContext = baselineRef
731
- ? { branch: baselineRef.refBranch, prHeadSha: baselineRef.refSha }
732
- : this.#gitRunContext();
809
+ const runContext = baselineRef ? { branch: baselineRef.refBranch, prHeadSha: baselineRef.refSha } : this.#gitRunContext();
733
810
 
734
811
  if (isLocalBaseUrl(config.baseUrl)) {
735
812
  return this.#captureLocalAndUpload({
@@ -743,7 +820,8 @@ export class SnapProvider {
743
820
  configuredRouteIds,
744
821
  expectedCaptures,
745
822
  runContext,
746
- baselineRef
823
+ baselineRef,
824
+ comparisonPolicy: resolveComparisonPolicy(config)
747
825
  });
748
826
  }
749
827
 
@@ -772,46 +850,61 @@ export class SnapProvider {
772
850
  // profile does not populate — which crashes the server with a 500. The
773
851
  // render worker already defaults locale/timezone/viewport when no profile
774
852
  // is present, so omitting it here is behaviour-preserving.
775
- await this.#request('POST', `/v1/visual/projects/${this.#projectId}/runs`, {
776
- id: runId,
777
- baseUrl: config.baseUrl,
778
- trigger: 'ci',
779
- ...(baselineId ? { baselineId } : {}),
780
- // A baseline run must not be diffed. Suppress the server's
781
- // auto-resolve-by-branch so no prior baseline is attached.
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
853
+ const comparisonPolicy = resolveComparisonPolicy(config);
854
+ const runResponse = await this.#request(
855
+ 'POST',
856
+ `/v1/visual/projects/${this.#projectId}/runs`,
857
+ {
858
+ id: runId,
859
+ baseUrl: config.baseUrl,
860
+ trigger: 'ci',
861
+ ...(baselineId ? { baselineId } : {}),
862
+ // A baseline run must not be diffed. Suppress the server's
863
+ // auto-resolve-by-branch so no prior baseline is attached.
864
+ ...(purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
865
+ capturePlan: {
866
+ purpose: purpose === 'baseline' ? 'baseline' : 'diff',
867
+ ...(baselineRef
868
+ ? {
869
+ publicationWorkflowRef: baselineRef.publicationWorkflowRef,
870
+ publicationSequence: baselineRef.publicationSequence
871
+ }
872
+ : {}),
873
+ configuredRouteIds,
874
+ selectedRouteIds,
875
+ expectedCaptures
876
+ },
877
+ ...runContext,
878
+ ...(comparisonPolicy ? { comparisonPolicy } : {})
794
879
  },
795
- ...runContext,
796
- }, idempotencyKey, operation);
880
+ idempotencyKey,
881
+ operation
882
+ );
883
+ if (comparisonPolicy) {
884
+ assertComparisonPolicyAcknowledged(runResponse?.comparisonPolicy, comparisonPolicy, 'Snap run creation');
885
+ }
797
886
 
798
887
  // Submit each route as a capture with its own idempotency key
799
888
  for (const route of routes) {
800
889
  const captureKey = this.#generateIdempotencyKey();
801
890
  const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
802
891
  const viewportDescriptor = resolveViewportDescriptor(route.viewport);
803
- await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
804
- id: captureId,
805
- routeId: route.id,
806
- routePath: route.path,
807
- viewportDescriptorJson: JSON.stringify(viewportDescriptor),
808
- }, captureKey, operation);
892
+ await this.#request(
893
+ 'POST',
894
+ `/v1/visual/runs/${runId}/captures`,
895
+ {
896
+ id: captureId,
897
+ routeId: route.id,
898
+ routePath: route.path,
899
+ viewportDescriptorJson: JSON.stringify(viewportDescriptor)
900
+ },
901
+ captureKey,
902
+ operation
903
+ );
809
904
  }
810
905
 
811
906
  // Write run metadata to a temp file so diff() can thread the run_id
812
- const outDir = options.outDir
813
- ? path.resolve(options.outDir)
814
- : await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-'));
907
+ const outDir = options.outDir ? path.resolve(options.outDir) : await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-'));
815
908
 
816
909
  await fs.mkdir(outDir, { recursive: true });
817
910
 
@@ -823,6 +916,7 @@ export class SnapProvider {
823
916
  runId,
824
917
  projectId: this.#projectId,
825
918
  purpose,
919
+ ...(comparisonPolicy ? { comparisonPolicy } : {}),
826
920
  ...(baselineRef ? baselineRef : {}),
827
921
  startedAt: new Date().toISOString(),
828
922
  configuredRouteIds,
@@ -831,18 +925,25 @@ export class SnapProvider {
831
925
  };
832
926
  await Promise.all([
833
927
  fs.writeFile(resultsPath, JSON.stringify(runMetadata, null, 2)),
834
- fs.writeFile(manifestPath, JSON.stringify({
835
- generatedAt: new Date().toISOString(),
836
- baseUrl: config.baseUrl,
837
- screenshots: routes.map((r) => ({
838
- id: r.id,
839
- path: r.path,
840
- viewport: r.viewport,
841
- imagePath: `screenshots/${sanitizeRouteId(r.id)}.png`,
842
- width: 0,
843
- height: 0
844
- }))
845
- }, null, 2))
928
+ fs.writeFile(
929
+ manifestPath,
930
+ JSON.stringify(
931
+ {
932
+ generatedAt: new Date().toISOString(),
933
+ baseUrl: config.baseUrl,
934
+ screenshots: routes.map((r) => ({
935
+ id: r.id,
936
+ path: r.path,
937
+ viewport: r.viewport,
938
+ imagePath: `screenshots/${sanitizeRouteId(r.id)}.png`,
939
+ width: 0,
940
+ height: 0
941
+ }))
942
+ },
943
+ null,
944
+ 2
945
+ )
946
+ )
846
947
  ]);
847
948
 
848
949
  return {
@@ -872,31 +973,39 @@ export class SnapProvider {
872
973
  metadataSource = await fs.readFile(currentResultsPath, 'utf-8');
873
974
  } catch (error) {
874
975
  throw hostedDiffError(
875
- `could not read capture results metadata at "${currentResultsPath}". ` +
876
- `${error instanceof Error ? error.message : String(error)}`
976
+ `could not read capture results metadata at "${currentResultsPath}". ` + `${error instanceof Error ? error.message : String(error)}`
877
977
  );
878
978
  }
879
979
  try {
880
980
  metadata = JSON.parse(metadataSource);
881
981
  } catch (error) {
882
982
  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)}`
983
+ `capture results metadata is not valid JSON; recapture with SnapDrift 0.8.2 or newer. ` + `${error instanceof Error ? error.message : String(error)}`
885
984
  );
886
985
  }
887
986
  const { config } = await loadSnapdriftConfig(options.configPath);
888
987
  const diffPlan = validateHostedDiffMetadata(metadata, this.#projectId);
988
+ /** @type {ComparisonPolicy | undefined} */
989
+ let comparisonPolicy;
990
+ if (metadata.comparisonPolicy !== undefined) {
991
+ if (!isComparisonPolicy(metadata.comparisonPolicy)) {
992
+ throw hostedDiffError('capture results metadata has an invalid comparison policy; recapture with the updated SnapDrift client.');
993
+ }
994
+ comparisonPolicy = { ...metadata.comparisonPolicy };
995
+ }
889
996
 
890
997
  // Poll until the run reaches a terminal state
891
998
  const operation = this.#createOperationDeadline('diff');
892
999
  const run = await this.#pollRun(diffPlan.runId, diffPlan.expectedCaptures.length, hostedDiffError, operation);
893
1000
  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
- );
1001
+ throw hostedDiffError(`Snap returned run "${run?.id || 'unknown'}" for requested run "${diffPlan.runId}".`);
897
1002
  }
898
1003
 
899
- const summary = this.#mapRunToSummary(run, config, diffPlan);
1004
+ if (comparisonPolicy) {
1005
+ assertComparisonPolicyAcknowledged(run.comparisonPolicy, comparisonPolicy, 'Snap run retrieval');
1006
+ }
1007
+
1008
+ const summary = this.#mapRunToSummary(run, config, diffPlan, comparisonPolicy);
900
1009
 
901
1010
  return {
902
1011
  summary,
@@ -916,8 +1025,7 @@ export class SnapProvider {
916
1025
  * @returns {Promise<PublishBaselineResult>}
917
1026
  */
918
1027
  async publishBaseline(options) {
919
- const resultsPath = options.resultsPath
920
- ?? (options.bundleDir ? path.join(path.resolve(options.bundleDir), 'results.json') : undefined);
1028
+ const resultsPath = options.resultsPath ?? (options.bundleDir ? path.join(path.resolve(options.bundleDir), 'results.json') : undefined);
921
1029
 
922
1030
  // Snap-native baseline: poll the run that capture() submitted, then
923
1031
  // create a baseline from the rendered S3 keys.
@@ -941,9 +1049,9 @@ export class SnapProvider {
941
1049
  // and surfacing its body-shape error. See #109.
942
1050
  throw new Error(
943
1051
  `Cannot publish a Snap baseline: no run id found in ${resultsPath}. ` +
944
- 'A baseline is created from the object keys of a completed Snap run, so the capture step ' +
945
- 'must have recorded one. Run `snapdrift baseline` (or actions/baseline) with ' +
946
- `provider "snap".${readError ? ` Could not read that file: ${readError instanceof Error ? readError.message : String(readError)}` : ''}`
1052
+ 'A baseline is created from the object keys of a completed Snap run, so the capture step ' +
1053
+ 'must have recorded one. Run `snapdrift baseline` (or actions/baseline) with ' +
1054
+ `provider "snap".${readError ? ` Could not read that file: ${readError instanceof Error ? readError.message : String(readError)}` : ''}`
947
1055
  );
948
1056
  }
949
1057
 
@@ -953,9 +1061,7 @@ export class SnapProvider {
953
1061
  const operation = this.#createOperationDeadline('baseline publication');
954
1062
 
955
1063
  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.'
958
- );
1064
+ throw completeBaselineError('results metadata has no expected captures; capture again with SnapDrift 0.8.2 or newer.');
959
1065
  }
960
1066
 
961
1067
  // Older Snap servers could mark a baseline run terminal after the first
@@ -964,9 +1070,7 @@ export class SnapProvider {
964
1070
  const run = await this.#pollRun(runId, metadata.expectedCaptures.length, undefined, operation);
965
1071
 
966
1072
  if (run.id && run.id !== runId) {
967
- throw new Error(
968
- `Cannot publish a complete Snap baseline: requested source run ${runId}, but Snap returned ${run.id}.`
969
- );
1073
+ throw new Error(`Cannot publish a complete Snap baseline: requested source run ${runId}, but Snap returned ${run.id}.`);
970
1074
  }
971
1075
 
972
1076
  // Hosted baseline runs intentionally use `new` because they omit a
@@ -977,8 +1081,8 @@ export class SnapProvider {
977
1081
  if (run.status !== BASELINE_RUN_SUCCESS_STATUS) {
978
1082
  throw new Error(
979
1083
  `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.'
1084
+ `complete baseline publication requires "${BASELINE_RUN_SUCCESS_STATUS}". ` +
1085
+ 'Check the Snap dashboard for capture error details.'
982
1086
  );
983
1087
  }
984
1088
 
@@ -1016,8 +1120,8 @@ export class SnapProvider {
1016
1120
  // No resultsPath and no bundleDir: there is no run to build a baseline from.
1017
1121
  throw new Error(
1018
1122
  'Cannot publish a Snap baseline: neither resultsPath nor bundleDir was provided, so there ' +
1019
- 'is no Snap run to harvest object keys from. Run `snapdrift baseline` (or actions/baseline) ' +
1020
- 'with provider "snap", which captures first and passes the run results through.'
1123
+ 'is no Snap run to harvest object keys from. Run `snapdrift baseline` (or actions/baseline) ' +
1124
+ 'with provider "snap", which captures first and passes the run results through.'
1021
1125
  );
1022
1126
  }
1023
1127
 
@@ -1089,7 +1193,13 @@ export class SnapProvider {
1089
1193
  async checkBaselineExists(headSha) {
1090
1194
  const operation = this.#createOperationDeadline('baseline existence check');
1091
1195
  try {
1092
- const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest?sha=${encodeURIComponent(headSha)}`, undefined, undefined, operation);
1196
+ const baseline = await this.#request(
1197
+ 'GET',
1198
+ `/v1/visual/projects/${this.#projectId}/baselines/latest?sha=${encodeURIComponent(headSha)}`,
1199
+ undefined,
1200
+ undefined,
1201
+ operation
1202
+ );
1093
1203
  return baseline;
1094
1204
  } catch (error) {
1095
1205
  if (error instanceof SnapApiError && error.status === 404) {
@@ -1141,21 +1251,21 @@ export class SnapProvider {
1141
1251
  if (error.status === 403) {
1142
1252
  throw new Error(
1143
1253
  `Snap refused the export (403): the API key does not have the "visual:export" scope. ` +
1144
- `Issue a key with visual:export access for project "${this.#projectId}" and retry.`,
1254
+ `Issue a key with visual:export access for project "${this.#projectId}" and retry.`,
1145
1255
  { cause: error }
1146
1256
  );
1147
1257
  }
1148
1258
  if (error.status === 404) {
1149
1259
  throw new Error(
1150
1260
  `Snap project "${this.#projectId}" was not found (404). ` +
1151
- `Check snap.projectId in snapdrift.json (or GITHUB_REPOSITORY when projectId is "auto").`,
1261
+ `Check snap.projectId in snapdrift.json (or GITHUB_REPOSITORY when projectId is "auto").`,
1152
1262
  { cause: error }
1153
1263
  );
1154
1264
  }
1155
1265
  if (error.status === 413) {
1156
1266
  throw new Error(
1157
1267
  `Snap export for project "${this.#projectId}" is too large (413): ${error.message}. ` +
1158
- `The export endpoint caps the number of accepted baselines and total archive bytes.`,
1268
+ `The export endpoint caps the number of accepted baselines and total archive bytes.`,
1159
1269
  { cause: error }
1160
1270
  );
1161
1271
  }
@@ -1172,8 +1282,7 @@ export class SnapProvider {
1172
1282
  const baselines = Array.isArray(exportManifest.baselines) ? exportManifest.baselines : [];
1173
1283
  if (baselines.length === 0) {
1174
1284
  throw new Error(
1175
- `Snap project "${this.#projectId}" has no accepted baselines to export. ` +
1176
- `Publish a baseline first (snapdrift baseline with provider: "snap").`
1285
+ `Snap project "${this.#projectId}" has no accepted baselines to export. ` + `Publish a baseline first (snapdrift baseline with provider: "snap").`
1177
1286
  );
1178
1287
  }
1179
1288
 
@@ -1189,21 +1298,16 @@ export class SnapProvider {
1189
1298
  if (!baseline.sourceManifest) {
1190
1299
  throw new Error(
1191
1300
  `Snap baseline "${baseline.id}" predates manifest tracking (no source manifest was recorded ` +
1192
- `at publish time) and cannot be migrated. Publish a fresh baseline with a current SnapDrift ` +
1193
- `version, then re-run the migration.`
1301
+ `at publish time) and cannot be migrated. Publish a fresh baseline with a current SnapDrift ` +
1302
+ `version, then re-run the migration.`
1194
1303
  );
1195
1304
  }
1196
1305
  const routes = Array.isArray(baseline.sourceManifest.routes) ? baseline.sourceManifest.routes : null;
1197
1306
  if (!routes) {
1198
- throw new Error(
1199
- `Snap baseline "${baseline.id}" has an unrecognised source manifest format ` +
1200
- `(expected a "routes" array) and cannot be migrated.`
1201
- );
1307
+ throw new Error(`Snap baseline "${baseline.id}" has an unrecognised source manifest format ` + `(expected a "routes" array) and cannot be migrated.`);
1202
1308
  }
1203
1309
 
1204
- const archivePathByKey = new Map(
1205
- (baseline.objects || []).map((object) => [object.sourceKey, object.archivePath])
1206
- );
1310
+ const archivePathByKey = new Map((baseline.objects || []).map((object) => [object.sourceKey, object.archivePath]));
1207
1311
 
1208
1312
  const sourceLabel = `Snap baseline "${baseline.id}" source manifest`;
1209
1313
  /** @type {Map<string, unknown[]>} */
@@ -1229,8 +1333,7 @@ export class SnapProvider {
1229
1333
  const bytes = archivePath ? entries.get(archivePath) : undefined;
1230
1334
  if (!bytes) {
1231
1335
  process.stderr.write(
1232
- `[SnapDrift] Warning: Snap export has no image for route "${route.routeId}" ` +
1233
- `in baseline ${baseline.id}. Skipping the route.\n`
1336
+ `[SnapDrift] Warning: Snap export has no image for route "${route.routeId}" ` + `in baseline ${baseline.id}. Skipping the route.\n`
1234
1337
  );
1235
1338
  continue;
1236
1339
  }
@@ -1241,13 +1344,13 @@ export class SnapProvider {
1241
1344
  // exactly; otherwise keep custom dimensions. This round-trips with the
1242
1345
  // descriptor expansion capture() applies on the way up.
1243
1346
  const presetName = hasDescriptorDims ? viewportHash(descriptor) : null;
1244
- const dimensions = readPngDimensions(bytes)
1245
- ?? (hasDescriptorDims ? { width: descriptor.width, height: descriptor.height } : { width: 1, height: 1 });
1246
- const viewport = presetName && VIEWPORT_PRESETS[presetName]
1247
- ? presetName
1248
- : hasDescriptorDims
1249
- ? { width: descriptor.width, height: descriptor.height }
1250
- : { width: dimensions.width, height: dimensions.height };
1347
+ const dimensions = readPngDimensions(bytes) ?? (hasDescriptorDims ? { width: descriptor.width, height: descriptor.height } : { width: 1, height: 1 });
1348
+ const viewport =
1349
+ presetName && VIEWPORT_PRESETS[presetName]
1350
+ ? presetName
1351
+ : hasDescriptorDims
1352
+ ? { width: descriptor.width, height: descriptor.height }
1353
+ : { width: dimensions.width, height: dimensions.height };
1251
1354
 
1252
1355
  const extension = typeof archivePath === 'string' ? path.extname(archivePath) || '.png' : '.png';
1253
1356
  const filename = `${sanitizeRouteId(route.routeId)}${extension}`;
@@ -1263,15 +1366,10 @@ export class SnapProvider {
1263
1366
  }
1264
1367
 
1265
1368
  if (screenshots.length === 0) {
1266
- throw new Error(
1267
- `Snap baseline "${baseline.id}" contained no exportable screenshots — ` +
1268
- `the export archive had no image bytes for any of its routes.`
1269
- );
1369
+ throw new Error(`Snap baseline "${baseline.id}" contained no exportable screenshots — ` + `the export archive had no image bytes for any of its routes.`);
1270
1370
  }
1271
1371
 
1272
- const captureProfile = safeJsonParse(
1273
- entries.get(`${baseline.id}/capture_profile.json`)?.toString('utf-8')
1274
- ) ?? {};
1372
+ const captureProfile = safeJsonParse(entries.get(`${baseline.id}/capture_profile.json`)?.toString('utf-8')) ?? {};
1275
1373
  const engine = resolveExportEngine(captureProfile);
1276
1374
 
1277
1375
  const generatedAt = new Date().toISOString();
@@ -1352,7 +1450,7 @@ export class SnapProvider {
1352
1450
  if (!repo) {
1353
1451
  throw new Error(
1354
1452
  'Snap project ID is set to "auto" but GITHUB_REPOSITORY is not set. ' +
1355
- 'Set the GITHUB_REPOSITORY environment variable or provide an explicit snap.projectId.'
1453
+ 'Set the GITHUB_REPOSITORY environment variable or provide an explicit snap.projectId.'
1356
1454
  );
1357
1455
  }
1358
1456
  return repoSlugToProjectId(repo);
@@ -1430,8 +1528,7 @@ export class SnapProvider {
1430
1528
  */
1431
1529
  #operationDeadlineError(requestPath, operation) {
1432
1530
  return new SnapUnavailableError(
1433
- `Snap ${operation.label} operation deadline exceeded while requesting ${requestPath}; ` +
1434
- `the ${OPERATION_LIMIT_TEXT} Snap operation limit was reached`
1531
+ `Snap ${operation.label} operation deadline exceeded while requesting ${requestPath}; ` + `the ${OPERATION_LIMIT_TEXT} Snap operation limit was reached`
1435
1532
  );
1436
1533
  }
1437
1534
 
@@ -1445,8 +1542,8 @@ export class SnapProvider {
1445
1542
  #requestTimeoutError(requestPath, timeoutMs, operation, requestLimitMs) {
1446
1543
  const error = new Error(
1447
1544
  `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})`
1545
+ `${Math.max(0, Math.round(timeoutMs))}ms (request limit ${requestLimitMs}ms; ` +
1546
+ `operation limit ${OPERATION_LIMIT_TEXT})`
1450
1547
  );
1451
1548
  error.name = 'SnapTransportTimeoutError';
1452
1549
  return error;
@@ -1454,17 +1551,13 @@ export class SnapProvider {
1454
1551
 
1455
1552
  /** @param {number} attempt @returns {number} */
1456
1553
  #retryDelay(attempt) {
1457
- return Math.min(
1458
- INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)),
1459
- RETRY_DELAY_CAP_MS
1460
- );
1554
+ return Math.min(INITIAL_RETRY_DELAY_MS * RETRY_MULTIPLIER ** (attempt - 1), RETRY_DELAY_CAP_MS);
1461
1555
  }
1462
1556
 
1463
1557
  /** @param {string} requestPath @param {number} delayMs @param {OperationDeadline} operation @returns {SnapUnavailableError} */
1464
1558
  #sleepTimeoutError(requestPath, delayMs, operation) {
1465
1559
  return new SnapUnavailableError(
1466
- `Snap ${operation.label} wait for ${requestPath} timed out after ${delayMs}ms ` +
1467
- `before the ${OPERATION_LIMIT_TEXT} operation deadline`
1560
+ `Snap ${operation.label} wait for ${requestPath} timed out after ${delayMs}ms ` + `before the ${OPERATION_LIMIT_TEXT} operation deadline`
1468
1561
  );
1469
1562
  }
1470
1563
 
@@ -1486,38 +1579,25 @@ export class SnapProvider {
1486
1579
  async #readApiError(response, requestPath, controller, attemptDeadline, operation, requestLimitMs) {
1487
1580
  let errorBody;
1488
1581
  try {
1489
- errorBody = await this.#readResponseBody(
1490
- response,
1491
- requestPath,
1492
- controller,
1493
- attemptDeadline,
1494
- operation,
1495
- requestLimitMs,
1496
- () => response.text()
1497
- );
1582
+ errorBody = await this.#readResponseBody(response, requestPath, controller, attemptDeadline, operation, requestLimitMs, () => response.text());
1498
1583
  } catch (error) {
1499
1584
  if (response.status < 400 || response.status >= 500) throw error;
1500
1585
  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
- );
1586
+ const reason =
1587
+ error instanceof SnapUnavailableError || error?.name === 'SnapTransportTimeoutError'
1588
+ ? `diagnostic body timed out (${detail})`
1589
+ : `diagnostic body unavailable (${detail})`;
1590
+ return new SnapApiError(response.status, `Snap API ${response.status}: ${reason}`, requestPath);
1509
1591
  }
1510
1592
 
1511
1593
  let detail = '';
1512
1594
  try {
1513
1595
  const parsed = JSON.parse(errorBody);
1514
1596
  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
- );
1597
+ } catch {
1598
+ /* use default message */
1599
+ }
1600
+ return new SnapApiError(response.status, detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`, requestPath);
1521
1601
  }
1522
1602
 
1523
1603
  /**
@@ -1555,9 +1635,10 @@ export class SnapProvider {
1555
1635
  controller.abort();
1556
1636
  cancelResponseBody(response);
1557
1637
  },
1558
- () => this.#remainingOperationMs(operation) <= 0
1559
- ? this.#operationDeadlineError(requestPath, operation)
1560
- : this.#requestTimeoutError(requestPath, remaining, operation, requestLimitMs)
1638
+ () =>
1639
+ this.#remainingOperationMs(operation) <= 0
1640
+ ? this.#operationDeadlineError(requestPath, operation)
1641
+ : this.#requestTimeoutError(requestPath, remaining, operation, requestLimitMs)
1561
1642
  );
1562
1643
  }
1563
1644
 
@@ -1606,7 +1687,7 @@ export class SnapProvider {
1606
1687
  async #requestTransport(options) {
1607
1688
  const serializedBody = options.body ? JSON.stringify(options.body) : undefined;
1608
1689
  const requestHeaders = {
1609
- 'Authorization': `Bearer ${this.#apiKey}`
1690
+ Authorization: `Bearer ${this.#apiKey}`
1610
1691
  };
1611
1692
  if (options.accept) {
1612
1693
  requestHeaders['Accept'] = options.accept;
@@ -1629,12 +1710,13 @@ export class SnapProvider {
1629
1710
  const controller = new AbortController();
1630
1711
  try {
1631
1712
  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
- }),
1713
+ () =>
1714
+ this.#fetchFn(`${this.#apiUrl}${options.requestPath}`, {
1715
+ method: options.method,
1716
+ headers: { ...requestHeaders },
1717
+ body: serializedBody,
1718
+ signal: controller.signal
1719
+ }),
1638
1720
  attemptBudget,
1639
1721
  () => controller.abort(),
1640
1722
  () => this.#requestTimeoutError(options.requestPath, attemptBudget, options.operation, options.requestTimeoutMs)
@@ -1648,14 +1730,7 @@ export class SnapProvider {
1648
1730
  // read timeout still preserves the received HTTP status, rather than
1649
1731
  // turning an already-classified API response into an availability retry.
1650
1732
  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
- );
1733
+ throw await this.#readApiError(response, options.requestPath, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
1659
1734
  }
1660
1735
 
1661
1736
  // 5xx — retry with backoff unless this is the last attempt. Cancel
@@ -1666,14 +1741,7 @@ export class SnapProvider {
1666
1741
  continue;
1667
1742
  }
1668
1743
 
1669
- throw await this.#readApiError(
1670
- response,
1671
- options.requestPath,
1672
- controller,
1673
- attemptDeadline,
1674
- options.operation,
1675
- options.requestTimeoutMs
1676
- );
1744
+ throw await this.#readApiError(response, options.requestPath, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
1677
1745
  } catch (error) {
1678
1746
  if (error instanceof SnapSkipError || error instanceof SnapFallbackError) {
1679
1747
  throw error;
@@ -1696,19 +1764,13 @@ export class SnapProvider {
1696
1764
  try {
1697
1765
  await this.#sleepWithinOperation(this.#retryDelay(attempt), options.operation, options.requestPath);
1698
1766
  } catch (retryError) {
1699
- return this.#handleUnavailable(retryError instanceof Error
1700
- ? retryError
1701
- : new SnapUnavailableError(String(retryError)));
1767
+ return this.#handleUnavailable(retryError instanceof Error ? retryError : new SnapUnavailableError(String(retryError)));
1702
1768
  }
1703
1769
  continue;
1704
1770
  }
1705
1771
 
1706
1772
  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
- );
1773
+ return this.#handleUnavailable(new SnapUnavailableError(`Snap request ${options.requestPath} failed after ${MAX_RETRIES} attempts: ${detail}`));
1712
1774
  } finally {
1713
1775
  controller.abort();
1714
1776
  }
@@ -1738,15 +1800,7 @@ export class SnapProvider {
1738
1800
  operation,
1739
1801
  requestTimeoutMs: JSON_REQUEST_TIMEOUT_MS,
1740
1802
  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
- );
1803
+ const text = await this.#readResponseBody(response, requestPath, controller, attemptDeadline, requestOperation, requestLimitMs, () => response.text());
1750
1804
  return text ? JSON.parse(text) : {};
1751
1805
  }
1752
1806
  });
@@ -1767,14 +1821,8 @@ export class SnapProvider {
1767
1821
  operation,
1768
1822
  requestTimeoutMs: BINARY_REQUEST_TIMEOUT_MS,
1769
1823
  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()
1824
+ const bytes = await this.#readResponseBody(response, requestPath, controller, attemptDeadline, requestOperation, requestLimitMs, () =>
1825
+ response.arrayBuffer()
1778
1826
  );
1779
1827
  return Buffer.from(bytes);
1780
1828
  }
@@ -1791,10 +1839,11 @@ export class SnapProvider {
1791
1839
  * selectedRouteIds: string[],
1792
1840
  * purpose: 'baseline' | 'capture' | 'diff',
1793
1841
  * 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 }
1797
- * }} options
1842
+ * expectedCaptures: import('../types/visual-diff-types').SnapExpectedCaptureIdentity[],
1843
+ * runContext: { branch?: string, prHeadSha?: string },
1844
+ * baselineRef?: { refBranch: string, refSha: string, publicationWorkflowRef: string, publicationSequence: number },
1845
+ * comparisonPolicy?: ComparisonPolicy
1846
+ * }} options
1798
1847
  * @returns {Promise<CaptureResult>}
1799
1848
  */
1800
1849
  async #captureLocalAndUpload(options) {
@@ -1829,53 +1878,75 @@ export class SnapProvider {
1829
1878
  // attaches the latest baseline so the backend can compare against it.
1830
1879
  const baselineId = options.purpose === 'diff' ? await this.#resolveLatestBaselineId(operation) : null;
1831
1880
 
1832
- await this.#request('POST', `/v1/visual/projects/${this.#projectId}/runs`, {
1833
- id: runId,
1834
- baseUrl: options.config.baseUrl,
1835
- trigger: 'ci',
1836
- ...(baselineId ? { baselineId } : {}),
1837
- // A baseline run must not be diffed. Suppress the server's
1838
- // auto-resolve-by-branch so no prior baseline is attached.
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
1881
+ const runResponse = await this.#request(
1882
+ 'POST',
1883
+ `/v1/visual/projects/${this.#projectId}/runs`,
1884
+ {
1885
+ id: runId,
1886
+ baseUrl: options.config.baseUrl,
1887
+ trigger: 'ci',
1888
+ ...(baselineId ? { baselineId } : {}),
1889
+ // A baseline run must not be diffed. Suppress the server's
1890
+ // auto-resolve-by-branch so no prior baseline is attached.
1891
+ ...(options.purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
1892
+ capturePlan: {
1893
+ purpose: options.purpose === 'baseline' ? 'baseline' : 'diff',
1894
+ ...(options.baselineRef
1895
+ ? {
1896
+ publicationWorkflowRef: options.baselineRef.publicationWorkflowRef,
1897
+ publicationSequence: options.baselineRef.publicationSequence
1898
+ }
1899
+ : {}),
1900
+ configuredRouteIds: options.configuredRouteIds,
1901
+ selectedRouteIds: options.selectedRouteIds,
1902
+ expectedCaptures: options.expectedCaptures
1903
+ },
1904
+ ...options.runContext,
1905
+ ...(options.comparisonPolicy ? { comparisonPolicy: options.comparisonPolicy } : {})
1851
1906
  },
1852
- ...options.runContext
1853
- }, this.#generateIdempotencyKey(), operation);
1907
+ this.#generateIdempotencyKey(),
1908
+ operation
1909
+ );
1910
+ if (options.comparisonPolicy) {
1911
+ assertComparisonPolicyAcknowledged(runResponse?.comparisonPolicy, options.comparisonPolicy, 'Snap local-upload run creation');
1912
+ }
1854
1913
 
1855
1914
  for (const { route, manifestEntry } of uploads) {
1856
1915
  const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
1857
1916
  const viewportDescriptor = resolveViewportDescriptor(route.viewport);
1858
1917
 
1859
- await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
1860
- id: captureId,
1861
- routeId: route.id,
1862
- routePath: route.path,
1863
- viewportDescriptorJson: JSON.stringify(viewportDescriptor),
1864
- // The screenshot is captured locally and uploaded below, not rendered by
1865
- // Snap. This flag tells the backend to keep the capture out of the render
1866
- // worker's queue — without it the worker would try to render baseUrl
1867
- // (a client-only/loopback address it can't reach) and error the run.
1868
- localCapture: true,
1869
- }, this.#generateIdempotencyKey(), operation);
1918
+ await this.#request(
1919
+ 'POST',
1920
+ `/v1/visual/runs/${runId}/captures`,
1921
+ {
1922
+ id: captureId,
1923
+ routeId: route.id,
1924
+ routePath: route.path,
1925
+ viewportDescriptorJson: JSON.stringify(viewportDescriptor),
1926
+ // The screenshot is captured locally and uploaded below, not rendered by
1927
+ // Snap. This flag tells the backend to keep the capture out of the render
1928
+ // worker's queue — without it the worker would try to render baseUrl
1929
+ // (a client-only/loopback address it can't reach) and error the run.
1930
+ localCapture: true
1931
+ },
1932
+ this.#generateIdempotencyKey(),
1933
+ operation
1934
+ );
1870
1935
 
1871
1936
  const imagePath = path.resolve(localCapture.screenshotsRoot, manifestEntry.imagePath);
1872
1937
  const imageBytes = await fs.readFile(imagePath);
1873
1938
 
1874
- await this.#request('POST', `/v1/visual/captures/${captureId}/local-result`, {
1875
- imageBase64: imageBytes.toString('base64'),
1876
- width: manifestEntry.width,
1877
- height: manifestEntry.height
1878
- }, this.#generateIdempotencyKey(), operation);
1939
+ await this.#request(
1940
+ 'POST',
1941
+ `/v1/visual/captures/${captureId}/local-result`,
1942
+ {
1943
+ imageBase64: imageBytes.toString('base64'),
1944
+ width: manifestEntry.width,
1945
+ height: manifestEntry.height
1946
+ },
1947
+ this.#generateIdempotencyKey(),
1948
+ operation
1949
+ );
1879
1950
  }
1880
1951
 
1881
1952
  const snapResults = {
@@ -1885,6 +1956,7 @@ export class SnapProvider {
1885
1956
  runId,
1886
1957
  projectId: this.#projectId,
1887
1958
  purpose: options.purpose,
1959
+ ...(options.comparisonPolicy ? { comparisonPolicy: options.comparisonPolicy } : {}),
1888
1960
  ...(options.baselineRef ? options.baselineRef : {}),
1889
1961
  configuredRouteIds: options.configuredRouteIds,
1890
1962
  selectedRouteIds: options.selectedRouteIds,
@@ -1929,12 +2001,7 @@ export class SnapProvider {
1929
2001
  * @param {OperationDeadline} [operation]
1930
2002
  * @returns {Promise<JsonObject>}
1931
2003
  */
1932
- async #pollRun(
1933
- runId,
1934
- expectedCaptureCount,
1935
- errorFactory = (detail) => new Error(detail),
1936
- operation = this.#createOperationDeadline('run polling')
1937
- ) {
2004
+ async #pollRun(runId, expectedCaptureCount, errorFactory = (detail) => new Error(detail), operation = this.#createOperationDeadline('run polling')) {
1938
2005
  let incompleteTerminalSignature;
1939
2006
  let stableIncompleteTerminalPolls = 0;
1940
2007
  let incompleteSignature;
@@ -1949,20 +2016,18 @@ export class SnapProvider {
1949
2016
  const captures = Array.isArray(run.captures) ? run.captures : [];
1950
2017
  const completeCaptureSet =
1951
2018
  expectedCaptureCount === undefined ||
1952
- (captures.length >= expectedCaptureCount &&
1953
- captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status)));
2019
+ (captures.length >= expectedCaptureCount && captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status)));
1954
2020
 
1955
2021
  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
- }));
2022
+ const signature = JSON.stringify(
2023
+ captures
2024
+ .map((capture) => [capture?.id, capture?.routeId, capture?.status, capture?.currentObjectKey])
2025
+ .sort((left, right) => {
2026
+ const leftJson = JSON.stringify(left);
2027
+ const rightJson = JSON.stringify(right);
2028
+ return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
2029
+ })
2030
+ );
1966
2031
  if (signature === incompleteSignature) stableIncompletePolls += 1;
1967
2032
  else {
1968
2033
  incompleteSignature = signature;
@@ -1986,19 +2051,16 @@ export class SnapProvider {
1986
2051
  // An older server can report a terminal run after only an early subset
1987
2052
  // has settled. Keep polling for the full capture set until the normal
1988
2053
  // 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
- }));
2054
+ if (captures.length < expectedCaptureCount && captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status))) {
2055
+ const signature = JSON.stringify(
2056
+ captures
2057
+ .map((capture) => [capture?.id, capture?.routeId, capture?.status])
2058
+ .sort((left, right) => {
2059
+ const leftJson = JSON.stringify(left);
2060
+ const rightJson = JSON.stringify(right);
2061
+ return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
2062
+ })
2063
+ );
2002
2064
  if (signature === incompleteTerminalSignature) stableIncompleteTerminalPolls += 1;
2003
2065
  else {
2004
2066
  incompleteTerminalSignature = signature;
@@ -2016,32 +2078,33 @@ export class SnapProvider {
2016
2078
  try {
2017
2079
  await this.#sleepWithinOperation(POLL_INTERVAL_MS, operation, requestPath);
2018
2080
  } catch (error) {
2019
- return this.#handleUnavailable(error instanceof Error
2020
- ? error
2021
- : new SnapUnavailableError(String(error)));
2081
+ return this.#handleUnavailable(error instanceof Error ? error : new SnapUnavailableError(String(error)));
2022
2082
  }
2023
2083
  }
2024
2084
 
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
- ));
2085
+ return this.#handleUnavailable(
2086
+ new SnapUnavailableError(
2087
+ `Snap ${operation.label} run ${runId} did not complete within ${OPERATION_LIMIT_TEXT}; ` + 'the Snap operation deadline was reached while polling'
2088
+ )
2089
+ );
2029
2090
  }
2030
2091
 
2031
2092
  /**
2032
2093
  * Map a Snap run result to a VisualDiffSummary.
2033
2094
  * @param {JsonObject} run
2034
2095
  * @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
2096
+ * @param {{
2097
+ * selectedRouteIds: string[],
2098
+ * expectedCaptures: Array<{ routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
2099
+ * expectedByIdentity: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
2100
+ * expectedByRouteId: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>
2101
+ * }} diffPlan
2102
+ * @param {ComparisonPolicy} [comparisonPolicy]
2041
2103
  * @returns {import('../types/visual-diff-types').VisualDiffSummary}
2042
2104
  */
2043
- #mapRunToSummary(run, config, diffPlan) {
2105
+ #mapRunToSummary(run, config, diffPlan, comparisonPolicy) {
2044
2106
  const captures = Array.isArray(run.captures) ? run.captures : [];
2107
+ const comparisonThreshold = comparisonPolicy?.threshold ?? config.diff.threshold;
2045
2108
  const totalScreenshots = diffPlan.expectedCaptures.length;
2046
2109
  let matchedScreenshots = 0;
2047
2110
  let changedScreenshots = 0;
@@ -2075,12 +2138,7 @@ export class SnapProvider {
2075
2138
  }
2076
2139
 
2077
2140
  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
- );
2141
+ addError(`run:${run.id}`, undefined, undefined, run.errorDetails?.message || run.errorCode || run.message || 'Snap run ended with status "error".');
2084
2142
  }
2085
2143
 
2086
2144
  const seenIdentities = new Set();
@@ -2097,15 +2155,9 @@ export class SnapProvider {
2097
2155
 
2098
2156
  let identity;
2099
2157
  try {
2100
- identity = captureIdentityKey(
2101
- capture,
2102
- `source capture "${capture.routeId || capture.id || 'unknown'}"`,
2103
- hostedDiffError
2104
- );
2158
+ identity = captureIdentityKey(capture, `source capture "${capture.routeId || capture.id || 'unknown'}"`, hostedDiffError);
2105
2159
  } catch (error) {
2106
- const malformedRouteId = typeof capture.routeId === 'string' && capture.routeId
2107
- ? capture.routeId
2108
- : 'unknown';
2160
+ const malformedRouteId = typeof capture.routeId === 'string' && capture.routeId ? capture.routeId : 'unknown';
2109
2161
  const expected = diffPlan.expectedByRouteId.get(malformedRouteId);
2110
2162
  addError(
2111
2163
  malformedRouteId,
@@ -2118,32 +2170,17 @@ export class SnapProvider {
2118
2170
 
2119
2171
  const expected = diffPlan.expectedByIdentity.get(identity);
2120
2172
  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
- );
2173
+ addError(capture.routeId, capture.routePath, capture.viewport, `Snap run returned an unexpected route/viewport capture for "${capture.routeId}".`);
2127
2174
  continue;
2128
2175
  }
2129
2176
  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
- );
2177
+ addError(expected.routeId, expected.routePath, expected.viewport, `Snap run returned a duplicate route/viewport capture for "${expected.routeId}".`);
2136
2178
  continue;
2137
2179
  }
2138
2180
  seenIdentities.add(identity);
2139
2181
 
2140
2182
  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
- );
2183
+ addError(expected.routeId, capture.routePath, expected.viewport, `Snap run returned an unexpected path for "${expected.routeId}".`);
2147
2184
  continue;
2148
2185
  }
2149
2186
 
@@ -2168,12 +2205,7 @@ export class SnapProvider {
2168
2205
  }
2169
2206
 
2170
2207
  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
- );
2208
+ addError(expected.routeId, expected.routePath, expected.viewport, `Snap capture "${expected.routeId}" has no currentObjectKey.`);
2177
2209
  continue;
2178
2210
  }
2179
2211
 
@@ -2190,15 +2222,8 @@ export class SnapProvider {
2190
2222
  continue;
2191
2223
  }
2192
2224
 
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
- );
2225
+ if (capture.baselineObjectKey !== undefined && capture.baselineObjectKey !== null && typeof capture.baselineObjectKey !== 'string') {
2226
+ addError(expected.routeId, expected.routePath, expected.viewport, `Snap capture "${expected.routeId}" has an invalid baselineObjectKey.`);
2202
2227
  continue;
2203
2228
  }
2204
2229
 
@@ -2216,17 +2241,42 @@ export class SnapProvider {
2216
2241
  continue;
2217
2242
  }
2218
2243
 
2244
+ const comparison = comparisonFromCapture(capture);
2245
+ if (comparisonPolicy && !comparison) {
2246
+ addError(
2247
+ expected.routeId,
2248
+ expected.routePath,
2249
+ expected.viewport,
2250
+ `Snap capture "${expected.routeId}" is missing complete comparison metadata; ` +
2251
+ 'upgrade the Snap server and diff Lambda before enabling comparisonPolicy v1.'
2252
+ );
2253
+ continue;
2254
+ }
2255
+
2219
2256
  if (!Number.isFinite(capture.diffPct) || capture.diffPct < 0 || capture.diffPct > 1) {
2257
+ addError(expected.routeId, expected.routePath, expected.viewport, `Snap capture "${expected.routeId}" has an invalid diffPct.`);
2258
+ continue;
2259
+ }
2260
+
2261
+ if (comparisonPolicy && comparison && !hasCompleteComparisonResult(capture, comparison, comparisonPolicy.threshold)) {
2220
2262
  addError(
2221
2263
  expected.routeId,
2222
2264
  expected.routePath,
2223
2265
  expected.viewport,
2224
- `Snap capture "${expected.routeId}" has an invalid diffPct.`
2266
+ `Snap capture "${expected.routeId}" has incomplete or inconsistent comparison statistics.`
2225
2267
  );
2226
2268
  continue;
2227
2269
  }
2228
2270
 
2229
- if (capture.diffPct > config.diff.threshold) {
2271
+ if (comparison && (!Number.isInteger(capture.diffPixels) || capture.diffPixels < 0 || capture.diffPixels > comparison.totalPixels)) {
2272
+ addError(expected.routeId, expected.routePath, expected.viewport, `Snap capture "${expected.routeId}" has invalid comparison pixel statistics.`);
2273
+ continue;
2274
+ }
2275
+
2276
+ const legacyWidth = capture.width || 0;
2277
+ const legacyHeight = capture.height || 0;
2278
+ const dimensionsChanged = comparison?.dimensionsChanged === true;
2279
+ if (dimensionsChanged || capture.diffPct > comparisonThreshold) {
2230
2280
  comparedWithBaseline++;
2231
2281
  changedScreenshots++;
2232
2282
  changed.push({
@@ -2235,12 +2285,13 @@ export class SnapProvider {
2235
2285
  viewport: expected.viewport,
2236
2286
  baselineImagePath: capture.baselineObjectKey,
2237
2287
  currentImagePath: capture.currentObjectKey,
2238
- width: capture.width || 0,
2239
- height: capture.height || 0,
2288
+ width: comparison?.canvas.width ?? legacyWidth,
2289
+ height: comparison?.canvas.height ?? legacyHeight,
2240
2290
  differentPixels: capture.diffPixels || 0,
2241
- totalPixels: (capture.width || 0) * (capture.height || 0),
2291
+ totalPixels: comparison?.totalPixels ?? legacyWidth * legacyHeight,
2242
2292
  mismatchRatio: capture.diffPct,
2243
- status: 'changed'
2293
+ status: 'changed',
2294
+ ...(comparison ? { comparison } : {})
2244
2295
  });
2245
2296
  } else {
2246
2297
  comparedWithBaseline++;
@@ -2277,9 +2328,9 @@ export class SnapProvider {
2277
2328
  if (comparedWithBaseline > 1 && exactZeroMatches === comparedWithBaseline) {
2278
2329
  process.stderr.write(
2279
2330
  `[SnapDrift] Warning: all ${comparedWithBaseline} compared route(s) were pixel-identical ` +
2280
- `to the baseline (0% drift). If you expected a visual change, the captured page may be ` +
2281
- `stale or wrong — verify the resolved baseUrl points at the PR preview and that the ` +
2282
- `preview was deployed before capture ran. See issue #93.\n`
2331
+ `to the baseline (0% drift). If you expected a visual change, the captured page may be ` +
2332
+ `stale or wrong — verify the resolved baseUrl points at the PR preview and that the ` +
2333
+ `preview was deployed before capture ran. See issue #93.\n`
2283
2334
  );
2284
2335
  }
2285
2336
 
@@ -2293,7 +2344,7 @@ export class SnapProvider {
2293
2344
  baselineManifestPath: '',
2294
2345
  currentManifestPath: '',
2295
2346
  diffMode: config.diff.mode,
2296
- threshold: config.diff.threshold,
2347
+ threshold: comparisonThreshold,
2297
2348
  baselineResultsPath: '',
2298
2349
  currentResultsPath: '',
2299
2350
  totalScreenshots,
@@ -2305,6 +2356,7 @@ export class SnapProvider {
2305
2356
  missing,
2306
2357
  errors,
2307
2358
  dimensionChanges: [],
2359
+ ...(comparisonPolicy ? { comparisonPolicy: { ...comparisonPolicy } } : {}),
2308
2360
  dashboardUrl: `${this.#apiUrl}/dashboard/visual/${this.#projectId}/runs/${run.id}`
2309
2361
  };
2310
2362
 
@@ -2335,6 +2387,21 @@ export class SnapApiError extends Error {
2335
2387
  }
2336
2388
  }
2337
2389
 
2390
+ /**
2391
+ * Error raised when an updated client opts into a comparison policy that the
2392
+ * connected Snap server does not acknowledge.
2393
+ */
2394
+ export class SnapUpgradeRequiredError extends Error {
2395
+ /** @type {'comparison_policy_unsupported'} */
2396
+ code = 'comparison_policy_unsupported';
2397
+
2398
+ /** @param {string} message */
2399
+ constructor(message) {
2400
+ super(message);
2401
+ this.name = 'SnapUpgradeRequiredError';
2402
+ }
2403
+ }
2404
+
2338
2405
  /**
2339
2406
  * Error thrown when the Snap API is unreachable after all retries.
2340
2407
  */