snapdrift 0.8.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/README.md +8 -3
- package/action.yml +10 -2
- package/actions/comment/action.yml +6 -0
- package/actions/compare/action.yml +6 -0
- package/actions/pr-diff/action.yml +102 -55
- package/actions/resolve-baseline/action.yml +36 -50
- package/actions/scope/action.yml +24 -1
- package/actions/stage/action.yml +7 -1
- package/bin/snapdrift.mjs +6 -3
- package/docs/contracts.md +125 -9
- package/docs/integration-guide.md +61 -12
- package/docs/local-cli.md +15 -1
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +1 -1
- package/lib/cli.mjs +140 -52
- package/lib/init-from-action.mjs +24 -13
- package/lib/outage-policy.mjs +35 -2
- package/lib/provider.mjs +39 -12
- package/lib/report.mjs +3 -2
- package/lib/resolve-baseline-artifact.mjs +147 -0
- package/lib/resolve-baseline-paths.mjs +40 -0
- package/lib/snap-provider.mjs +1164 -481
- package/package.json +15 -11
- package/types/visual-diff-types.d.ts +30 -1
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 */
|
|
@@ -23,16 +31,22 @@ import { selectConfiguredRoutes, splitCommaList, determineDriftStatus, VIEWPORT_
|
|
|
23
31
|
/** @typedef {import('../types/visual-diff-types').ProviderBaselineData} BaselineData */
|
|
24
32
|
/** @typedef {import('../types/visual-diff-types').SnapConfig} SnapConfig */
|
|
25
33
|
/** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
|
|
34
|
+
/** @typedef {import('../types/visual-diff-types').ComparisonPolicy} ComparisonPolicy */
|
|
35
|
+
/** @typedef {import('../types/visual-diff-types').ComparisonMetadata} ComparisonMetadata */
|
|
26
36
|
/** @typedef {import('../types/visual-diff-types').JsonObject} JsonObject */
|
|
37
|
+
/** @typedef {{ deadline: number, label: string }} OperationDeadline */
|
|
27
38
|
|
|
28
39
|
const DEFAULT_API_URL = 'https://snap.i2dev.com';
|
|
29
40
|
const MAX_RETRIES = 3;
|
|
30
41
|
const INITIAL_RETRY_DELAY_MS = 1000;
|
|
31
42
|
const RETRY_MULTIPLIER = 2;
|
|
32
|
-
const
|
|
43
|
+
const RETRY_DELAY_CAP_MS = 30000;
|
|
44
|
+
const JSON_REQUEST_TIMEOUT_MS = 30 * 1000;
|
|
45
|
+
const BINARY_REQUEST_TIMEOUT_MS = 120 * 1000;
|
|
33
46
|
const POLL_INTERVAL_MS = 2000;
|
|
34
|
-
const
|
|
47
|
+
const MAX_OPERATION_DURATION_MS = 10 * 60 * 1000;
|
|
35
48
|
const MAX_STABLE_INCOMPLETE_POLLS = 30;
|
|
49
|
+
const MAX_COMPARISON_PIXELS = 32 * 1024 * 1024;
|
|
36
50
|
const BASELINE_RUN_SUCCESS_STATUS = 'new';
|
|
37
51
|
const BASELINE_CAPTURE_SUCCESS_STATUS = 'new';
|
|
38
52
|
const TERMINAL_CAPTURE_STATUSES = new Set(['new', 'diffed', 'error']);
|
|
@@ -43,19 +57,201 @@ const TERMINAL_CAPTURE_STATUSES = new Set(['new', 'diffed', 'error']);
|
|
|
43
57
|
// client must stop polling on it just like pass/fail/error.
|
|
44
58
|
const TERMINAL_RUN_STATUSES = new Set(['pass', 'fail', 'error', 'new']);
|
|
45
59
|
|
|
60
|
+
/** @param {number} milliseconds @returns {string} */
|
|
61
|
+
function formatDuration(milliseconds) {
|
|
62
|
+
const seconds = Math.round(milliseconds / 1000);
|
|
63
|
+
if (seconds % 60 === 0) return `${seconds / 60} minutes`;
|
|
64
|
+
return `${seconds} seconds`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const OPERATION_LIMIT_TEXT = formatDuration(MAX_OPERATION_DURATION_MS);
|
|
68
|
+
|
|
69
|
+
/** @returns {number} A monotonic timestamp in milliseconds. */
|
|
70
|
+
function monotonicNow() {
|
|
71
|
+
return typeof globalThis.performance?.now === 'function' ? globalThis.performance.now() : Date.now();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Run an asynchronous operation with a timer that also settles when the
|
|
76
|
+
* underlying promise ignores cancellation. The promise remains observed after
|
|
77
|
+
* the timeout, so a late rejection cannot become an unhandled rejection.
|
|
78
|
+
*
|
|
79
|
+
* @template T
|
|
80
|
+
* @param {() => Promise<T> | T} task
|
|
81
|
+
* @param {number} timeoutMs
|
|
82
|
+
* @param {() => void} onTimeout
|
|
83
|
+
* @param {() => Error} timeoutError
|
|
84
|
+
* @returns {Promise<T>}
|
|
85
|
+
*/
|
|
86
|
+
function runWithTimeout(task, timeoutMs, onTimeout, timeoutError) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
let settled = false;
|
|
89
|
+
let taskPromise;
|
|
90
|
+
try {
|
|
91
|
+
taskPromise = Promise.resolve(task());
|
|
92
|
+
} catch (error) {
|
|
93
|
+
reject(error);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const timer = setTimeout(
|
|
97
|
+
() => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
try {
|
|
101
|
+
onTimeout();
|
|
102
|
+
} catch {
|
|
103
|
+
// Abort/cancel is best effort; the timeout still settles the caller.
|
|
104
|
+
}
|
|
105
|
+
reject(timeoutError());
|
|
106
|
+
},
|
|
107
|
+
Math.max(0, timeoutMs)
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
taskPromise.then(
|
|
111
|
+
(value) => {
|
|
112
|
+
if (settled) {
|
|
113
|
+
if (value && typeof value === 'object') cancelResponseBody(value);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
settled = true;
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
resolve(value);
|
|
119
|
+
},
|
|
120
|
+
(error) => {
|
|
121
|
+
if (settled) return;
|
|
122
|
+
settled = true;
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
reject(error);
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {any} response */
|
|
131
|
+
function cancelResponseBody(response) {
|
|
132
|
+
const body = response && typeof response === 'object' ? response.body : undefined;
|
|
133
|
+
if (body && typeof body.cancel === 'function') {
|
|
134
|
+
Promise.resolve(body.cancel()).catch(() => {});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
46
138
|
/** @param {string} detail @returns {Error} */
|
|
47
139
|
function completeBaselineError(detail) {
|
|
48
140
|
return new Error(`Cannot publish a complete Snap baseline: ${detail}`);
|
|
49
141
|
}
|
|
50
142
|
|
|
143
|
+
/** @param {string} detail @returns {Error} */
|
|
144
|
+
function hostedDiffError(detail) {
|
|
145
|
+
return new Error(`Cannot compare a Snap visual diff: ${detail}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @param {unknown} value @returns {value is ComparisonPolicy} */
|
|
149
|
+
function isComparisonPolicy(value) {
|
|
150
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
151
|
+
const policy = /** @type {Record<string, any>} */ (value);
|
|
152
|
+
return policy.version === 1 && typeof policy.threshold === 'number' && Number.isFinite(policy.threshold) && policy.threshold >= 0 && policy.threshold <= 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @param {unknown} value @returns {value is ComparisonMetadata} */
|
|
156
|
+
function isComparisonMetadata(value) {
|
|
157
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
158
|
+
const comparison = /** @type {Record<string, any>} */ (value);
|
|
159
|
+
const dimensions = [comparison.baseline, comparison.current, comparison.canvas];
|
|
160
|
+
if (
|
|
161
|
+
dimensions.some(
|
|
162
|
+
(dimension) =>
|
|
163
|
+
!dimension ||
|
|
164
|
+
typeof dimension !== 'object' ||
|
|
165
|
+
!Number.isInteger(dimension.width) ||
|
|
166
|
+
dimension.width < 0 ||
|
|
167
|
+
!Number.isInteger(dimension.height) ||
|
|
168
|
+
dimension.height < 0
|
|
169
|
+
)
|
|
170
|
+
) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
const [baseline, current, canvas] = dimensions;
|
|
174
|
+
return (
|
|
175
|
+
typeof comparison.dimensionsChanged === 'boolean' &&
|
|
176
|
+
comparison.dimensionsChanged === (baseline.width !== current.width || baseline.height !== current.height) &&
|
|
177
|
+
canvas.width === Math.max(baseline.width, current.width) &&
|
|
178
|
+
canvas.height === Math.max(baseline.height, current.height) &&
|
|
179
|
+
Number.isInteger(comparison.totalPixels) &&
|
|
180
|
+
comparison.totalPixels >= 0 &&
|
|
181
|
+
comparison.totalPixels <= canvas.width * canvas.height &&
|
|
182
|
+
canvas.width * canvas.height <= MAX_COMPARISON_PIXELS
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {any} capture
|
|
188
|
+
* @param {ComparisonMetadata} comparison
|
|
189
|
+
* @param {number} expectedThreshold
|
|
190
|
+
* @returns {boolean}
|
|
191
|
+
*/
|
|
192
|
+
function hasCompleteComparisonResult(capture, comparison, expectedThreshold) {
|
|
193
|
+
if (
|
|
194
|
+
!Number.isFinite(capture.diffPct) ||
|
|
195
|
+
capture.diffPct < 0 ||
|
|
196
|
+
capture.diffPct > 1 ||
|
|
197
|
+
!Number.isInteger(capture.diffPixels) ||
|
|
198
|
+
capture.diffPixels < 0 ||
|
|
199
|
+
capture.diffPixels > comparison.totalPixels ||
|
|
200
|
+
typeof capture.diffObjectKey !== 'string' ||
|
|
201
|
+
capture.diffObjectKey.trim().length === 0 ||
|
|
202
|
+
capture.thresholdUsed !== expectedThreshold
|
|
203
|
+
) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const expectedRatio = comparison.totalPixels === 0 ? 0 : capture.diffPixels / comparison.totalPixels;
|
|
208
|
+
return Math.abs(capture.diffPct - expectedRatio) <= 1e-12;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** @param {any} capture @returns {ComparisonMetadata | undefined} */
|
|
212
|
+
function comparisonFromCapture(capture) {
|
|
213
|
+
const candidate =
|
|
214
|
+
capture?.comparison ??
|
|
215
|
+
(() => {
|
|
216
|
+
if (typeof capture?.comparisonJson !== 'string') return undefined;
|
|
217
|
+
try {
|
|
218
|
+
return JSON.parse(capture.comparisonJson);
|
|
219
|
+
} catch {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
return isComparisonMetadata(candidate) ? candidate : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** @param {unknown} actual @param {ComparisonPolicy} expected @param {string} context */
|
|
227
|
+
function assertComparisonPolicyAcknowledged(actual, expected, context) {
|
|
228
|
+
if (!isComparisonPolicy(actual) || actual.threshold !== expected.threshold) {
|
|
229
|
+
throw new SnapUpgradeRequiredError(
|
|
230
|
+
`${context} did not acknowledge comparisonPolicy v1 with threshold ${expected.threshold}. ` +
|
|
231
|
+
'Upgrade the Snap server before enabling unequal-dimension comparisons.'
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Updated SnapDrift clients explicitly opt into the union-canvas comparison.
|
|
238
|
+
* Keep the config override for callers that want the policy visible in their
|
|
239
|
+
* config, but derive it from the existing diff threshold by default so a
|
|
240
|
+
* normal v1 client does not need to repeat that value.
|
|
241
|
+
*
|
|
242
|
+
* @param {VisualRegressionConfig} config
|
|
243
|
+
* @returns {ComparisonPolicy}
|
|
244
|
+
*/
|
|
245
|
+
function resolveComparisonPolicy(config) {
|
|
246
|
+
return config.diff.comparisonPolicy ? { ...config.diff.comparisonPolicy } : { version: 1, threshold: config.diff.threshold };
|
|
247
|
+
}
|
|
248
|
+
|
|
51
249
|
/**
|
|
52
250
|
* @param {import('../types/visual-diff-types').VisualViewport} viewport
|
|
53
251
|
* @returns {{ width: number, height: number, deviceScaleFactor?: number, isMobile?: boolean, hasTouch?: boolean }}
|
|
54
252
|
*/
|
|
55
253
|
function resolveViewportDescriptor(viewport) {
|
|
56
|
-
return typeof viewport === 'string'
|
|
57
|
-
? (VIEWPORT_PRESETS[viewport] ?? { width: 1280, height: 720 })
|
|
58
|
-
: viewport;
|
|
254
|
+
return typeof viewport === 'string' ? (VIEWPORT_PRESETS[viewport] ?? { width: 1280, height: 720 }) : viewport;
|
|
59
255
|
}
|
|
60
256
|
|
|
61
257
|
/** @param {VisualRegressionConfig['routes'][number]} route */
|
|
@@ -67,43 +263,140 @@ function expectedCaptureIdentity(route) {
|
|
|
67
263
|
};
|
|
68
264
|
}
|
|
69
265
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
266
|
+
/**
|
|
267
|
+
* @param {unknown} value
|
|
268
|
+
* @param {string} context
|
|
269
|
+
* @param {(detail: string) => Error} errorFactory
|
|
270
|
+
* @returns {string}
|
|
271
|
+
*/
|
|
272
|
+
function normalizeViewportDescriptor(value, context, errorFactory) {
|
|
72
273
|
let descriptor;
|
|
73
274
|
try {
|
|
74
275
|
descriptor = typeof value === 'string' ? JSON.parse(value) : value;
|
|
75
276
|
} catch {
|
|
76
|
-
throw
|
|
277
|
+
throw errorFactory(`${context} has malformed viewportDescriptorJson.`);
|
|
77
278
|
}
|
|
78
|
-
if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor) ||
|
|
79
|
-
|
|
80
|
-
throw completeBaselineError(`${context} has an invalid viewport descriptor.`);
|
|
279
|
+
if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor) || !Number.isFinite(descriptor.width) || !Number.isFinite(descriptor.height)) {
|
|
280
|
+
throw errorFactory(`${context} has an invalid viewport descriptor.`);
|
|
81
281
|
}
|
|
82
|
-
return JSON.stringify(Object.fromEntries(
|
|
83
|
-
Object.entries(descriptor).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
84
|
-
));
|
|
282
|
+
return JSON.stringify(Object.fromEntries(Object.entries(descriptor).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))));
|
|
85
283
|
}
|
|
86
284
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
285
|
+
/**
|
|
286
|
+
* @param {{ routeId?: unknown, viewportDescriptorJson?: unknown }} capture
|
|
287
|
+
* @param {string} context
|
|
288
|
+
* @param {(detail: string) => Error} errorFactory
|
|
289
|
+
*/
|
|
290
|
+
function captureIdentityKey(capture, context, errorFactory) {
|
|
89
291
|
if (typeof capture.routeId !== 'string' || !capture.routeId) {
|
|
90
|
-
throw
|
|
292
|
+
throw errorFactory(`${context} has no routeId.`);
|
|
91
293
|
}
|
|
92
|
-
return `${capture.routeId}\u0000${normalizeViewportDescriptor(capture.viewportDescriptorJson, context)}`;
|
|
294
|
+
return `${capture.routeId}\u0000${normalizeViewportDescriptor(capture.viewportDescriptorJson, context, errorFactory)}`;
|
|
93
295
|
}
|
|
94
296
|
|
|
95
|
-
/**
|
|
96
|
-
|
|
297
|
+
/**
|
|
298
|
+
* @param {unknown} value
|
|
299
|
+
* @param {string} field
|
|
300
|
+
* @param {(detail: string) => Error} errorFactory
|
|
301
|
+
* @returns {Set<string>}
|
|
302
|
+
*/
|
|
303
|
+
function uniqueRouteIdSet(value, field, errorFactory) {
|
|
97
304
|
if (!Array.isArray(value) || value.some((id) => typeof id !== 'string' || !id)) {
|
|
98
|
-
throw
|
|
305
|
+
throw errorFactory(`results metadata has no valid ${field}; capture again with SnapDrift 0.8.2 or newer.`);
|
|
99
306
|
}
|
|
100
307
|
const ids = new Set(value);
|
|
101
308
|
if (ids.size !== value.length) {
|
|
102
|
-
throw
|
|
309
|
+
throw errorFactory(`results metadata ${field} contains duplicate route ids.`);
|
|
103
310
|
}
|
|
104
311
|
return ids;
|
|
105
312
|
}
|
|
106
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Validate the immutable capture plan written by SnapProvider.capture().
|
|
316
|
+
*
|
|
317
|
+
* Diff runs are allowed to be scoped, so this deliberately validates the
|
|
318
|
+
* expected set against selectedRouteIds rather than requiring every configured
|
|
319
|
+
* route as hosted baseline publication does.
|
|
320
|
+
*
|
|
321
|
+
* @param {JsonObject} metadata
|
|
322
|
+
* @param {string} projectId
|
|
323
|
+
* @returns {{
|
|
324
|
+
* runId: string,
|
|
325
|
+
* selectedRouteIds: string[],
|
|
326
|
+
* expectedCaptures: Array<{ routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
|
|
327
|
+
* expectedByIdentity: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
|
|
328
|
+
* expectedByRouteId: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>
|
|
329
|
+
* }}
|
|
330
|
+
*/
|
|
331
|
+
function validateHostedDiffMetadata(metadata, projectId) {
|
|
332
|
+
if (!metadata || typeof metadata !== 'object') {
|
|
333
|
+
throw hostedDiffError('capture results metadata is not an object; capture again with SnapDrift 0.8.2 or newer.');
|
|
334
|
+
}
|
|
335
|
+
if (typeof metadata.runId !== 'string' || !metadata.runId.trim()) {
|
|
336
|
+
throw hostedDiffError('capture results metadata has no run id; capture again with SnapDrift 0.8.2 or newer.');
|
|
337
|
+
}
|
|
338
|
+
if (metadata.projectId !== projectId) {
|
|
339
|
+
throw hostedDiffError(`capture results belong to project "${metadata.projectId || 'unknown'}", not "${projectId}".`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length === 0) {
|
|
343
|
+
throw hostedDiffError('capture results have no expected capture identities; recapture with SnapDrift 0.8.2 or newer.');
|
|
344
|
+
}
|
|
345
|
+
if (metadata.purpose !== 'diff') {
|
|
346
|
+
throw hostedDiffError(`capture results have purpose "${metadata.purpose || 'unknown'}", not "diff"; ` + 'recapture with SnapDrift 0.8.2 or newer.');
|
|
347
|
+
}
|
|
348
|
+
const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds', hostedDiffError);
|
|
349
|
+
if (selected.size === 0) {
|
|
350
|
+
throw hostedDiffError('capture results selected no routes.');
|
|
351
|
+
}
|
|
352
|
+
if (metadata.expectedCaptures.length !== selected.size) {
|
|
353
|
+
throw hostedDiffError(`capture results describe ${metadata.expectedCaptures.length} expected capture(s) for ${selected.size} selected route(s).`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const expectedRouteIds = new Set();
|
|
357
|
+
const expectedByIdentity = new Map();
|
|
358
|
+
const expectedByRouteId = new Map();
|
|
359
|
+
for (const [index, expected] of metadata.expectedCaptures.entries()) {
|
|
360
|
+
if (
|
|
361
|
+
!expected ||
|
|
362
|
+
typeof expected !== 'object' ||
|
|
363
|
+
typeof expected.routeId !== 'string' ||
|
|
364
|
+
!expected.routeId ||
|
|
365
|
+
typeof expected.routePath !== 'string' ||
|
|
366
|
+
!expected.routePath ||
|
|
367
|
+
!selected.has(expected.routeId)
|
|
368
|
+
) {
|
|
369
|
+
throw hostedDiffError(`expected capture metadata at index ${index} is invalid or not selected.`);
|
|
370
|
+
}
|
|
371
|
+
if (expectedRouteIds.has(expected.routeId)) {
|
|
372
|
+
throw hostedDiffError(`expected capture metadata contains duplicate route "${expected.routeId}".`);
|
|
373
|
+
}
|
|
374
|
+
expectedRouteIds.add(expected.routeId);
|
|
375
|
+
const viewportDescriptorJson = normalizeViewportDescriptor(expected.viewportDescriptorJson, `expected capture "${expected.routeId}"`, hostedDiffError);
|
|
376
|
+
const viewportDescriptor = JSON.parse(viewportDescriptorJson);
|
|
377
|
+
const expectedCapture = {
|
|
378
|
+
routeId: expected.routeId,
|
|
379
|
+
routePath: expected.routePath,
|
|
380
|
+
viewportDescriptorJson,
|
|
381
|
+
viewport: { width: viewportDescriptor.width, height: viewportDescriptor.height }
|
|
382
|
+
};
|
|
383
|
+
const identity = captureIdentityKey(expectedCapture, `expected capture "${expected.routeId}"`, hostedDiffError);
|
|
384
|
+
if (expectedByIdentity.has(identity)) {
|
|
385
|
+
throw hostedDiffError(`expected capture metadata contains duplicate route/viewport identity for "${expected.routeId}".`);
|
|
386
|
+
}
|
|
387
|
+
expectedByIdentity.set(identity, expectedCapture);
|
|
388
|
+
expectedByRouteId.set(expected.routeId, expectedCapture);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
runId: metadata.runId,
|
|
393
|
+
selectedRouteIds: [...selected],
|
|
394
|
+
expectedCaptures: [...expectedByIdentity.values()],
|
|
395
|
+
expectedByIdentity,
|
|
396
|
+
expectedByRouteId
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
107
400
|
/** @param {string[]} configuredRouteIds @param {string[]} selectedRouteIds */
|
|
108
401
|
function assertCompleteHostedBaselineSelection(configuredRouteIds, selectedRouteIds) {
|
|
109
402
|
const selected = new Set(selectedRouteIds);
|
|
@@ -111,9 +404,9 @@ function assertCompleteHostedBaselineSelection(configuredRouteIds, selectedRoute
|
|
|
111
404
|
if (missing.length === 0 && selected.size === configuredRouteIds.length) return;
|
|
112
405
|
throw new Error(
|
|
113
406
|
`Hosted Snap baseline publication requires all ${configuredRouteIds.length} configured route(s), ` +
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
407
|
+
`but only ${selected.size} were selected. Remove route-ids/--routes scoping ` +
|
|
408
|
+
`(missing: ${missing.join(', ') || 'unknown'}). Scoped runs remain supported for PR diffs ` +
|
|
409
|
+
'and local-provider baselines.'
|
|
117
410
|
);
|
|
118
411
|
}
|
|
119
412
|
|
|
@@ -129,27 +422,18 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
|
|
|
129
422
|
if (metadata.purpose !== 'baseline') {
|
|
130
423
|
throw completeBaselineError('source results were not recorded as a baseline capture.');
|
|
131
424
|
}
|
|
132
|
-
if (typeof metadata.refBranch !== 'string' || !metadata.refBranch ||
|
|
133
|
-
typeof metadata.refSha !== 'string' || !metadata.refSha) {
|
|
425
|
+
if (typeof metadata.refBranch !== 'string' || !metadata.refBranch || typeof metadata.refSha !== 'string' || !metadata.refSha) {
|
|
134
426
|
throw completeBaselineError('source results have no resolved ref; capture again with SnapDrift 0.8.2 or newer.');
|
|
135
427
|
}
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
!Number.isSafeInteger(metadata.publicationSequence) ||
|
|
139
|
-
metadata.publicationSequence <= 0
|
|
140
|
-
) {
|
|
141
|
-
throw completeBaselineError(
|
|
142
|
-
'source results have no valid publication sequence; capture again with SnapDrift 0.8.2 or newer.'
|
|
143
|
-
);
|
|
428
|
+
if (typeof metadata.publicationSequence !== 'number' || !Number.isSafeInteger(metadata.publicationSequence) || metadata.publicationSequence <= 0) {
|
|
429
|
+
throw completeBaselineError('source results have no valid publication sequence; capture again with SnapDrift 0.8.2 or newer.');
|
|
144
430
|
}
|
|
145
431
|
if (typeof metadata.publicationWorkflowRef !== 'string' || !metadata.publicationWorkflowRef.trim()) {
|
|
146
|
-
throw completeBaselineError(
|
|
147
|
-
'source results have no publication workflow identity; capture again with SnapDrift 0.8.2 or newer.'
|
|
148
|
-
);
|
|
432
|
+
throw completeBaselineError('source results have no publication workflow identity; capture again with SnapDrift 0.8.2 or newer.');
|
|
149
433
|
}
|
|
150
434
|
|
|
151
|
-
const configured = uniqueRouteIdSet(metadata.configuredRouteIds, 'configuredRouteIds');
|
|
152
|
-
const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds');
|
|
435
|
+
const configured = uniqueRouteIdSet(metadata.configuredRouteIds, 'configuredRouteIds', completeBaselineError);
|
|
436
|
+
const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds', completeBaselineError);
|
|
153
437
|
assertCompleteHostedBaselineSelection([...configured], [...selected]);
|
|
154
438
|
if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length !== configured.size) {
|
|
155
439
|
throw completeBaselineError(`expected capture metadata does not match ${configured.size} configured route(s).`);
|
|
@@ -158,15 +442,17 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
|
|
|
158
442
|
const expectedRoutes = new Set();
|
|
159
443
|
const expectedByIdentity = new Map();
|
|
160
444
|
for (const expected of metadata.expectedCaptures) {
|
|
161
|
-
if (
|
|
162
|
-
|
|
445
|
+
if (
|
|
446
|
+
!expected ||
|
|
447
|
+
typeof expected !== 'object' ||
|
|
448
|
+
typeof expected.routePath !== 'string' ||
|
|
449
|
+
!configured.has(expected.routeId) ||
|
|
450
|
+
expectedRoutes.has(expected.routeId)
|
|
451
|
+
) {
|
|
163
452
|
throw completeBaselineError(`expected capture metadata has duplicate or extra route "${expected?.routeId || 'unknown'}".`);
|
|
164
453
|
}
|
|
165
454
|
expectedRoutes.add(expected.routeId);
|
|
166
|
-
expectedByIdentity.set(
|
|
167
|
-
captureIdentityKey(expected, `expected capture "${expected.routeId}"`),
|
|
168
|
-
expected
|
|
169
|
-
);
|
|
455
|
+
expectedByIdentity.set(captureIdentityKey(expected, `expected capture "${expected.routeId}"`, completeBaselineError), expected);
|
|
170
456
|
}
|
|
171
457
|
|
|
172
458
|
if (!Array.isArray(run.captures)) throw completeBaselineError('source run has no captures array.');
|
|
@@ -179,7 +465,7 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
|
|
|
179
465
|
if (!capture || typeof capture !== 'object') {
|
|
180
466
|
throw completeBaselineError('source run contains a malformed capture.');
|
|
181
467
|
}
|
|
182
|
-
const identity = captureIdentityKey(capture, `source capture "${capture.routeId || capture.id || 'unknown'}"
|
|
468
|
+
const identity = captureIdentityKey(capture, `source capture "${capture.routeId || capture.id || 'unknown'}"`, completeBaselineError);
|
|
183
469
|
if (seen.has(identity)) {
|
|
184
470
|
throw completeBaselineError(`duplicate source route/viewport identity for "${capture.routeId}".`);
|
|
185
471
|
}
|
|
@@ -206,8 +492,8 @@ function validateCompleteBaselineRun(metadata, run, projectId) {
|
|
|
206
492
|
});
|
|
207
493
|
|
|
208
494
|
return routes.sort((left, right) => {
|
|
209
|
-
const leftKey = captureIdentityKey(left, `route "${left.routeId}"
|
|
210
|
-
const rightKey = captureIdentityKey(right, `route "${right.routeId}"
|
|
495
|
+
const leftKey = captureIdentityKey(left, `route "${left.routeId}"`, completeBaselineError);
|
|
496
|
+
const rightKey = captureIdentityKey(right, `route "${right.routeId}"`, completeBaselineError);
|
|
211
497
|
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
212
498
|
});
|
|
213
499
|
}
|
|
@@ -283,11 +569,7 @@ function gitOutput(args) {
|
|
|
283
569
|
export function resolveGitRef() {
|
|
284
570
|
const branch = gitOutput(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
285
571
|
return {
|
|
286
|
-
refBranch:
|
|
287
|
-
process.env.GITHUB_REF_NAME ||
|
|
288
|
-
process.env.GITHUB_HEAD_REF ||
|
|
289
|
-
(branch && branch !== 'HEAD' ? branch : undefined) ||
|
|
290
|
-
'main',
|
|
572
|
+
refBranch: process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || (branch && branch !== 'HEAD' ? branch : undefined) || 'main',
|
|
291
573
|
refSha: process.env.GITHUB_SHA || gitOutput(['rev-parse', 'HEAD']) || 'unknown'
|
|
292
574
|
};
|
|
293
575
|
}
|
|
@@ -296,8 +578,7 @@ function resolveHostedBaselinePublicationRef() {
|
|
|
296
578
|
const gitRef = resolveGitRef();
|
|
297
579
|
const refBranch = process.env.GITHUB_REF_NAME || gitRef.refBranch;
|
|
298
580
|
const refSha = process.env.GITHUB_SHA || gitRef.refSha;
|
|
299
|
-
const publicationWorkflowRef =
|
|
300
|
-
process.env.SNAPDRIFT_PUBLICATION_WORKFLOW_REF || process.env.GITHUB_WORKFLOW_REF;
|
|
581
|
+
const publicationWorkflowRef = process.env.SNAPDRIFT_PUBLICATION_WORKFLOW_REF || process.env.GITHUB_WORKFLOW_REF;
|
|
301
582
|
const sequenceValue = process.env.SNAPDRIFT_PUBLICATION_SEQUENCE || process.env.GITHUB_RUN_NUMBER;
|
|
302
583
|
const publicationSequence = Number(sequenceValue);
|
|
303
584
|
if (
|
|
@@ -310,10 +591,10 @@ function resolveHostedBaselinePublicationRef() {
|
|
|
310
591
|
) {
|
|
311
592
|
throw new Error(
|
|
312
593
|
'Hosted Snap baseline publication requires a branch, a resolved 40-character commit SHA, ' +
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
594
|
+
'a publication workflow identity, and a positive publication sequence. GitHub Actions ' +
|
|
595
|
+
'provides these as GITHUB_REF_NAME/GITHUB_SHA/GITHUB_WORKFLOW_REF/GITHUB_RUN_NUMBER; ' +
|
|
596
|
+
'other CI systems may use SNAPDRIFT_PUBLICATION_WORKFLOW_REF and ' +
|
|
597
|
+
'SNAPDRIFT_PUBLICATION_SEQUENCE.'
|
|
317
598
|
);
|
|
318
599
|
}
|
|
319
600
|
return { refBranch, refSha, publicationWorkflowRef, publicationSequence };
|
|
@@ -336,23 +617,6 @@ function getPackageVersion() {
|
|
|
336
617
|
const TAR_BLOCK_SIZE = 512;
|
|
337
618
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
338
619
|
|
|
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
620
|
/**
|
|
357
621
|
* Parse a plain ustar-style tar archive (512-byte headers, bodies padded to
|
|
358
622
|
* 512-byte blocks, terminated by zero blocks) into a map of entry name →
|
|
@@ -457,22 +721,6 @@ function resolveExportEngine(captureProfile) {
|
|
|
457
721
|
return { name: 'snap-hosted', version: 'unknown' };
|
|
458
722
|
}
|
|
459
723
|
|
|
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
724
|
/**
|
|
477
725
|
* SnapProvider — VisualProvider that delegates capture + diff to Snap's
|
|
478
726
|
* hosted /v1/visual/* API.
|
|
@@ -484,8 +732,10 @@ export class SnapProvider {
|
|
|
484
732
|
#snapConfig;
|
|
485
733
|
/** @type {typeof globalThis.fetch} */
|
|
486
734
|
#fetchFn;
|
|
487
|
-
/** @type {(ms: number) => Promise<void>} */
|
|
735
|
+
/** @type {(ms: number, signal?: AbortSignal) => Promise<void>} */
|
|
488
736
|
#sleepFn;
|
|
737
|
+
/** @type {() => number} */
|
|
738
|
+
#nowFn;
|
|
489
739
|
/** @type {(options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>} */
|
|
490
740
|
#localCaptureFn;
|
|
491
741
|
/** @type {string} */
|
|
@@ -499,14 +749,30 @@ export class SnapProvider {
|
|
|
499
749
|
* @param {SnapConfig} snapConfig — validated snap section from config
|
|
500
750
|
* @param {{
|
|
501
751
|
* fetchFn?: typeof globalThis.fetch,
|
|
502
|
-
* sleepFn?: (ms: number) => Promise<void>,
|
|
752
|
+
* sleepFn?: (ms: number, signal?: AbortSignal) => Promise<void>,
|
|
753
|
+
* nowFn?: () => number,
|
|
503
754
|
* localCaptureFn?: (options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>
|
|
504
755
|
* }} [options]
|
|
505
756
|
*/
|
|
506
757
|
constructor(snapConfig, options = {}) {
|
|
507
758
|
this.#snapConfig = snapConfig;
|
|
508
759
|
this.#fetchFn = options.fetchFn ?? globalThis.fetch;
|
|
509
|
-
this.#sleepFn =
|
|
760
|
+
this.#sleepFn =
|
|
761
|
+
options.sleepFn ??
|
|
762
|
+
((ms, signal) =>
|
|
763
|
+
new Promise((resolve, reject) => {
|
|
764
|
+
const timer = setTimeout(() => {
|
|
765
|
+
signal?.removeEventListener('abort', onAbort);
|
|
766
|
+
resolve();
|
|
767
|
+
}, ms);
|
|
768
|
+
const onAbort = () => {
|
|
769
|
+
clearTimeout(timer);
|
|
770
|
+
signal?.removeEventListener('abort', onAbort);
|
|
771
|
+
reject(signal?.reason ?? new Error('Sleep aborted.'));
|
|
772
|
+
};
|
|
773
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
774
|
+
}));
|
|
775
|
+
this.#nowFn = options.nowFn ?? monotonicNow;
|
|
510
776
|
this.#localCaptureFn = options.localCaptureFn ?? runBaselineCapture;
|
|
511
777
|
this.#apiUrl = (snapConfig.apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
512
778
|
this.#apiKey = this.#resolveApiKey(snapConfig);
|
|
@@ -529,9 +795,7 @@ export class SnapProvider {
|
|
|
529
795
|
*/
|
|
530
796
|
async capture(options) {
|
|
531
797
|
const { config } = await loadSnapdriftConfig(options.configPath);
|
|
532
|
-
const requestedRouteIds = options.routeIds
|
|
533
|
-
? [...options.routeIds]
|
|
534
|
-
: splitCommaList(process.env.SNAPDRIFT_ROUTE_IDS);
|
|
798
|
+
const requestedRouteIds = options.routeIds ? [...options.routeIds] : splitCommaList(process.env.SNAPDRIFT_ROUTE_IDS);
|
|
535
799
|
const { routes, selectedRouteIds } = selectConfiguredRoutes(config, requestedRouteIds);
|
|
536
800
|
const purpose = options.purpose ?? 'diff';
|
|
537
801
|
const configuredRouteIds = config.routes.map((route) => route.id);
|
|
@@ -542,9 +806,7 @@ export class SnapProvider {
|
|
|
542
806
|
}
|
|
543
807
|
|
|
544
808
|
const baselineRef = purpose === 'baseline' ? resolveHostedBaselinePublicationRef() : undefined;
|
|
545
|
-
const runContext = baselineRef
|
|
546
|
-
? { branch: baselineRef.refBranch, prHeadSha: baselineRef.refSha }
|
|
547
|
-
: this.#gitRunContext();
|
|
809
|
+
const runContext = baselineRef ? { branch: baselineRef.refBranch, prHeadSha: baselineRef.refSha } : this.#gitRunContext();
|
|
548
810
|
|
|
549
811
|
if (isLocalBaseUrl(config.baseUrl)) {
|
|
550
812
|
return this.#captureLocalAndUpload({
|
|
@@ -558,12 +820,14 @@ export class SnapProvider {
|
|
|
558
820
|
configuredRouteIds,
|
|
559
821
|
expectedCaptures,
|
|
560
822
|
runContext,
|
|
561
|
-
baselineRef
|
|
823
|
+
baselineRef,
|
|
824
|
+
comparisonPolicy: resolveComparisonPolicy(config)
|
|
562
825
|
});
|
|
563
826
|
}
|
|
564
827
|
|
|
565
828
|
const idempotencyKey = this.#generateIdempotencyKey();
|
|
566
829
|
const runId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
830
|
+
const operation = this.#createOperationDeadline('capture');
|
|
567
831
|
|
|
568
832
|
// For a diff run, resolve the latest accepted baseline so the Snap backend
|
|
569
833
|
// can attach it to this run's captures. Without a baselineId the server has
|
|
@@ -575,7 +839,7 @@ export class SnapProvider {
|
|
|
575
839
|
// Attaching one would make the backend diff the fresh captures against the
|
|
576
840
|
// prior baseline — wasteful at best, and on a dimension mismatch it errors
|
|
577
841
|
// the run and fails the publish.
|
|
578
|
-
const baselineId = purpose === 'diff' ? await this.#resolveLatestBaselineId() : null;
|
|
842
|
+
const baselineId = purpose === 'diff' ? await this.#resolveLatestBaselineId(operation) : null;
|
|
579
843
|
|
|
580
844
|
// We intentionally do NOT send captureProfileJson on run creation. SnapDrift
|
|
581
845
|
// submits routes for server-side rendering, so the render environment
|
|
@@ -586,46 +850,61 @@ export class SnapProvider {
|
|
|
586
850
|
// profile does not populate — which crashes the server with a 500. The
|
|
587
851
|
// render worker already defaults locale/timezone/viewport when no profile
|
|
588
852
|
// is present, so omitting it here is behaviour-preserving.
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
853
|
+
const comparisonPolicy = resolveComparisonPolicy(config);
|
|
854
|
+
const runResponse = await this.#request(
|
|
855
|
+
'POST',
|
|
856
|
+
`/v1/visual/projects/${this.#projectId}/runs`,
|
|
857
|
+
{
|
|
858
|
+
id: runId,
|
|
859
|
+
baseUrl: config.baseUrl,
|
|
860
|
+
trigger: 'ci',
|
|
861
|
+
...(baselineId ? { baselineId } : {}),
|
|
862
|
+
// A baseline run must not be diffed. Suppress the server's
|
|
863
|
+
// auto-resolve-by-branch so no prior baseline is attached.
|
|
864
|
+
...(purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
|
|
865
|
+
capturePlan: {
|
|
866
|
+
purpose: purpose === 'baseline' ? 'baseline' : 'diff',
|
|
867
|
+
...(baselineRef
|
|
868
|
+
? {
|
|
869
|
+
publicationWorkflowRef: baselineRef.publicationWorkflowRef,
|
|
870
|
+
publicationSequence: baselineRef.publicationSequence
|
|
871
|
+
}
|
|
872
|
+
: {}),
|
|
873
|
+
configuredRouteIds,
|
|
874
|
+
selectedRouteIds,
|
|
875
|
+
expectedCaptures
|
|
876
|
+
},
|
|
877
|
+
...runContext,
|
|
878
|
+
...(comparisonPolicy ? { comparisonPolicy } : {})
|
|
608
879
|
},
|
|
609
|
-
|
|
610
|
-
|
|
880
|
+
idempotencyKey,
|
|
881
|
+
operation
|
|
882
|
+
);
|
|
883
|
+
if (comparisonPolicy) {
|
|
884
|
+
assertComparisonPolicyAcknowledged(runResponse?.comparisonPolicy, comparisonPolicy, 'Snap run creation');
|
|
885
|
+
}
|
|
611
886
|
|
|
612
887
|
// Submit each route as a capture with its own idempotency key
|
|
613
888
|
for (const route of routes) {
|
|
614
889
|
const captureKey = this.#generateIdempotencyKey();
|
|
615
890
|
const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
616
891
|
const viewportDescriptor = resolveViewportDescriptor(route.viewport);
|
|
617
|
-
await this.#request(
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
892
|
+
await this.#request(
|
|
893
|
+
'POST',
|
|
894
|
+
`/v1/visual/runs/${runId}/captures`,
|
|
895
|
+
{
|
|
896
|
+
id: captureId,
|
|
897
|
+
routeId: route.id,
|
|
898
|
+
routePath: route.path,
|
|
899
|
+
viewportDescriptorJson: JSON.stringify(viewportDescriptor)
|
|
900
|
+
},
|
|
901
|
+
captureKey,
|
|
902
|
+
operation
|
|
903
|
+
);
|
|
623
904
|
}
|
|
624
905
|
|
|
625
906
|
// Write run metadata to a temp file so diff() can thread the run_id
|
|
626
|
-
const outDir = options.outDir
|
|
627
|
-
? path.resolve(options.outDir)
|
|
628
|
-
: await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-'));
|
|
907
|
+
const outDir = options.outDir ? path.resolve(options.outDir) : await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-'));
|
|
629
908
|
|
|
630
909
|
await fs.mkdir(outDir, { recursive: true });
|
|
631
910
|
|
|
@@ -637,6 +916,7 @@ export class SnapProvider {
|
|
|
637
916
|
runId,
|
|
638
917
|
projectId: this.#projectId,
|
|
639
918
|
purpose,
|
|
919
|
+
...(comparisonPolicy ? { comparisonPolicy } : {}),
|
|
640
920
|
...(baselineRef ? baselineRef : {}),
|
|
641
921
|
startedAt: new Date().toISOString(),
|
|
642
922
|
configuredRouteIds,
|
|
@@ -645,18 +925,25 @@ export class SnapProvider {
|
|
|
645
925
|
};
|
|
646
926
|
await Promise.all([
|
|
647
927
|
fs.writeFile(resultsPath, JSON.stringify(runMetadata, null, 2)),
|
|
648
|
-
fs.writeFile(
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
928
|
+
fs.writeFile(
|
|
929
|
+
manifestPath,
|
|
930
|
+
JSON.stringify(
|
|
931
|
+
{
|
|
932
|
+
generatedAt: new Date().toISOString(),
|
|
933
|
+
baseUrl: config.baseUrl,
|
|
934
|
+
screenshots: routes.map((r) => ({
|
|
935
|
+
id: r.id,
|
|
936
|
+
path: r.path,
|
|
937
|
+
viewport: r.viewport,
|
|
938
|
+
imagePath: `screenshots/${sanitizeRouteId(r.id)}.png`,
|
|
939
|
+
width: 0,
|
|
940
|
+
height: 0
|
|
941
|
+
}))
|
|
942
|
+
},
|
|
943
|
+
null,
|
|
944
|
+
2
|
|
945
|
+
)
|
|
946
|
+
)
|
|
660
947
|
]);
|
|
661
948
|
|
|
662
949
|
return {
|
|
@@ -680,14 +967,45 @@ export class SnapProvider {
|
|
|
680
967
|
throw new Error('SnapProvider.diff requires currentResultsPath from a prior capture() call.');
|
|
681
968
|
}
|
|
682
969
|
|
|
683
|
-
|
|
684
|
-
|
|
970
|
+
let metadata;
|
|
971
|
+
let metadataSource;
|
|
972
|
+
try {
|
|
973
|
+
metadataSource = await fs.readFile(currentResultsPath, 'utf-8');
|
|
974
|
+
} catch (error) {
|
|
975
|
+
throw hostedDiffError(
|
|
976
|
+
`could not read capture results metadata at "${currentResultsPath}". ` + `${error instanceof Error ? error.message : String(error)}`
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
try {
|
|
980
|
+
metadata = JSON.parse(metadataSource);
|
|
981
|
+
} catch (error) {
|
|
982
|
+
throw hostedDiffError(
|
|
983
|
+
`capture results metadata is not valid JSON; recapture with SnapDrift 0.8.2 or newer. ` + `${error instanceof Error ? error.message : String(error)}`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
const { config } = await loadSnapdriftConfig(options.configPath);
|
|
987
|
+
const diffPlan = validateHostedDiffMetadata(metadata, this.#projectId);
|
|
988
|
+
/** @type {ComparisonPolicy | undefined} */
|
|
989
|
+
let comparisonPolicy;
|
|
990
|
+
if (metadata.comparisonPolicy !== undefined) {
|
|
991
|
+
if (!isComparisonPolicy(metadata.comparisonPolicy)) {
|
|
992
|
+
throw hostedDiffError('capture results metadata has an invalid comparison policy; recapture with the updated SnapDrift client.');
|
|
993
|
+
}
|
|
994
|
+
comparisonPolicy = { ...metadata.comparisonPolicy };
|
|
995
|
+
}
|
|
685
996
|
|
|
686
997
|
// Poll until the run reaches a terminal state
|
|
687
|
-
const
|
|
998
|
+
const operation = this.#createOperationDeadline('diff');
|
|
999
|
+
const run = await this.#pollRun(diffPlan.runId, diffPlan.expectedCaptures.length, hostedDiffError, operation);
|
|
1000
|
+
if (!run || typeof run !== 'object' || run.id !== diffPlan.runId) {
|
|
1001
|
+
throw hostedDiffError(`Snap returned run "${run?.id || 'unknown'}" for requested run "${diffPlan.runId}".`);
|
|
1002
|
+
}
|
|
688
1003
|
|
|
689
|
-
|
|
690
|
-
|
|
1004
|
+
if (comparisonPolicy) {
|
|
1005
|
+
assertComparisonPolicyAcknowledged(run.comparisonPolicy, comparisonPolicy, 'Snap run retrieval');
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const summary = this.#mapRunToSummary(run, config, diffPlan, comparisonPolicy);
|
|
691
1009
|
|
|
692
1010
|
return {
|
|
693
1011
|
summary,
|
|
@@ -707,8 +1025,7 @@ export class SnapProvider {
|
|
|
707
1025
|
* @returns {Promise<PublishBaselineResult>}
|
|
708
1026
|
*/
|
|
709
1027
|
async publishBaseline(options) {
|
|
710
|
-
const resultsPath = options.resultsPath
|
|
711
|
-
?? (options.bundleDir ? path.join(path.resolve(options.bundleDir), 'results.json') : undefined);
|
|
1028
|
+
const resultsPath = options.resultsPath ?? (options.bundleDir ? path.join(path.resolve(options.bundleDir), 'results.json') : undefined);
|
|
712
1029
|
|
|
713
1030
|
// Snap-native baseline: poll the run that capture() submitted, then
|
|
714
1031
|
// create a baseline from the rendered S3 keys.
|
|
@@ -732,31 +1049,28 @@ export class SnapProvider {
|
|
|
732
1049
|
// and surfacing its body-shape error. See #109.
|
|
733
1050
|
throw new Error(
|
|
734
1051
|
`Cannot publish a Snap baseline: no run id found in ${resultsPath}. ` +
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1052
|
+
'A baseline is created from the object keys of a completed Snap run, so the capture step ' +
|
|
1053
|
+
'must have recorded one. Run `snapdrift baseline` (or actions/baseline) with ' +
|
|
1054
|
+
`provider "snap".${readError ? ` Could not read that file: ${readError instanceof Error ? readError.message : String(readError)}` : ''}`
|
|
738
1055
|
);
|
|
739
1056
|
}
|
|
740
1057
|
|
|
741
1058
|
{
|
|
742
1059
|
const { runId } = metadata;
|
|
743
1060
|
const idempotencyKey = `baseline-${runId}`;
|
|
1061
|
+
const operation = this.#createOperationDeadline('baseline publication');
|
|
744
1062
|
|
|
745
1063
|
if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length === 0) {
|
|
746
|
-
throw completeBaselineError(
|
|
747
|
-
'results metadata has no expected captures; capture again with SnapDrift 0.8.2 or newer.'
|
|
748
|
-
);
|
|
1064
|
+
throw completeBaselineError('results metadata has no expected captures; capture again with SnapDrift 0.8.2 or newer.');
|
|
749
1065
|
}
|
|
750
1066
|
|
|
751
1067
|
// Older Snap servers could mark a baseline run terminal after the first
|
|
752
1068
|
// submitted capture. Keep polling until the complete planned capture set
|
|
753
1069
|
// is present and terminal so a mixed-version rollout remains fail-closed.
|
|
754
|
-
const run = await this.#pollRun(runId, metadata.expectedCaptures.length);
|
|
1070
|
+
const run = await this.#pollRun(runId, metadata.expectedCaptures.length, undefined, operation);
|
|
755
1071
|
|
|
756
1072
|
if (run.id && run.id !== runId) {
|
|
757
|
-
throw new Error(
|
|
758
|
-
`Cannot publish a complete Snap baseline: requested source run ${runId}, but Snap returned ${run.id}.`
|
|
759
|
-
);
|
|
1073
|
+
throw new Error(`Cannot publish a complete Snap baseline: requested source run ${runId}, but Snap returned ${run.id}.`);
|
|
760
1074
|
}
|
|
761
1075
|
|
|
762
1076
|
// Hosted baseline runs intentionally use `new` because they omit a
|
|
@@ -767,8 +1081,8 @@ export class SnapProvider {
|
|
|
767
1081
|
if (run.status !== BASELINE_RUN_SUCCESS_STATUS) {
|
|
768
1082
|
throw new Error(
|
|
769
1083
|
`Snap baseline run ${runId} ended with status "${run.status || 'unknown'}"; ` +
|
|
770
|
-
|
|
771
|
-
|
|
1084
|
+
`complete baseline publication requires "${BASELINE_RUN_SUCCESS_STATUS}". ` +
|
|
1085
|
+
'Check the Snap dashboard for capture error details.'
|
|
772
1086
|
);
|
|
773
1087
|
}
|
|
774
1088
|
|
|
@@ -794,7 +1108,8 @@ export class SnapProvider {
|
|
|
794
1108
|
manifestJson,
|
|
795
1109
|
captureProfileJson: JSON.stringify(this.#buildCaptureProfile())
|
|
796
1110
|
},
|
|
797
|
-
idempotencyKey
|
|
1111
|
+
idempotencyKey,
|
|
1112
|
+
operation
|
|
798
1113
|
);
|
|
799
1114
|
|
|
800
1115
|
const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
|
|
@@ -805,8 +1120,8 @@ export class SnapProvider {
|
|
|
805
1120
|
// No resultsPath and no bundleDir: there is no run to build a baseline from.
|
|
806
1121
|
throw new Error(
|
|
807
1122
|
'Cannot publish a Snap baseline: neither resultsPath nor bundleDir was provided, so there ' +
|
|
808
|
-
|
|
809
|
-
|
|
1123
|
+
'is no Snap run to harvest object keys from. Run `snapdrift baseline` (or actions/baseline) ' +
|
|
1124
|
+
'with provider "snap", which captures first and passes the run results through.'
|
|
810
1125
|
);
|
|
811
1126
|
}
|
|
812
1127
|
|
|
@@ -817,8 +1132,9 @@ export class SnapProvider {
|
|
|
817
1132
|
* @returns {Promise<BaselineData | null>}
|
|
818
1133
|
*/
|
|
819
1134
|
async fetchLatestBaseline(_options) {
|
|
1135
|
+
const operation = this.#createOperationDeadline('baseline lookup');
|
|
820
1136
|
try {
|
|
821
|
-
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest
|
|
1137
|
+
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`, undefined, undefined, operation);
|
|
822
1138
|
|
|
823
1139
|
const runDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
|
|
824
1140
|
const resultsPath = path.join(runDir, 'results.json');
|
|
@@ -855,7 +1171,7 @@ export class SnapProvider {
|
|
|
855
1171
|
* Build a PR comment body from a diff summary.
|
|
856
1172
|
* SnapProvider includes the dashboard URL from the summary when available.
|
|
857
1173
|
*
|
|
858
|
-
* @param {
|
|
1174
|
+
* @param {import('@snapdrift/manifest').VisualReportSummary} summary
|
|
859
1175
|
* @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
|
|
860
1176
|
* @returns {string}
|
|
861
1177
|
*/
|
|
@@ -875,8 +1191,15 @@ export class SnapProvider {
|
|
|
875
1191
|
* @returns {Promise<object | null>} Baseline data if found, null on 404
|
|
876
1192
|
*/
|
|
877
1193
|
async checkBaselineExists(headSha) {
|
|
1194
|
+
const operation = this.#createOperationDeadline('baseline existence check');
|
|
878
1195
|
try {
|
|
879
|
-
const baseline = await this.#request(
|
|
1196
|
+
const baseline = await this.#request(
|
|
1197
|
+
'GET',
|
|
1198
|
+
`/v1/visual/projects/${this.#projectId}/baselines/latest?sha=${encodeURIComponent(headSha)}`,
|
|
1199
|
+
undefined,
|
|
1200
|
+
undefined,
|
|
1201
|
+
operation
|
|
1202
|
+
);
|
|
880
1203
|
return baseline;
|
|
881
1204
|
} catch (error) {
|
|
882
1205
|
if (error instanceof SnapApiError && error.status === 404) {
|
|
@@ -919,29 +1242,30 @@ export class SnapProvider {
|
|
|
919
1242
|
*/
|
|
920
1243
|
async exportBaselines(_options) {
|
|
921
1244
|
const exportPath = `/v1/visual/projects/${this.#projectId}/export`;
|
|
1245
|
+
const operation = this.#createOperationDeadline('baseline export');
|
|
922
1246
|
let archive;
|
|
923
1247
|
try {
|
|
924
|
-
archive = await this.#requestBinary('GET', exportPath);
|
|
1248
|
+
archive = await this.#requestBinary('GET', exportPath, operation);
|
|
925
1249
|
} catch (error) {
|
|
926
1250
|
if (error instanceof SnapApiError) {
|
|
927
1251
|
if (error.status === 403) {
|
|
928
1252
|
throw new Error(
|
|
929
1253
|
`Snap refused the export (403): the API key does not have the "visual:export" scope. ` +
|
|
930
|
-
|
|
1254
|
+
`Issue a key with visual:export access for project "${this.#projectId}" and retry.`,
|
|
931
1255
|
{ cause: error }
|
|
932
1256
|
);
|
|
933
1257
|
}
|
|
934
1258
|
if (error.status === 404) {
|
|
935
1259
|
throw new Error(
|
|
936
1260
|
`Snap project "${this.#projectId}" was not found (404). ` +
|
|
937
|
-
|
|
1261
|
+
`Check snap.projectId in snapdrift.json (or GITHUB_REPOSITORY when projectId is "auto").`,
|
|
938
1262
|
{ cause: error }
|
|
939
1263
|
);
|
|
940
1264
|
}
|
|
941
1265
|
if (error.status === 413) {
|
|
942
1266
|
throw new Error(
|
|
943
1267
|
`Snap export for project "${this.#projectId}" is too large (413): ${error.message}. ` +
|
|
944
|
-
|
|
1268
|
+
`The export endpoint caps the number of accepted baselines and total archive bytes.`,
|
|
945
1269
|
{ cause: error }
|
|
946
1270
|
);
|
|
947
1271
|
}
|
|
@@ -958,8 +1282,7 @@ export class SnapProvider {
|
|
|
958
1282
|
const baselines = Array.isArray(exportManifest.baselines) ? exportManifest.baselines : [];
|
|
959
1283
|
if (baselines.length === 0) {
|
|
960
1284
|
throw new Error(
|
|
961
|
-
`Snap project "${this.#projectId}" has no accepted baselines to export. ` +
|
|
962
|
-
`Publish a baseline first (snapdrift baseline with provider: "snap").`
|
|
1285
|
+
`Snap project "${this.#projectId}" has no accepted baselines to export. ` + `Publish a baseline first (snapdrift baseline with provider: "snap").`
|
|
963
1286
|
);
|
|
964
1287
|
}
|
|
965
1288
|
|
|
@@ -975,21 +1298,30 @@ export class SnapProvider {
|
|
|
975
1298
|
if (!baseline.sourceManifest) {
|
|
976
1299
|
throw new Error(
|
|
977
1300
|
`Snap baseline "${baseline.id}" predates manifest tracking (no source manifest was recorded ` +
|
|
978
|
-
|
|
979
|
-
|
|
1301
|
+
`at publish time) and cannot be migrated. Publish a fresh baseline with a current SnapDrift ` +
|
|
1302
|
+
`version, then re-run the migration.`
|
|
980
1303
|
);
|
|
981
1304
|
}
|
|
982
1305
|
const routes = Array.isArray(baseline.sourceManifest.routes) ? baseline.sourceManifest.routes : null;
|
|
983
1306
|
if (!routes) {
|
|
984
|
-
throw new Error(
|
|
985
|
-
`Snap baseline "${baseline.id}" has an unrecognised source manifest format ` +
|
|
986
|
-
`(expected a "routes" array) and cannot be migrated.`
|
|
987
|
-
);
|
|
1307
|
+
throw new Error(`Snap baseline "${baseline.id}" has an unrecognised source manifest format ` + `(expected a "routes" array) and cannot be migrated.`);
|
|
988
1308
|
}
|
|
989
1309
|
|
|
990
|
-
const archivePathByKey = new Map(
|
|
991
|
-
|
|
992
|
-
|
|
1310
|
+
const archivePathByKey = new Map((baseline.objects || []).map((object) => [object.sourceKey, object.archivePath]));
|
|
1311
|
+
|
|
1312
|
+
const sourceLabel = `Snap baseline "${baseline.id}" source manifest`;
|
|
1313
|
+
/** @type {Map<string, unknown[]>} */
|
|
1314
|
+
const routeIdsByExtension = new Map();
|
|
1315
|
+
for (const route of routes) {
|
|
1316
|
+
const archivePath = route && route.objectKey ? archivePathByKey.get(route.objectKey) : undefined;
|
|
1317
|
+
const extension = typeof archivePath === 'string' ? path.extname(archivePath) || '.png' : '.png';
|
|
1318
|
+
const routeIds = routeIdsByExtension.get(extension) || [];
|
|
1319
|
+
routeIds.push(route?.routeId);
|
|
1320
|
+
routeIdsByExtension.set(extension, routeIds);
|
|
1321
|
+
}
|
|
1322
|
+
for (const [extension, routeIds] of routeIdsByExtension) {
|
|
1323
|
+
assertUniqueRouteIdFilenames(routeIds, sourceLabel, extension);
|
|
1324
|
+
}
|
|
993
1325
|
|
|
994
1326
|
/** @type {Array<{ filename: string, data: Buffer }>} */
|
|
995
1327
|
const screenshots = [];
|
|
@@ -1001,8 +1333,7 @@ export class SnapProvider {
|
|
|
1001
1333
|
const bytes = archivePath ? entries.get(archivePath) : undefined;
|
|
1002
1334
|
if (!bytes) {
|
|
1003
1335
|
process.stderr.write(
|
|
1004
|
-
`[SnapDrift] Warning: Snap export has no image for route "${route.routeId}" ` +
|
|
1005
|
-
`in baseline ${baseline.id}. Skipping the route.\n`
|
|
1336
|
+
`[SnapDrift] Warning: Snap export has no image for route "${route.routeId}" ` + `in baseline ${baseline.id}. Skipping the route.\n`
|
|
1006
1337
|
);
|
|
1007
1338
|
continue;
|
|
1008
1339
|
}
|
|
@@ -1013,15 +1344,15 @@ export class SnapProvider {
|
|
|
1013
1344
|
// exactly; otherwise keep custom dimensions. This round-trips with the
|
|
1014
1345
|
// descriptor expansion capture() applies on the way up.
|
|
1015
1346
|
const presetName = hasDescriptorDims ? viewportHash(descriptor) : null;
|
|
1016
|
-
const dimensions = readPngDimensions(bytes)
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
const extension = path.extname(archivePath) || '.png';
|
|
1347
|
+
const dimensions = readPngDimensions(bytes) ?? (hasDescriptorDims ? { width: descriptor.width, height: descriptor.height } : { width: 1, height: 1 });
|
|
1348
|
+
const viewport =
|
|
1349
|
+
presetName && VIEWPORT_PRESETS[presetName]
|
|
1350
|
+
? presetName
|
|
1351
|
+
: hasDescriptorDims
|
|
1352
|
+
? { width: descriptor.width, height: descriptor.height }
|
|
1353
|
+
: { width: dimensions.width, height: dimensions.height };
|
|
1354
|
+
|
|
1355
|
+
const extension = typeof archivePath === 'string' ? path.extname(archivePath) || '.png' : '.png';
|
|
1025
1356
|
const filename = `${sanitizeRouteId(route.routeId)}${extension}`;
|
|
1026
1357
|
screenshots.push({ filename, data: Buffer.from(bytes) });
|
|
1027
1358
|
manifestEntries.push({
|
|
@@ -1035,15 +1366,10 @@ export class SnapProvider {
|
|
|
1035
1366
|
}
|
|
1036
1367
|
|
|
1037
1368
|
if (screenshots.length === 0) {
|
|
1038
|
-
throw new Error(
|
|
1039
|
-
`Snap baseline "${baseline.id}" contained no exportable screenshots — ` +
|
|
1040
|
-
`the export archive had no image bytes for any of its routes.`
|
|
1041
|
-
);
|
|
1369
|
+
throw new Error(`Snap baseline "${baseline.id}" contained no exportable screenshots — ` + `the export archive had no image bytes for any of its routes.`);
|
|
1042
1370
|
}
|
|
1043
1371
|
|
|
1044
|
-
const captureProfile = safeJsonParse(
|
|
1045
|
-
entries.get(`${baseline.id}/capture_profile.json`)?.toString('utf-8')
|
|
1046
|
-
) ?? {};
|
|
1372
|
+
const captureProfile = safeJsonParse(entries.get(`${baseline.id}/capture_profile.json`)?.toString('utf-8')) ?? {};
|
|
1047
1373
|
const engine = resolveExportEngine(captureProfile);
|
|
1048
1374
|
|
|
1049
1375
|
const generatedAt = new Date().toISOString();
|
|
@@ -1124,7 +1450,7 @@ export class SnapProvider {
|
|
|
1124
1450
|
if (!repo) {
|
|
1125
1451
|
throw new Error(
|
|
1126
1452
|
'Snap project ID is set to "auto" but GITHUB_REPOSITORY is not set. ' +
|
|
1127
|
-
|
|
1453
|
+
'Set the GITHUB_REPOSITORY environment variable or provide an explicit snap.projectId.'
|
|
1128
1454
|
);
|
|
1129
1455
|
}
|
|
1130
1456
|
return repoSlugToProjectId(repo);
|
|
@@ -1153,9 +1479,9 @@ export class SnapProvider {
|
|
|
1153
1479
|
*
|
|
1154
1480
|
* @returns {Promise<string | null>}
|
|
1155
1481
|
*/
|
|
1156
|
-
async #resolveLatestBaselineId() {
|
|
1482
|
+
async #resolveLatestBaselineId(operation = this.#createOperationDeadline('baseline lookup')) {
|
|
1157
1483
|
try {
|
|
1158
|
-
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest
|
|
1484
|
+
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`, undefined, undefined, operation);
|
|
1159
1485
|
return baseline?.id ?? null;
|
|
1160
1486
|
} catch (error) {
|
|
1161
1487
|
if (error instanceof SnapApiError && error.status === 404) {
|
|
@@ -1185,6 +1511,324 @@ export class SnapProvider {
|
|
|
1185
1511
|
return crypto.randomUUID();
|
|
1186
1512
|
}
|
|
1187
1513
|
|
|
1514
|
+
/** @param {string} [label] @returns {OperationDeadline} */
|
|
1515
|
+
#createOperationDeadline(label = 'Snap operation') {
|
|
1516
|
+
return { deadline: this.#nowFn() + MAX_OPERATION_DURATION_MS, label };
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/** @param {OperationDeadline} operation @returns {number} */
|
|
1520
|
+
#remainingOperationMs(operation) {
|
|
1521
|
+
return operation.deadline - this.#nowFn();
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* @param {string} requestPath
|
|
1526
|
+
* @param {OperationDeadline} operation
|
|
1527
|
+
* @returns {SnapUnavailableError}
|
|
1528
|
+
*/
|
|
1529
|
+
#operationDeadlineError(requestPath, operation) {
|
|
1530
|
+
return new SnapUnavailableError(
|
|
1531
|
+
`Snap ${operation.label} operation deadline exceeded while requesting ${requestPath}; ` + `the ${OPERATION_LIMIT_TEXT} Snap operation limit was reached`
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* @param {string} requestPath
|
|
1537
|
+
* @param {number} timeoutMs
|
|
1538
|
+
* @param {OperationDeadline} operation
|
|
1539
|
+
* @param {number} requestLimitMs
|
|
1540
|
+
* @returns {Error}
|
|
1541
|
+
*/
|
|
1542
|
+
#requestTimeoutError(requestPath, timeoutMs, operation, requestLimitMs) {
|
|
1543
|
+
const error = new Error(
|
|
1544
|
+
`Snap ${operation.label} request ${requestPath} timed out after ` +
|
|
1545
|
+
`${Math.max(0, Math.round(timeoutMs))}ms (request limit ${requestLimitMs}ms; ` +
|
|
1546
|
+
`operation limit ${OPERATION_LIMIT_TEXT})`
|
|
1547
|
+
);
|
|
1548
|
+
error.name = 'SnapTransportTimeoutError';
|
|
1549
|
+
return error;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/** @param {number} attempt @returns {number} */
|
|
1553
|
+
#retryDelay(attempt) {
|
|
1554
|
+
return Math.min(INITIAL_RETRY_DELAY_MS * RETRY_MULTIPLIER ** (attempt - 1), RETRY_DELAY_CAP_MS);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/** @param {string} requestPath @param {number} delayMs @param {OperationDeadline} operation @returns {SnapUnavailableError} */
|
|
1558
|
+
#sleepTimeoutError(requestPath, delayMs, operation) {
|
|
1559
|
+
return new SnapUnavailableError(
|
|
1560
|
+
`Snap ${operation.label} wait for ${requestPath} timed out after ${delayMs}ms ` + `before the ${OPERATION_LIMIT_TEXT} operation deadline`
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Read and classify an HTTP error response under the current deadline.
|
|
1566
|
+
*
|
|
1567
|
+
* A received 4xx remains non-retryable even when its diagnostic body cannot
|
|
1568
|
+
* be read. Retryable 5xx body failures are returned to the transport loop so
|
|
1569
|
+
* they retain the normal availability semantics.
|
|
1570
|
+
*
|
|
1571
|
+
* @param {any} response
|
|
1572
|
+
* @param {string} requestPath
|
|
1573
|
+
* @param {AbortController} controller
|
|
1574
|
+
* @param {number} attemptDeadline
|
|
1575
|
+
* @param {OperationDeadline} operation
|
|
1576
|
+
* @param {number} requestLimitMs
|
|
1577
|
+
* @returns {Promise<SnapApiError>}
|
|
1578
|
+
*/
|
|
1579
|
+
async #readApiError(response, requestPath, controller, attemptDeadline, operation, requestLimitMs) {
|
|
1580
|
+
let errorBody;
|
|
1581
|
+
try {
|
|
1582
|
+
errorBody = await this.#readResponseBody(response, requestPath, controller, attemptDeadline, operation, requestLimitMs, () => response.text());
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
if (response.status < 400 || response.status >= 500) throw error;
|
|
1585
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1586
|
+
const reason =
|
|
1587
|
+
error instanceof SnapUnavailableError || error?.name === 'SnapTransportTimeoutError'
|
|
1588
|
+
? `diagnostic body timed out (${detail})`
|
|
1589
|
+
: `diagnostic body unavailable (${detail})`;
|
|
1590
|
+
return new SnapApiError(response.status, `Snap API ${response.status}: ${reason}`, requestPath);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
let detail = '';
|
|
1594
|
+
try {
|
|
1595
|
+
const parsed = JSON.parse(errorBody);
|
|
1596
|
+
detail = parsed?.error || parsed?.message || '';
|
|
1597
|
+
} catch {
|
|
1598
|
+
/* use default message */
|
|
1599
|
+
}
|
|
1600
|
+
return new SnapApiError(response.status, detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`, requestPath);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/**
|
|
1604
|
+
* Read a response body under the current attempt and operation deadlines.
|
|
1605
|
+
*
|
|
1606
|
+
* @template T
|
|
1607
|
+
* @param {any} response
|
|
1608
|
+
* @param {string} requestPath
|
|
1609
|
+
* @param {AbortController} controller
|
|
1610
|
+
* @param {number} attemptDeadline
|
|
1611
|
+
* @param {OperationDeadline} operation
|
|
1612
|
+
* @param {number} requestLimitMs
|
|
1613
|
+
* @param {() => Promise<T>} reader
|
|
1614
|
+
* @returns {Promise<T>}
|
|
1615
|
+
*/
|
|
1616
|
+
async #readResponseBody(response, requestPath, controller, attemptDeadline, operation, requestLimitMs, reader) {
|
|
1617
|
+
const now = this.#nowFn();
|
|
1618
|
+
const attemptRemaining = attemptDeadline - now;
|
|
1619
|
+
const operationRemaining = this.#remainingOperationMs(operation);
|
|
1620
|
+
if (operationRemaining <= 0) {
|
|
1621
|
+
controller.abort();
|
|
1622
|
+
cancelResponseBody(response);
|
|
1623
|
+
throw this.#operationDeadlineError(requestPath, operation);
|
|
1624
|
+
}
|
|
1625
|
+
if (attemptRemaining <= 0) {
|
|
1626
|
+
controller.abort();
|
|
1627
|
+
cancelResponseBody(response);
|
|
1628
|
+
throw this.#requestTimeoutError(requestPath, requestLimitMs, operation, requestLimitMs);
|
|
1629
|
+
}
|
|
1630
|
+
const remaining = Math.min(attemptRemaining, operationRemaining);
|
|
1631
|
+
return runWithTimeout(
|
|
1632
|
+
reader,
|
|
1633
|
+
remaining,
|
|
1634
|
+
() => {
|
|
1635
|
+
controller.abort();
|
|
1636
|
+
cancelResponseBody(response);
|
|
1637
|
+
},
|
|
1638
|
+
() =>
|
|
1639
|
+
this.#remainingOperationMs(operation) <= 0
|
|
1640
|
+
? this.#operationDeadlineError(requestPath, operation)
|
|
1641
|
+
: this.#requestTimeoutError(requestPath, remaining, operation, requestLimitMs)
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* Sleep for a retry or poll interval without exceeding the operation budget.
|
|
1647
|
+
*
|
|
1648
|
+
* @param {number} delayMs
|
|
1649
|
+
* @param {OperationDeadline} operation
|
|
1650
|
+
* @param {string} requestPath
|
|
1651
|
+
* @returns {Promise<void>}
|
|
1652
|
+
*/
|
|
1653
|
+
async #sleepWithinOperation(delayMs, operation, requestPath) {
|
|
1654
|
+
const remaining = this.#remainingOperationMs(operation);
|
|
1655
|
+
if (remaining <= 0) {
|
|
1656
|
+
throw this.#operationDeadlineError(requestPath, operation);
|
|
1657
|
+
}
|
|
1658
|
+
const delay = Math.min(delayMs, remaining);
|
|
1659
|
+
const controller = new AbortController();
|
|
1660
|
+
await runWithTimeout(
|
|
1661
|
+
() => this.#sleepFn(delay, controller.signal),
|
|
1662
|
+
// Keep a one-millisecond watchdog margin so a sleep timer scheduled by
|
|
1663
|
+
// an async injected sleepFn can settle before the watchdog at the same
|
|
1664
|
+
// nominal deadline. The signal aborts the default timer on timeout.
|
|
1665
|
+
delay + 1,
|
|
1666
|
+
() => controller.abort(),
|
|
1667
|
+
() => this.#sleepTimeoutError(requestPath, delay, operation)
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* Shared JSON/binary transport with retries and bounded request bodies.
|
|
1673
|
+
*
|
|
1674
|
+
* @param {{
|
|
1675
|
+
* method: string,
|
|
1676
|
+
* requestPath: string,
|
|
1677
|
+
* body?: object,
|
|
1678
|
+
* idempotencyKey?: string,
|
|
1679
|
+
* contentType?: string,
|
|
1680
|
+
* accept?: string,
|
|
1681
|
+
* operation: OperationDeadline,
|
|
1682
|
+
* requestTimeoutMs: number,
|
|
1683
|
+
* decode: (response: any, controller: AbortController, attemptDeadline: number, operation: OperationDeadline, requestLimitMs: number) => Promise<any>
|
|
1684
|
+
* }} options
|
|
1685
|
+
* @returns {Promise<any>}
|
|
1686
|
+
*/
|
|
1687
|
+
async #requestTransport(options) {
|
|
1688
|
+
const serializedBody = options.body ? JSON.stringify(options.body) : undefined;
|
|
1689
|
+
const requestHeaders = {
|
|
1690
|
+
Authorization: `Bearer ${this.#apiKey}`
|
|
1691
|
+
};
|
|
1692
|
+
if (options.accept) {
|
|
1693
|
+
requestHeaders['Accept'] = options.accept;
|
|
1694
|
+
}
|
|
1695
|
+
if (options.contentType) {
|
|
1696
|
+
requestHeaders['Content-Type'] = options.contentType;
|
|
1697
|
+
}
|
|
1698
|
+
if (options.idempotencyKey && options.method === 'POST') {
|
|
1699
|
+
requestHeaders['Idempotency-Key'] = options.idempotencyKey;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
1703
|
+
const remaining = this.#remainingOperationMs(options.operation);
|
|
1704
|
+
if (remaining <= 0) {
|
|
1705
|
+
return this.#handleUnavailable(this.#operationDeadlineError(options.requestPath, options.operation));
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
const attemptBudget = Math.min(options.requestTimeoutMs, remaining);
|
|
1709
|
+
const attemptDeadline = this.#nowFn() + attemptBudget;
|
|
1710
|
+
const controller = new AbortController();
|
|
1711
|
+
try {
|
|
1712
|
+
const response = await runWithTimeout(
|
|
1713
|
+
() =>
|
|
1714
|
+
this.#fetchFn(`${this.#apiUrl}${options.requestPath}`, {
|
|
1715
|
+
method: options.method,
|
|
1716
|
+
headers: { ...requestHeaders },
|
|
1717
|
+
body: serializedBody,
|
|
1718
|
+
signal: controller.signal
|
|
1719
|
+
}),
|
|
1720
|
+
attemptBudget,
|
|
1721
|
+
() => controller.abort(),
|
|
1722
|
+
() => this.#requestTimeoutError(options.requestPath, attemptBudget, options.operation, options.requestTimeoutMs)
|
|
1723
|
+
);
|
|
1724
|
+
|
|
1725
|
+
if (response.ok) {
|
|
1726
|
+
return await options.decode(response, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// 4xx — non-retryable when the diagnostic body is available. A body
|
|
1730
|
+
// read timeout still preserves the received HTTP status, rather than
|
|
1731
|
+
// turning an already-classified API response into an availability retry.
|
|
1732
|
+
if (response.status >= 400 && response.status < 500) {
|
|
1733
|
+
throw await this.#readApiError(response, options.requestPath, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// 5xx — retry with backoff unless this is the last attempt. Cancel
|
|
1737
|
+
// discarded response bodies so a slow server cannot retain a stream.
|
|
1738
|
+
if (attempt < MAX_RETRIES) {
|
|
1739
|
+
cancelResponseBody(response);
|
|
1740
|
+
await this.#sleepWithinOperation(this.#retryDelay(attempt), options.operation, options.requestPath);
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
throw await this.#readApiError(response, options.requestPath, controller, attemptDeadline, options.operation, options.requestTimeoutMs);
|
|
1745
|
+
} catch (error) {
|
|
1746
|
+
if (error instanceof SnapSkipError || error instanceof SnapFallbackError) {
|
|
1747
|
+
throw error;
|
|
1748
|
+
}
|
|
1749
|
+
if (error instanceof SnapApiError && error.status >= 400 && error.status < 500) {
|
|
1750
|
+
throw error;
|
|
1751
|
+
}
|
|
1752
|
+
if (error instanceof SnapApiError && error.status >= 500) {
|
|
1753
|
+
return this.#handleUnavailable(error);
|
|
1754
|
+
}
|
|
1755
|
+
if (error instanceof SnapUnavailableError) {
|
|
1756
|
+
return this.#handleUnavailable(error);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
if (this.#remainingOperationMs(options.operation) <= 0) {
|
|
1760
|
+
return this.#handleUnavailable(this.#operationDeadlineError(options.requestPath, options.operation));
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
if (attempt < MAX_RETRIES) {
|
|
1764
|
+
try {
|
|
1765
|
+
await this.#sleepWithinOperation(this.#retryDelay(attempt), options.operation, options.requestPath);
|
|
1766
|
+
} catch (retryError) {
|
|
1767
|
+
return this.#handleUnavailable(retryError instanceof Error ? retryError : new SnapUnavailableError(String(retryError)));
|
|
1768
|
+
}
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1773
|
+
return this.#handleUnavailable(new SnapUnavailableError(`Snap request ${options.requestPath} failed after ${MAX_RETRIES} attempts: ${detail}`));
|
|
1774
|
+
} finally {
|
|
1775
|
+
controller.abort();
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
return this.#handleUnavailable(this.#operationDeadlineError(options.requestPath, options.operation));
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/**
|
|
1783
|
+
* Make a JSON request with retry and operation deadlines.
|
|
1784
|
+
*
|
|
1785
|
+
* @param {string} method
|
|
1786
|
+
* @param {string} requestPath
|
|
1787
|
+
* @param {object} [body]
|
|
1788
|
+
* @param {string} [idempotencyKey]
|
|
1789
|
+
* @param {OperationDeadline} [operation]
|
|
1790
|
+
* @returns {Promise<any>}
|
|
1791
|
+
*/
|
|
1792
|
+
async #request(method, requestPath, body, idempotencyKey, operation = this.#createOperationDeadline('Snap request')) {
|
|
1793
|
+
return this.#requestTransport({
|
|
1794
|
+
method,
|
|
1795
|
+
requestPath,
|
|
1796
|
+
body,
|
|
1797
|
+
idempotencyKey,
|
|
1798
|
+
contentType: 'application/json',
|
|
1799
|
+
accept: 'application/json',
|
|
1800
|
+
operation,
|
|
1801
|
+
requestTimeoutMs: JSON_REQUEST_TIMEOUT_MS,
|
|
1802
|
+
decode: async (response, controller, attemptDeadline, requestOperation, requestLimitMs) => {
|
|
1803
|
+
const text = await this.#readResponseBody(response, requestPath, controller, attemptDeadline, requestOperation, requestLimitMs, () => response.text());
|
|
1804
|
+
return text ? JSON.parse(text) : {};
|
|
1805
|
+
}
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* Make a binary request with retry and operation deadlines.
|
|
1811
|
+
*
|
|
1812
|
+
* @param {string} method
|
|
1813
|
+
* @param {string} requestPath
|
|
1814
|
+
* @param {OperationDeadline} [operation]
|
|
1815
|
+
* @returns {Promise<Buffer>}
|
|
1816
|
+
*/
|
|
1817
|
+
async #requestBinary(method, requestPath, operation = this.#createOperationDeadline('binary export')) {
|
|
1818
|
+
return this.#requestTransport({
|
|
1819
|
+
method,
|
|
1820
|
+
requestPath,
|
|
1821
|
+
operation,
|
|
1822
|
+
requestTimeoutMs: BINARY_REQUEST_TIMEOUT_MS,
|
|
1823
|
+
decode: async (response, controller, attemptDeadline, requestOperation, requestLimitMs) => {
|
|
1824
|
+
const bytes = await this.#readResponseBody(response, requestPath, controller, attemptDeadline, requestOperation, requestLimitMs, () =>
|
|
1825
|
+
response.arrayBuffer()
|
|
1826
|
+
);
|
|
1827
|
+
return Buffer.from(bytes);
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1188
1832
|
/**
|
|
1189
1833
|
* @param {{
|
|
1190
1834
|
* configPath?: string,
|
|
@@ -1195,10 +1839,11 @@ export class SnapProvider {
|
|
|
1195
1839
|
* selectedRouteIds: string[],
|
|
1196
1840
|
* purpose: 'baseline' | 'capture' | 'diff',
|
|
1197
1841
|
* configuredRouteIds: string[],
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1842
|
+
* expectedCaptures: import('../types/visual-diff-types').SnapExpectedCaptureIdentity[],
|
|
1843
|
+
* runContext: { branch?: string, prHeadSha?: string },
|
|
1844
|
+
* baselineRef?: { refBranch: string, refSha: string, publicationWorkflowRef: string, publicationSequence: number },
|
|
1845
|
+
* comparisonPolicy?: ComparisonPolicy
|
|
1846
|
+
* }} options
|
|
1202
1847
|
* @returns {Promise<CaptureResult>}
|
|
1203
1848
|
*/
|
|
1204
1849
|
async #captureLocalAndUpload(options) {
|
|
@@ -1226,59 +1871,82 @@ export class SnapProvider {
|
|
|
1226
1871
|
});
|
|
1227
1872
|
|
|
1228
1873
|
const runId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
1874
|
+
const operation = this.#createOperationDeadline('capture');
|
|
1229
1875
|
// A baseline run establishes new ground truth and must not be diffed, so we
|
|
1230
1876
|
// omit the baselineId. Captures then settle to "new" server-side (no
|
|
1231
1877
|
// comparison), and publishBaseline harvests their object keys. A diff run
|
|
1232
1878
|
// attaches the latest baseline so the backend can compare against it.
|
|
1233
|
-
const baselineId = options.purpose === 'diff' ? await this.#resolveLatestBaselineId() : null;
|
|
1234
|
-
|
|
1235
|
-
await this.#request(
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
...(options.
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1879
|
+
const baselineId = options.purpose === 'diff' ? await this.#resolveLatestBaselineId(operation) : null;
|
|
1880
|
+
|
|
1881
|
+
const runResponse = await this.#request(
|
|
1882
|
+
'POST',
|
|
1883
|
+
`/v1/visual/projects/${this.#projectId}/runs`,
|
|
1884
|
+
{
|
|
1885
|
+
id: runId,
|
|
1886
|
+
baseUrl: options.config.baseUrl,
|
|
1887
|
+
trigger: 'ci',
|
|
1888
|
+
...(baselineId ? { baselineId } : {}),
|
|
1889
|
+
// A baseline run must not be diffed. Suppress the server's
|
|
1890
|
+
// auto-resolve-by-branch so no prior baseline is attached.
|
|
1891
|
+
...(options.purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
|
|
1892
|
+
capturePlan: {
|
|
1893
|
+
purpose: options.purpose === 'baseline' ? 'baseline' : 'diff',
|
|
1894
|
+
...(options.baselineRef
|
|
1895
|
+
? {
|
|
1896
|
+
publicationWorkflowRef: options.baselineRef.publicationWorkflowRef,
|
|
1897
|
+
publicationSequence: options.baselineRef.publicationSequence
|
|
1898
|
+
}
|
|
1899
|
+
: {}),
|
|
1900
|
+
configuredRouteIds: options.configuredRouteIds,
|
|
1901
|
+
selectedRouteIds: options.selectedRouteIds,
|
|
1902
|
+
expectedCaptures: options.expectedCaptures
|
|
1903
|
+
},
|
|
1904
|
+
...options.runContext,
|
|
1905
|
+
...(options.comparisonPolicy ? { comparisonPolicy: options.comparisonPolicy } : {})
|
|
1254
1906
|
},
|
|
1255
|
-
|
|
1256
|
-
|
|
1907
|
+
this.#generateIdempotencyKey(),
|
|
1908
|
+
operation
|
|
1909
|
+
);
|
|
1910
|
+
if (options.comparisonPolicy) {
|
|
1911
|
+
assertComparisonPolicyAcknowledged(runResponse?.comparisonPolicy, options.comparisonPolicy, 'Snap local-upload run creation');
|
|
1912
|
+
}
|
|
1257
1913
|
|
|
1258
1914
|
for (const { route, manifestEntry } of uploads) {
|
|
1259
1915
|
const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
1260
1916
|
const viewportDescriptor = resolveViewportDescriptor(route.viewport);
|
|
1261
1917
|
|
|
1262
|
-
await this.#request(
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1918
|
+
await this.#request(
|
|
1919
|
+
'POST',
|
|
1920
|
+
`/v1/visual/runs/${runId}/captures`,
|
|
1921
|
+
{
|
|
1922
|
+
id: captureId,
|
|
1923
|
+
routeId: route.id,
|
|
1924
|
+
routePath: route.path,
|
|
1925
|
+
viewportDescriptorJson: JSON.stringify(viewportDescriptor),
|
|
1926
|
+
// The screenshot is captured locally and uploaded below, not rendered by
|
|
1927
|
+
// Snap. This flag tells the backend to keep the capture out of the render
|
|
1928
|
+
// worker's queue — without it the worker would try to render baseUrl
|
|
1929
|
+
// (a client-only/loopback address it can't reach) and error the run.
|
|
1930
|
+
localCapture: true
|
|
1931
|
+
},
|
|
1932
|
+
this.#generateIdempotencyKey(),
|
|
1933
|
+
operation
|
|
1934
|
+
);
|
|
1273
1935
|
|
|
1274
1936
|
const imagePath = path.resolve(localCapture.screenshotsRoot, manifestEntry.imagePath);
|
|
1275
1937
|
const imageBytes = await fs.readFile(imagePath);
|
|
1276
1938
|
|
|
1277
|
-
await this.#request(
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1939
|
+
await this.#request(
|
|
1940
|
+
'POST',
|
|
1941
|
+
`/v1/visual/captures/${captureId}/local-result`,
|
|
1942
|
+
{
|
|
1943
|
+
imageBase64: imageBytes.toString('base64'),
|
|
1944
|
+
width: manifestEntry.width,
|
|
1945
|
+
height: manifestEntry.height
|
|
1946
|
+
},
|
|
1947
|
+
this.#generateIdempotencyKey(),
|
|
1948
|
+
operation
|
|
1949
|
+
);
|
|
1282
1950
|
}
|
|
1283
1951
|
|
|
1284
1952
|
const snapResults = {
|
|
@@ -1288,6 +1956,7 @@ export class SnapProvider {
|
|
|
1288
1956
|
runId,
|
|
1289
1957
|
projectId: this.#projectId,
|
|
1290
1958
|
purpose: options.purpose,
|
|
1959
|
+
...(options.comparisonPolicy ? { comparisonPolicy: options.comparisonPolicy } : {}),
|
|
1291
1960
|
...(options.baselineRef ? options.baselineRef : {}),
|
|
1292
1961
|
configuredRouteIds: options.configuredRouteIds,
|
|
1293
1962
|
selectedRouteIds: options.selectedRouteIds,
|
|
@@ -1299,149 +1968,6 @@ export class SnapProvider {
|
|
|
1299
1968
|
return localCapture;
|
|
1300
1969
|
}
|
|
1301
1970
|
|
|
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
1971
|
/**
|
|
1446
1972
|
* Handle unavailable behavior based on onUnavailable config.
|
|
1447
1973
|
* @param {Error} error
|
|
@@ -1471,34 +1997,37 @@ export class SnapProvider {
|
|
|
1471
1997
|
* Poll a run until it reaches a terminal state.
|
|
1472
1998
|
* @param {string} runId
|
|
1473
1999
|
* @param {number} [expectedCaptureCount]
|
|
2000
|
+
* @param {(detail: string) => Error} [errorFactory]
|
|
2001
|
+
* @param {OperationDeadline} [operation]
|
|
1474
2002
|
* @returns {Promise<JsonObject>}
|
|
1475
2003
|
*/
|
|
1476
|
-
async #pollRun(runId, expectedCaptureCount) {
|
|
1477
|
-
const startTime = Date.now();
|
|
2004
|
+
async #pollRun(runId, expectedCaptureCount, errorFactory = (detail) => new Error(detail), operation = this.#createOperationDeadline('run polling')) {
|
|
1478
2005
|
let incompleteTerminalSignature;
|
|
1479
2006
|
let stableIncompleteTerminalPolls = 0;
|
|
1480
2007
|
let incompleteSignature;
|
|
1481
2008
|
let stableIncompletePolls = 0;
|
|
1482
2009
|
|
|
1483
|
-
while (
|
|
1484
|
-
const
|
|
2010
|
+
while (this.#remainingOperationMs(operation) > 0) {
|
|
2011
|
+
const requestPath = `/v1/visual/runs/${runId}`;
|
|
2012
|
+
const run = await this.#request('GET', requestPath, undefined, undefined, operation);
|
|
2013
|
+
if (!run || typeof run !== 'object' || Array.isArray(run)) {
|
|
2014
|
+
throw errorFactory(`Snap returned a malformed run response for requested run "${runId}".`);
|
|
2015
|
+
}
|
|
1485
2016
|
const captures = Array.isArray(run.captures) ? run.captures : [];
|
|
1486
2017
|
const completeCaptureSet =
|
|
1487
2018
|
expectedCaptureCount === undefined ||
|
|
1488
|
-
(captures.length >= expectedCaptureCount &&
|
|
1489
|
-
captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status)));
|
|
2019
|
+
(captures.length >= expectedCaptureCount && captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status)));
|
|
1490
2020
|
|
|
1491
2021
|
if (expectedCaptureCount !== undefined && !completeCaptureSet) {
|
|
1492
|
-
const signature = JSON.stringify(
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
}));
|
|
2022
|
+
const signature = JSON.stringify(
|
|
2023
|
+
captures
|
|
2024
|
+
.map((capture) => [capture?.id, capture?.routeId, capture?.status, capture?.currentObjectKey])
|
|
2025
|
+
.sort((left, right) => {
|
|
2026
|
+
const leftJson = JSON.stringify(left);
|
|
2027
|
+
const rightJson = JSON.stringify(right);
|
|
2028
|
+
return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
|
|
2029
|
+
})
|
|
2030
|
+
);
|
|
1502
2031
|
if (signature === incompleteSignature) stableIncompletePolls += 1;
|
|
1503
2032
|
else {
|
|
1504
2033
|
incompleteSignature = signature;
|
|
@@ -1522,19 +2051,16 @@ export class SnapProvider {
|
|
|
1522
2051
|
// An older server can report a terminal run after only an early subset
|
|
1523
2052
|
// has settled. Keep polling for the full capture set until the normal
|
|
1524
2053
|
// ten-minute deadline; ordinary renders routinely exceed a few seconds.
|
|
1525
|
-
if (
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
const rightJson = JSON.stringify(right);
|
|
1536
|
-
return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
|
|
1537
|
-
}));
|
|
2054
|
+
if (captures.length < expectedCaptureCount && captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status))) {
|
|
2055
|
+
const signature = JSON.stringify(
|
|
2056
|
+
captures
|
|
2057
|
+
.map((capture) => [capture?.id, capture?.routeId, capture?.status])
|
|
2058
|
+
.sort((left, right) => {
|
|
2059
|
+
const leftJson = JSON.stringify(left);
|
|
2060
|
+
const rightJson = JSON.stringify(right);
|
|
2061
|
+
return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
|
|
2062
|
+
})
|
|
2063
|
+
);
|
|
1538
2064
|
if (signature === incompleteTerminalSignature) stableIncompleteTerminalPolls += 1;
|
|
1539
2065
|
else {
|
|
1540
2066
|
incompleteTerminalSignature = signature;
|
|
@@ -1549,21 +2075,37 @@ export class SnapProvider {
|
|
|
1549
2075
|
}
|
|
1550
2076
|
}
|
|
1551
2077
|
|
|
1552
|
-
|
|
2078
|
+
try {
|
|
2079
|
+
await this.#sleepWithinOperation(POLL_INTERVAL_MS, operation, requestPath);
|
|
2080
|
+
} catch (error) {
|
|
2081
|
+
return this.#handleUnavailable(error instanceof Error ? error : new SnapUnavailableError(String(error)));
|
|
2082
|
+
}
|
|
1553
2083
|
}
|
|
1554
2084
|
|
|
1555
|
-
|
|
2085
|
+
return this.#handleUnavailable(
|
|
2086
|
+
new SnapUnavailableError(
|
|
2087
|
+
`Snap ${operation.label} run ${runId} did not complete within ${OPERATION_LIMIT_TEXT}; ` + 'the Snap operation deadline was reached while polling'
|
|
2088
|
+
)
|
|
2089
|
+
);
|
|
1556
2090
|
}
|
|
1557
2091
|
|
|
1558
2092
|
/**
|
|
1559
2093
|
* Map a Snap run result to a VisualDiffSummary.
|
|
1560
2094
|
* @param {JsonObject} run
|
|
1561
2095
|
* @param {VisualRegressionConfig} config
|
|
2096
|
+
* @param {{
|
|
2097
|
+
* selectedRouteIds: string[],
|
|
2098
|
+
* expectedCaptures: Array<{ routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
|
|
2099
|
+
* expectedByIdentity: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>,
|
|
2100
|
+
* expectedByRouteId: Map<string, { routeId: string, routePath: string, viewportDescriptorJson: string, viewport: import('../types/visual-diff-types').VisualViewport }>
|
|
2101
|
+
* }} diffPlan
|
|
2102
|
+
* @param {ComparisonPolicy} [comparisonPolicy]
|
|
1562
2103
|
* @returns {import('../types/visual-diff-types').VisualDiffSummary}
|
|
1563
2104
|
*/
|
|
1564
|
-
#mapRunToSummary(run, config) {
|
|
1565
|
-
const captures = run.captures
|
|
1566
|
-
const
|
|
2105
|
+
#mapRunToSummary(run, config, diffPlan, comparisonPolicy) {
|
|
2106
|
+
const captures = Array.isArray(run.captures) ? run.captures : [];
|
|
2107
|
+
const comparisonThreshold = comparisonPolicy?.threshold ?? config.diff.threshold;
|
|
2108
|
+
const totalScreenshots = diffPlan.expectedCaptures.length;
|
|
1567
2109
|
let matchedScreenshots = 0;
|
|
1568
2110
|
let changedScreenshots = 0;
|
|
1569
2111
|
let missingInBaseline = 0;
|
|
@@ -1581,72 +2123,197 @@ export class SnapProvider {
|
|
|
1581
2123
|
/** @type {import('../types/visual-diff-types').VisualDiffErrorItem[]} */
|
|
1582
2124
|
const errors = [];
|
|
1583
2125
|
|
|
2126
|
+
/**
|
|
2127
|
+
* @param {string} id
|
|
2128
|
+
* @param {string | undefined} path
|
|
2129
|
+
* @param {import('../types/visual-diff-types').VisualViewport | undefined} viewport
|
|
2130
|
+
* @param {string} message
|
|
2131
|
+
*/
|
|
2132
|
+
const addError = (id, path, viewport, message) => {
|
|
2133
|
+
errors.push({ id, path, viewport, status: 'error', message });
|
|
2134
|
+
};
|
|
2135
|
+
|
|
2136
|
+
if (!Array.isArray(run.captures)) {
|
|
2137
|
+
addError(`run:${run.id}`, undefined, undefined, 'Snap run returned no valid captures array.');
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
if (run.status === 'error') {
|
|
2141
|
+
addError(`run:${run.id}`, undefined, undefined, run.errorDetails?.message || run.errorCode || run.message || 'Snap run ended with status "error".');
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
const seenIdentities = new Set();
|
|
2145
|
+
const coveredExpectedRoutes = new Set();
|
|
1584
2146
|
for (const capture of captures) {
|
|
2147
|
+
if (!capture || typeof capture !== 'object' || Array.isArray(capture)) {
|
|
2148
|
+
addError('unknown', undefined, undefined, 'Snap run returned a malformed capture.');
|
|
2149
|
+
continue;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
if (typeof capture.routeId === 'string' && diffPlan.expectedByRouteId.has(capture.routeId)) {
|
|
2153
|
+
coveredExpectedRoutes.add(capture.routeId);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
let identity;
|
|
2157
|
+
try {
|
|
2158
|
+
identity = captureIdentityKey(capture, `source capture "${capture.routeId || capture.id || 'unknown'}"`, hostedDiffError);
|
|
2159
|
+
} catch (error) {
|
|
2160
|
+
const malformedRouteId = typeof capture.routeId === 'string' && capture.routeId ? capture.routeId : 'unknown';
|
|
2161
|
+
const expected = diffPlan.expectedByRouteId.get(malformedRouteId);
|
|
2162
|
+
addError(
|
|
2163
|
+
malformedRouteId,
|
|
2164
|
+
typeof capture.routePath === 'string' ? capture.routePath : expected?.routePath,
|
|
2165
|
+
expected?.viewport ?? capture.viewport,
|
|
2166
|
+
error instanceof Error ? error.message : String(error)
|
|
2167
|
+
);
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
const expected = diffPlan.expectedByIdentity.get(identity);
|
|
2172
|
+
if (!expected) {
|
|
2173
|
+
addError(capture.routeId, capture.routePath, capture.viewport, `Snap run returned an unexpected route/viewport capture for "${capture.routeId}".`);
|
|
2174
|
+
continue;
|
|
2175
|
+
}
|
|
2176
|
+
if (seenIdentities.has(identity)) {
|
|
2177
|
+
addError(expected.routeId, expected.routePath, expected.viewport, `Snap run returned a duplicate route/viewport capture for "${expected.routeId}".`);
|
|
2178
|
+
continue;
|
|
2179
|
+
}
|
|
2180
|
+
seenIdentities.add(identity);
|
|
2181
|
+
|
|
2182
|
+
if (capture.routePath !== expected.routePath) {
|
|
2183
|
+
addError(expected.routeId, capture.routePath, expected.viewport, `Snap run returned an unexpected path for "${expected.routeId}".`);
|
|
2184
|
+
continue;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
1585
2187
|
if (capture.status === 'error') {
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
status
|
|
1591
|
-
|
|
1592
|
-
|
|
2188
|
+
addError(
|
|
2189
|
+
expected.routeId,
|
|
2190
|
+
expected.routePath,
|
|
2191
|
+
expected.viewport,
|
|
2192
|
+
capture.errorDetails?.message || capture.errorCode || 'Snap capture ended with status "error".'
|
|
2193
|
+
);
|
|
2194
|
+
continue;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
if (capture.status !== 'new' && capture.status !== 'diffed') {
|
|
2198
|
+
addError(
|
|
2199
|
+
expected.routeId,
|
|
2200
|
+
expected.routePath,
|
|
2201
|
+
expected.viewport,
|
|
2202
|
+
`Snap capture "${expected.routeId}" ended with unknown status "${capture.status || 'unknown'}".`
|
|
2203
|
+
);
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
if (typeof capture.currentObjectKey !== 'string' || !capture.currentObjectKey) {
|
|
2208
|
+
addError(expected.routeId, expected.routePath, expected.viewport, `Snap capture "${expected.routeId}" has no currentObjectKey.`);
|
|
1593
2209
|
continue;
|
|
1594
2210
|
}
|
|
1595
2211
|
|
|
1596
2212
|
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.
|
|
2213
|
+
// The backend rendered this capture but had no baseline to diff against.
|
|
1600
2214
|
missingInBaseline++;
|
|
1601
2215
|
missing.push({
|
|
1602
|
-
id:
|
|
2216
|
+
id: expected.routeId,
|
|
1603
2217
|
reason: 'no baseline capture found on Snap',
|
|
1604
|
-
path:
|
|
1605
|
-
viewport:
|
|
2218
|
+
path: expected.routePath,
|
|
2219
|
+
viewport: expected.viewport,
|
|
1606
2220
|
location: 'baseline'
|
|
1607
2221
|
});
|
|
1608
2222
|
continue;
|
|
1609
2223
|
}
|
|
1610
2224
|
|
|
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
|
-
|
|
2225
|
+
if (capture.baselineObjectKey !== undefined && capture.baselineObjectKey !== null && typeof capture.baselineObjectKey !== 'string') {
|
|
2226
|
+
addError(expected.routeId, expected.routePath, expected.viewport, `Snap capture "${expected.routeId}" has an invalid baselineObjectKey.`);
|
|
2227
|
+
continue;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
if (!capture.baselineObjectKey) {
|
|
2231
|
+
// No baseline was attached to this capture: the backend short-circuits
|
|
2232
|
+
// to "diffed" with no comparison data.
|
|
2233
|
+
missingInBaseline++;
|
|
2234
|
+
missing.push({
|
|
2235
|
+
id: expected.routeId,
|
|
2236
|
+
reason: 'no baseline capture found on Snap',
|
|
2237
|
+
path: expected.routePath,
|
|
2238
|
+
viewport: expected.viewport,
|
|
2239
|
+
location: 'baseline'
|
|
2240
|
+
});
|
|
2241
|
+
continue;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
const comparison = comparisonFromCapture(capture);
|
|
2245
|
+
if (comparisonPolicy && !comparison) {
|
|
2246
|
+
addError(
|
|
2247
|
+
expected.routeId,
|
|
2248
|
+
expected.routePath,
|
|
2249
|
+
expected.viewport,
|
|
2250
|
+
`Snap capture "${expected.routeId}" is missing complete comparison metadata; ` +
|
|
2251
|
+
'upgrade the Snap server and diff Lambda before enabling comparisonPolicy v1.'
|
|
2252
|
+
);
|
|
2253
|
+
continue;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
if (!Number.isFinite(capture.diffPct) || capture.diffPct < 0 || capture.diffPct > 1) {
|
|
2257
|
+
addError(expected.routeId, expected.routePath, expected.viewport, `Snap capture "${expected.routeId}" has an invalid diffPct.`);
|
|
2258
|
+
continue;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
if (comparisonPolicy && comparison && !hasCompleteComparisonResult(capture, comparison, comparisonPolicy.threshold)) {
|
|
2262
|
+
addError(
|
|
2263
|
+
expected.routeId,
|
|
2264
|
+
expected.routePath,
|
|
2265
|
+
expected.viewport,
|
|
2266
|
+
`Snap capture "${expected.routeId}" has incomplete or inconsistent comparison statistics.`
|
|
2267
|
+
);
|
|
2268
|
+
continue;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
if (comparison && (!Number.isInteger(capture.diffPixels) || capture.diffPixels < 0 || capture.diffPixels > comparison.totalPixels)) {
|
|
2272
|
+
addError(expected.routeId, expected.routePath, expected.viewport, `Snap capture "${expected.routeId}" has invalid comparison pixel statistics.`);
|
|
2273
|
+
continue;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
const legacyWidth = capture.width || 0;
|
|
2277
|
+
const legacyHeight = capture.height || 0;
|
|
2278
|
+
const dimensionsChanged = comparison?.dimensionsChanged === true;
|
|
2279
|
+
if (dimensionsChanged || capture.diffPct > comparisonThreshold) {
|
|
2280
|
+
comparedWithBaseline++;
|
|
2281
|
+
changedScreenshots++;
|
|
2282
|
+
changed.push({
|
|
2283
|
+
id: expected.routeId,
|
|
2284
|
+
path: expected.routePath,
|
|
2285
|
+
viewport: expected.viewport,
|
|
2286
|
+
baselineImagePath: capture.baselineObjectKey,
|
|
2287
|
+
currentImagePath: capture.currentObjectKey,
|
|
2288
|
+
width: comparison?.canvas.width ?? legacyWidth,
|
|
2289
|
+
height: comparison?.canvas.height ?? legacyHeight,
|
|
2290
|
+
differentPixels: capture.diffPixels || 0,
|
|
2291
|
+
totalPixels: comparison?.totalPixels ?? legacyWidth * legacyHeight,
|
|
2292
|
+
mismatchRatio: capture.diffPct,
|
|
2293
|
+
status: 'changed',
|
|
2294
|
+
...(comparison ? { comparison } : {})
|
|
2295
|
+
});
|
|
2296
|
+
} else {
|
|
2297
|
+
comparedWithBaseline++;
|
|
2298
|
+
if (capture.diffPct === 0) {
|
|
2299
|
+
exactZeroMatches++;
|
|
1646
2300
|
}
|
|
2301
|
+
matchedScreenshots++;
|
|
1647
2302
|
}
|
|
1648
2303
|
}
|
|
1649
2304
|
|
|
2305
|
+
for (const expected of diffPlan.expectedByRouteId.values()) {
|
|
2306
|
+
if (coveredExpectedRoutes.has(expected.routeId)) continue;
|
|
2307
|
+
missingInCurrent++;
|
|
2308
|
+
missing.push({
|
|
2309
|
+
id: expected.routeId,
|
|
2310
|
+
reason: 'capture missing from Snap run',
|
|
2311
|
+
path: expected.routePath,
|
|
2312
|
+
viewport: expected.viewport,
|
|
2313
|
+
location: 'current'
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2316
|
+
|
|
1650
2317
|
// False-negative guard: when a diff run compared every route against a real
|
|
1651
2318
|
// baseline yet every single comparison came back pixel-identical (0% drift),
|
|
1652
2319
|
// the most likely explanation is that the client captured a stale or wrong
|
|
@@ -1661,9 +2328,9 @@ export class SnapProvider {
|
|
|
1661
2328
|
if (comparedWithBaseline > 1 && exactZeroMatches === comparedWithBaseline) {
|
|
1662
2329
|
process.stderr.write(
|
|
1663
2330
|
`[SnapDrift] Warning: all ${comparedWithBaseline} compared route(s) were pixel-identical ` +
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
2331
|
+
`to the baseline (0% drift). If you expected a visual change, the captured page may be ` +
|
|
2332
|
+
`stale or wrong — verify the resolved baseUrl points at the PR preview and that the ` +
|
|
2333
|
+
`preview was deployed before capture ran. See issue #93.\n`
|
|
1667
2334
|
);
|
|
1668
2335
|
}
|
|
1669
2336
|
|
|
@@ -1673,11 +2340,11 @@ export class SnapProvider {
|
|
|
1673
2340
|
finishedAt: run.finishedAt || new Date().toISOString(),
|
|
1674
2341
|
completed: true,
|
|
1675
2342
|
status: run.status === 'pass' ? 'clean' : run.status === 'fail' ? 'changes-detected' : 'incomplete',
|
|
1676
|
-
selectedRoutes:
|
|
2343
|
+
selectedRoutes: diffPlan.selectedRouteIds,
|
|
1677
2344
|
baselineManifestPath: '',
|
|
1678
2345
|
currentManifestPath: '',
|
|
1679
2346
|
diffMode: config.diff.mode,
|
|
1680
|
-
threshold:
|
|
2347
|
+
threshold: comparisonThreshold,
|
|
1681
2348
|
baselineResultsPath: '',
|
|
1682
2349
|
currentResultsPath: '',
|
|
1683
2350
|
totalScreenshots,
|
|
@@ -1689,6 +2356,7 @@ export class SnapProvider {
|
|
|
1689
2356
|
missing,
|
|
1690
2357
|
errors,
|
|
1691
2358
|
dimensionChanges: [],
|
|
2359
|
+
...(comparisonPolicy ? { comparisonPolicy: { ...comparisonPolicy } } : {}),
|
|
1692
2360
|
dashboardUrl: `${this.#apiUrl}/dashboard/visual/${this.#projectId}/runs/${run.id}`
|
|
1693
2361
|
};
|
|
1694
2362
|
|
|
@@ -1719,6 +2387,21 @@ export class SnapApiError extends Error {
|
|
|
1719
2387
|
}
|
|
1720
2388
|
}
|
|
1721
2389
|
|
|
2390
|
+
/**
|
|
2391
|
+
* Error raised when an updated client opts into a comparison policy that the
|
|
2392
|
+
* connected Snap server does not acknowledge.
|
|
2393
|
+
*/
|
|
2394
|
+
export class SnapUpgradeRequiredError extends Error {
|
|
2395
|
+
/** @type {'comparison_policy_unsupported'} */
|
|
2396
|
+
code = 'comparison_policy_unsupported';
|
|
2397
|
+
|
|
2398
|
+
/** @param {string} message */
|
|
2399
|
+
constructor(message) {
|
|
2400
|
+
super(message);
|
|
2401
|
+
this.name = 'SnapUpgradeRequiredError';
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
|
|
1722
2405
|
/**
|
|
1723
2406
|
* Error thrown when the Snap API is unreachable after all retries.
|
|
1724
2407
|
*/
|