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