@vizzly-testing/cli 0.35.2 → 0.35.3-beta.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.
@@ -57,6 +57,9 @@ export async function createBuild({
57
57
  let client = createApiClient(clientOptions);
58
58
  let payload = buildApiBuildPayload(runOptions, config.comparison);
59
59
  let buildResult = await createApiBuild(client, payload);
60
+ if (!buildResult?.id) {
61
+ throw new Error('Vizzly API did not return a build ID');
62
+ }
60
63
  output.debug('build', `created ${buildResult.id}`);
61
64
  return buildResult.id;
62
65
  }
@@ -227,6 +230,26 @@ export function executeTestCommand({
227
230
  resolve.__process = testProcess;
228
231
  });
229
232
  }
233
+ async function executeDisabledTestRun({
234
+ testCommand,
235
+ json,
236
+ deps
237
+ }) {
238
+ let {
239
+ spawn,
240
+ createError
241
+ } = deps;
242
+ await executeTestCommand({
243
+ command: testCommand,
244
+ env: buildDisabledEnv(),
245
+ json,
246
+ deps: {
247
+ spawn,
248
+ createError
249
+ }
250
+ });
251
+ return buildDisabledRunResult();
252
+ }
230
253
 
231
254
  // ============================================================================
232
255
  // High-Level Run Operations
@@ -278,25 +301,22 @@ export async function runTests({
278
301
  hasApiKey: hasApiKey(config),
279
302
  tdd
280
303
  })) {
281
- let env = buildDisabledEnv();
282
- await executeTestCommand({
283
- command: testCommand,
284
- env,
304
+ return executeDisabledTestRun({
305
+ testCommand,
285
306
  json: runOptions.json,
286
307
  deps: {
287
308
  spawn,
288
309
  createError
289
310
  }
290
311
  });
291
- return buildDisabledRunResult();
292
312
  }
293
313
  let buildId = null;
294
314
  let buildUrl = null;
295
315
  let screenshotCount = 0;
296
316
  let testSuccess = false;
297
317
  let testError = null;
318
+ let setBaseline;
298
319
  try {
299
- // Create build
300
320
  buildId = await createBuild({
301
321
  runOptions,
302
322
  tdd,
@@ -332,7 +352,7 @@ export async function runTests({
332
352
  }
333
353
 
334
354
  // Start server
335
- let setBaseline = normalizeSetBaseline(runOptions);
355
+ setBaseline = normalizeSetBaseline(runOptions);
336
356
  await serverManager.start(buildId, tdd, setBaseline);
337
357
  if (onServerReady) {
338
358
  onServerReady({
@@ -341,32 +361,48 @@ export async function runTests({
341
361
  tdd
342
362
  });
343
363
  }
344
-
345
- // Execute test command
346
- let env = buildTestEnv({
347
- port: config.server?.port,
348
- buildId,
349
- setBaseline,
350
- failOnDiff: runOptions.failOnDiff || false
364
+ } catch (error) {
365
+ output.warn('Vizzly encountered an error. Running tests without visual testing.');
366
+ output.debug('run', 'setup failed; disabling Vizzly', {
367
+ error: error.message
351
368
  });
352
369
  try {
353
- await executeTestCommand({
354
- command: testCommand,
355
- env,
356
- json: runOptions.json,
357
- deps: {
358
- spawn,
359
- createError
360
- }
370
+ await serverManager.stop();
371
+ } catch (stopError) {
372
+ output.debug('run', 'failed to stop Vizzly after setup error', {
373
+ error: stopError.message
361
374
  });
362
- testSuccess = true;
363
- } catch (error) {
364
- testError = error;
365
- testSuccess = false;
366
375
  }
376
+ return executeDisabledTestRun({
377
+ testCommand,
378
+ json: runOptions.json,
379
+ deps: {
380
+ spawn,
381
+ createError
382
+ }
383
+ });
384
+ }
385
+
386
+ // Execute the user's tests. This is the only error that can fail the run.
387
+ let env = buildTestEnv({
388
+ port: config.server?.port,
389
+ buildId,
390
+ setBaseline,
391
+ failOnDiff: runOptions.failOnDiff || false
392
+ });
393
+ try {
394
+ await executeTestCommand({
395
+ command: testCommand,
396
+ env,
397
+ json: runOptions.json,
398
+ deps: {
399
+ spawn,
400
+ createError
401
+ }
402
+ });
403
+ testSuccess = true;
367
404
  } catch (error) {
368
405
  testError = error;
369
- testSuccess = false;
370
406
  }
371
407
 
372
408
  // Get TDD results before stopping the server
@@ -410,7 +446,13 @@ export async function runTests({
410
446
 
411
447
  // In API mode, get actual screenshot count from handler after flush
412
448
  if (!tdd && serverManager.server?.getScreenshotCount) {
413
- screenshotCount = serverManager.server.getScreenshotCount(buildId) || 0;
449
+ try {
450
+ screenshotCount = serverManager.server.getScreenshotCount(buildId) || 0;
451
+ } catch (countError) {
452
+ output.debug('build', 'failed to get screenshot count', {
453
+ error: countError.message
454
+ });
455
+ }
414
456
  }
415
457
  } finally {
416
458
  try {
@@ -205,10 +205,12 @@ export interface BuildResult {
205
205
  status: 'completed' | 'failed' | 'pending';
206
206
  build: unknown;
207
207
  comparisons?: number;
208
- totalComparisons: number;
208
+ totalComparisons?: number;
209
209
  passedComparisons?: number;
210
210
  failedComparisons?: number;
211
- newComparisons: number;
211
+ newComparisons?: number;
212
+ identicalComparisons?: number;
213
+ approvalStatus?: string;
212
214
  url?: string;
213
215
  }
214
216
 
@@ -340,15 +340,15 @@ export function buildUploadResult({
340
340
  * @returns {Object} Wait result
341
341
  */
342
342
  export function buildWaitResult(build) {
343
- let totalComparisons = build.comparisonsTotal ?? build.total_comparisons ?? 0;
344
- let passedComparisons = build.comparisonsPassed ?? build.passed_comparisons ?? build.identicalComparisons ?? build.identical_comparisons ?? 0;
345
- let failedComparisons = build.comparisonsFailed ?? build.failed_comparisons ?? build.changedComparisons ?? build.changed_comparisons ?? 0;
346
- let newComparisons = build.comparisonsNew ?? build.newComparisons ?? build.new_comparisons ?? 0;
343
+ let totalComparisons = build.comparisonsTotal ?? build.total_comparisons;
344
+ let passedComparisons = build.comparisonsPassed ?? build.passed_comparisons ?? build.identicalComparisons ?? build.identical_comparisons;
345
+ let failedComparisons = build.comparisonsFailed ?? build.failed_comparisons ?? build.changedComparisons ?? build.changed_comparisons;
346
+ let newComparisons = build.comparisonsNew ?? build.newComparisons ?? build.new_comparisons;
347
347
  let identicalComparisons = build.identicalComparisons ?? build.identical_comparisons ?? passedComparisons;
348
- let approvalStatus = build.approvalStatus ?? build.approval_status ?? 'pending';
348
+ let approvalStatus = build.approvalStatus ?? build.approval_status;
349
349
  let hasComparisonCounts = build.comparisonsTotal !== undefined || build.total_comparisons !== undefined || build.comparisonsPassed !== undefined || build.passed_comparisons !== undefined || build.comparisonsFailed !== undefined || build.failed_comparisons !== undefined || build.comparisonsNew !== undefined || build.new_comparisons !== undefined || build.identicalComparisons !== undefined || build.identical_comparisons !== undefined;
350
350
  let result = {
351
- status: 'completed',
351
+ status: build.status,
352
352
  build,
353
353
  passedComparisons,
354
354
  failedComparisons,
@@ -281,7 +281,10 @@ export async function waitForBuild({
281
281
  return buildWaitResult(build);
282
282
  }
283
283
  if (build.status === 'failed') {
284
- throw createError(`Build failed: ${build.error || 'Unknown error'}`, 'BUILD_FAILED');
284
+ throw createError(`Build failed: ${build.error || 'Unknown error'}`, 'BUILD_FAILED', {
285
+ buildId,
286
+ build
287
+ });
285
288
  }
286
289
  let remaining = timeout - getElapsedTime(startTime, now());
287
290
  if (remaining <= 0) {
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Read the API's canonical visual-review state with legacy approval fallbacks.
3
+ *
4
+ * @param {Object} record - Comparison or review record from the API.
5
+ * @returns {string|null} The review state when the API supplied one.
6
+ */
7
+ export function getVisualReviewState(record = {}) {
8
+ return record.visual_review?.state || record.review_state || record.approval_status || null;
9
+ }
10
+
11
+ /**
12
+ * Keep visual outcome separate from processing status when both are available.
13
+ *
14
+ * @param {Object} comparison - Comparison record from the API.
15
+ * @returns {string|null} The visual result, or the legacy status fallback.
16
+ */
17
+ export function getComparisonResult(comparison = {}) {
18
+ return comparison.result || comparison.status || null;
19
+ }
20
+
21
+ /**
22
+ * Resolve a screenshot name across current and legacy response shapes.
23
+ *
24
+ * @param {Object} comparison - Comparison record from the API.
25
+ * @returns {string|null} The first API-provided name or identity fallback.
26
+ */
27
+ export function getComparisonName(comparison = {}) {
28
+ return comparison.name || comparison.screenshot_name || comparison.current_screenshot?.name || comparison.screenshot?.name || comparison.current_name || comparison.id || null;
29
+ }
30
+
31
+ /**
32
+ * Resolve CSS viewport dimensions without confusing them with bitmap size.
33
+ *
34
+ * @param {Object} comparison - Comparison record from the API.
35
+ * @returns {{width: number|null, height: number|null}|null} Viewport dimensions.
36
+ */
37
+ export function getComparisonViewport(comparison = {}) {
38
+ let current = comparison.current_screenshot || comparison.screenshot || {};
39
+ let viewport = comparison.viewport || current.viewport || {};
40
+ let width = viewport.width ?? comparison.viewport_width ?? current.viewport_width ?? comparison.current_viewport_width ?? null;
41
+ let height = viewport.height ?? comparison.viewport_height ?? current.viewport_height ?? comparison.current_viewport_height ?? null;
42
+ return width != null || height != null ? {
43
+ width,
44
+ height
45
+ } : null;
46
+ }
47
+
48
+ /**
49
+ * Resolve the browser from nested current screenshot or legacy metadata fields.
50
+ *
51
+ * @param {Object} comparison - Comparison record from the API.
52
+ * @returns {string|null} The browser name when present.
53
+ */
54
+ export function getComparisonBrowser(comparison = {}) {
55
+ return comparison.browser || comparison.current_screenshot?.browser || comparison.screenshot?.browser || comparison.current_browser || comparison.current_metadata?.browser || comparison.current_metadata?.properties?.browser || null;
56
+ }
57
+
58
+ /**
59
+ * Resolve bitmap dimensions from nested or flat screenshot fields.
60
+ *
61
+ * @param {Object} record - Preferred screenshot record.
62
+ * @param {Object} fallback - Legacy fields to use when the record is incomplete.
63
+ * @returns {{width: number|null, height: number|null}|null} Bitmap dimensions.
64
+ */
65
+ function getBitmap(record = {}, fallback = {}) {
66
+ let bitmap = record.bitmap || fallback.bitmap || {};
67
+ let width = bitmap.width ?? record.bitmap_width ?? fallback.bitmap_width ?? null;
68
+ let height = bitmap.height ?? record.bitmap_height ?? fallback.bitmap_height ?? null;
69
+ return width != null || height != null ? {
70
+ width,
71
+ height
72
+ } : null;
73
+ }
74
+
75
+ /**
76
+ * Resolve viewport dimensions from one screenshot-shaped record.
77
+ *
78
+ * @param {Object} record - Preferred screenshot record.
79
+ * @param {Object} fallback - Legacy fields to use when the record is incomplete.
80
+ * @returns {{width: number|null, height: number|null}|null} Viewport dimensions.
81
+ */
82
+ function getRecordViewport(record = {}, fallback = {}) {
83
+ let viewport = record.viewport || fallback.viewport || {};
84
+ let width = viewport.width ?? record.viewport_width ?? fallback.viewport_width ?? null;
85
+ let height = viewport.height ?? record.viewport_height ?? fallback.viewport_height ?? null;
86
+ return width != null || height != null ? {
87
+ width,
88
+ height
89
+ } : null;
90
+ }
91
+
92
+ /**
93
+ * Normalize current screenshot identity, render facts, metadata, and asset URL.
94
+ *
95
+ * @param {Object} comparison - Comparison record from the API.
96
+ * @returns {Object} A stable current screenshot projection.
97
+ */
98
+ function getCurrentScreenshot(comparison = {}) {
99
+ let screenshot = comparison.current_screenshot || comparison.screenshot || {};
100
+ return {
101
+ id: screenshot.id || comparison.current_screenshot_id || null,
102
+ name: screenshot.name || getComparisonName(comparison),
103
+ status: screenshot.status || comparison.screenshot_status || null,
104
+ browser: screenshot.browser || getComparisonBrowser(comparison),
105
+ device: screenshot.device || comparison.device || null,
106
+ viewport: getRecordViewport(screenshot, {
107
+ viewport: comparison.viewport,
108
+ viewport_width: comparison.current_viewport_width ?? comparison.viewport_width,
109
+ viewport_height: comparison.current_viewport_height ?? comparison.viewport_height
110
+ }) || getComparisonViewport(comparison),
111
+ bitmap: getBitmap(screenshot, {
112
+ bitmap: comparison.bitmap,
113
+ bitmap_width: comparison.current_width ?? comparison.bitmap_width,
114
+ bitmap_height: comparison.current_height ?? comparison.bitmap_height
115
+ }),
116
+ metadata: screenshot.metadata || comparison.current_metadata || comparison.metadata || null,
117
+ signature: screenshot.signature || comparison.current_signature || comparison.signature || null,
118
+ url: screenshot.url || screenshot.original_url || comparison.current_url || comparison.current_original_url || comparison.current_screenshot_url || null,
119
+ error_message: screenshot.error_message || comparison.error_message || null
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Normalize the baseline screenshot without inventing missing render facts.
125
+ *
126
+ * @param {Object} comparison - Comparison record from the API.
127
+ * @returns {Object} A stable baseline screenshot projection.
128
+ */
129
+ function getBaselineScreenshot(comparison = {}) {
130
+ let current = comparison.current_screenshot || comparison.screenshot || {};
131
+ let baseline = comparison.baseline_screenshot || comparison.baseline || current.baseline || {};
132
+ return {
133
+ id: baseline.id || comparison.baseline_screenshot_id || null,
134
+ build_id: baseline.build_id || comparison.baseline_build_id || null,
135
+ name: baseline.name || comparison.baseline_name || null,
136
+ browser: baseline.browser || null,
137
+ viewport: getRecordViewport(baseline),
138
+ bitmap: getBitmap(baseline),
139
+ metadata: baseline.metadata || comparison.baseline_metadata || null,
140
+ signature: baseline.signature || comparison.baseline_signature || null,
141
+ url: baseline.url || baseline.original_url || comparison.baseline_url || comparison.baseline_original_url || comparison.baseline_screenshot_url || null
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Project compact Honeydiff facts without making raw geometry the default.
147
+ *
148
+ * Agents need stable counts, fingerprints, URLs, and the API projection for
149
+ * first-pass diagnosis. Raw regions and scoring details stay behind an
150
+ * explicit include because they can dominate an otherwise bounded handoff.
151
+ *
152
+ * @param {Object} comparison - Comparison record from the API.
153
+ * @param {boolean} includeDiffs - Whether to include raw Honeydiff diagnostics.
154
+ * @returns {Object} Compact diff evidence suitable for context summaries.
155
+ */
156
+ function getComparisonDiff(comparison = {}, includeDiffs = false) {
157
+ let diff = comparison.diff || comparison.diff_image || comparison.analysis || {};
158
+ let analysis = comparison.analysis || {};
159
+ let regions = diff.regions || diff.diff_regions || analysis.diff_regions || comparison.diff_regions;
160
+ let projection = diff.projection || diff.analysis_projection || analysis.projection || analysis.analysis_projection || comparison.analysis_projection || comparison.projection || null;
161
+ let compact = {
162
+ percentage: diff.percentage ?? analysis.percentage ?? analysis.diff_percentage ?? comparison.diff_percentage ?? null,
163
+ changed_pixels: diff.changed_pixels ?? analysis.changed_pixels ?? comparison.changed_pixels ?? null,
164
+ total_pixels: diff.total_pixels ?? analysis.total_pixels ?? comparison.total_pixels ?? null,
165
+ threshold: diff.threshold ?? analysis.threshold ?? comparison.threshold ?? null,
166
+ fingerprint_hash: diff.fingerprint_hash || analysis.fingerprint_hash || comparison.fingerprint_hash || null,
167
+ region_count: diff.region_count ?? analysis.region_count ?? comparison.region_count ?? projection?.clusters?.count ?? (Array.isArray(regions) ? regions.length : null),
168
+ projection,
169
+ image_url: diff.image_url || diff.url || analysis.diff_image_url || comparison.diff_url || comparison.diff_image_url || null
170
+ };
171
+ if (includeDiffs) {
172
+ compact.regions = regions || [];
173
+ compact.cluster_metadata = diff.cluster_metadata || analysis.cluster_metadata || comparison.cluster_metadata || null;
174
+ compact.ssim_score = diff.ssim_score ?? analysis.ssim_score ?? comparison.ssim_score ?? null;
175
+ compact.gmsd_score = diff.gmsd_score ?? analysis.gmsd_score ?? comparison.gmsd_score ?? null;
176
+ compact.diff_lines = diff.diff_lines || analysis.diff_lines || comparison.diff_lines || [];
177
+ }
178
+ return compact;
179
+ }
180
+
181
+ /**
182
+ * Prefer explicit review need and return null when the API supplied no review fact.
183
+ *
184
+ * @param {Object} comparison - Comparison record from the API.
185
+ * @param {string|null} reviewState - Canonical review state for the record.
186
+ * @returns {boolean|null} Whether the comparison needs review, when known.
187
+ */
188
+ function comparisonNeedsReview(comparison = {}, reviewState = null) {
189
+ if (comparison.needs_review != null) {
190
+ return comparison.needs_review === true;
191
+ }
192
+ if (reviewState == null) {
193
+ return null;
194
+ }
195
+ return reviewState === 'pending';
196
+ }
197
+
198
+ /**
199
+ * Normalize one comparison across grouped and legacy flat API response shapes.
200
+ *
201
+ * @param {Object} comparison - Comparison record from the API.
202
+ * @param {Object} options - Normalization options.
203
+ * @param {string|null} [options.fallbackName] - Group name for unnamed variants.
204
+ * @param {boolean} [options.includeDiffs] - Include raw Honeydiff diagnostics.
205
+ * @returns {Object} A stable comparison evidence record.
206
+ */
207
+ export function normalizeComparisonRecord(comparison = {}, options = {}) {
208
+ let reviewState = getVisualReviewState(comparison);
209
+ let name = comparison.name || comparison.screenshot_name || comparison.current_screenshot?.name || comparison.screenshot?.name || comparison.current_name || options.fallbackName || comparison.id || null;
210
+ return {
211
+ id: comparison.id || null,
212
+ name,
213
+ result: getComparisonResult(comparison),
214
+ status: comparison.status || null,
215
+ review_state: reviewState,
216
+ visual_review: comparison.visual_review || null,
217
+ approval_status: comparison.approval_status || null,
218
+ build_branch: comparison.build_branch || null,
219
+ needs_review: comparisonNeedsReview(comparison, reviewState),
220
+ is_flaky: comparison.is_flaky == null ? null : comparison.is_flaky === true,
221
+ browser: getComparisonBrowser(comparison),
222
+ viewport: getComparisonViewport(comparison),
223
+ screenshot: getCurrentScreenshot(comparison),
224
+ baseline: getBaselineScreenshot(comparison),
225
+ diff: getComparisonDiff(comparison, options.includeDiffs === true)
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Read the server's total variant count across supported response shapes.
231
+ *
232
+ * @param {Object} group - Screenshot group from the API.
233
+ * @returns {number|null} The explicit total when present.
234
+ */
235
+ function getExplicitVariantCount(group = {}) {
236
+ return group.variant_count ?? group.total_variants ?? group.totalVariants ?? null;
237
+ }
238
+
239
+ /**
240
+ * Prove whether every variant is available before deriving aggregate facts.
241
+ *
242
+ * @param {Object} group - Screenshot group from the API.
243
+ * @param {Object[]} variants - Returned normalized variants.
244
+ * @param {boolean} forcedComplete - Whether the caller owns the complete set.
245
+ * @returns {boolean} Whether aggregate derivation is safe.
246
+ */
247
+ function hasCompleteVariants(group = {}, variants = [], forcedComplete = false) {
248
+ if (forcedComplete || group.variants_complete === true) {
249
+ return true;
250
+ }
251
+ let explicitCount = getExplicitVariantCount(group);
252
+ return explicitCount != null && explicitCount === variants.length;
253
+ }
254
+
255
+ /**
256
+ * Derive only facts backed by a complete variant set and complete source fields.
257
+ *
258
+ * Unknown review, flaky, result, or diff values stay null instead of becoming
259
+ * client-authored false or zero values.
260
+ *
261
+ * @param {Object[]} variants - Complete normalized variants for one screenshot.
262
+ * @param {boolean} complete - Whether every variant is present.
263
+ * @returns {Object} Exact derived aggregate facts, or an empty object.
264
+ */
265
+ function deriveAggregateFacts(variants = [], complete = false) {
266
+ if (!complete) {
267
+ return {};
268
+ }
269
+ let results = variants.map(variant => variant.result);
270
+ let reviewStates = variants.map(variant => variant.review_state);
271
+ let needsReview = variants.map(variant => variant.needs_review);
272
+ let flaky = variants.map(variant => variant.is_flaky);
273
+ let percentages = variants.map(variant => variant.diff.percentage).filter(value => value != null);
274
+ let allResultsKnown = results.every(value => value != null);
275
+ let allReviewStatesKnown = reviewStates.every(value => value != null);
276
+ let allNeedsReviewKnown = needsReview.every(value => value != null);
277
+ let allFlakyKnown = flaky.every(value => value != null);
278
+ return {
279
+ has_changes: results.includes('changed') ? true : allResultsKnown ? false : null,
280
+ has_new: results.includes('new') ? true : allResultsKnown ? false : null,
281
+ all_approved: variants.length > 0 && allReviewStatesKnown ? reviewStates.every(state => state === 'approved') : null,
282
+ needs_review: allNeedsReviewKnown ? needsReview.some(value => value === true) : null,
283
+ needs_review_count: allNeedsReviewKnown ? needsReview.filter(value => value === true).length : null,
284
+ failed_count: allResultsKnown ? results.filter(result => ['failed', 'error'].includes(result)).length : null,
285
+ has_rejected: reviewStates.includes('rejected') ? true : allReviewStatesKnown ? false : null,
286
+ has_flaky: flaky.includes(true) ? true : allFlakyKnown ? false : null,
287
+ max_diff_percentage: percentages.length === variants.length && percentages.length > 0 ? Math.max(...percentages) : null
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Normalize a screenshot group while keeping explicit server aggregates authoritative.
293
+ *
294
+ * @param {Object} group - Grouped visual-review record from the API.
295
+ * @param {Object} options - Normalization options.
296
+ * @param {boolean} [options.variantsComplete] - Marks a caller-owned complete set.
297
+ * @returns {Object} A normalized screenshot group and its variants.
298
+ */
299
+ export function normalizeComparisonGroup(group = {}, options = {}) {
300
+ let rawVariants = group.variants || group.comparisons || [];
301
+ let groupName = group.name || group.test_name || group.testName || null;
302
+ let variants = rawVariants.map(variant => normalizeComparisonRecord(variant, {
303
+ fallbackName: groupName,
304
+ includeDiffs: options.includeDiffs
305
+ }));
306
+ let complete = hasCompleteVariants(group, variants, options.variantsComplete === true);
307
+ let derived = deriveAggregateFacts(variants, complete);
308
+ let aggregate = group.aggregate_status || {};
309
+ let needsReviewCount = aggregate.needs_review_count ?? derived.needs_review_count ?? null;
310
+ return {
311
+ name: groupName || variants[0]?.name || 'unknown screenshot',
312
+ variant_count: getExplicitVariantCount(group) ?? (complete ? variants.length : null),
313
+ variants_complete: complete,
314
+ variants,
315
+ aggregate_status: {
316
+ has_changes: aggregate.has_changes ?? derived.has_changes ?? null,
317
+ has_new: aggregate.has_new ?? derived.has_new ?? null,
318
+ all_approved: aggregate.all_approved ?? derived.all_approved ?? null,
319
+ needs_review: aggregate.needs_review ?? (aggregate.needs_review_count != null ? aggregate.needs_review_count > 0 : derived.needs_review ?? null),
320
+ needs_review_count: needsReviewCount,
321
+ failed_count: aggregate.failed_count ?? derived.failed_count ?? null,
322
+ has_rejected: aggregate.has_rejected ?? derived.has_rejected ?? null,
323
+ has_flaky: aggregate.has_flaky ?? derived.has_flaky ?? null,
324
+ max_diff_percentage: aggregate.max_diff_percentage ?? derived.max_diff_percentage ?? null
325
+ }
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Group a complete legacy flat comparison list by screenshot name.
331
+ *
332
+ * @param {Object[]} comparisons - Complete flat comparisons from build context.
333
+ * @param {Object} options - Normalization options passed to each comparison.
334
+ * @returns {Object[]} Normalized screenshot groups in API order.
335
+ */
336
+ function groupFlatComparisons(comparisons = [], options = {}) {
337
+ let grouped = new Map();
338
+ for (let comparison of comparisons) {
339
+ let name = getComparisonName(comparison) || 'unknown screenshot';
340
+ let group = grouped.get(name) || {
341
+ name,
342
+ variants: []
343
+ };
344
+ group.variants.push(comparison);
345
+ grouped.set(name, group);
346
+ }
347
+ return [...grouped.values()].map(group => normalizeComparisonGroup(group, {
348
+ ...options,
349
+ variantsComplete: true
350
+ }));
351
+ }
352
+
353
+ /**
354
+ * Join compact group variants to the exact comparison records in the same API
355
+ * response.
356
+ *
357
+ * The build context API deliberately keeps group variants small while also
358
+ * returning complete comparison evidence at the top level. Joining by the
359
+ * server-owned comparison ID keeps the useful group ordering and aggregates
360
+ * without dropping screenshot identity, baseline facts, or Honeydiff data.
361
+ *
362
+ * @param {Object[]} groups - Raw comparison groups from build context.
363
+ * @param {Object[]} comparisons - Exact top-level comparison records.
364
+ * @returns {Object[]} Groups whose variants include exact comparison evidence.
365
+ */
366
+ function joinGroupComparisons(groups = [], comparisons = []) {
367
+ let comparisonsById = new Map(comparisons.filter(comparison => comparison?.id).map(comparison => [comparison.id, comparison]));
368
+ return groups.map(group => {
369
+ let variantKey = Array.isArray(group.variants) ? 'variants' : 'comparisons';
370
+ let variants = Array.isArray(group[variantKey]) ? group[variantKey] : [];
371
+ return {
372
+ ...group,
373
+ [variantKey]: variants.map(variant => ({
374
+ ...variant,
375
+ ...(comparisonsById.get(variant.id) || {})
376
+ }))
377
+ };
378
+ });
379
+ }
380
+
381
+ /**
382
+ * Keep failed capture identity, render evidence, and the API error together.
383
+ *
384
+ * A capture can fail before any comparison exists, so it must remain useful
385
+ * evidence without inheriting comparison-only assumptions.
386
+ *
387
+ * @param {Object} screenshot - Failed screenshot record from the API.
388
+ * @param {Object} options - Normalization options passed to the record.
389
+ * @returns {Object} A normalized failed-capture record.
390
+ */
391
+ function normalizeFailedCapture(screenshot = {}, options = {}) {
392
+ let normalized = normalizeComparisonRecord({
393
+ ...screenshot,
394
+ screenshot,
395
+ result: screenshot.result || screenshot.status || 'failed'
396
+ }, options);
397
+ return {
398
+ ...normalized,
399
+ error_message: screenshot.error_message || normalized.screenshot.error_message || null
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Collect failed captures from explicit and screenshot-list response shapes.
405
+ *
406
+ * @param {Object} context - Raw build context response.
407
+ * @param {Object} options - Normalization options passed to each capture.
408
+ * @returns {Object[]} Deduplicated failed captures in API order.
409
+ */
410
+ function getFailedCaptures(context = {}, options = {}) {
411
+ let explicit = Array.isArray(context.failed_captures) ? context.failed_captures : Array.isArray(context.failed_screenshots) ? context.failed_screenshots : [];
412
+ let screenshots = (context.screenshots || []).filter(screenshot => ['failed', 'error'].includes(screenshot.status));
413
+ let captures = [...explicit, ...screenshots];
414
+ let seen = new Set();
415
+ return captures.map(capture => normalizeFailedCapture(capture, options)).filter(capture => {
416
+ let key = capture.id || capture.screenshot.signature || capture.name;
417
+ if (seen.has(key)) {
418
+ return false;
419
+ }
420
+ seen.add(key);
421
+ return true;
422
+ });
423
+ }
424
+
425
+ /**
426
+ * Adapt raw build context for human presentation across current and legacy APIs.
427
+ *
428
+ * Raw JSON output intentionally bypasses this lossy presentation boundary.
429
+ *
430
+ * @param {Object} context - Raw build context response from the provider.
431
+ * @param {Object} options - Normalization options.
432
+ * @param {boolean} [options.includeDiffs] - Include raw Honeydiff diagnostics.
433
+ * @returns {Object} Normalized comparisons, groups, and failed captures.
434
+ */
435
+ export function normalizeBuildContext(context = {}, options = {}) {
436
+ let rawComparisons = context.comparisons || [];
437
+ let comparisons = rawComparisons.map(comparison => normalizeComparisonRecord(comparison, options));
438
+ let joinedGroups = joinGroupComparisons(context.groups || [], rawComparisons);
439
+ let groups = joinedGroups.length > 0 ? joinedGroups.map(group => normalizeComparisonGroup(group, options)) : groupFlatComparisons(rawComparisons, options);
440
+ return {
441
+ ...context,
442
+ comparisons,
443
+ groups,
444
+ failed_captures: getFailedCaptures(context, options)
445
+ };
446
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.35.2",
3
+ "version": "0.35.3-beta.0",
4
4
  "description": "Visual regression testing from your terminal",
5
5
  "keywords": [
6
6
  "llm-context",
@@ -78,7 +78,6 @@
78
78
  "test:swift:e2e": "pnpm run build && node clients/swift/scripts/run-e2e.js",
79
79
  "test:tui": "node --test --test-reporter=spec tests/tui/*.test.js",
80
80
  "test:tui:docker": "./tests/tui/run-tui-tests.sh",
81
- "smoke:prod:pack": "node scripts/smoke-prod-pack.js",
82
81
  "lint": "biome check src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin",
83
82
  "lint:fix": "biome check --write --unsafe src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin",
84
83
  "format": "biome format --write src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin",
@@ -93,10 +92,10 @@
93
92
  "registry": "https://registry.npmjs.org/"
94
93
  },
95
94
  "dependencies": {
96
- "@vizzly-testing/bear-den": "0.1.2",
95
+ "@vizzly-testing/bear-den": "0.1.4",
97
96
  "@vizzly-testing/honeydiff": "^0.11.1",
98
97
  "ansis": "^4.2.0",
99
- "commander": "^14.0.0",
98
+ "commander": "^15.0.0",
100
99
  "cosmiconfig": "^9.0.0",
101
100
  "dotenv": "^17.2.1",
102
101
  "form-data": "^4.0.0",