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.
@@ -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 === 'baseline' ? null : await this.#resolveLatestBaselineId();
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 === 'baseline' ? { skipBaselineResolution: true } : {}),
377
- ...this.#gitRunContext(),
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 = typeof route.viewport === 'string'
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
- const runMetadata = { runId, projectId: this.#projectId, startedAt: new Date().toISOString() };
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 (!metadata?.runId) {
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
- // Poll until the run reaches a terminal state (renders + auto-diffs finish)
505
- const run = await this.#pollRun(runId);
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
- const captures = run.captures ?? [];
515
- const rendered = captures.filter((c) => c.currentObjectKey);
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 (rendered.length === 0) {
756
+ if (run.id && run.id !== runId) {
518
757
  throw new Error(
519
- `Snap baseline run ${runId} completed but no captures produced a screenshot ` +
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
- if (rendered.length < captures.length) {
525
- const failed = captures.length - rendered.length;
526
- process.stderr.write(
527
- `[SnapDrift] Warning: ${failed} of ${captures.length} capture(s) did not produce ` +
528
- `a screenshot and will be excluded from the baseline.\n`
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
- // Build a manifest from the rendered captures
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 = `bsl_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
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
- ...resolveGitRef(),
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 === 'baseline' ? null : await this.#resolveLatestBaselineId();
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 === 'baseline' ? { skipBaselineResolution: true } : {}),
998
- ...this.#gitRunContext()
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 = typeof route.viewport === 'string'
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
- return run;
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.1",
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;