snapdrift 0.8.1 → 0.8.2
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 +13 -0
- package/README.md +7 -4
- package/action.yml +8 -9
- package/actions/baseline/action.yml +68 -47
- package/actions/pr-diff/action.yml +119 -58
- package/docs/contracts.md +36 -3
- package/docs/integration-guide.md +12 -10
- package/docs/local-cli.md +6 -5
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +25 -13
- package/lib/cli.mjs +118 -127
- package/lib/outage-policy.mjs +245 -0
- package/lib/snap-provider.mjs +371 -44
- package/package.json +3 -1
- package/types/visual-diff-types.d.ts +26 -0
package/lib/snap-provider.mjs
CHANGED
|
@@ -32,6 +32,10 @@ const RETRY_MULTIPLIER = 2;
|
|
|
32
32
|
const MAX_RETRY_TOTAL_MS = 30000;
|
|
33
33
|
const POLL_INTERVAL_MS = 2000;
|
|
34
34
|
const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
|
|
35
|
+
const MAX_STABLE_INCOMPLETE_POLLS = 30;
|
|
36
|
+
const BASELINE_RUN_SUCCESS_STATUS = 'new';
|
|
37
|
+
const BASELINE_CAPTURE_SUCCESS_STATUS = 'new';
|
|
38
|
+
const TERMINAL_CAPTURE_STATUSES = new Set(['new', 'diffed', 'error']);
|
|
35
39
|
|
|
36
40
|
// Snap reports a run as "new" when it has no baseline to compare against (first
|
|
37
41
|
// capture for a project, or any baseline-publish run that intentionally omits a
|
|
@@ -39,6 +43,180 @@ const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
|
|
|
39
43
|
// client must stop polling on it just like pass/fail/error.
|
|
40
44
|
const TERMINAL_RUN_STATUSES = new Set(['pass', 'fail', 'error', 'new']);
|
|
41
45
|
|
|
46
|
+
/** @param {string} detail @returns {Error} */
|
|
47
|
+
function completeBaselineError(detail) {
|
|
48
|
+
return new Error(`Cannot publish a complete Snap baseline: ${detail}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {import('../types/visual-diff-types').VisualViewport} viewport
|
|
53
|
+
* @returns {{ width: number, height: number, deviceScaleFactor?: number, isMobile?: boolean, hasTouch?: boolean }}
|
|
54
|
+
*/
|
|
55
|
+
function resolveViewportDescriptor(viewport) {
|
|
56
|
+
return typeof viewport === 'string'
|
|
57
|
+
? (VIEWPORT_PRESETS[viewport] ?? { width: 1280, height: 720 })
|
|
58
|
+
: viewport;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {VisualRegressionConfig['routes'][number]} route */
|
|
62
|
+
function expectedCaptureIdentity(route) {
|
|
63
|
+
return {
|
|
64
|
+
routeId: route.id,
|
|
65
|
+
routePath: route.path,
|
|
66
|
+
viewportDescriptorJson: JSON.stringify(resolveViewportDescriptor(route.viewport))
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {unknown} value @param {string} context @returns {string} */
|
|
71
|
+
function normalizeViewportDescriptor(value, context) {
|
|
72
|
+
let descriptor;
|
|
73
|
+
try {
|
|
74
|
+
descriptor = typeof value === 'string' ? JSON.parse(value) : value;
|
|
75
|
+
} catch {
|
|
76
|
+
throw completeBaselineError(`${context} has malformed viewportDescriptorJson.`);
|
|
77
|
+
}
|
|
78
|
+
if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor) ||
|
|
79
|
+
!Number.isFinite(descriptor.width) || !Number.isFinite(descriptor.height)) {
|
|
80
|
+
throw completeBaselineError(`${context} has an invalid viewport descriptor.`);
|
|
81
|
+
}
|
|
82
|
+
return JSON.stringify(Object.fromEntries(
|
|
83
|
+
Object.entries(descriptor).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
84
|
+
));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @param {{ routeId?: unknown, viewportDescriptorJson?: unknown }} capture @param {string} context */
|
|
88
|
+
function captureIdentityKey(capture, context) {
|
|
89
|
+
if (typeof capture.routeId !== 'string' || !capture.routeId) {
|
|
90
|
+
throw completeBaselineError(`${context} has no routeId.`);
|
|
91
|
+
}
|
|
92
|
+
return `${capture.routeId}\u0000${normalizeViewportDescriptor(capture.viewportDescriptorJson, context)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {unknown} value @param {string} field @returns {Set<string>} */
|
|
96
|
+
function uniqueRouteIdSet(value, field) {
|
|
97
|
+
if (!Array.isArray(value) || value.some((id) => typeof id !== 'string' || !id)) {
|
|
98
|
+
throw completeBaselineError(`results metadata has no valid ${field}; capture again with SnapDrift 0.8.2 or newer.`);
|
|
99
|
+
}
|
|
100
|
+
const ids = new Set(value);
|
|
101
|
+
if (ids.size !== value.length) {
|
|
102
|
+
throw completeBaselineError(`results metadata ${field} contains duplicate route ids.`);
|
|
103
|
+
}
|
|
104
|
+
return ids;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {string[]} configuredRouteIds @param {string[]} selectedRouteIds */
|
|
108
|
+
function assertCompleteHostedBaselineSelection(configuredRouteIds, selectedRouteIds) {
|
|
109
|
+
const selected = new Set(selectedRouteIds);
|
|
110
|
+
const missing = configuredRouteIds.filter((id) => !selected.has(id));
|
|
111
|
+
if (missing.length === 0 && selected.size === configuredRouteIds.length) return;
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Hosted Snap baseline publication requires all ${configuredRouteIds.length} configured route(s), ` +
|
|
114
|
+
`but only ${selected.size} were selected. Remove route-ids/--routes scoping ` +
|
|
115
|
+
`(missing: ${missing.join(', ') || 'unknown'}). Scoped runs remain supported for PR diffs ` +
|
|
116
|
+
'and local-provider baselines.'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {import('../types/visual-diff-types').SnapRunMetadata} metadata
|
|
122
|
+
* @param {JsonObject} run
|
|
123
|
+
* @param {string} projectId
|
|
124
|
+
*/
|
|
125
|
+
function validateCompleteBaselineRun(metadata, run, projectId) {
|
|
126
|
+
if (metadata.projectId !== projectId) {
|
|
127
|
+
throw completeBaselineError(`source run belongs to project "${metadata.projectId || 'unknown'}", not "${projectId}".`);
|
|
128
|
+
}
|
|
129
|
+
if (metadata.purpose !== 'baseline') {
|
|
130
|
+
throw completeBaselineError('source results were not recorded as a baseline capture.');
|
|
131
|
+
}
|
|
132
|
+
if (typeof metadata.refBranch !== 'string' || !metadata.refBranch ||
|
|
133
|
+
typeof metadata.refSha !== 'string' || !metadata.refSha) {
|
|
134
|
+
throw completeBaselineError('source results have no resolved ref; capture again with SnapDrift 0.8.2 or newer.');
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
typeof metadata.publicationSequence !== 'number' ||
|
|
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
|
+
);
|
|
144
|
+
}
|
|
145
|
+
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
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const configured = uniqueRouteIdSet(metadata.configuredRouteIds, 'configuredRouteIds');
|
|
152
|
+
const selected = uniqueRouteIdSet(metadata.selectedRouteIds, 'selectedRouteIds');
|
|
153
|
+
assertCompleteHostedBaselineSelection([...configured], [...selected]);
|
|
154
|
+
if (!Array.isArray(metadata.expectedCaptures) || metadata.expectedCaptures.length !== configured.size) {
|
|
155
|
+
throw completeBaselineError(`expected capture metadata does not match ${configured.size} configured route(s).`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const expectedRoutes = new Set();
|
|
159
|
+
const expectedByIdentity = new Map();
|
|
160
|
+
for (const expected of metadata.expectedCaptures) {
|
|
161
|
+
if (!expected || typeof expected !== 'object' || typeof expected.routePath !== 'string' ||
|
|
162
|
+
!configured.has(expected.routeId) || expectedRoutes.has(expected.routeId)) {
|
|
163
|
+
throw completeBaselineError(`expected capture metadata has duplicate or extra route "${expected?.routeId || 'unknown'}".`);
|
|
164
|
+
}
|
|
165
|
+
expectedRoutes.add(expected.routeId);
|
|
166
|
+
expectedByIdentity.set(
|
|
167
|
+
captureIdentityKey(expected, `expected capture "${expected.routeId}"`),
|
|
168
|
+
expected
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!Array.isArray(run.captures)) throw completeBaselineError('source run has no captures array.');
|
|
173
|
+
if (run.captures.length !== expectedByIdentity.size) {
|
|
174
|
+
throw completeBaselineError(`source run has ${run.captures.length} capture(s), expected ${expectedByIdentity.size}.`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
const routes = run.captures.map((capture) => {
|
|
179
|
+
if (!capture || typeof capture !== 'object') {
|
|
180
|
+
throw completeBaselineError('source run contains a malformed capture.');
|
|
181
|
+
}
|
|
182
|
+
const identity = captureIdentityKey(capture, `source capture "${capture.routeId || capture.id || 'unknown'}"`);
|
|
183
|
+
if (seen.has(identity)) {
|
|
184
|
+
throw completeBaselineError(`duplicate source route/viewport identity for "${capture.routeId}".`);
|
|
185
|
+
}
|
|
186
|
+
seen.add(identity);
|
|
187
|
+
const expected = expectedByIdentity.get(identity);
|
|
188
|
+
if (!expected) {
|
|
189
|
+
throw completeBaselineError(`source run contains an unexpected route/viewport capture for "${capture.routeId}".`);
|
|
190
|
+
}
|
|
191
|
+
if (capture.routePath !== expected.routePath) {
|
|
192
|
+
throw completeBaselineError(`source route "${capture.routeId}" used an unexpected path.`);
|
|
193
|
+
}
|
|
194
|
+
if (capture.status !== BASELINE_CAPTURE_SUCCESS_STATUS) {
|
|
195
|
+
throw completeBaselineError(`source capture "${capture.routeId}" ended with status "${capture.status || 'unknown'}".`);
|
|
196
|
+
}
|
|
197
|
+
if (typeof capture.currentObjectKey !== 'string' || !capture.currentObjectKey) {
|
|
198
|
+
throw completeBaselineError(`source capture "${capture.routeId}" has no currentObjectKey.`);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
routeId: expected.routeId,
|
|
202
|
+
routePath: expected.routePath,
|
|
203
|
+
viewportDescriptorJson: expected.viewportDescriptorJson,
|
|
204
|
+
objectKey: capture.currentObjectKey
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return routes.sort((left, right) => {
|
|
209
|
+
const leftKey = captureIdentityKey(left, `route "${left.routeId}"`);
|
|
210
|
+
const rightKey = captureIdentityKey(right, `route "${right.routeId}"`);
|
|
211
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @param {string} runId */
|
|
216
|
+
function baselineIdForRun(runId) {
|
|
217
|
+
return `bsl_${crypto.createHash('sha256').update(runId).digest('hex').slice(0, 24)}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
42
220
|
/**
|
|
43
221
|
* @param {string | undefined} baseUrl
|
|
44
222
|
* @returns {boolean}
|
|
@@ -114,6 +292,33 @@ export function resolveGitRef() {
|
|
|
114
292
|
};
|
|
115
293
|
}
|
|
116
294
|
|
|
295
|
+
function resolveHostedBaselinePublicationRef() {
|
|
296
|
+
const gitRef = resolveGitRef();
|
|
297
|
+
const refBranch = process.env.GITHUB_REF_NAME || gitRef.refBranch;
|
|
298
|
+
const refSha = process.env.GITHUB_SHA || gitRef.refSha;
|
|
299
|
+
const publicationWorkflowRef =
|
|
300
|
+
process.env.SNAPDRIFT_PUBLICATION_WORKFLOW_REF || process.env.GITHUB_WORKFLOW_REF;
|
|
301
|
+
const sequenceValue = process.env.SNAPDRIFT_PUBLICATION_SEQUENCE || process.env.GITHUB_RUN_NUMBER;
|
|
302
|
+
const publicationSequence = Number(sequenceValue);
|
|
303
|
+
if (
|
|
304
|
+
!refBranch ||
|
|
305
|
+
!refSha ||
|
|
306
|
+
!/^[0-9a-f]{40}$/i.test(refSha) ||
|
|
307
|
+
!publicationWorkflowRef ||
|
|
308
|
+
!Number.isSafeInteger(publicationSequence) ||
|
|
309
|
+
publicationSequence <= 0
|
|
310
|
+
) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
'Hosted Snap baseline publication requires a branch, a resolved 40-character commit SHA, ' +
|
|
313
|
+
'a publication workflow identity, and a positive publication sequence. GitHub Actions ' +
|
|
314
|
+
'provides these as GITHUB_REF_NAME/GITHUB_SHA/GITHUB_WORKFLOW_REF/GITHUB_RUN_NUMBER; ' +
|
|
315
|
+
'other CI systems may use SNAPDRIFT_PUBLICATION_WORKFLOW_REF and ' +
|
|
316
|
+
'SNAPDRIFT_PUBLICATION_SEQUENCE.'
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return { refBranch, refSha, publicationWorkflowRef, publicationSequence };
|
|
320
|
+
}
|
|
321
|
+
|
|
117
322
|
/**
|
|
118
323
|
* Read the package version from package.json.
|
|
119
324
|
* @returns {string}
|
|
@@ -329,6 +534,17 @@ export class SnapProvider {
|
|
|
329
534
|
: splitCommaList(process.env.SNAPDRIFT_ROUTE_IDS);
|
|
330
535
|
const { routes, selectedRouteIds } = selectConfiguredRoutes(config, requestedRouteIds);
|
|
331
536
|
const purpose = options.purpose ?? 'diff';
|
|
537
|
+
const configuredRouteIds = config.routes.map((route) => route.id);
|
|
538
|
+
const expectedCaptures = routes.map(expectedCaptureIdentity);
|
|
539
|
+
|
|
540
|
+
if (purpose === 'baseline') {
|
|
541
|
+
assertCompleteHostedBaselineSelection(configuredRouteIds, selectedRouteIds);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const baselineRef = purpose === 'baseline' ? resolveHostedBaselinePublicationRef() : undefined;
|
|
545
|
+
const runContext = baselineRef
|
|
546
|
+
? { branch: baselineRef.refBranch, prHeadSha: baselineRef.refSha }
|
|
547
|
+
: this.#gitRunContext();
|
|
332
548
|
|
|
333
549
|
if (isLocalBaseUrl(config.baseUrl)) {
|
|
334
550
|
return this.#captureLocalAndUpload({
|
|
@@ -338,7 +554,11 @@ export class SnapProvider {
|
|
|
338
554
|
config,
|
|
339
555
|
routes,
|
|
340
556
|
selectedRouteIds,
|
|
341
|
-
purpose
|
|
557
|
+
purpose,
|
|
558
|
+
configuredRouteIds,
|
|
559
|
+
expectedCaptures,
|
|
560
|
+
runContext,
|
|
561
|
+
baselineRef
|
|
342
562
|
});
|
|
343
563
|
}
|
|
344
564
|
|
|
@@ -355,7 +575,7 @@ export class SnapProvider {
|
|
|
355
575
|
// Attaching one would make the backend diff the fresh captures against the
|
|
356
576
|
// prior baseline — wasteful at best, and on a dimension mismatch it errors
|
|
357
577
|
// the run and fails the publish.
|
|
358
|
-
const baselineId = purpose === '
|
|
578
|
+
const baselineId = purpose === 'diff' ? await this.#resolveLatestBaselineId() : null;
|
|
359
579
|
|
|
360
580
|
// We intentionally do NOT send captureProfileJson on run creation. SnapDrift
|
|
361
581
|
// submits routes for server-side rendering, so the render environment
|
|
@@ -373,17 +593,27 @@ export class SnapProvider {
|
|
|
373
593
|
...(baselineId ? { baselineId } : {}),
|
|
374
594
|
// A baseline run must not be diffed. Suppress the server's
|
|
375
595
|
// auto-resolve-by-branch so no prior baseline is attached.
|
|
376
|
-
...(purpose
|
|
377
|
-
|
|
596
|
+
...(purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
|
|
597
|
+
capturePlan: {
|
|
598
|
+
purpose: purpose === 'baseline' ? 'baseline' : 'diff',
|
|
599
|
+
...(baselineRef
|
|
600
|
+
? {
|
|
601
|
+
publicationWorkflowRef: baselineRef.publicationWorkflowRef,
|
|
602
|
+
publicationSequence: baselineRef.publicationSequence
|
|
603
|
+
}
|
|
604
|
+
: {}),
|
|
605
|
+
configuredRouteIds,
|
|
606
|
+
selectedRouteIds,
|
|
607
|
+
expectedCaptures
|
|
608
|
+
},
|
|
609
|
+
...runContext,
|
|
378
610
|
}, idempotencyKey);
|
|
379
611
|
|
|
380
612
|
// Submit each route as a capture with its own idempotency key
|
|
381
613
|
for (const route of routes) {
|
|
382
614
|
const captureKey = this.#generateIdempotencyKey();
|
|
383
615
|
const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
384
|
-
const viewportDescriptor =
|
|
385
|
-
? (VIEWPORT_PRESETS[route.viewport] ?? { width: 1280, height: 720 })
|
|
386
|
-
: route.viewport;
|
|
616
|
+
const viewportDescriptor = resolveViewportDescriptor(route.viewport);
|
|
387
617
|
await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
|
|
388
618
|
id: captureId,
|
|
389
619
|
routeId: route.id,
|
|
@@ -402,7 +632,17 @@ export class SnapProvider {
|
|
|
402
632
|
const resultsPath = path.join(outDir, 'results.json');
|
|
403
633
|
const manifestPath = path.join(outDir, 'manifest.json');
|
|
404
634
|
|
|
405
|
-
|
|
635
|
+
/** @type {import('../types/visual-diff-types').SnapRunMetadata} */
|
|
636
|
+
const runMetadata = {
|
|
637
|
+
runId,
|
|
638
|
+
projectId: this.#projectId,
|
|
639
|
+
purpose,
|
|
640
|
+
...(baselineRef ? baselineRef : {}),
|
|
641
|
+
startedAt: new Date().toISOString(),
|
|
642
|
+
configuredRouteIds,
|
|
643
|
+
selectedRouteIds,
|
|
644
|
+
expectedCaptures
|
|
645
|
+
};
|
|
406
646
|
await Promise.all([
|
|
407
647
|
fs.writeFile(resultsPath, JSON.stringify(runMetadata, null, 2)),
|
|
408
648
|
fs.writeFile(manifestPath, JSON.stringify({
|
|
@@ -473,6 +713,7 @@ export class SnapProvider {
|
|
|
473
713
|
// Snap-native baseline: poll the run that capture() submitted, then
|
|
474
714
|
// create a baseline from the rendered S3 keys.
|
|
475
715
|
if (resultsPath) {
|
|
716
|
+
/** @type {import('../types/visual-diff-types').SnapRunMetadata | undefined} */
|
|
476
717
|
let metadata;
|
|
477
718
|
/** @type {unknown} */
|
|
478
719
|
let readError;
|
|
@@ -482,7 +723,7 @@ export class SnapProvider {
|
|
|
482
723
|
readError = error;
|
|
483
724
|
}
|
|
484
725
|
|
|
485
|
-
if (
|
|
726
|
+
if (typeof metadata?.runId !== 'string' || !metadata.runId) {
|
|
486
727
|
// Previously this fell through to a "legacy" branch that POSTed
|
|
487
728
|
// `manifest`/`results` as objects. Snap has never read those fields, so
|
|
488
729
|
// the request could only ever fail — with an opaque 500 before, and with
|
|
@@ -501,55 +742,55 @@ export class SnapProvider {
|
|
|
501
742
|
const { runId } = metadata;
|
|
502
743
|
const idempotencyKey = `baseline-${runId}`;
|
|
503
744
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
if (run.status === 'error') {
|
|
508
|
-
throw new Error(
|
|
509
|
-
`Snap baseline run ${runId} ended with status "error" — cannot publish baseline. ` +
|
|
510
|
-
`Check the snap dashboard for capture error details.`
|
|
745
|
+
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.'
|
|
511
748
|
);
|
|
512
749
|
}
|
|
513
750
|
|
|
514
|
-
|
|
515
|
-
|
|
751
|
+
// Older Snap servers could mark a baseline run terminal after the first
|
|
752
|
+
// submitted capture. Keep polling until the complete planned capture set
|
|
753
|
+
// is present and terminal so a mixed-version rollout remains fail-closed.
|
|
754
|
+
const run = await this.#pollRun(runId, metadata.expectedCaptures.length);
|
|
516
755
|
|
|
517
|
-
if (
|
|
756
|
+
if (run.id && run.id !== runId) {
|
|
518
757
|
throw new Error(
|
|
519
|
-
`Snap baseline run ${runId}
|
|
520
|
-
`(${captures.length} capture(s) found, 0 rendered). Cannot publish an empty baseline.`
|
|
758
|
+
`Cannot publish a complete Snap baseline: requested source run ${runId}, but Snap returned ${run.id}.`
|
|
521
759
|
);
|
|
522
760
|
}
|
|
523
761
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
762
|
+
// Hosted baseline runs intentionally use `new` because they omit a
|
|
763
|
+
// baseline comparison. The server accepts a wider terminal set for
|
|
764
|
+
// legacy/intentional workflows, but this client is stricter: any other
|
|
765
|
+
// terminal status means the baseline capture did not establish fresh
|
|
766
|
+
// ground truth.
|
|
767
|
+
if (run.status !== BASELINE_RUN_SUCCESS_STATUS) {
|
|
768
|
+
throw new Error(
|
|
769
|
+
`Snap baseline run ${runId} ended with status "${run.status || 'unknown'}"; ` +
|
|
770
|
+
`complete baseline publication requires "${BASELINE_RUN_SUCCESS_STATUS}". ` +
|
|
771
|
+
'Check the Snap dashboard for capture error details.'
|
|
529
772
|
);
|
|
530
773
|
}
|
|
531
774
|
|
|
532
|
-
|
|
533
|
-
const routes = rendered.map((c) => ({
|
|
534
|
-
routeId: c.routeId,
|
|
535
|
-
routePath: c.routePath,
|
|
536
|
-
viewportDescriptorJson: c.viewportDescriptorJson,
|
|
537
|
-
objectKey: c.currentObjectKey
|
|
538
|
-
}));
|
|
775
|
+
const routes = validateCompleteBaselineRun(metadata, run, this.#projectId);
|
|
539
776
|
|
|
540
777
|
const manifestJson = JSON.stringify({
|
|
541
778
|
schemaVersion: 1,
|
|
542
779
|
sourceRunId: runId,
|
|
780
|
+
publicationWorkflowRef: metadata.publicationWorkflowRef,
|
|
543
781
|
routes
|
|
544
782
|
});
|
|
545
783
|
|
|
546
|
-
const baselineId =
|
|
784
|
+
const baselineId = baselineIdForRun(runId);
|
|
547
785
|
await this.#request(
|
|
548
786
|
'POST',
|
|
549
787
|
`/v1/visual/projects/${this.#projectId}/baselines`,
|
|
550
788
|
{
|
|
551
789
|
id: baselineId,
|
|
552
|
-
|
|
790
|
+
refBranch: metadata.refBranch,
|
|
791
|
+
refSha: metadata.refSha,
|
|
792
|
+
publicationMode: 'complete',
|
|
793
|
+
sourceRunId: runId,
|
|
553
794
|
manifestJson,
|
|
554
795
|
captureProfileJson: JSON.stringify(this.#buildCaptureProfile())
|
|
555
796
|
},
|
|
@@ -952,7 +1193,11 @@ export class SnapProvider {
|
|
|
952
1193
|
* config: VisualRegressionConfig,
|
|
953
1194
|
* routes: VisualRegressionConfig['routes'],
|
|
954
1195
|
* selectedRouteIds: string[],
|
|
955
|
-
* purpose: 'baseline' | 'diff'
|
|
1196
|
+
* purpose: 'baseline' | 'capture' | 'diff',
|
|
1197
|
+
* configuredRouteIds: string[],
|
|
1198
|
+
* expectedCaptures: import('../types/visual-diff-types').SnapExpectedCaptureIdentity[],
|
|
1199
|
+
* runContext: { branch?: string, prHeadSha?: string },
|
|
1200
|
+
* baselineRef?: { refBranch: string, refSha: string, publicationWorkflowRef: string, publicationSequence: number }
|
|
956
1201
|
* }} options
|
|
957
1202
|
* @returns {Promise<CaptureResult>}
|
|
958
1203
|
*/
|
|
@@ -985,7 +1230,7 @@ export class SnapProvider {
|
|
|
985
1230
|
// omit the baselineId. Captures then settle to "new" server-side (no
|
|
986
1231
|
// comparison), and publishBaseline harvests their object keys. A diff run
|
|
987
1232
|
// attaches the latest baseline so the backend can compare against it.
|
|
988
|
-
const baselineId = options.purpose === '
|
|
1233
|
+
const baselineId = options.purpose === 'diff' ? await this.#resolveLatestBaselineId() : null;
|
|
989
1234
|
|
|
990
1235
|
await this.#request('POST', `/v1/visual/projects/${this.#projectId}/runs`, {
|
|
991
1236
|
id: runId,
|
|
@@ -994,15 +1239,25 @@ export class SnapProvider {
|
|
|
994
1239
|
...(baselineId ? { baselineId } : {}),
|
|
995
1240
|
// A baseline run must not be diffed. Suppress the server's
|
|
996
1241
|
// auto-resolve-by-branch so no prior baseline is attached.
|
|
997
|
-
...(options.purpose
|
|
998
|
-
|
|
1242
|
+
...(options.purpose !== 'diff' ? { skipBaselineResolution: true } : {}),
|
|
1243
|
+
capturePlan: {
|
|
1244
|
+
purpose: options.purpose === 'baseline' ? 'baseline' : 'diff',
|
|
1245
|
+
...(options.baselineRef
|
|
1246
|
+
? {
|
|
1247
|
+
publicationWorkflowRef: options.baselineRef.publicationWorkflowRef,
|
|
1248
|
+
publicationSequence: options.baselineRef.publicationSequence
|
|
1249
|
+
}
|
|
1250
|
+
: {}),
|
|
1251
|
+
configuredRouteIds: options.configuredRouteIds,
|
|
1252
|
+
selectedRouteIds: options.selectedRouteIds,
|
|
1253
|
+
expectedCaptures: options.expectedCaptures
|
|
1254
|
+
},
|
|
1255
|
+
...options.runContext
|
|
999
1256
|
}, this.#generateIdempotencyKey());
|
|
1000
1257
|
|
|
1001
1258
|
for (const { route, manifestEntry } of uploads) {
|
|
1002
1259
|
const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
1003
|
-
const viewportDescriptor =
|
|
1004
|
-
? (VIEWPORT_PRESETS[route.viewport] ?? { width: 1280, height: 720 })
|
|
1005
|
-
: route.viewport;
|
|
1260
|
+
const viewportDescriptor = resolveViewportDescriptor(route.viewport);
|
|
1006
1261
|
|
|
1007
1262
|
await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
|
|
1008
1263
|
id: captureId,
|
|
@@ -1032,6 +1287,11 @@ export class SnapProvider {
|
|
|
1032
1287
|
captureMode: 'local-upload',
|
|
1033
1288
|
runId,
|
|
1034
1289
|
projectId: this.#projectId,
|
|
1290
|
+
purpose: options.purpose,
|
|
1291
|
+
...(options.baselineRef ? options.baselineRef : {}),
|
|
1292
|
+
configuredRouteIds: options.configuredRouteIds,
|
|
1293
|
+
selectedRouteIds: options.selectedRouteIds,
|
|
1294
|
+
expectedCaptures: options.expectedCaptures,
|
|
1035
1295
|
snapStartedAt: new Date().toISOString()
|
|
1036
1296
|
};
|
|
1037
1297
|
await fs.writeFile(localCapture.resultsPath, JSON.stringify(snapResults, null, 2));
|
|
@@ -1210,16 +1470,83 @@ export class SnapProvider {
|
|
|
1210
1470
|
/**
|
|
1211
1471
|
* Poll a run until it reaches a terminal state.
|
|
1212
1472
|
* @param {string} runId
|
|
1473
|
+
* @param {number} [expectedCaptureCount]
|
|
1213
1474
|
* @returns {Promise<JsonObject>}
|
|
1214
1475
|
*/
|
|
1215
|
-
async #pollRun(runId) {
|
|
1476
|
+
async #pollRun(runId, expectedCaptureCount) {
|
|
1216
1477
|
const startTime = Date.now();
|
|
1478
|
+
let incompleteTerminalSignature;
|
|
1479
|
+
let stableIncompleteTerminalPolls = 0;
|
|
1480
|
+
let incompleteSignature;
|
|
1481
|
+
let stableIncompletePolls = 0;
|
|
1217
1482
|
|
|
1218
1483
|
while (Date.now() - startTime < MAX_POLL_DURATION_MS) {
|
|
1219
1484
|
const run = await this.#request('GET', `/v1/visual/runs/${runId}`);
|
|
1485
|
+
const captures = Array.isArray(run.captures) ? run.captures : [];
|
|
1486
|
+
const completeCaptureSet =
|
|
1487
|
+
expectedCaptureCount === undefined ||
|
|
1488
|
+
(captures.length >= expectedCaptureCount &&
|
|
1489
|
+
captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status)));
|
|
1490
|
+
|
|
1491
|
+
if (expectedCaptureCount !== undefined && !completeCaptureSet) {
|
|
1492
|
+
const signature = JSON.stringify(captures.map((capture) => [
|
|
1493
|
+
capture?.id,
|
|
1494
|
+
capture?.routeId,
|
|
1495
|
+
capture?.status,
|
|
1496
|
+
capture?.currentObjectKey
|
|
1497
|
+
]).sort((left, right) => {
|
|
1498
|
+
const leftJson = JSON.stringify(left);
|
|
1499
|
+
const rightJson = JSON.stringify(right);
|
|
1500
|
+
return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
|
|
1501
|
+
}));
|
|
1502
|
+
if (signature === incompleteSignature) stableIncompletePolls += 1;
|
|
1503
|
+
else {
|
|
1504
|
+
incompleteSignature = signature;
|
|
1505
|
+
stableIncompletePolls = 0;
|
|
1506
|
+
}
|
|
1507
|
+
// A server-side run can remain rendering forever after a cancelled
|
|
1508
|
+
// worker. Do not spend the full ten-minute poll budget on a capture set
|
|
1509
|
+
// that has made no progress for roughly one minute.
|
|
1510
|
+
if (stableIncompletePolls >= MAX_STABLE_INCOMPLETE_POLLS) return run;
|
|
1511
|
+
} else {
|
|
1512
|
+
incompleteSignature = undefined;
|
|
1513
|
+
stableIncompletePolls = 0;
|
|
1514
|
+
}
|
|
1220
1515
|
|
|
1221
1516
|
if (TERMINAL_RUN_STATUSES.has(run.status)) {
|
|
1222
|
-
|
|
1517
|
+
if (expectedCaptureCount === undefined || run.status === 'error') {
|
|
1518
|
+
return run;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
if (completeCaptureSet) return run;
|
|
1522
|
+
// An older server can report a terminal run after only an early subset
|
|
1523
|
+
// has settled. Keep polling for the full capture set until the normal
|
|
1524
|
+
// ten-minute deadline; ordinary renders routinely exceed a few seconds.
|
|
1525
|
+
if (
|
|
1526
|
+
captures.length < expectedCaptureCount &&
|
|
1527
|
+
captures.every((capture) => TERMINAL_CAPTURE_STATUSES.has(capture?.status))
|
|
1528
|
+
) {
|
|
1529
|
+
const signature = JSON.stringify(captures.map((capture) => [
|
|
1530
|
+
capture?.id,
|
|
1531
|
+
capture?.routeId,
|
|
1532
|
+
capture?.status
|
|
1533
|
+
]).sort((left, right) => {
|
|
1534
|
+
const leftJson = JSON.stringify(left);
|
|
1535
|
+
const rightJson = JSON.stringify(right);
|
|
1536
|
+
return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
|
|
1537
|
+
}));
|
|
1538
|
+
if (signature === incompleteTerminalSignature) stableIncompleteTerminalPolls += 1;
|
|
1539
|
+
else {
|
|
1540
|
+
incompleteTerminalSignature = signature;
|
|
1541
|
+
stableIncompleteTerminalPolls = 0;
|
|
1542
|
+
}
|
|
1543
|
+
// A stable terminal subset cannot make render progress. Return it for
|
|
1544
|
+
// precise fail-closed count validation instead of waiting ten minutes.
|
|
1545
|
+
if (stableIncompleteTerminalPolls >= 2) return run;
|
|
1546
|
+
} else {
|
|
1547
|
+
incompleteTerminalSignature = undefined;
|
|
1548
|
+
stableIncompleteTerminalPolls = 0;
|
|
1549
|
+
}
|
|
1223
1550
|
}
|
|
1224
1551
|
|
|
1225
1552
|
await this.#sleepFn(POLL_INTERVAL_MS);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snapdrift",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "SnapDrift captures, compares, and reports UI drift for Playwright-based app checks.",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"lint": "eslint .",
|
|
27
27
|
"typecheck": "tsc --project tsconfig.json",
|
|
28
28
|
"validate:actions": "node scripts/validate-actions.mjs",
|
|
29
|
+
"validate:release": "node scripts/validate-release.mjs",
|
|
29
30
|
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
|
30
31
|
"test:coverage": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage --runInBand",
|
|
31
32
|
"pack:check": "npm pack --dry-run --cache .npm-cache",
|
|
@@ -77,6 +78,7 @@
|
|
|
77
78
|
"./lib/report.mjs": "./lib/report.mjs",
|
|
78
79
|
"./lib/cli.mjs": "./lib/cli.mjs",
|
|
79
80
|
"./lib/provider.mjs": "./lib/provider.mjs",
|
|
81
|
+
"./lib/outage-policy.mjs": "./lib/outage-policy.mjs",
|
|
80
82
|
"./lib/snap-provider.mjs": "./lib/snap-provider.mjs",
|
|
81
83
|
"./lib/migrate-baselines.mjs": "./lib/migrate-baselines.mjs",
|
|
82
84
|
"./lib/init-from-action.mjs": "./lib/init-from-action.mjs"
|
|
@@ -74,6 +74,32 @@ export interface VisualBaselineResults {
|
|
|
74
74
|
passed?: boolean;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Exact route/viewport capture identity persisted for hosted baseline validation. */
|
|
78
|
+
export interface SnapExpectedCaptureIdentity {
|
|
79
|
+
routeId: string;
|
|
80
|
+
routePath: string;
|
|
81
|
+
viewportDescriptorJson: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Metadata written by SnapProvider.capture() and consumed by publishBaseline(). */
|
|
85
|
+
export interface SnapRunMetadata {
|
|
86
|
+
runId: string;
|
|
87
|
+
projectId: string;
|
|
88
|
+
purpose: 'baseline' | 'capture' | 'diff';
|
|
89
|
+
/** CI source branch for hosted baseline runs; omitted for ordinary diff runs. */
|
|
90
|
+
refBranch?: string;
|
|
91
|
+
/** Resolved 40-character CI commit for hosted baseline runs. */
|
|
92
|
+
refSha?: string;
|
|
93
|
+
/** Stable CI workflow identity that owns the publication sequence. */
|
|
94
|
+
publicationWorkflowRef?: string;
|
|
95
|
+
/** Monotonic CI publication sequence for stale-publisher rejection. */
|
|
96
|
+
publicationSequence?: number;
|
|
97
|
+
startedAt: string;
|
|
98
|
+
configuredRouteIds: string[];
|
|
99
|
+
selectedRouteIds: string[];
|
|
100
|
+
expectedCaptures: SnapExpectedCaptureIdentity[];
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
export interface VisualDiffMissingItem {
|
|
78
104
|
id: string;
|
|
79
105
|
reason: string;
|