snapdrift 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +3 -3
- package/action.yml +2 -2
- package/actions/comment/action.yml +6 -0
- package/actions/compare/action.yml +6 -0
- package/actions/pr-diff/action.yml +10 -3
- package/actions/stage/action.yml +7 -1
- package/docs/contracts.md +19 -2
- package/docs/integration-guide.md +19 -9
- package/docs/local-cli.md +15 -1
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +1 -1
- package/lib/cli.mjs +15 -11
- package/lib/provider.mjs +19 -10
- package/lib/report.mjs +3 -2
- package/lib/snap-provider.mjs +495 -428
- package/package.json +5 -5
- package/types/visual-diff-types.d.ts +28 -1
package/lib/snap-provider.mjs
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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 (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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 (
|
|
361
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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 =
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
resolve
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
796
|
-
|
|
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(
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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(
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
981
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1193
|
-
|
|
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
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1449
|
-
|
|
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 =
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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 {
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
-
() =>
|
|
1559
|
-
|
|
1560
|
-
|
|
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
|
-
|
|
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
|
-
() =>
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
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
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
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(
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
...(options.
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
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
|
-
|
|
1853
|
-
|
|
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(
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
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(
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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(
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
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
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
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(
|
|
2026
|
-
|
|
2027
|
-
|
|
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
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
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
|
-
|
|
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
|
|
2266
|
+
`Snap capture "${expected.routeId}" has incomplete or inconsistent comparison statistics.`
|
|
2225
2267
|
);
|
|
2226
2268
|
continue;
|
|
2227
2269
|
}
|
|
2228
2270
|
|
|
2229
|
-
if (capture.
|
|
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:
|
|
2239
|
-
height:
|
|
2288
|
+
width: comparison?.canvas.width ?? legacyWidth,
|
|
2289
|
+
height: comparison?.canvas.height ?? legacyHeight,
|
|
2240
2290
|
differentPixels: capture.diffPixels || 0,
|
|
2241
|
-
totalPixels:
|
|
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
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
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:
|
|
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
|
*/
|