snapdrift 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +12 -4
- package/action.yml +16 -9
- package/actions/baseline/action.yml +68 -47
- package/actions/pr-diff/action.yml +208 -107
- 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 +141 -9
- package/docs/integration-guide.md +54 -13
- package/docs/local-cli.md +6 -5
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +25 -13
- package/lib/cli.mjs +243 -168
- package/lib/init-from-action.mjs +24 -13
- package/lib/outage-policy.mjs +278 -0
- 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 +1246 -303
- package/package.json +15 -9
- package/types/visual-diff-types.d.ts +28 -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,21 @@ import { selectConfiguredRoutes, splitCommaList, determineDriftStatus, VIEWPORT_
|
|
|
24
32
|
/** @typedef {import('../types/visual-diff-types').SnapConfig} SnapConfig */
|
|
25
33
|
/** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
|
|
26
34
|
/** @typedef {import('../types/visual-diff-types').JsonObject} JsonObject */
|
|
35
|
+
/** @typedef {{ deadline: number, label: string }} OperationDeadline */
|
|
27
36
|
|
|
28
37
|
const DEFAULT_API_URL = 'https://snap.i2dev.com';
|
|
29
38
|
const MAX_RETRIES = 3;
|
|
30
39
|
const INITIAL_RETRY_DELAY_MS = 1000;
|
|
31
40
|
const RETRY_MULTIPLIER = 2;
|
|
32
|
-
const
|
|
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;
|
|
46
|
+
const MAX_STABLE_INCOMPLETE_POLLS = 30;
|
|
47
|
+
const BASELINE_RUN_SUCCESS_STATUS = 'new';
|
|
48
|
+
const BASELINE_CAPTURE_SUCCESS_STATUS = 'new';
|
|
49
|
+
const TERMINAL_CAPTURE_STATUSES = new Set(['new', 'diffed', 'error']);
|
|
35
50
|
|
|
36
51
|
// Snap reports a run as "new" when it has no baseline to compare against (first
|
|
37
52
|
// capture for a project, or any baseline-publish run that intentionally omits a
|
|
@@ -39,6 +54,372 @@ const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
|
|
|
39
54
|
// client must stop polling on it just like pass/fail/error.
|
|
40
55
|
const TERMINAL_RUN_STATUSES = new Set(['pass', 'fail', 'error', 'new']);
|
|
41
56
|
|
|
57
|
+
/** @param {number} milliseconds @returns {string} */
|
|
58
|
+
function formatDuration(milliseconds) {
|
|
59
|
+
const seconds = Math.round(milliseconds / 1000);
|
|
60
|
+
if (seconds % 60 === 0) return `${seconds / 60} minutes`;
|
|
61
|
+
return `${seconds} seconds`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const OPERATION_LIMIT_TEXT = formatDuration(MAX_OPERATION_DURATION_MS);
|
|
65
|
+
|
|
66
|
+
/** @returns {number} A monotonic timestamp in milliseconds. */
|
|
67
|
+
function monotonicNow() {
|
|
68
|
+
return typeof globalThis.performance?.now === 'function'
|
|
69
|
+
? globalThis.performance.now()
|
|
70
|
+
: Date.now();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Run an asynchronous operation with a timer that also settles when the
|
|
75
|
+
* underlying promise ignores cancellation. The promise remains observed after
|
|
76
|
+
* the timeout, so a late rejection cannot become an unhandled rejection.
|
|
77
|
+
*
|
|
78
|
+
* @template T
|
|
79
|
+
* @param {() => Promise<T> | T} task
|
|
80
|
+
* @param {number} timeoutMs
|
|
81
|
+
* @param {() => void} onTimeout
|
|
82
|
+
* @param {() => Error} timeoutError
|
|
83
|
+
* @returns {Promise<T>}
|
|
84
|
+
*/
|
|
85
|
+
function runWithTimeout(task, timeoutMs, onTimeout, timeoutError) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
let settled = false;
|
|
88
|
+
let taskPromise;
|
|
89
|
+
try {
|
|
90
|
+
taskPromise = Promise.resolve(task());
|
|
91
|
+
} catch (error) {
|
|
92
|
+
reject(error);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
if (settled) return;
|
|
97
|
+
settled = true;
|
|
98
|
+
try {
|
|
99
|
+
onTimeout();
|
|
100
|
+
} catch {
|
|
101
|
+
// Abort/cancel is best effort; the timeout still settles the caller.
|
|
102
|
+
}
|
|
103
|
+
reject(timeoutError());
|
|
104
|
+
}, Math.max(0, timeoutMs));
|
|
105
|
+
|
|
106
|
+
taskPromise
|
|
107
|
+
.then((value) => {
|
|
108
|
+
if (settled) {
|
|
109
|
+
if (value && typeof value === 'object') cancelResponseBody(value);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
settled = true;
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
resolve(value);
|
|
115
|
+
}, (error) => {
|
|
116
|
+
if (settled) return;
|
|
117
|
+
settled = true;
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
reject(error);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @param {any} response */
|
|
125
|
+
function cancelResponseBody(response) {
|
|
126
|
+
const body = response && typeof response === 'object' ? response.body : undefined;
|
|
127
|
+
if (body && typeof body.cancel === 'function') {
|
|
128
|
+
Promise.resolve(body.cancel()).catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @param {string} detail @returns {Error} */
|
|
133
|
+
function completeBaselineError(detail) {
|
|
134
|
+
return new Error(`Cannot publish a complete Snap baseline: ${detail}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @param {string} detail @returns {Error} */
|
|
138
|
+
function hostedDiffError(detail) {
|
|
139
|
+
return new Error(`Cannot compare a Snap visual diff: ${detail}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {import('../types/visual-diff-types').VisualViewport} viewport
|
|
144
|
+
* @returns {{ width: number, height: number, deviceScaleFactor?: number, isMobile?: boolean, hasTouch?: boolean }}
|
|
145
|
+
*/
|
|
146
|
+
function resolveViewportDescriptor(viewport) {
|
|
147
|
+
return typeof viewport === 'string'
|
|
148
|
+
? (VIEWPORT_PRESETS[viewport] ?? { width: 1280, height: 720 })
|
|
149
|
+
: viewport;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @param {VisualRegressionConfig['routes'][number]} route */
|
|
153
|
+
function expectedCaptureIdentity(route) {
|
|
154
|
+
return {
|
|
155
|
+
routeId: route.id,
|
|
156
|
+
routePath: route.path,
|
|
157
|
+
viewportDescriptorJson: JSON.stringify(resolveViewportDescriptor(route.viewport))
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {unknown} value
|
|
163
|
+
* @param {string} context
|
|
164
|
+
* @param {(detail: string) => Error} errorFactory
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
function normalizeViewportDescriptor(value, context, errorFactory) {
|
|
168
|
+
let descriptor;
|
|
169
|
+
try {
|
|
170
|
+
descriptor = typeof value === 'string' ? JSON.parse(value) : value;
|
|
171
|
+
} catch {
|
|
172
|
+
throw errorFactory(`${context} has malformed viewportDescriptorJson.`);
|
|
173
|
+
}
|
|
174
|
+
if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor) ||
|
|
175
|
+
!Number.isFinite(descriptor.width) || !Number.isFinite(descriptor.height)) {
|
|
176
|
+
throw errorFactory(`${context} has an invalid viewport descriptor.`);
|
|
177
|
+
}
|
|
178
|
+
return JSON.stringify(Object.fromEntries(
|
|
179
|
+
Object.entries(descriptor).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
180
|
+
));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {{ routeId?: unknown, viewportDescriptorJson?: unknown }} capture
|
|
185
|
+
* @param {string} context
|
|
186
|
+
* @param {(detail: string) => Error} errorFactory
|
|
187
|
+
*/
|
|
188
|
+
function captureIdentityKey(capture, context, errorFactory) {
|
|
189
|
+
if (typeof capture.routeId !== 'string' || !capture.routeId) {
|
|
190
|
+
throw errorFactory(`${context} has no routeId.`);
|
|
191
|
+
}
|
|
192
|
+
return `${capture.routeId}\u0000${normalizeViewportDescriptor(capture.viewportDescriptorJson, context, errorFactory)}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {unknown} value
|
|
197
|
+
* @param {string} field
|
|
198
|
+
* @param {(detail: string) => Error} errorFactory
|
|
199
|
+
* @returns {Set<string>}
|
|
200
|
+
*/
|
|
201
|
+
function uniqueRouteIdSet(value, field, errorFactory) {
|
|
202
|
+
if (!Array.isArray(value) || value.some((id) => typeof id !== 'string' || !id)) {
|
|
203
|
+
throw errorFactory(`results metadata has no valid ${field}; capture again with SnapDrift 0.8.2 or newer.`);
|
|
204
|
+
}
|
|
205
|
+
const ids = new Set(value);
|
|
206
|
+
if (ids.size !== value.length) {
|
|
207
|
+
throw errorFactory(`results metadata ${field} contains duplicate route ids.`);
|
|
208
|
+
}
|
|
209
|
+
return ids;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Validate the immutable capture plan written by SnapProvider.capture().
|
|
214
|
+
*
|
|
215
|
+
* Diff runs are allowed to be scoped, so this deliberately validates the
|
|
216
|
+
* expected set against selectedRouteIds rather than requiring every configured
|
|
217
|
+
* route as hosted baseline publication does.
|
|
218
|
+
*
|
|
219
|
+
* @param {JsonObject} metadata
|
|
220
|
+
* @param {string} projectId
|
|
221
|
+
* @returns {{
|
|
222
|
+
* runId: string,
|
|
223
|
+
* selectedRouteIds: string[],
|
|
224
|
+
* expectedCaptures: Array<{ routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
|
|
225
|
+
* expectedByIdentity: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
|
|
226
|
+
* expectedByRouteId: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>
|
|
227
|
+
* }}
|
|
228
|
+
*/
|
|
229
|
+
function validateHostedDiffMetadata(metadata, projectId) {
|
|
230
|
+
if (!metadata || typeof metadata !== 'object') {
|
|
231
|
+
throw hostedDiffError('capture results metadata is not an object; capture again with SnapDrift 0.8.2 or newer.');
|
|
232
|
+
}
|
|
233
|
+
if (typeof metadata.runId !== 'string' || !metadata.runId.trim()) {
|
|
234
|
+
throw hostedDiffError('capture results metadata has no run id; capture again with SnapDrift 0.8.2 or newer.');
|
|
235
|
+
}
|
|
236
|
+
if (metadata.projectId !== projectId) {
|
|
237
|
+
throw hostedDiffError(
|
|
238
|
+
`capture results belong to project "${metadata.projectId || 'unknown'}", not "${projectId}".`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length === 0) {
|
|
243
|
+
throw hostedDiffError(
|
|
244
|
+
'capture results have no expected capture identities; recapture with SnapDrift 0.8.2 or newer.'
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (metadata.purpose !== 'diff') {
|
|
248
|
+
throw hostedDiffError(
|
|
249
|
+
`capture results have purpose "${metadata.purpose || 'unknown'}", not "diff"; ` +
|
|
250
|
+
'recapture with SnapDrift 0.8.2 or newer.'
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds', hostedDiffError);
|
|
254
|
+
if (selected.size === 0) {
|
|
255
|
+
throw hostedDiffError('capture results selected no routes.');
|
|
256
|
+
}
|
|
257
|
+
if (metadata.expectedCaptures.length !== selected.size) {
|
|
258
|
+
throw hostedDiffError(
|
|
259
|
+
`capture results describe ${metadata.expectedCaptures.length} expected capture(s) for ${selected.size} selected route(s).`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const expectedRouteIds = new Set();
|
|
264
|
+
const expectedByIdentity = new Map();
|
|
265
|
+
const expectedByRouteId = new Map();
|
|
266
|
+
for (const [index, expected] of metadata.expectedCaptures.entries()) {
|
|
267
|
+
if (!expected || typeof expected !== 'object' ||
|
|
268
|
+
typeof expected.routeId !== 'string' || !expected.routeId ||
|
|
269
|
+
typeof expected.routePath !== 'string' || !expected.routePath ||
|
|
270
|
+
!selected.has(expected.routeId)) {
|
|
271
|
+
throw hostedDiffError(`expected capture metadata at index ${index} is invalid or not selected.`);
|
|
272
|
+
}
|
|
273
|
+
if (expectedRouteIds.has(expected.routeId)) {
|
|
274
|
+
throw hostedDiffError(`expected capture metadata contains duplicate route "${expected.routeId}".`);
|
|
275
|
+
}
|
|
276
|
+
expectedRouteIds.add(expected.routeId);
|
|
277
|
+
const viewportDescriptorJson = normalizeViewportDescriptor(
|
|
278
|
+
expected.viewportDescriptorJson,
|
|
279
|
+
`expected capture "${expected.routeId}"`,
|
|
280
|
+
hostedDiffError
|
|
281
|
+
);
|
|
282
|
+
const viewportDescriptor = JSON.parse(viewportDescriptorJson);
|
|
283
|
+
const expectedCapture = {
|
|
284
|
+
routeId: expected.routeId,
|
|
285
|
+
routePath: expected.routePath,
|
|
286
|
+
viewportDescriptorJson,
|
|
287
|
+
viewport: { width: viewportDescriptor.width, height: viewportDescriptor.height }
|
|
288
|
+
};
|
|
289
|
+
const identity = captureIdentityKey(expectedCapture, `expected capture "${expected.routeId}"`, hostedDiffError);
|
|
290
|
+
if (expectedByIdentity.has(identity)) {
|
|
291
|
+
throw hostedDiffError(`expected capture metadata contains duplicate route/viewport identity for "${expected.routeId}".`);
|
|
292
|
+
}
|
|
293
|
+
expectedByIdentity.set(identity, expectedCapture);
|
|
294
|
+
expectedByRouteId.set(expected.routeId, expectedCapture);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
runId: metadata.runId,
|
|
299
|
+
selectedRouteIds: [...selected],
|
|
300
|
+
expectedCaptures: [...expectedByIdentity.values()],
|
|
301
|
+
expectedByIdentity,
|
|
302
|
+
expectedByRouteId
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** @param {string[]} configuredRouteIds @param {string[]} selectedRouteIds */
|
|
307
|
+
function assertCompleteHostedBaselineSelection(configuredRouteIds, selectedRouteIds) {
|
|
308
|
+
const selected = new Set(selectedRouteIds);
|
|
309
|
+
const missing = configuredRouteIds.filter((id) => !selected.has(id));
|
|
310
|
+
if (missing.length === 0 && selected.size === configuredRouteIds.length) return;
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Hosted Snap baseline publication requires all ${configuredRouteIds.length} configured route(s), ` +
|
|
313
|
+
`but only ${selected.size} were selected. Remove route-ids/--routes scoping ` +
|
|
314
|
+
`(missing: ${missing.join(', ') || 'unknown'}). Scoped runs remain supported for PR diffs ` +
|
|
315
|
+
'and local-provider baselines.'
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @param {import('../types/visual-diff-types').SnapRunMetadata} metadata
|
|
321
|
+
* @param {JsonObject} run
|
|
322
|
+
* @param {string} projectId
|
|
323
|
+
*/
|
|
324
|
+
function validateCompleteBaselineRun(metadata, run, projectId) {
|
|
325
|
+
if (metadata.projectId !== projectId) {
|
|
326
|
+
throw completeBaselineError(`source run belongs to project "${metadata.projectId || 'unknown'}", not "${projectId}".`);
|
|
327
|
+
}
|
|
328
|
+
if (metadata.purpose !== 'baseline') {
|
|
329
|
+
throw completeBaselineError('source results were not recorded as a baseline capture.');
|
|
330
|
+
}
|
|
331
|
+
if (typeof metadata.refBranch !== 'string' || !metadata.refBranch ||
|
|
332
|
+
typeof metadata.refSha !== 'string' || !metadata.refSha) {
|
|
333
|
+
throw completeBaselineError('source results have no resolved ref; capture again with SnapDrift 0.8.2 or newer.');
|
|
334
|
+
}
|
|
335
|
+
if (
|
|
336
|
+
typeof metadata.publicationSequence !== 'number' ||
|
|
337
|
+
!Number.isSafeInteger(metadata.publicationSequence) ||
|
|
338
|
+
metadata.publicationSequence <= 0
|
|
339
|
+
) {
|
|
340
|
+
throw completeBaselineError(
|
|
341
|
+
'source results have no valid publication sequence; capture again with SnapDrift 0.8.2 or newer.'
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (typeof metadata.publicationWorkflowRef !== 'string' || !metadata.publicationWorkflowRef.trim()) {
|
|
345
|
+
throw completeBaselineError(
|
|
346
|
+
'source results have no publication workflow identity; capture again with SnapDrift 0.8.2 or newer.'
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const configured = uniqueRouteIdSet(metadata.configuredRouteIds, 'configuredRouteIds', completeBaselineError);
|
|
351
|
+
const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds', completeBaselineError);
|
|
352
|
+
assertCompleteHostedBaselineSelection([...configured], [...selected]);
|
|
353
|
+
if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length !== configured.size) {
|
|
354
|
+
throw completeBaselineError(`expected capture metadata does not match ${configured.size} configured route(s).`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const expectedRoutes = new Set();
|
|
358
|
+
const expectedByIdentity = new Map();
|
|
359
|
+
for (const expected of metadata.expectedCaptures) {
|
|
360
|
+
if (!expected || typeof expected !== 'object' || typeof expected.routePath !== 'string' ||
|
|
361
|
+
!configured.has(expected.routeId) || expectedRoutes.has(expected.routeId)) {
|
|
362
|
+
throw completeBaselineError(`expected capture metadata has duplicate or extra route "${expected?.routeId || 'unknown'}".`);
|
|
363
|
+
}
|
|
364
|
+
expectedRoutes.add(expected.routeId);
|
|
365
|
+
expectedByIdentity.set(
|
|
366
|
+
captureIdentityKey(expected, `expected capture "${expected.routeId}"`, completeBaselineError),
|
|
367
|
+
expected
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (!Array.isArray(run.captures)) throw completeBaselineError('source run has no captures array.');
|
|
372
|
+
if (run.captures.length !== expectedByIdentity.size) {
|
|
373
|
+
throw completeBaselineError(`source run has ${run.captures.length} capture(s), expected ${expectedByIdentity.size}.`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const seen = new Set();
|
|
377
|
+
const routes = run.captures.map((capture) => {
|
|
378
|
+
if (!capture || typeof capture !== 'object') {
|
|
379
|
+
throw completeBaselineError('source run contains a malformed capture.');
|
|
380
|
+
}
|
|
381
|
+
const identity = captureIdentityKey(
|
|
382
|
+
capture,
|
|
383
|
+
`source capture "${capture.routeId || capture.id || 'unknown'}"`,
|
|
384
|
+
completeBaselineError
|
|
385
|
+
);
|
|
386
|
+
if (seen.has(identity)) {
|
|
387
|
+
throw completeBaselineError(`duplicate source route/viewport identity for "${capture.routeId}".`);
|
|
388
|
+
}
|
|
389
|
+
seen.add(identity);
|
|
390
|
+
const expected = expectedByIdentity.get(identity);
|
|
391
|
+
if (!expected) {
|
|
392
|
+
throw completeBaselineError(`source run contains an unexpected route/viewport capture for "${capture.routeId}".`);
|
|
393
|
+
}
|
|
394
|
+
if (capture.routePath !== expected.routePath) {
|
|
395
|
+
throw completeBaselineError(`source route "${capture.routeId}" used an unexpected path.`);
|
|
396
|
+
}
|
|
397
|
+
if (capture.status !== BASELINE_CAPTURE_SUCCESS_STATUS) {
|
|
398
|
+
throw completeBaselineError(`source capture "${capture.routeId}" ended with status "${capture.status || 'unknown'}".`);
|
|
399
|
+
}
|
|
400
|
+
if (typeof capture.currentObjectKey !== 'string' || !capture.currentObjectKey) {
|
|
401
|
+
throw completeBaselineError(`source capture "${capture.routeId}" has no currentObjectKey.`);
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
routeId: expected.routeId,
|
|
405
|
+
routePath: expected.routePath,
|
|
406
|
+
viewportDescriptorJson: expected.viewportDescriptorJson,
|
|
407
|
+
objectKey: capture.currentObjectKey
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
return routes.sort((left, right) => {
|
|
412
|
+
const leftKey = captureIdentityKey(left, `route "${left.routeId}"`, completeBaselineError);
|
|
413
|
+
const rightKey = captureIdentityKey(right, `route "${right.routeId}"`, completeBaselineError);
|
|
414
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** @param {string} runId */
|
|
419
|
+
function baselineIdForRun(runId) {
|
|
420
|
+
return `bsl_${crypto.createHash('sha256').update(runId).digest('hex').slice(0, 24)}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
42
423
|
/**
|
|
43
424
|
* @param {string | undefined} baseUrl
|
|
44
425
|
* @returns {boolean}
|
|
@@ -114,6 +495,33 @@ export function resolveGitRef() {
|
|
|
114
495
|
};
|
|
115
496
|
}
|
|
116
497
|
|
|
498
|
+
function resolveHostedBaselinePublicationRef() {
|
|
499
|
+
const gitRef = resolveGitRef();
|
|
500
|
+
const refBranch = process.env.GITHUB_REF_NAME || gitRef.refBranch;
|
|
501
|
+
const refSha = process.env.GITHUB_SHA || gitRef.refSha;
|
|
502
|
+
const publicationWorkflowRef =
|
|
503
|
+
process.env.SNAPDRIFT_PUBLICATION_WORKFLOW_REF || process.env.GITHUB_WORKFLOW_REF;
|
|
504
|
+
const sequenceValue = process.env.SNAPDRIFT_PUBLICATION_SEQUENCE || process.env.GITHUB_RUN_NUMBER;
|
|
505
|
+
const publicationSequence = Number(sequenceValue);
|
|
506
|
+
if (
|
|
507
|
+
!refBranch ||
|
|
508
|
+
!refSha ||
|
|
509
|
+
!/^[0-9a-f]{40}$/i.test(refSha) ||
|
|
510
|
+
!publicationWorkflowRef ||
|
|
511
|
+
!Number.isSafeInteger(publicationSequence) ||
|
|
512
|
+
publicationSequence <= 0
|
|
513
|
+
) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
'Hosted Snap baseline publication requires a branch, a resolved 40-character commit SHA, ' +
|
|
516
|
+
'a publication workflow identity, and a positive publication sequence. GitHub Actions ' +
|
|
517
|
+
'provides these as GITHUB_REF_NAME/GITHUB_SHA/GITHUB_WORKFLOW_REF/GITHUB_RUN_NUMBER; ' +
|
|
518
|
+
'other CI systems may use SNAPDRIFT_PUBLICATION_WORKFLOW_REF and ' +
|
|
519
|
+
'SNAPDRIFT_PUBLICATION_SEQUENCE.'
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
return { refBranch, refSha, publicationWorkflowRef, publicationSequence };
|
|
523
|
+
}
|
|
524
|
+
|
|
117
525
|
/**
|
|
118
526
|
* Read the package version from package.json.
|
|
119
527
|
* @returns {string}
|
|
@@ -131,23 +539,6 @@ function getPackageVersion() {
|
|
|
131
539
|
const TAR_BLOCK_SIZE = 512;
|
|
132
540
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
133
541
|
|
|
134
|
-
/**
|
|
135
|
-
* Strips characters that could cause path traversal or produce invalid
|
|
136
|
-
* filenames when a route id is used as a screenshot filename. Mirrors the
|
|
137
|
-
* sanitisation applied by `@snapdrift/adapter-fs` during local capture so
|
|
138
|
-
* imported baselines land on the same filenames a local capture would produce.
|
|
139
|
-
*
|
|
140
|
-
* @param {string} id
|
|
141
|
-
* @returns {string}
|
|
142
|
-
*/
|
|
143
|
-
function sanitizeRouteId(id) {
|
|
144
|
-
return id
|
|
145
|
-
.replace(/\.\./g, '_')
|
|
146
|
-
.replace(/[/\\]/g, '_')
|
|
147
|
-
// eslint-disable-next-line no-control-regex
|
|
148
|
-
.replace(/[\x00-\x1f\x7f]/g, '');
|
|
149
|
-
}
|
|
150
|
-
|
|
151
542
|
/**
|
|
152
543
|
* Parse a plain ustar-style tar archive (512-byte headers, bodies padded to
|
|
153
544
|
* 512-byte blocks, terminated by zero blocks) into a map of entry name →
|
|
@@ -252,22 +643,6 @@ function resolveExportEngine(captureProfile) {
|
|
|
252
643
|
return { name: 'snap-hosted', version: 'unknown' };
|
|
253
644
|
}
|
|
254
645
|
|
|
255
|
-
/**
|
|
256
|
-
* Extract a human-readable detail string from a Snap API error response.
|
|
257
|
-
*
|
|
258
|
-
* @param {{ text: () => Promise<string> }} response
|
|
259
|
-
* @returns {Promise<string>}
|
|
260
|
-
*/
|
|
261
|
-
async function readErrorDetail(response) {
|
|
262
|
-
const errorBody = await response.text().catch(() => '');
|
|
263
|
-
try {
|
|
264
|
-
const parsed = JSON.parse(errorBody);
|
|
265
|
-
return parsed.error || parsed.message || '';
|
|
266
|
-
} catch {
|
|
267
|
-
return '';
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
646
|
/**
|
|
272
647
|
* SnapProvider — VisualProvider that delegates capture + diff to Snap's
|
|
273
648
|
* hosted /v1/visual/* API.
|
|
@@ -279,8 +654,10 @@ export class SnapProvider {
|
|
|
279
654
|
#snapConfig;
|
|
280
655
|
/** @type {typeof globalThis.fetch} */
|
|
281
656
|
#fetchFn;
|
|
282
|
-
/** @type {(ms: number) => Promise<void>} */
|
|
657
|
+
/** @type {(ms: number, signal?: AbortSignal) => Promise<void>} */
|
|
283
658
|
#sleepFn;
|
|
659
|
+
/** @type {() => number} */
|
|
660
|
+
#nowFn;
|
|
284
661
|
/** @type {(options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>} */
|
|
285
662
|
#localCaptureFn;
|
|
286
663
|
/** @type {string} */
|
|
@@ -294,14 +671,27 @@ export class SnapProvider {
|
|
|
294
671
|
* @param {SnapConfig} snapConfig — validated snap section from config
|
|
295
672
|
* @param {{
|
|
296
673
|
* fetchFn?: typeof globalThis.fetch,
|
|
297
|
-
* sleepFn?: (ms: number) => Promise<void>,
|
|
674
|
+
* sleepFn?: (ms: number, signal?: AbortSignal) => Promise<void>,
|
|
675
|
+
* nowFn?: () => number,
|
|
298
676
|
* localCaptureFn?: (options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>
|
|
299
677
|
* }} [options]
|
|
300
678
|
*/
|
|
301
679
|
constructor(snapConfig, options = {}) {
|
|
302
680
|
this.#snapConfig = snapConfig;
|
|
303
681
|
this.#fetchFn = options.fetchFn ?? globalThis.fetch;
|
|
304
|
-
this.#sleepFn = options.sleepFn ?? ((ms) => new Promise((resolve) =>
|
|
682
|
+
this.#sleepFn = options.sleepFn ?? ((ms, signal) => new Promise((resolve, reject) => {
|
|
683
|
+
const timer = setTimeout(() => {
|
|
684
|
+
signal?.removeEventListener('abort', onAbort);
|
|
685
|
+
resolve();
|
|
686
|
+
}, ms);
|
|
687
|
+
const onAbort = () => {
|
|
688
|
+
clearTimeout(timer);
|
|
689
|
+
signal?.removeEventListener('abort', onAbort);
|
|
690
|
+
reject(signal?.reason ?? new Error('Sleep aborted.'));
|
|
691
|
+
};
|
|
692
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
693
|
+
}));
|
|
694
|
+
this.#nowFn = options.nowFn ?? monotonicNow;
|
|
305
695
|
this.#localCaptureFn = options.localCaptureFn ?? runBaselineCapture;
|
|
306
696
|
this.#apiUrl = (snapConfig.apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
307
697
|
this.#apiKey = this.#resolveApiKey(snapConfig);
|
|
@@ -329,6 +719,17 @@ export class SnapProvider {
|
|
|
329
719
|
: splitCommaList(process.env.SNAPDRIFT_ROUTE_IDS);
|
|
330
720
|
const { routes, selectedRouteIds } = selectConfiguredRoutes(config, requestedRouteIds);
|
|
331
721
|
const purpose = options.purpose ?? 'diff';
|
|
722
|
+
const configuredRouteIds = config.routes.map((route) => route.id);
|
|
723
|
+
const expectedCaptures = routes.map(expectedCaptureIdentity);
|
|
724
|
+
|
|
725
|
+
if (purpose === 'baseline') {
|
|
726
|
+
assertCompleteHostedBaselineSelection(configuredRouteIds, selectedRouteIds);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const baselineRef = purpose === 'baseline' ? resolveHostedBaselinePublicationRef() : undefined;
|
|
730
|
+
const runContext = baselineRef
|
|
731
|
+
? { branch: baselineRef.refBranch, prHeadSha: baselineRef.refSha }
|
|
732
|
+
: this.#gitRunContext();
|
|
332
733
|
|
|
333
734
|
if (isLocalBaseUrl(config.baseUrl)) {
|
|
334
735
|
return this.#captureLocalAndUpload({
|
|
@@ -338,12 +739,17 @@ export class SnapProvider {
|
|
|
338
739
|
config,
|
|
339
740
|
routes,
|
|
340
741
|
selectedRouteIds,
|
|
341
|
-
purpose
|
|
742
|
+
purpose,
|
|
743
|
+
configuredRouteIds,
|
|
744
|
+
expectedCaptures,
|
|
745
|
+
runContext,
|
|
746
|
+
baselineRef
|
|
342
747
|
});
|
|
343
748
|
}
|
|
344
749
|
|
|
345
750
|
const idempotencyKey = this.#generateIdempotencyKey();
|
|
346
751
|
const runId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
752
|
+
const operation = this.#createOperationDeadline('capture');
|
|
347
753
|
|
|
348
754
|
// For a diff run, resolve the latest accepted baseline so the Snap backend
|
|
349
755
|
// can attach it to this run's captures. Without a baselineId the server has
|
|
@@ -355,7 +761,7 @@ export class SnapProvider {
|
|
|
355
761
|
// Attaching one would make the backend diff the fresh captures against the
|
|
356
762
|
// prior baseline — wasteful at best, and on a dimension mismatch it errors
|
|
357
763
|
// the run and fails the publish.
|
|
358
|
-
const baselineId = purpose === '
|
|
764
|
+
const baselineId = purpose === 'diff' ? await this.#resolveLatestBaselineId(operation) : null;
|
|
359
765
|
|
|
360
766
|
// We intentionally do NOT send captureProfileJson on run creation. SnapDrift
|
|
361
767
|
// submits routes for server-side rendering, so the render environment
|
|
@@ -373,23 +779,33 @@ export class SnapProvider {
|
|
|
373
779
|
...(baselineId ? { baselineId } : {}),
|
|
374
780
|
// A baseline run must not be diffed. Suppress the server's
|
|
375
781
|
// auto-resolve-by-branch so no prior baseline is attached.
|
|
376
|
-
...(purpose
|
|
377
|
-
|
|
378
|
-
|
|
782
|
+
...(purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
|
|
783
|
+
capturePlan: {
|
|
784
|
+
purpose: purpose === 'baseline' ? 'baseline' : 'diff',
|
|
785
|
+
...(baselineRef
|
|
786
|
+
? {
|
|
787
|
+
publicationWorkflowRef: baselineRef.publicationWorkflowRef,
|
|
788
|
+
publicationSequence: baselineRef.publicationSequence
|
|
789
|
+
}
|
|
790
|
+
: {}),
|
|
791
|
+
configuredRouteIds,
|
|
792
|
+
selectedRouteIds,
|
|
793
|
+
expectedCaptures
|
|
794
|
+
},
|
|
795
|
+
...runContext,
|
|
796
|
+
}, idempotencyKey, operation);
|
|
379
797
|
|
|
380
798
|
// Submit each route as a capture with its own idempotency key
|
|
381
799
|
for (const route of routes) {
|
|
382
800
|
const captureKey = this.#generateIdempotencyKey();
|
|
383
801
|
const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
384
|
-
const viewportDescriptor =
|
|
385
|
-
? (VIEWPORT_PRESETS[route.viewport] ?? { width: 1280, height: 720 })
|
|
386
|
-
: route.viewport;
|
|
802
|
+
const viewportDescriptor = resolveViewportDescriptor(route.viewport);
|
|
387
803
|
await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
|
|
388
804
|
id: captureId,
|
|
389
805
|
routeId: route.id,
|
|
390
806
|
routePath: route.path,
|
|
391
807
|
viewportDescriptorJson: JSON.stringify(viewportDescriptor),
|
|
392
|
-
}, captureKey);
|
|
808
|
+
}, captureKey, operation);
|
|
393
809
|
}
|
|
394
810
|
|
|
395
811
|
// Write run metadata to a temp file so diff() can thread the run_id
|
|
@@ -402,7 +818,17 @@ export class SnapProvider {
|
|
|
402
818
|
const resultsPath = path.join(outDir, 'results.json');
|
|
403
819
|
const manifestPath = path.join(outDir, 'manifest.json');
|
|
404
820
|
|
|
405
|
-
|
|
821
|
+
/** @type {import('../types/visual-diff-types').SnapRunMetadata} */
|
|
822
|
+
const runMetadata = {
|
|
823
|
+
runId,
|
|
824
|
+
projectId: this.#projectId,
|
|
825
|
+
purpose,
|
|
826
|
+
...(baselineRef ? baselineRef : {}),
|
|
827
|
+
startedAt: new Date().toISOString(),
|
|
828
|
+
configuredRouteIds,
|
|
829
|
+
selectedRouteIds,
|
|
830
|
+
expectedCaptures
|
|
831
|
+
};
|
|
406
832
|
await Promise.all([
|
|
407
833
|
fs.writeFile(resultsPath, JSON.stringify(runMetadata, null, 2)),
|
|
408
834
|
fs.writeFile(manifestPath, JSON.stringify({
|
|
@@ -412,7 +838,7 @@ export class SnapProvider {
|
|
|
412
838
|
id: r.id,
|
|
413
839
|
path: r.path,
|
|
414
840
|
viewport: r.viewport,
|
|
415
|
-
imagePath: `screenshots/${r.id}.png`,
|
|
841
|
+
imagePath: `screenshots/${sanitizeRouteId(r.id)}.png`,
|
|
416
842
|
width: 0,
|
|
417
843
|
height: 0
|
|
418
844
|
}))
|
|
@@ -440,14 +866,37 @@ export class SnapProvider {
|
|
|
440
866
|
throw new Error('SnapProvider.diff requires currentResultsPath from a prior capture() call.');
|
|
441
867
|
}
|
|
442
868
|
|
|
443
|
-
|
|
444
|
-
|
|
869
|
+
let metadata;
|
|
870
|
+
let metadataSource;
|
|
871
|
+
try {
|
|
872
|
+
metadataSource = await fs.readFile(currentResultsPath, 'utf-8');
|
|
873
|
+
} catch (error) {
|
|
874
|
+
throw hostedDiffError(
|
|
875
|
+
`could not read capture results metadata at "${currentResultsPath}". ` +
|
|
876
|
+
`${error instanceof Error ? error.message : String(error)}`
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
metadata = JSON.parse(metadataSource);
|
|
881
|
+
} catch (error) {
|
|
882
|
+
throw hostedDiffError(
|
|
883
|
+
`capture results metadata is not valid JSON; recapture with SnapDrift 0.8.2 or newer. ` +
|
|
884
|
+
`${error instanceof Error ? error.message : String(error)}`
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
const { config } = await loadSnapdriftConfig(options.configPath);
|
|
888
|
+
const diffPlan = validateHostedDiffMetadata(metadata, this.#projectId);
|
|
445
889
|
|
|
446
890
|
// Poll until the run reaches a terminal state
|
|
447
|
-
const
|
|
891
|
+
const operation = this.#createOperationDeadline('diff');
|
|
892
|
+
const run = await this.#pollRun(diffPlan.runId, diffPlan.expectedCaptures.length, hostedDiffError, operation);
|
|
893
|
+
if (!run || typeof run !== 'object' || run.id !== diffPlan.runId) {
|
|
894
|
+
throw hostedDiffError(
|
|
895
|
+
`Snap returned run "${run?.id || 'unknown'}" for requested run "${diffPlan.runId}".`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
448
898
|
|
|
449
|
-
const
|
|
450
|
-
const summary = this.#mapRunToSummary(run, config);
|
|
899
|
+
const summary = this.#mapRunToSummary(run, config, diffPlan);
|
|
451
900
|
|
|
452
901
|
return {
|
|
453
902
|
summary,
|
|
@@ -473,6 +922,7 @@ export class SnapProvider {
|
|
|
473
922
|
// Snap-native baseline: poll the run that capture() submitted, then
|
|
474
923
|
// create a baseline from the rendered S3 keys.
|
|
475
924
|
if (resultsPath) {
|
|
925
|
+
/** @type {import('../types/visual-diff-types').SnapRunMetadata | undefined} */
|
|
476
926
|
let metadata;
|
|
477
927
|
/** @type {unknown} */
|
|
478
928
|
let readError;
|
|
@@ -482,7 +932,7 @@ export class SnapProvider {
|
|
|
482
932
|
readError = error;
|
|
483
933
|
}
|
|
484
934
|
|
|
485
|
-
if (
|
|
935
|
+
if (typeof metadata?.runId !== 'string' || !metadata.runId) {
|
|
486
936
|
// Previously this fell through to a "legacy" branch that POSTed
|
|
487
937
|
// `manifest`/`results` as objects. Snap has never read those fields, so
|
|
488
938
|
// the request could only ever fail — with an opaque 500 before, and with
|
|
@@ -500,60 +950,62 @@ export class SnapProvider {
|
|
|
500
950
|
{
|
|
501
951
|
const { runId } = metadata;
|
|
502
952
|
const idempotencyKey = `baseline-${runId}`;
|
|
953
|
+
const operation = this.#createOperationDeadline('baseline publication');
|
|
503
954
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
if (run.status === 'error') {
|
|
508
|
-
throw new Error(
|
|
509
|
-
`Snap baseline run ${runId} ended with status "error" — cannot publish baseline. ` +
|
|
510
|
-
`Check the snap dashboard for capture error details.`
|
|
955
|
+
if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length === 0) {
|
|
956
|
+
throw completeBaselineError(
|
|
957
|
+
'results metadata has no expected captures; capture again with SnapDrift 0.8.2 or newer.'
|
|
511
958
|
);
|
|
512
959
|
}
|
|
513
960
|
|
|
514
|
-
|
|
515
|
-
|
|
961
|
+
// Older Snap servers could mark a baseline run terminal after the first
|
|
962
|
+
// submitted capture. Keep polling until the complete planned capture set
|
|
963
|
+
// is present and terminal so a mixed-version rollout remains fail-closed.
|
|
964
|
+
const run = await this.#pollRun(runId, metadata.expectedCaptures.length, undefined, operation);
|
|
516
965
|
|
|
517
|
-
if (
|
|
966
|
+
if (run.id && run.id !== runId) {
|
|
518
967
|
throw new Error(
|
|
519
|
-
`Snap baseline run ${runId}
|
|
520
|
-
`(${captures.length} capture(s) found, 0 rendered). Cannot publish an empty baseline.`
|
|
968
|
+
`Cannot publish a complete Snap baseline: requested source run ${runId}, but Snap returned ${run.id}.`
|
|
521
969
|
);
|
|
522
970
|
}
|
|
523
971
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
972
|
+
// Hosted baseline runs intentionally use `new` because they omit a
|
|
973
|
+
// baseline comparison. The server accepts a wider terminal set for
|
|
974
|
+
// legacy/intentional workflows, but this client is stricter: any other
|
|
975
|
+
// terminal status means the baseline capture did not establish fresh
|
|
976
|
+
// ground truth.
|
|
977
|
+
if (run.status !== BASELINE_RUN_SUCCESS_STATUS) {
|
|
978
|
+
throw new Error(
|
|
979
|
+
`Snap baseline run ${runId} ended with status "${run.status || 'unknown'}"; ` +
|
|
980
|
+
`complete baseline publication requires "${BASELINE_RUN_SUCCESS_STATUS}". ` +
|
|
981
|
+
'Check the Snap dashboard for capture error details.'
|
|
529
982
|
);
|
|
530
983
|
}
|
|
531
984
|
|
|
532
|
-
|
|
533
|
-
const routes = rendered.map((c) => ({
|
|
534
|
-
routeId: c.routeId,
|
|
535
|
-
routePath: c.routePath,
|
|
536
|
-
viewportDescriptorJson: c.viewportDescriptorJson,
|
|
537
|
-
objectKey: c.currentObjectKey
|
|
538
|
-
}));
|
|
985
|
+
const routes = validateCompleteBaselineRun(metadata, run, this.#projectId);
|
|
539
986
|
|
|
540
987
|
const manifestJson = JSON.stringify({
|
|
541
988
|
schemaVersion: 1,
|
|
542
989
|
sourceRunId: runId,
|
|
990
|
+
publicationWorkflowRef: metadata.publicationWorkflowRef,
|
|
543
991
|
routes
|
|
544
992
|
});
|
|
545
993
|
|
|
546
|
-
const baselineId =
|
|
994
|
+
const baselineId = baselineIdForRun(runId);
|
|
547
995
|
await this.#request(
|
|
548
996
|
'POST',
|
|
549
997
|
`/v1/visual/projects/${this.#projectId}/baselines`,
|
|
550
998
|
{
|
|
551
999
|
id: baselineId,
|
|
552
|
-
|
|
1000
|
+
refBranch: metadata.refBranch,
|
|
1001
|
+
refSha: metadata.refSha,
|
|
1002
|
+
publicationMode: 'complete',
|
|
1003
|
+
sourceRunId: runId,
|
|
553
1004
|
manifestJson,
|
|
554
1005
|
captureProfileJson: JSON.stringify(this.#buildCaptureProfile())
|
|
555
1006
|
},
|
|
556
|
-
idempotencyKey
|
|
1007
|
+
idempotencyKey,
|
|
1008
|
+
operation
|
|
557
1009
|
);
|
|
558
1010
|
|
|
559
1011
|
const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
|
|
@@ -576,8 +1028,9 @@ export class SnapProvider {
|
|
|
576
1028
|
* @returns {Promise<BaselineData | null>}
|
|
577
1029
|
*/
|
|
578
1030
|
async fetchLatestBaseline(_options) {
|
|
1031
|
+
const operation = this.#createOperationDeadline('baseline lookup');
|
|
579
1032
|
try {
|
|
580
|
-
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest
|
|
1033
|
+
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`, undefined, undefined, operation);
|
|
581
1034
|
|
|
582
1035
|
const runDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
|
|
583
1036
|
const resultsPath = path.join(runDir, 'results.json');
|
|
@@ -614,7 +1067,7 @@ export class SnapProvider {
|
|
|
614
1067
|
* Build a PR comment body from a diff summary.
|
|
615
1068
|
* SnapProvider includes the dashboard URL from the summary when available.
|
|
616
1069
|
*
|
|
617
|
-
* @param {
|
|
1070
|
+
* @param {import('@snapdrift/manifest').VisualReportSummary} summary
|
|
618
1071
|
* @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
|
|
619
1072
|
* @returns {string}
|
|
620
1073
|
*/
|
|
@@ -634,8 +1087,9 @@ export class SnapProvider {
|
|
|
634
1087
|
* @returns {Promise<object | null>} Baseline data if found, null on 404
|
|
635
1088
|
*/
|
|
636
1089
|
async checkBaselineExists(headSha) {
|
|
1090
|
+
const operation = this.#createOperationDeadline('baseline existence check');
|
|
637
1091
|
try {
|
|
638
|
-
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest?sha=${encodeURIComponent(headSha)}
|
|
1092
|
+
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest?sha=${encodeURIComponent(headSha)}`, undefined, undefined, operation);
|
|
639
1093
|
return baseline;
|
|
640
1094
|
} catch (error) {
|
|
641
1095
|
if (error instanceof SnapApiError && error.status === 404) {
|
|
@@ -678,9 +1132,10 @@ export class SnapProvider {
|
|
|
678
1132
|
*/
|
|
679
1133
|
async exportBaselines(_options) {
|
|
680
1134
|
const exportPath = `/v1/visual/projects/${this.#projectId}/export`;
|
|
1135
|
+
const operation = this.#createOperationDeadline('baseline export');
|
|
681
1136
|
let archive;
|
|
682
1137
|
try {
|
|
683
|
-
archive = await this.#requestBinary('GET', exportPath);
|
|
1138
|
+
archive = await this.#requestBinary('GET', exportPath, operation);
|
|
684
1139
|
} catch (error) {
|
|
685
1140
|
if (error instanceof SnapApiError) {
|
|
686
1141
|
if (error.status === 403) {
|
|
@@ -750,6 +1205,20 @@ export class SnapProvider {
|
|
|
750
1205
|
(baseline.objects || []).map((object) => [object.sourceKey, object.archivePath])
|
|
751
1206
|
);
|
|
752
1207
|
|
|
1208
|
+
const sourceLabel = `Snap baseline "${baseline.id}" source manifest`;
|
|
1209
|
+
/** @type {Map<string, unknown[]>} */
|
|
1210
|
+
const routeIdsByExtension = new Map();
|
|
1211
|
+
for (const route of routes) {
|
|
1212
|
+
const archivePath = route && route.objectKey ? archivePathByKey.get(route.objectKey) : undefined;
|
|
1213
|
+
const extension = typeof archivePath === 'string' ? path.extname(archivePath) || '.png' : '.png';
|
|
1214
|
+
const routeIds = routeIdsByExtension.get(extension) || [];
|
|
1215
|
+
routeIds.push(route?.routeId);
|
|
1216
|
+
routeIdsByExtension.set(extension, routeIds);
|
|
1217
|
+
}
|
|
1218
|
+
for (const [extension, routeIds] of routeIdsByExtension) {
|
|
1219
|
+
assertUniqueRouteIdFilenames(routeIds, sourceLabel, extension);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
753
1222
|
/** @type {Array<{ filename: string, data: Buffer }>} */
|
|
754
1223
|
const screenshots = [];
|
|
755
1224
|
/** @type {JsonObject[]} */
|
|
@@ -780,7 +1249,7 @@ export class SnapProvider {
|
|
|
780
1249
|
? { width: descriptor.width, height: descriptor.height }
|
|
781
1250
|
: { width: dimensions.width, height: dimensions.height };
|
|
782
1251
|
|
|
783
|
-
const extension = path.extname(archivePath) || '.png';
|
|
1252
|
+
const extension = typeof archivePath === 'string' ? path.extname(archivePath) || '.png' : '.png';
|
|
784
1253
|
const filename = `${sanitizeRouteId(route.routeId)}${extension}`;
|
|
785
1254
|
screenshots.push({ filename, data: Buffer.from(bytes) });
|
|
786
1255
|
manifestEntries.push({
|
|
@@ -912,9 +1381,9 @@ export class SnapProvider {
|
|
|
912
1381
|
*
|
|
913
1382
|
* @returns {Promise<string | null>}
|
|
914
1383
|
*/
|
|
915
|
-
async #resolveLatestBaselineId() {
|
|
1384
|
+
async #resolveLatestBaselineId(operation = this.#createOperationDeadline('baseline lookup')) {
|
|
916
1385
|
try {
|
|
917
|
-
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest
|
|
1386
|
+
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`, undefined, undefined, operation);
|
|
918
1387
|
return baseline?.id ?? null;
|
|
919
1388
|
} catch (error) {
|
|
920
1389
|
if (error instanceof SnapApiError && error.status === 404) {
|
|
@@ -944,6 +1413,374 @@ export class SnapProvider {
|
|
|
944
1413
|
return crypto.randomUUID();
|
|
945
1414
|
}
|
|
946
1415
|
|
|
1416
|
+
/** @param {string} [label] @returns {OperationDeadline} */
|
|
1417
|
+
#createOperationDeadline(label = 'Snap operation') {
|
|
1418
|
+
return { deadline: this.#nowFn() + MAX_OPERATION_DURATION_MS, label };
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** @param {OperationDeadline} operation @returns {number} */
|
|
1422
|
+
#remainingOperationMs(operation) {
|
|
1423
|
+
return operation.deadline - this.#nowFn();
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* @param {string} requestPath
|
|
1428
|
+
* @param {OperationDeadline} operation
|
|
1429
|
+
* @returns {SnapUnavailableError}
|
|
1430
|
+
*/
|
|
1431
|
+
#operationDeadlineError(requestPath, operation) {
|
|
1432
|
+
return new SnapUnavailableError(
|
|
1433
|
+
`Snap ${operation.label} operation deadline exceeded while requesting ${requestPath}; ` +
|
|
1434
|
+
`the ${OPERATION_LIMIT_TEXT} Snap operation limit was reached`
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* @param {string} requestPath
|
|
1440
|
+
* @param {number} timeoutMs
|
|
1441
|
+
* @param {OperationDeadline} operation
|
|
1442
|
+
* @param {number} requestLimitMs
|
|
1443
|
+
* @returns {Error}
|
|
1444
|
+
*/
|
|
1445
|
+
#requestTimeoutError(requestPath, timeoutMs, operation, requestLimitMs) {
|
|
1446
|
+
const error = new Error(
|
|
1447
|
+
`Snap ${operation.label} request ${requestPath} timed out after ` +
|
|
1448
|
+
`${Math.max(0, Math.round(timeoutMs))}ms (request limit ${requestLimitMs}ms; ` +
|
|
1449
|
+
`operation limit ${OPERATION_LIMIT_TEXT})`
|
|
1450
|
+
);
|
|
1451
|
+
error.name = 'SnapTransportTimeoutError';
|
|
1452
|
+
return error;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/** @param {number} attempt @returns {number} */
|
|
1456
|
+
#retryDelay(attempt) {
|
|
1457
|
+
return Math.min(
|
|
1458
|
+
INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)),
|
|
1459
|
+
RETRY_DELAY_CAP_MS
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/** @param {string} requestPath @param {number} delayMs @param {OperationDeadline} operation @returns {SnapUnavailableError} */
|
|
1464
|
+
#sleepTimeoutError(requestPath, delayMs, operation) {
|
|
1465
|
+
return new SnapUnavailableError(
|
|
1466
|
+
`Snap ${operation.label} wait for ${requestPath} timed out after ${delayMs}ms ` +
|
|
1467
|
+
`before the ${OPERATION_LIMIT_TEXT} operation deadline`
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* Read and classify an HTTP error response under the current deadline.
|
|
1473
|
+
*
|
|
1474
|
+
* A received 4xx remains non-retryable even when its diagnostic body cannot
|
|
1475
|
+
* be read. Retryable 5xx body failures are returned to the transport loop so
|
|
1476
|
+
* they retain the normal availability semantics.
|
|
1477
|
+
*
|
|
1478
|
+
* @param {any} response
|
|
1479
|
+
* @param {string} requestPath
|
|
1480
|
+
* @param {AbortController} controller
|
|
1481
|
+
* @param {number} attemptDeadline
|
|
1482
|
+
* @param {OperationDeadline} operation
|
|
1483
|
+
* @param {number} requestLimitMs
|
|
1484
|
+
* @returns {Promise<SnapApiError>}
|
|
1485
|
+
*/
|
|
1486
|
+
async #readApiError(response, requestPath, controller, attemptDeadline, operation, requestLimitMs) {
|
|
1487
|
+
let errorBody;
|
|
1488
|
+
try {
|
|
1489
|
+
errorBody = await this.#readResponseBody(
|
|
1490
|
+
response,
|
|
1491
|
+
requestPath,
|
|
1492
|
+
controller,
|
|
1493
|
+
attemptDeadline,
|
|
1494
|
+
operation,
|
|
1495
|
+
requestLimitMs,
|
|
1496
|
+
() => response.text()
|
|
1497
|
+
);
|
|
1498
|
+
} catch (error) {
|
|
1499
|
+
if (response.status < 400 || response.status >= 500) throw error;
|
|
1500
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1501
|
+
const reason = error instanceof SnapUnavailableError || error?.name === 'SnapTransportTimeoutError'
|
|
1502
|
+
? `diagnostic body timed out (${detail})`
|
|
1503
|
+
: `diagnostic body unavailable (${detail})`;
|
|
1504
|
+
return new SnapApiError(
|
|
1505
|
+
response.status,
|
|
1506
|
+
`Snap API ${response.status}: ${reason}`,
|
|
1507
|
+
requestPath
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
let detail = '';
|
|
1512
|
+
try {
|
|
1513
|
+
const parsed = JSON.parse(errorBody);
|
|
1514
|
+
detail = parsed?.error || parsed?.message || '';
|
|
1515
|
+
} catch { /* use default message */ }
|
|
1516
|
+
return new SnapApiError(
|
|
1517
|
+
response.status,
|
|
1518
|
+
detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`,
|
|
1519
|
+
requestPath
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Read a response body under the current attempt and operation deadlines.
|
|
1525
|
+
*
|
|
1526
|
+
* @template T
|
|
1527
|
+
* @param {any} response
|
|
1528
|
+
* @param {string} requestPath
|
|
1529
|
+
* @param {AbortController} controller
|
|
1530
|
+
* @param {number} attemptDeadline
|
|
1531
|
+
* @param {OperationDeadline} operation
|
|
1532
|
+
* @param {number} requestLimitMs
|
|
1533
|
+
* @param {() => Promise<T>} reader
|
|
1534
|
+
* @returns {Promise<T>}
|
|
1535
|
+
*/
|
|
1536
|
+
async #readResponseBody(response, requestPath, controller, attemptDeadline, operation, requestLimitMs, reader) {
|
|
1537
|
+
const now = this.#nowFn();
|
|
1538
|
+
const attemptRemaining = attemptDeadline - now;
|
|
1539
|
+
const operationRemaining = this.#remainingOperationMs(operation);
|
|
1540
|
+
if (operationRemaining <= 0) {
|
|
1541
|
+
controller.abort();
|
|
1542
|
+
cancelResponseBody(response);
|
|
1543
|
+
throw this.#operationDeadlineError(requestPath, operation);
|
|
1544
|
+
}
|
|
1545
|
+
if (attemptRemaining <= 0) {
|
|
1546
|
+
controller.abort();
|
|
1547
|
+
cancelResponseBody(response);
|
|
1548
|
+
throw this.#requestTimeoutError(requestPath, requestLimitMs, operation, requestLimitMs);
|
|
1549
|
+
}
|
|
1550
|
+
const remaining = Math.min(attemptRemaining, operationRemaining);
|
|
1551
|
+
return runWithTimeout(
|
|
1552
|
+
reader,
|
|
1553
|
+
remaining,
|
|
1554
|
+
() => {
|
|
1555
|
+
controller.abort();
|
|
1556
|
+
cancelResponseBody(response);
|
|
1557
|
+
},
|
|
1558
|
+
() => this.#remainingOperationMs(operation) <= 0
|
|
1559
|
+
? this.#operationDeadlineError(requestPath, operation)
|
|
1560
|
+
: this.#requestTimeoutError(requestPath, remaining, operation, requestLimitMs)
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Sleep for a retry or poll interval without exceeding the operation budget.
|
|
1566
|
+
*
|
|
1567
|
+
* @param {number} delayMs
|
|
1568
|
+
* @param {OperationDeadline} operation
|
|
1569
|
+
* @param {string} requestPath
|
|
1570
|
+
* @returns {Promise<void>}
|
|
1571
|
+
*/
|
|
1572
|
+
async #sleepWithinOperation(delayMs, operation, requestPath) {
|
|
1573
|
+
const remaining = this.#remainingOperationMs(operation);
|
|
1574
|
+
if (remaining <= 0) {
|
|
1575
|
+
throw this.#operationDeadlineError(requestPath, operation);
|
|
1576
|
+
}
|
|
1577
|
+
const delay = Math.min(delayMs, remaining);
|
|
1578
|
+
const controller = new AbortController();
|
|
1579
|
+
await runWithTimeout(
|
|
1580
|
+
() => this.#sleepFn(delay, controller.signal),
|
|
1581
|
+
// Keep a one-millisecond watchdog margin so a sleep timer scheduled by
|
|
1582
|
+
// an async injected sleepFn can settle before the watchdog at the same
|
|
1583
|
+
// nominal deadline. The signal aborts the default timer on timeout.
|
|
1584
|
+
delay + 1,
|
|
1585
|
+
() => controller.abort(),
|
|
1586
|
+
() => this.#sleepTimeoutError(requestPath, delay, operation)
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* Shared JSON/binary transport with retries and bounded request bodies.
|
|
1592
|
+
*
|
|
1593
|
+
* @param {{
|
|
1594
|
+
* method: string,
|
|
1595
|
+
* requestPath: string,
|
|
1596
|
+
* body?: object,
|
|
1597
|
+
* idempotencyKey?: string,
|
|
1598
|
+
* contentType?: string,
|
|
1599
|
+
* accept?: string,
|
|
1600
|
+
* operation: OperationDeadline,
|
|
1601
|
+
* requestTimeoutMs: number,
|
|
1602
|
+
* decode: (response: any, controller: AbortController, attemptDeadline: number, operation: OperationDeadline, requestLimitMs: number) => Promise<any>
|
|
1603
|
+
* }} options
|
|
1604
|
+
* @returns {Promise<any>}
|
|
1605
|
+
*/
|
|
1606
|
+
async #requestTransport(options) {
|
|
1607
|
+
const serializedBody = options.body ? JSON.stringify(options.body) : undefined;
|
|
1608
|
+
const requestHeaders = {
|
|
1609
|
+
'Authorization': `Bearer ${this.#apiKey}`
|
|
1610
|
+
};
|
|
1611
|
+
if (options.accept) {
|
|
1612
|
+
requestHeaders['Accept'] = options.accept;
|
|
1613
|
+
}
|
|
1614
|
+
if (options.contentType) {
|
|
1615
|
+
requestHeaders['Content-Type'] = options.contentType;
|
|
1616
|
+
}
|
|
1617
|
+
if (options.idempotencyKey && options.method === 'POST') {
|
|
1618
|
+
requestHeaders['Idempotency-Key'] = options.idempotencyKey;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
1622
|
+
const remaining = this.#remainingOperationMs(options.operation);
|
|
1623
|
+
if (remaining <= 0) {
|
|
1624
|
+
return this.#handleUnavailable(this.#operationDeadlineError(options.requestPath, options.operation));
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
const attemptBudget = Math.min(options.requestTimeoutMs, remaining);
|
|
1628
|
+
const attemptDeadline = this.#nowFn() + attemptBudget;
|
|
1629
|
+
const controller = new AbortController();
|
|
1630
|
+
try {
|
|
1631
|
+
const response = await runWithTimeout(
|
|
1632
|
+
() => this.#fetchFn(`${this.#apiUrl}${options.requestPath}`, {
|
|
1633
|
+
method: options.method,
|
|
1634
|
+
headers: { ...requestHeaders },
|
|
1635
|
+
body: serializedBody,
|
|
1636
|
+
signal: controller.signal
|
|
1637
|
+
}),
|
|
1638
|
+
attemptBudget,
|
|
1639
|
+
() => controller.abort(),
|
|
1640
|
+
() => this.#requestTimeoutError(options.requestPath, attemptBudget, options.operation, options.requestTimeoutMs)
|
|
1641
|
+
);
|
|
1642
|
+
|
|
1643
|
+
if (response.ok) {
|
|
1644
|
+
return await options.decode(response, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// 4xx — non-retryable when the diagnostic body is available. A body
|
|
1648
|
+
// read timeout still preserves the received HTTP status, rather than
|
|
1649
|
+
// turning an already-classified API response into an availability retry.
|
|
1650
|
+
if (response.status >= 400 && response.status < 500) {
|
|
1651
|
+
throw await this.#readApiError(
|
|
1652
|
+
response,
|
|
1653
|
+
options.requestPath,
|
|
1654
|
+
controller,
|
|
1655
|
+
attemptDeadline,
|
|
1656
|
+
options.operation,
|
|
1657
|
+
options.requestTimeoutMs
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// 5xx — retry with backoff unless this is the last attempt. Cancel
|
|
1662
|
+
// discarded response bodies so a slow server cannot retain a stream.
|
|
1663
|
+
if (attempt < MAX_RETRIES) {
|
|
1664
|
+
cancelResponseBody(response);
|
|
1665
|
+
await this.#sleepWithinOperation(this.#retryDelay(attempt), options.operation, options.requestPath);
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
throw await this.#readApiError(
|
|
1670
|
+
response,
|
|
1671
|
+
options.requestPath,
|
|
1672
|
+
controller,
|
|
1673
|
+
attemptDeadline,
|
|
1674
|
+
options.operation,
|
|
1675
|
+
options.requestTimeoutMs
|
|
1676
|
+
);
|
|
1677
|
+
} catch (error) {
|
|
1678
|
+
if (error instanceof SnapSkipError || error instanceof SnapFallbackError) {
|
|
1679
|
+
throw error;
|
|
1680
|
+
}
|
|
1681
|
+
if (error instanceof SnapApiError && error.status >= 400 && error.status < 500) {
|
|
1682
|
+
throw error;
|
|
1683
|
+
}
|
|
1684
|
+
if (error instanceof SnapApiError && error.status >= 500) {
|
|
1685
|
+
return this.#handleUnavailable(error);
|
|
1686
|
+
}
|
|
1687
|
+
if (error instanceof SnapUnavailableError) {
|
|
1688
|
+
return this.#handleUnavailable(error);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
if (this.#remainingOperationMs(options.operation) <= 0) {
|
|
1692
|
+
return this.#handleUnavailable(this.#operationDeadlineError(options.requestPath, options.operation));
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
if (attempt < MAX_RETRIES) {
|
|
1696
|
+
try {
|
|
1697
|
+
await this.#sleepWithinOperation(this.#retryDelay(attempt), options.operation, options.requestPath);
|
|
1698
|
+
} catch (retryError) {
|
|
1699
|
+
return this.#handleUnavailable(retryError instanceof Error
|
|
1700
|
+
? retryError
|
|
1701
|
+
: new SnapUnavailableError(String(retryError)));
|
|
1702
|
+
}
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1707
|
+
return this.#handleUnavailable(
|
|
1708
|
+
new SnapUnavailableError(
|
|
1709
|
+
`Snap request ${options.requestPath} failed after ${MAX_RETRIES} attempts: ${detail}`
|
|
1710
|
+
)
|
|
1711
|
+
);
|
|
1712
|
+
} finally {
|
|
1713
|
+
controller.abort();
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
return this.#handleUnavailable(this.#operationDeadlineError(options.requestPath, options.operation));
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* Make a JSON request with retry and operation deadlines.
|
|
1722
|
+
*
|
|
1723
|
+
* @param {string} method
|
|
1724
|
+
* @param {string} requestPath
|
|
1725
|
+
* @param {object} [body]
|
|
1726
|
+
* @param {string} [idempotencyKey]
|
|
1727
|
+
* @param {OperationDeadline} [operation]
|
|
1728
|
+
* @returns {Promise<any>}
|
|
1729
|
+
*/
|
|
1730
|
+
async #request(method, requestPath, body, idempotencyKey, operation = this.#createOperationDeadline('Snap request')) {
|
|
1731
|
+
return this.#requestTransport({
|
|
1732
|
+
method,
|
|
1733
|
+
requestPath,
|
|
1734
|
+
body,
|
|
1735
|
+
idempotencyKey,
|
|
1736
|
+
contentType: 'application/json',
|
|
1737
|
+
accept: 'application/json',
|
|
1738
|
+
operation,
|
|
1739
|
+
requestTimeoutMs: JSON_REQUEST_TIMEOUT_MS,
|
|
1740
|
+
decode: async (response, controller, attemptDeadline, requestOperation, requestLimitMs) => {
|
|
1741
|
+
const text = await this.#readResponseBody(
|
|
1742
|
+
response,
|
|
1743
|
+
requestPath,
|
|
1744
|
+
controller,
|
|
1745
|
+
attemptDeadline,
|
|
1746
|
+
requestOperation,
|
|
1747
|
+
requestLimitMs,
|
|
1748
|
+
() => response.text()
|
|
1749
|
+
);
|
|
1750
|
+
return text ? JSON.parse(text) : {};
|
|
1751
|
+
}
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/**
|
|
1756
|
+
* Make a binary request with retry and operation deadlines.
|
|
1757
|
+
*
|
|
1758
|
+
* @param {string} method
|
|
1759
|
+
* @param {string} requestPath
|
|
1760
|
+
* @param {OperationDeadline} [operation]
|
|
1761
|
+
* @returns {Promise<Buffer>}
|
|
1762
|
+
*/
|
|
1763
|
+
async #requestBinary(method, requestPath, operation = this.#createOperationDeadline('binary export')) {
|
|
1764
|
+
return this.#requestTransport({
|
|
1765
|
+
method,
|
|
1766
|
+
requestPath,
|
|
1767
|
+
operation,
|
|
1768
|
+
requestTimeoutMs: BINARY_REQUEST_TIMEOUT_MS,
|
|
1769
|
+
decode: async (response, controller, attemptDeadline, requestOperation, requestLimitMs) => {
|
|
1770
|
+
const bytes = await this.#readResponseBody(
|
|
1771
|
+
response,
|
|
1772
|
+
requestPath,
|
|
1773
|
+
controller,
|
|
1774
|
+
attemptDeadline,
|
|
1775
|
+
requestOperation,
|
|
1776
|
+
requestLimitMs,
|
|
1777
|
+
() => response.arrayBuffer()
|
|
1778
|
+
);
|
|
1779
|
+
return Buffer.from(bytes);
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
|
|
947
1784
|
/**
|
|
948
1785
|
* @param {{
|
|
949
1786
|
* configPath?: string,
|
|
@@ -952,7 +1789,11 @@ export class SnapProvider {
|
|
|
952
1789
|
* config: VisualRegressionConfig,
|
|
953
1790
|
* routes: VisualRegressionConfig['routes'],
|
|
954
1791
|
* selectedRouteIds: string[],
|
|
955
|
-
* purpose: 'baseline' | 'diff'
|
|
1792
|
+
* purpose: 'baseline' | 'capture' | 'diff',
|
|
1793
|
+
* configuredRouteIds: string[],
|
|
1794
|
+
* expectedCaptures: import('../types/visual-diff-types').SnapExpectedCaptureIdentity[],
|
|
1795
|
+
* runContext: { branch?: string, prHeadSha?: string },
|
|
1796
|
+
* baselineRef?: { refBranch: string, refSha: string, publicationWorkflowRef: string, publicationSequence: number }
|
|
956
1797
|
* }} options
|
|
957
1798
|
* @returns {Promise<CaptureResult>}
|
|
958
1799
|
*/
|
|
@@ -981,11 +1822,12 @@ export class SnapProvider {
|
|
|
981
1822
|
});
|
|
982
1823
|
|
|
983
1824
|
const runId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
1825
|
+
const operation = this.#createOperationDeadline('capture');
|
|
984
1826
|
// A baseline run establishes new ground truth and must not be diffed, so we
|
|
985
1827
|
// omit the baselineId. Captures then settle to "new" server-side (no
|
|
986
1828
|
// comparison), and publishBaseline harvests their object keys. A diff run
|
|
987
1829
|
// attaches the latest baseline so the backend can compare against it.
|
|
988
|
-
const baselineId = options.purpose === '
|
|
1830
|
+
const baselineId = options.purpose === 'diff' ? await this.#resolveLatestBaselineId(operation) : null;
|
|
989
1831
|
|
|
990
1832
|
await this.#request('POST', `/v1/visual/projects/${this.#projectId}/runs`, {
|
|
991
1833
|
id: runId,
|
|
@@ -994,15 +1836,25 @@ export class SnapProvider {
|
|
|
994
1836
|
...(baselineId ? { baselineId } : {}),
|
|
995
1837
|
// A baseline run must not be diffed. Suppress the server's
|
|
996
1838
|
// auto-resolve-by-branch so no prior baseline is attached.
|
|
997
|
-
...(options.purpose
|
|
998
|
-
|
|
999
|
-
|
|
1839
|
+
...(options.purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
|
|
1840
|
+
capturePlan: {
|
|
1841
|
+
purpose: options.purpose === 'baseline' ? 'baseline' : 'diff',
|
|
1842
|
+
...(options.baselineRef
|
|
1843
|
+
? {
|
|
1844
|
+
publicationWorkflowRef: options.baselineRef.publicationWorkflowRef,
|
|
1845
|
+
publicationSequence: options.baselineRef.publicationSequence
|
|
1846
|
+
}
|
|
1847
|
+
: {}),
|
|
1848
|
+
configuredRouteIds: options.configuredRouteIds,
|
|
1849
|
+
selectedRouteIds: options.selectedRouteIds,
|
|
1850
|
+
expectedCaptures: options.expectedCaptures
|
|
1851
|
+
},
|
|
1852
|
+
...options.runContext
|
|
1853
|
+
}, this.#generateIdempotencyKey(), operation);
|
|
1000
1854
|
|
|
1001
1855
|
for (const { route, manifestEntry } of uploads) {
|
|
1002
1856
|
const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
1003
|
-
const viewportDescriptor =
|
|
1004
|
-
? (VIEWPORT_PRESETS[route.viewport] ?? { width: 1280, height: 720 })
|
|
1005
|
-
: route.viewport;
|
|
1857
|
+
const viewportDescriptor = resolveViewportDescriptor(route.viewport);
|
|
1006
1858
|
|
|
1007
1859
|
await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
|
|
1008
1860
|
id: captureId,
|
|
@@ -1014,7 +1866,7 @@ export class SnapProvider {
|
|
|
1014
1866
|
// worker's queue — without it the worker would try to render baseUrl
|
|
1015
1867
|
// (a client-only/loopback address it can't reach) and error the run.
|
|
1016
1868
|
localCapture: true,
|
|
1017
|
-
}, this.#generateIdempotencyKey());
|
|
1869
|
+
}, this.#generateIdempotencyKey(), operation);
|
|
1018
1870
|
|
|
1019
1871
|
const imagePath = path.resolve(localCapture.screenshotsRoot, manifestEntry.imagePath);
|
|
1020
1872
|
const imageBytes = await fs.readFile(imagePath);
|
|
@@ -1023,7 +1875,7 @@ export class SnapProvider {
|
|
|
1023
1875
|
imageBase64: imageBytes.toString('base64'),
|
|
1024
1876
|
width: manifestEntry.width,
|
|
1025
1877
|
height: manifestEntry.height
|
|
1026
|
-
}, this.#generateIdempotencyKey());
|
|
1878
|
+
}, this.#generateIdempotencyKey(), operation);
|
|
1027
1879
|
}
|
|
1028
1880
|
|
|
1029
1881
|
const snapResults = {
|
|
@@ -1032,6 +1884,11 @@ export class SnapProvider {
|
|
|
1032
1884
|
captureMode: 'local-upload',
|
|
1033
1885
|
runId,
|
|
1034
1886
|
projectId: this.#projectId,
|
|
1887
|
+
purpose: options.purpose,
|
|
1888
|
+
...(options.baselineRef ? options.baselineRef : {}),
|
|
1889
|
+
configuredRouteIds: options.configuredRouteIds,
|
|
1890
|
+
selectedRouteIds: options.selectedRouteIds,
|
|
1891
|
+
expectedCaptures: options.expectedCaptures,
|
|
1035
1892
|
snapStartedAt: new Date().toISOString()
|
|
1036
1893
|
};
|
|
1037
1894
|
await fs.writeFile(localCapture.resultsPath, JSON.stringify(snapResults, null, 2));
|
|
@@ -1039,149 +1896,6 @@ export class SnapProvider {
|
|
|
1039
1896
|
return localCapture;
|
|
1040
1897
|
}
|
|
1041
1898
|
|
|
1042
|
-
/**
|
|
1043
|
-
* Make an HTTP request to the Snap API with retry logic.
|
|
1044
|
-
*
|
|
1045
|
-
* @param {string} method
|
|
1046
|
-
* @param {string} path
|
|
1047
|
-
* @param {object} [body]
|
|
1048
|
-
* @param {string} [idempotencyKey]
|
|
1049
|
-
* @returns {Promise<any>} Parsed JSON response
|
|
1050
|
-
*/
|
|
1051
|
-
async #request(method, path, body, idempotencyKey) {
|
|
1052
|
-
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
1053
|
-
try {
|
|
1054
|
-
const headers = {
|
|
1055
|
-
'Authorization': `Bearer ${this.#apiKey}`,
|
|
1056
|
-
'Content-Type': 'application/json',
|
|
1057
|
-
'Accept': 'application/json'
|
|
1058
|
-
};
|
|
1059
|
-
if (idempotencyKey && method === 'POST') {
|
|
1060
|
-
headers['Idempotency-Key'] = idempotencyKey;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
const url = `${this.#apiUrl}${path}`;
|
|
1064
|
-
const response = await this.#fetchFn(url, {
|
|
1065
|
-
method,
|
|
1066
|
-
headers,
|
|
1067
|
-
body: body ? JSON.stringify(body) : undefined
|
|
1068
|
-
});
|
|
1069
|
-
|
|
1070
|
-
if (response.ok) {
|
|
1071
|
-
const text = await response.text();
|
|
1072
|
-
return text ? JSON.parse(text) : {};
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
// 4xx — never retry
|
|
1076
|
-
if (response.status >= 400 && response.status < 500) {
|
|
1077
|
-
const errorBody = await response.text().catch(() => '');
|
|
1078
|
-
let detail = '';
|
|
1079
|
-
try {
|
|
1080
|
-
const parsed = JSON.parse(errorBody);
|
|
1081
|
-
detail = parsed.error || parsed.message || '';
|
|
1082
|
-
} catch { /* use default message */ }
|
|
1083
|
-
const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
|
|
1084
|
-
throw new SnapApiError(response.status, errorMessage, path);
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
// 5xx — retry with backoff (unless this is the last attempt)
|
|
1088
|
-
if (attempt < MAX_RETRIES) {
|
|
1089
|
-
const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
|
|
1090
|
-
await this.#sleepFn(delay);
|
|
1091
|
-
continue;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
// Exhausted retries
|
|
1095
|
-
const errorBody = await response.text().catch(() => '');
|
|
1096
|
-
let detail = '';
|
|
1097
|
-
try {
|
|
1098
|
-
const parsed = JSON.parse(errorBody);
|
|
1099
|
-
detail = parsed.error || parsed.message || '';
|
|
1100
|
-
} catch { /* use default message */ }
|
|
1101
|
-
const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
|
|
1102
|
-
return this.#handleUnavailable(new SnapApiError(response.status, errorMessage, path));
|
|
1103
|
-
|
|
1104
|
-
} catch (error) {
|
|
1105
|
-
if (error instanceof SnapApiError) {
|
|
1106
|
-
throw error;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
// Network error — retry
|
|
1110
|
-
if (attempt < MAX_RETRIES) {
|
|
1111
|
-
const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
|
|
1112
|
-
await this.#sleepFn(delay);
|
|
1113
|
-
continue;
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
return this.#handleUnavailable(new SnapUnavailableError(error instanceof Error ? error.message : String(error)));
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
// Unreachable, but satisfies the type checker
|
|
1121
|
-
return this.#handleUnavailable(new SnapUnavailableError('Max retries exceeded'));
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
/**
|
|
1125
|
-
* Make an HTTP request to the Snap API that returns a binary body (e.g. the
|
|
1126
|
-
* export tar). Mirrors #request's retry and error semantics — 4xx never
|
|
1127
|
-
* retries and throws SnapApiError, 5xx/network errors retry with backoff and
|
|
1128
|
-
* consult onUnavailable when exhausted — but returns the raw bytes instead
|
|
1129
|
-
* of parsed JSON.
|
|
1130
|
-
*
|
|
1131
|
-
* @param {string} method
|
|
1132
|
-
* @param {string} path
|
|
1133
|
-
* @returns {Promise<any>} Raw response bytes as a Buffer
|
|
1134
|
-
*/
|
|
1135
|
-
async #requestBinary(method, path) {
|
|
1136
|
-
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
1137
|
-
try {
|
|
1138
|
-
const response = await this.#fetchFn(`${this.#apiUrl}${path}`, {
|
|
1139
|
-
method,
|
|
1140
|
-
headers: { 'Authorization': `Bearer ${this.#apiKey}` }
|
|
1141
|
-
});
|
|
1142
|
-
|
|
1143
|
-
if (response.ok) {
|
|
1144
|
-
return Buffer.from(await response.arrayBuffer());
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
// 4xx — never retry
|
|
1148
|
-
if (response.status >= 400 && response.status < 500) {
|
|
1149
|
-
const detail = await readErrorDetail(response);
|
|
1150
|
-
const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
|
|
1151
|
-
throw new SnapApiError(response.status, errorMessage, path);
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
// 5xx — retry with backoff (unless this is the last attempt)
|
|
1155
|
-
if (attempt < MAX_RETRIES) {
|
|
1156
|
-
const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
|
|
1157
|
-
await this.#sleepFn(delay);
|
|
1158
|
-
continue;
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
// Exhausted retries
|
|
1162
|
-
const detail = await readErrorDetail(response);
|
|
1163
|
-
const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
|
|
1164
|
-
return this.#handleUnavailable(new SnapApiError(response.status, errorMessage, path));
|
|
1165
|
-
} catch (error) {
|
|
1166
|
-
if (error instanceof SnapApiError) {
|
|
1167
|
-
throw error;
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
// Network error — retry
|
|
1171
|
-
if (attempt < MAX_RETRIES) {
|
|
1172
|
-
const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
|
|
1173
|
-
await this.#sleepFn(delay);
|
|
1174
|
-
continue;
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
return this.#handleUnavailable(new SnapUnavailableError(error instanceof Error ? error.message : String(error)));
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
// Unreachable, but satisfies the type checker
|
|
1182
|
-
return this.#handleUnavailable(new SnapUnavailableError('Max retries exceeded'));
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
1899
|
/**
|
|
1186
1900
|
* Handle unavailable behavior based on onUnavailable config.
|
|
1187
1901
|
* @param {Error} error
|
|
@@ -1210,33 +1924,125 @@ export class SnapProvider {
|
|
|
1210
1924
|
/**
|
|
1211
1925
|
* Poll a run until it reaches a terminal state.
|
|
1212
1926
|
* @param {string} runId
|
|
1927
|
+
* @param {number} [expectedCaptureCount]
|
|
1928
|
+
* @param {(detail: string) => Error} [errorFactory]
|
|
1929
|
+
* @param {OperationDeadline} [operation]
|
|
1213
1930
|
* @returns {Promise<JsonObject>}
|
|
1214
1931
|
*/
|
|
1215
|
-
async #pollRun(
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1932
|
+
async #pollRun(
|
|
1933
|
+
runId,
|
|
1934
|
+
expectedCaptureCount,
|
|
1935
|
+
errorFactory = (detail) => new Error(detail),
|
|
1936
|
+
operation = this.#createOperationDeadline('run polling')
|
|
1937
|
+
) {
|
|
1938
|
+
let incompleteTerminalSignature;
|
|
1939
|
+
let stableIncompleteTerminalPolls = 0;
|
|
1940
|
+
let incompleteSignature;
|
|
1941
|
+
let stableIncompletePolls = 0;
|
|
1942
|
+
|
|
1943
|
+
while (this.#remainingOperationMs(operation) > 0) {
|
|
1944
|
+
const requestPath = `/v1/visual/runs/${runId}`;
|
|
1945
|
+
const run = await this.#request('GET', requestPath, undefined, undefined, operation);
|
|
1946
|
+
if (!run || typeof run !== 'object' || Array.isArray(run)) {
|
|
1947
|
+
throw errorFactory(`Snap returned a malformed run response for requested run "${runId}".`);
|
|
1948
|
+
}
|
|
1949
|
+
const captures = Array.isArray(run.captures) ? run.captures : [];
|
|
1950
|
+
const completeCaptureSet =
|
|
1951
|
+
expectedCaptureCount === undefined ||
|
|
1952
|
+
(captures.length >= expectedCaptureCount &&
|
|
1953
|
+
captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status)));
|
|
1954
|
+
|
|
1955
|
+
if (expectedCaptureCount !== undefined && !completeCaptureSet) {
|
|
1956
|
+
const signature = JSON.stringify(captures.map((capture) => [
|
|
1957
|
+
capture?.id,
|
|
1958
|
+
capture?.routeId,
|
|
1959
|
+
capture?.status,
|
|
1960
|
+
capture?.currentObjectKey
|
|
1961
|
+
]).sort((left, right) => {
|
|
1962
|
+
const leftJson = JSON.stringify(left);
|
|
1963
|
+
const rightJson = JSON.stringify(right);
|
|
1964
|
+
return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
|
|
1965
|
+
}));
|
|
1966
|
+
if (signature === incompleteSignature) stableIncompletePolls += 1;
|
|
1967
|
+
else {
|
|
1968
|
+
incompleteSignature = signature;
|
|
1969
|
+
stableIncompletePolls = 0;
|
|
1970
|
+
}
|
|
1971
|
+
// A server-side run can remain rendering forever after a cancelled
|
|
1972
|
+
// worker. Do not spend the full ten-minute poll budget on a capture set
|
|
1973
|
+
// that has made no progress for roughly one minute.
|
|
1974
|
+
if (stableIncompletePolls >= MAX_STABLE_INCOMPLETE_POLLS) return run;
|
|
1975
|
+
} else {
|
|
1976
|
+
incompleteSignature = undefined;
|
|
1977
|
+
stableIncompletePolls = 0;
|
|
1978
|
+
}
|
|
1220
1979
|
|
|
1221
1980
|
if (TERMINAL_RUN_STATUSES.has(run.status)) {
|
|
1222
|
-
|
|
1981
|
+
if (expectedCaptureCount === undefined || run.status === 'error') {
|
|
1982
|
+
return run;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
if (completeCaptureSet) return run;
|
|
1986
|
+
// An older server can report a terminal run after only an early subset
|
|
1987
|
+
// has settled. Keep polling for the full capture set until the normal
|
|
1988
|
+
// ten-minute deadline; ordinary renders routinely exceed a few seconds.
|
|
1989
|
+
if (
|
|
1990
|
+
captures.length < expectedCaptureCount &&
|
|
1991
|
+
captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status))
|
|
1992
|
+
) {
|
|
1993
|
+
const signature = JSON.stringify(captures.map((capture) => [
|
|
1994
|
+
capture?.id,
|
|
1995
|
+
capture?.routeId,
|
|
1996
|
+
capture?.status
|
|
1997
|
+
]).sort((left, right) => {
|
|
1998
|
+
const leftJson = JSON.stringify(left);
|
|
1999
|
+
const rightJson = JSON.stringify(right);
|
|
2000
|
+
return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
|
|
2001
|
+
}));
|
|
2002
|
+
if (signature === incompleteTerminalSignature) stableIncompleteTerminalPolls += 1;
|
|
2003
|
+
else {
|
|
2004
|
+
incompleteTerminalSignature = signature;
|
|
2005
|
+
stableIncompleteTerminalPolls = 0;
|
|
2006
|
+
}
|
|
2007
|
+
// A stable terminal subset cannot make render progress. Return it for
|
|
2008
|
+
// precise fail-closed count validation instead of waiting ten minutes.
|
|
2009
|
+
if (stableIncompleteTerminalPolls >= 2) return run;
|
|
2010
|
+
} else {
|
|
2011
|
+
incompleteTerminalSignature = undefined;
|
|
2012
|
+
stableIncompleteTerminalPolls = 0;
|
|
2013
|
+
}
|
|
1223
2014
|
}
|
|
1224
2015
|
|
|
1225
|
-
|
|
2016
|
+
try {
|
|
2017
|
+
await this.#sleepWithinOperation(POLL_INTERVAL_MS, operation, requestPath);
|
|
2018
|
+
} catch (error) {
|
|
2019
|
+
return this.#handleUnavailable(error instanceof Error
|
|
2020
|
+
? error
|
|
2021
|
+
: new SnapUnavailableError(String(error)));
|
|
2022
|
+
}
|
|
1226
2023
|
}
|
|
1227
2024
|
|
|
1228
|
-
|
|
2025
|
+
return this.#handleUnavailable(new SnapUnavailableError(
|
|
2026
|
+
`Snap ${operation.label} run ${runId} did not complete within ${OPERATION_LIMIT_TEXT}; ` +
|
|
2027
|
+
'the Snap operation deadline was reached while polling'
|
|
2028
|
+
));
|
|
1229
2029
|
}
|
|
1230
2030
|
|
|
1231
2031
|
/**
|
|
1232
2032
|
* Map a Snap run result to a VisualDiffSummary.
|
|
1233
2033
|
* @param {JsonObject} run
|
|
1234
2034
|
* @param {VisualRegressionConfig} config
|
|
2035
|
+
* @param {{
|
|
2036
|
+
* selectedRouteIds: string[],
|
|
2037
|
+
* expectedCaptures: Array<{ routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
|
|
2038
|
+
* expectedByIdentity: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
|
|
2039
|
+
* expectedByRouteId: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>
|
|
2040
|
+
* }} diffPlan
|
|
1235
2041
|
* @returns {import('../types/visual-diff-types').VisualDiffSummary}
|
|
1236
2042
|
*/
|
|
1237
|
-
#mapRunToSummary(run, config) {
|
|
1238
|
-
const captures = run.captures
|
|
1239
|
-
const totalScreenshots =
|
|
2043
|
+
#mapRunToSummary(run, config, diffPlan) {
|
|
2044
|
+
const captures = Array.isArray(run.captures) ? run.captures : [];
|
|
2045
|
+
const totalScreenshots = diffPlan.expectedCaptures.length;
|
|
1240
2046
|
let matchedScreenshots = 0;
|
|
1241
2047
|
let changedScreenshots = 0;
|
|
1242
2048
|
let missingInBaseline = 0;
|
|
@@ -1254,72 +2060,209 @@ export class SnapProvider {
|
|
|
1254
2060
|
/** @type {import('../types/visual-diff-types').VisualDiffErrorItem[]} */
|
|
1255
2061
|
const errors = [];
|
|
1256
2062
|
|
|
2063
|
+
/**
|
|
2064
|
+
* @param {string} id
|
|
2065
|
+
* @param {string | undefined} path
|
|
2066
|
+
* @param {import('../types/visual-diff-types').VisualViewport | undefined} viewport
|
|
2067
|
+
* @param {string} message
|
|
2068
|
+
*/
|
|
2069
|
+
const addError = (id, path, viewport, message) => {
|
|
2070
|
+
errors.push({ id, path, viewport, status: 'error', message });
|
|
2071
|
+
};
|
|
2072
|
+
|
|
2073
|
+
if (!Array.isArray(run.captures)) {
|
|
2074
|
+
addError(`run:${run.id}`, undefined, undefined, 'Snap run returned no valid captures array.');
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
if (run.status === 'error') {
|
|
2078
|
+
addError(
|
|
2079
|
+
`run:${run.id}`,
|
|
2080
|
+
undefined,
|
|
2081
|
+
undefined,
|
|
2082
|
+
run.errorDetails?.message || run.errorCode || run.message || 'Snap run ended with status "error".'
|
|
2083
|
+
);
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
const seenIdentities = new Set();
|
|
2087
|
+
const coveredExpectedRoutes = new Set();
|
|
1257
2088
|
for (const capture of captures) {
|
|
2089
|
+
if (!capture || typeof capture !== 'object' || Array.isArray(capture)) {
|
|
2090
|
+
addError('unknown', undefined, undefined, 'Snap run returned a malformed capture.');
|
|
2091
|
+
continue;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
if (typeof capture.routeId === 'string' && diffPlan.expectedByRouteId.has(capture.routeId)) {
|
|
2095
|
+
coveredExpectedRoutes.add(capture.routeId);
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
let identity;
|
|
2099
|
+
try {
|
|
2100
|
+
identity = captureIdentityKey(
|
|
2101
|
+
capture,
|
|
2102
|
+
`source capture "${capture.routeId || capture.id || 'unknown'}"`,
|
|
2103
|
+
hostedDiffError
|
|
2104
|
+
);
|
|
2105
|
+
} catch (error) {
|
|
2106
|
+
const malformedRouteId = typeof capture.routeId === 'string' && capture.routeId
|
|
2107
|
+
? capture.routeId
|
|
2108
|
+
: 'unknown';
|
|
2109
|
+
const expected = diffPlan.expectedByRouteId.get(malformedRouteId);
|
|
2110
|
+
addError(
|
|
2111
|
+
malformedRouteId,
|
|
2112
|
+
typeof capture.routePath === 'string' ? capture.routePath : expected?.routePath,
|
|
2113
|
+
expected?.viewport ?? capture.viewport,
|
|
2114
|
+
error instanceof Error ? error.message : String(error)
|
|
2115
|
+
);
|
|
2116
|
+
continue;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
const expected = diffPlan.expectedByIdentity.get(identity);
|
|
2120
|
+
if (!expected) {
|
|
2121
|
+
addError(
|
|
2122
|
+
capture.routeId,
|
|
2123
|
+
capture.routePath,
|
|
2124
|
+
capture.viewport,
|
|
2125
|
+
`Snap run returned an unexpected route/viewport capture for "${capture.routeId}".`
|
|
2126
|
+
);
|
|
2127
|
+
continue;
|
|
2128
|
+
}
|
|
2129
|
+
if (seenIdentities.has(identity)) {
|
|
2130
|
+
addError(
|
|
2131
|
+
expected.routeId,
|
|
2132
|
+
expected.routePath,
|
|
2133
|
+
expected.viewport,
|
|
2134
|
+
`Snap run returned a duplicate route/viewport capture for "${expected.routeId}".`
|
|
2135
|
+
);
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
2138
|
+
seenIdentities.add(identity);
|
|
2139
|
+
|
|
2140
|
+
if (capture.routePath !== expected.routePath) {
|
|
2141
|
+
addError(
|
|
2142
|
+
expected.routeId,
|
|
2143
|
+
capture.routePath,
|
|
2144
|
+
expected.viewport,
|
|
2145
|
+
`Snap run returned an unexpected path for "${expected.routeId}".`
|
|
2146
|
+
);
|
|
2147
|
+
continue;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
1258
2150
|
if (capture.status === 'error') {
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
status
|
|
1264
|
-
|
|
1265
|
-
|
|
2151
|
+
addError(
|
|
2152
|
+
expected.routeId,
|
|
2153
|
+
expected.routePath,
|
|
2154
|
+
expected.viewport,
|
|
2155
|
+
capture.errorDetails?.message || capture.errorCode || 'Snap capture ended with status "error".'
|
|
2156
|
+
);
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
if (capture.status !== 'new' && capture.status !== 'diffed') {
|
|
2161
|
+
addError(
|
|
2162
|
+
expected.routeId,
|
|
2163
|
+
expected.routePath,
|
|
2164
|
+
expected.viewport,
|
|
2165
|
+
`Snap capture "${expected.routeId}" ended with unknown status "${capture.status || 'unknown'}".`
|
|
2166
|
+
);
|
|
2167
|
+
continue;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
if (typeof capture.currentObjectKey !== 'string' || !capture.currentObjectKey) {
|
|
2171
|
+
addError(
|
|
2172
|
+
expected.routeId,
|
|
2173
|
+
expected.routePath,
|
|
2174
|
+
expected.viewport,
|
|
2175
|
+
`Snap capture "${expected.routeId}" has no currentObjectKey.`
|
|
2176
|
+
);
|
|
1266
2177
|
continue;
|
|
1267
2178
|
}
|
|
1268
2179
|
|
|
1269
2180
|
if (capture.status === 'new') {
|
|
1270
|
-
// The backend rendered this capture but had no baseline to diff against
|
|
1271
|
-
// (first run for the route). Surface it as a missing baseline rather than
|
|
1272
|
-
// a silent match — there is nothing to compare yet.
|
|
2181
|
+
// The backend rendered this capture but had no baseline to diff against.
|
|
1273
2182
|
missingInBaseline++;
|
|
1274
2183
|
missing.push({
|
|
1275
|
-
id:
|
|
2184
|
+
id: expected.routeId,
|
|
1276
2185
|
reason: 'no baseline capture found on Snap',
|
|
1277
|
-
path:
|
|
1278
|
-
viewport:
|
|
2186
|
+
path: expected.routePath,
|
|
2187
|
+
viewport: expected.viewport,
|
|
1279
2188
|
location: 'baseline'
|
|
1280
2189
|
});
|
|
1281
2190
|
continue;
|
|
1282
2191
|
}
|
|
1283
2192
|
|
|
1284
|
-
if (capture.
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
2193
|
+
if (capture.baselineObjectKey !== undefined &&
|
|
2194
|
+
capture.baselineObjectKey !== null &&
|
|
2195
|
+
typeof capture.baselineObjectKey !== 'string') {
|
|
2196
|
+
addError(
|
|
2197
|
+
expected.routeId,
|
|
2198
|
+
expected.routePath,
|
|
2199
|
+
expected.viewport,
|
|
2200
|
+
`Snap capture "${expected.routeId}" has an invalid baselineObjectKey.`
|
|
2201
|
+
);
|
|
2202
|
+
continue;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
if (!capture.baselineObjectKey) {
|
|
2206
|
+
// No baseline was attached to this capture: the backend short-circuits
|
|
2207
|
+
// to "diffed" with no comparison data.
|
|
2208
|
+
missingInBaseline++;
|
|
2209
|
+
missing.push({
|
|
2210
|
+
id: expected.routeId,
|
|
2211
|
+
reason: 'no baseline capture found on Snap',
|
|
2212
|
+
path: expected.routePath,
|
|
2213
|
+
viewport: expected.viewport,
|
|
2214
|
+
location: 'baseline'
|
|
2215
|
+
});
|
|
2216
|
+
continue;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
if (!Number.isFinite(capture.diffPct) || capture.diffPct < 0 || capture.diffPct > 1) {
|
|
2220
|
+
addError(
|
|
2221
|
+
expected.routeId,
|
|
2222
|
+
expected.routePath,
|
|
2223
|
+
expected.viewport,
|
|
2224
|
+
`Snap capture "${expected.routeId}" has an invalid diffPct.`
|
|
2225
|
+
);
|
|
2226
|
+
continue;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
if (capture.diffPct > config.diff.threshold) {
|
|
2230
|
+
comparedWithBaseline++;
|
|
2231
|
+
changedScreenshots++;
|
|
2232
|
+
changed.push({
|
|
2233
|
+
id: expected.routeId,
|
|
2234
|
+
path: expected.routePath,
|
|
2235
|
+
viewport: expected.viewport,
|
|
2236
|
+
baselineImagePath: capture.baselineObjectKey,
|
|
2237
|
+
currentImagePath: capture.currentObjectKey,
|
|
2238
|
+
width: capture.width || 0,
|
|
2239
|
+
height: capture.height || 0,
|
|
2240
|
+
differentPixels: capture.diffPixels || 0,
|
|
2241
|
+
totalPixels: (capture.width || 0) * (capture.height || 0),
|
|
2242
|
+
mismatchRatio: capture.diffPct,
|
|
2243
|
+
status: 'changed'
|
|
2244
|
+
});
|
|
2245
|
+
} else {
|
|
2246
|
+
comparedWithBaseline++;
|
|
2247
|
+
if (capture.diffPct === 0) {
|
|
2248
|
+
exactZeroMatches++;
|
|
1319
2249
|
}
|
|
2250
|
+
matchedScreenshots++;
|
|
1320
2251
|
}
|
|
1321
2252
|
}
|
|
1322
2253
|
|
|
2254
|
+
for (const expected of diffPlan.expectedByRouteId.values()) {
|
|
2255
|
+
if (coveredExpectedRoutes.has(expected.routeId)) continue;
|
|
2256
|
+
missingInCurrent++;
|
|
2257
|
+
missing.push({
|
|
2258
|
+
id: expected.routeId,
|
|
2259
|
+
reason: 'capture missing from Snap run',
|
|
2260
|
+
path: expected.routePath,
|
|
2261
|
+
viewport: expected.viewport,
|
|
2262
|
+
location: 'current'
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
|
|
1323
2266
|
// False-negative guard: when a diff run compared every route against a real
|
|
1324
2267
|
// baseline yet every single comparison came back pixel-identical (0% drift),
|
|
1325
2268
|
// the most likely explanation is that the client captured a stale or wrong
|
|
@@ -1346,7 +2289,7 @@ export class SnapProvider {
|
|
|
1346
2289
|
finishedAt: run.finishedAt || new Date().toISOString(),
|
|
1347
2290
|
completed: true,
|
|
1348
2291
|
status: run.status === 'pass' ? 'clean' : run.status === 'fail' ? 'changes-detected' : 'incomplete',
|
|
1349
|
-
selectedRoutes:
|
|
2292
|
+
selectedRoutes: diffPlan.selectedRouteIds,
|
|
1350
2293
|
baselineManifestPath: '',
|
|
1351
2294
|
currentManifestPath: '',
|
|
1352
2295
|
diffMode: config.diff.mode,
|