@vizzly-testing/cli 0.35.1 → 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.
@@ -8,6 +8,7 @@ import { resolveContextSource as defaultResolveContextSource } from '../context/
8
8
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
9
9
  import * as defaultOutput from '../utils/output.js';
10
10
  import { readSession as defaultReadSession } from '../utils/session.js';
11
+ import { normalizeBuildContext } from '../utils/visual-context-normalizers.js';
11
12
  function buildAuthErrorMessage() {
12
13
  return 'Authentication required. Use --token, set VIZZLY_TOKEN, run "vizzly login", or link a project.';
13
14
  }
@@ -76,6 +77,24 @@ function createClient(config, createApiClient) {
76
77
  command: 'context'
77
78
  });
78
79
  }
80
+
81
+ /**
82
+ * Attach the provider that produced a context payload.
83
+ *
84
+ * Context endpoints return API-owned evidence, while source selection belongs
85
+ * to the CLI. Keeping both in one payload prevents follow-up agent commands
86
+ * from silently crossing between cloud and local evidence.
87
+ *
88
+ * @param {Object} context - Context payload returned by a provider.
89
+ * @param {string} source - Provider source selected by the CLI.
90
+ * @returns {Object} Context payload with explicit source provenance.
91
+ */
92
+ function attachContextSource(context, source) {
93
+ return {
94
+ ...context,
95
+ source: context?.source || source
96
+ };
97
+ }
79
98
  async function loadContextConfig(globalOptions, options, deps) {
80
99
  let {
81
100
  loadConfig = defaultLoadConfig,
@@ -108,20 +127,20 @@ function createCloudContextProvider(config, deps = {}) {
108
127
  let client = createClient(config, createApiClient);
109
128
  return {
110
129
  source: 'cloud',
111
- async getBuildContext(buildId) {
112
- return await getBuildContext(client, buildId);
130
+ async getBuildContext(buildId, query) {
131
+ return attachContextSource(await getBuildContext(client, buildId, query), 'cloud');
113
132
  },
114
133
  async getComparisonContext(comparisonId, query) {
115
- return await getComparisonContext(client, comparisonId, query);
134
+ return attachContextSource(await getComparisonContext(client, comparisonId, query), 'cloud');
116
135
  },
117
136
  async getScreenshotContext(screenshotName, query) {
118
- return await getScreenshotContext(client, screenshotName, query);
137
+ return attachContextSource(await getScreenshotContext(client, screenshotName, query), 'cloud');
119
138
  },
120
139
  async getSimilarFingerprintContext(fingerprintHash, query) {
121
- return await getSimilarFingerprintContext(client, fingerprintHash, query);
140
+ return attachContextSource(await getSimilarFingerprintContext(client, fingerprintHash, query), 'cloud');
122
141
  },
123
142
  async getReviewQueueContext(query) {
124
- return await getReviewQueueContext(client, query);
143
+ return attachContextSource(await getReviewQueueContext(client, query), 'cloud');
125
144
  }
126
145
  };
127
146
  }
@@ -235,139 +254,286 @@ function getComparisonDiffPercentage(comparison = {}) {
235
254
  function getComparisonFingerprint(comparison = {}) {
236
255
  return comparison.diff?.fingerprint_hash || comparison.analysis?.fingerprint_hash || null;
237
256
  }
238
- function getComparisonDiffImageUrl(comparison = {}) {
239
- return comparison.diff?.image_url || comparison.analysis?.diff_image_url || null;
240
- }
241
- function getComparisonRegionCount(comparison = {}) {
242
- let regions = comparison.diff?.regions || comparison.analysis?.diff_regions;
243
- return Array.isArray(regions) ? regions.length : 0;
244
- }
245
- function getComparisonScreenshot(comparison = {}) {
246
- return comparison.screenshot || {};
247
- }
248
- function getComparisonBaseline(comparison = {}) {
249
- return comparison.baseline || comparison.screenshot?.baseline || {};
250
- }
251
- function buildCompactDiff(comparison = {}, includeDiffs = false) {
252
- let diff = comparison.diff || comparison.analysis || {};
253
- let compact = {
254
- percentage: getComparisonDiffPercentage(comparison),
255
- changed_pixels: diff.changed_pixels ?? comparison.changed_pixels ?? null,
256
- total_pixels: diff.total_pixels ?? comparison.total_pixels ?? null,
257
- threshold: diff.threshold ?? comparison.threshold ?? null,
258
- fingerprint_hash: getComparisonFingerprint(comparison),
259
- region_count: getComparisonRegionCount(comparison),
260
- image_url: getComparisonDiffImageUrl(comparison)
261
- };
262
- if (includeDiffs) {
263
- compact.regions = diff.regions || diff.diff_regions || [];
264
- compact.cluster_metadata = diff.cluster_metadata || null;
265
- compact.ssim_score = diff.ssim_score ?? null;
266
- compact.gmsd_score = diff.gmsd_score ?? null;
267
- compact.diff_lines = diff.diff_lines || [];
257
+
258
+ /**
259
+ * Decide whether a comparison belongs in the agent handoff.
260
+ *
261
+ * Explicit API review state wins because an already-reviewed visual change is
262
+ * not actionable. Legacy result fallback keeps older flat responses useful
263
+ * only when the server did not supply that review fact.
264
+ *
265
+ * @param {Object} comparison - Normalized comparison record.
266
+ * @returns {boolean} Whether the record is actionable evidence.
267
+ */
268
+ function isEvidenceCandidate(comparison = {}) {
269
+ if (comparison.needs_review != null) {
270
+ return comparison.needs_review === true;
268
271
  }
269
- return compact;
272
+ return ['changed', 'new', 'failed', 'error'].includes(comparison.result);
270
273
  }
271
- function buildCompactComparison(comparison = {}, {
272
- includeDiffs = false
273
- } = {}) {
274
- let screenshot = getComparisonScreenshot(comparison);
275
- let baseline = getComparisonBaseline(comparison);
274
+
275
+ /**
276
+ * Keep the group facts needed to understand one comparison in isolation.
277
+ *
278
+ * Repeating this small server-owned summary on each record avoids returning
279
+ * the full, potentially unbounded group tree in compact agent output.
280
+ *
281
+ * @param {Object} group - Normalized screenshot group.
282
+ * @returns {Object} Compact API-backed aggregate facts for the group.
283
+ */
284
+ function buildEvidenceGroup(group = {}) {
285
+ let aggregate = group.aggregate_status || {};
276
286
  return {
277
- id: comparison.id || null,
278
- name: getComparisonName(comparison),
279
- result: getComparisonDisplayState(comparison),
280
- approval_status: comparison.approval_status || null,
281
- needs_review: Boolean(comparison.needs_review),
282
- is_flaky: Boolean(comparison.is_flaky),
283
- screenshot: {
284
- id: screenshot.id || null,
285
- url: screenshot.url || screenshot.original_url || null
286
- },
287
- baseline: {
288
- id: baseline.id || null,
289
- build_id: baseline.build_id || null,
290
- url: baseline.url || baseline.original_url || null
291
- },
292
- diff: buildCompactDiff(comparison, includeDiffs)
287
+ name: group.name || null,
288
+ variant_count: group.variant_count ?? null,
289
+ needs_review_count: aggregate.needs_review_count ?? null,
290
+ failed_count: aggregate.failed_count ?? null,
291
+ max_diff_percentage: aggregate.max_diff_percentage ?? null
293
292
  };
294
293
  }
295
- function summarizeComparisons(comparisons = []) {
294
+
295
+ /**
296
+ * Shape one normalized comparison for the bounded evidence queue.
297
+ *
298
+ * The projection keeps visual result, review state, render assets, and
299
+ * Honeydiff facts together so an agent can reason about a diff without
300
+ * joining separate collections client-side.
301
+ *
302
+ * @param {Object} comparison - Normalized comparison record.
303
+ * @param {Object} group - Normalized group containing the comparison.
304
+ * @returns {Object} One self-contained comparison evidence record.
305
+ */
306
+ function buildComparisonEvidence(comparison = {}, group = {}) {
296
307
  return {
297
- total: comparisons.length,
298
- changed: comparisons.filter(isChangedComparison).length,
299
- new: comparisons.filter(isNewComparison).length,
300
- needs_review: comparisons.filter(comparison => comparison.needs_review).length
308
+ kind: 'comparison',
309
+ id: comparison.id,
310
+ name: comparison.name,
311
+ result: comparison.result,
312
+ status: comparison.status,
313
+ review_state: comparison.review_state,
314
+ visual_review: comparison.visual_review,
315
+ approval_status: comparison.approval_status,
316
+ needs_review: comparison.needs_review,
317
+ is_flaky: comparison.is_flaky,
318
+ group: buildEvidenceGroup(group),
319
+ screenshot: comparison.screenshot,
320
+ baseline: comparison.baseline,
321
+ diff: comparison.diff
301
322
  };
302
323
  }
303
- function buildAgentNextActions(context = {}, comparisonSummary = {}) {
304
- if (context.status?.needs_review) {
305
- return ['Inspect the changed and new comparisons before editing related UI.', 'Use approved baselines as the expected visual behavior.', 'Leave approval decisions to human reviewers.'];
306
- }
307
- if (comparisonSummary.total > 0) {
308
- return ['Use the approved baseline and reviewed screenshots as current visual truth.', 'Prefer targeted edits that preserve identical comparisons.'];
309
- }
310
- return ['No comparisons were returned for this build context.', 'Open the build URL for more detail if this is unexpected.'];
311
- }
312
- function formatAgentStatus(status = {}) {
324
+
325
+ /**
326
+ * Represent a capture failure without pretending it is a comparison.
327
+ *
328
+ * Failed screenshots have useful render evidence but no comparison ID. The
329
+ * explicit kind and null ID prevent suggested commands from sending a
330
+ * screenshot identifier to the comparison endpoint.
331
+ *
332
+ * @param {Object} capture - Normalized failed screenshot capture.
333
+ * @returns {Object} One failed-capture evidence record.
334
+ */
335
+ function buildFailedCaptureEvidence(capture = {}) {
313
336
  return {
314
- needs_review: Boolean(status.needs_review),
315
- reasons: status.reasons || [],
316
- pending_comparisons: status.pending_comparisons || 0,
317
- unresolved_comments: status.unresolved_comments || 0
337
+ ...buildComparisonEvidence(capture, {
338
+ name: capture.name
339
+ }),
340
+ kind: 'failed_capture',
341
+ id: null,
342
+ error_message: capture.error_message
318
343
  };
319
344
  }
320
- function formatAgentSummary(context = {}, comparisons = []) {
321
- return {
322
- comparisons: context.summary?.comparisons || summarizeComparisons(comparisons),
323
- review: context.summary?.review || {},
324
- comments: context.summary?.comments || {
325
- build: getBuildCommentsCount(context),
326
- screenshot: getScreenshotCommentsCount(context)
345
+
346
+ /**
347
+ * Read actionable variants without overriding a server-reviewed group.
348
+ *
349
+ * A false aggregate is authoritative even when a partial variant payload
350
+ * appears pending, which prevents the client from reopening completed work.
351
+ *
352
+ * @param {Object} group - Normalized screenshot group.
353
+ * @returns {Object[]} Actionable variants in API order.
354
+ */
355
+ function getGroupEvidence(group = {}) {
356
+ if (group.aggregate_status?.needs_review === false) {
357
+ return [];
358
+ }
359
+ return (group.variants || []).filter(isEvidenceCandidate);
360
+ }
361
+
362
+ /**
363
+ * Interleave actionable variants across groups in their original API order.
364
+ *
365
+ * Taking one variant per group before taking second variants preserves useful
366
+ * breadth when the final handoff is capped and one screenshot has many device
367
+ * or browser variants.
368
+ *
369
+ * @param {Object[]} groups - Normalized screenshot groups.
370
+ * @returns {Object[]} Self-contained evidence records in breadth-first order.
371
+ */
372
+ function selectBreadthFirstEvidence(groups = []) {
373
+ let candidatesByGroup = groups.map(getGroupEvidence);
374
+ let evidence = [];
375
+ let variantIndex = 0;
376
+ let remaining = candidatesByGroup.some(candidates => candidates.length > 0);
377
+ while (remaining) {
378
+ remaining = false;
379
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
380
+ let comparison = candidatesByGroup[groupIndex][variantIndex];
381
+ if (comparison) {
382
+ evidence.push(buildComparisonEvidence(comparison, groups[groupIndex]));
383
+ remaining = true;
384
+ }
327
385
  }
328
- };
386
+ variantIndex += 1;
387
+ }
388
+ return evidence;
329
389
  }
390
+
391
+ /**
392
+ * Quote a value only when a suggested command needs shell protection.
393
+ *
394
+ * Suggested commands are meant to be executable, so names with whitespace or
395
+ * apostrophes must survive copy and paste without changing their value.
396
+ *
397
+ * @param {unknown} value - CLI argument value.
398
+ * @returns {string} A shell-safe argument for the displayed command.
399
+ */
400
+ function quoteCommandArgument(value) {
401
+ let stringValue = String(value);
402
+ if (/^[A-Za-z0-9._:/-]+$/.test(stringValue)) {
403
+ return stringValue;
404
+ }
405
+ return `'${stringValue.replaceAll("'", `'\\''`)}'`;
406
+ }
407
+
408
+ /**
409
+ * Recognize both local source labels emitted across supported context shapes.
410
+ *
411
+ * @param {Object} context - Normalized context response.
412
+ * @returns {boolean} Whether follow-up commands must stay in local mode.
413
+ */
414
+ function isLocalContext(context = {}) {
415
+ return ['local', 'local_workspace'].includes(context.source);
416
+ }
417
+
418
+ /**
419
+ * Keep executable suggestions on the source that produced their evidence.
420
+ *
421
+ * Mixed workspaces can contain both a cloud run session and persisted local
422
+ * TDD results. Pinning either source prevents a follow-up command from
423
+ * silently crossing that boundary and describing a different build.
424
+ *
425
+ * @param {string} command - Base CLI command.
426
+ * @param {Object} context - Context that produced the command.
427
+ * @returns {string} Command pinned to its originating source.
428
+ */
429
+ function appendContextSource(command, context = {}) {
430
+ let source = isLocalContext(context) ? 'local' : 'cloud';
431
+ return `${command} --source ${source}`;
432
+ }
433
+
434
+ /**
435
+ * Build concrete drill-down paths from the evidence actually returned.
436
+ *
437
+ * Executable commands replace generic client-authored advice. They let an
438
+ * agent ask the API for deeper comparison, history, or raw diff context, and
439
+ * only suggest the full build when the bounded queue omitted records.
440
+ *
441
+ * @param {Object} context - Normalized build context.
442
+ * @param {Object[]} evidence - Evidence included in the compact handoff.
443
+ * @param {boolean} truncated - Whether additional evidence was omitted.
444
+ * @returns {{label: string, command: string}[]} Suggested CLI commands.
445
+ */
446
+ function buildSuggestedCommands(context = {}, evidence = [], truncated = false) {
447
+ let commands = [];
448
+ let firstComparison = evidence.find(item => item.kind === 'comparison' && item.id);
449
+ let firstNamedEvidence = evidence.find(item => item.name);
450
+ let buildTarget = isLocalContext(context) ? 'current' : context.build?.id || null;
451
+ if (firstComparison?.id) {
452
+ commands.push({
453
+ label: 'Inspect comparison context',
454
+ command: appendContextSource(`vizzly --json context comparison ${quoteCommandArgument(firstComparison.id)}`, context)
455
+ });
456
+ }
457
+ if (firstNamedEvidence?.name) {
458
+ commands.push({
459
+ label: 'Inspect screenshot history',
460
+ command: appendContextSource(`vizzly --json context screenshot ${quoteCommandArgument(firstNamedEvidence.name)}`, context)
461
+ });
462
+ }
463
+ if (buildTarget && evidence.length > 0) {
464
+ commands.push({
465
+ label: 'Load raw diff diagnostics',
466
+ command: appendContextSource(`vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --include diffs`, context)
467
+ });
468
+ }
469
+ if (buildTarget && truncated) {
470
+ commands.push({
471
+ label: 'Load full build context',
472
+ command: appendContextSource(`vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --full`, context)
473
+ });
474
+ }
475
+ return commands;
476
+ }
477
+
478
+ /**
479
+ * Create the compact agent presentation without rewriting API truth.
480
+ *
481
+ * Status, summaries, review facts, assets, and Honeydiff values pass through
482
+ * normalization from the server. The client owns only the bounded selection,
483
+ * truthful truncation facts, explicit includes, and follow-up commands.
484
+ *
485
+ * @param {Object} context - Raw build context returned by the provider.
486
+ * @param {Object} options - Compact presentation options.
487
+ * @param {string|null} [options.source] - Resolved source fallback.
488
+ * @param {string[]} [options.include] - Explicit detail collections.
489
+ * @param {number} [options.evidenceLimit] - Maximum evidence record count.
490
+ * @returns {Object} Bounded agent build context.
491
+ */
330
492
  function buildAgentBuildPayload(context, {
331
493
  source = null,
332
494
  include = [],
333
495
  evidenceLimit = 10
334
496
  } = {}) {
335
- let comparisons = context.comparisons || [];
336
497
  let includeSet = new Set(include);
337
498
  let includeDiffs = includeSet.has('diffs');
338
- let changed = comparisons.filter(isChangedComparison);
339
- let fresh = comparisons.filter(isNewComparison);
340
- let evidence = [...changed, ...fresh].slice(0, evidenceLimit).map(comparison => buildCompactComparison(comparison, {
499
+ let normalized = normalizeBuildContext(context, {
341
500
  includeDiffs
342
- }));
343
- let comparisonSummary = summarizeComparisons(comparisons);
501
+ });
502
+ let candidates = [...normalized.failed_captures.map(buildFailedCaptureEvidence), ...selectBreadthFirstEvidence(normalized.groups)];
503
+ let evidence = candidates.slice(0, evidenceLimit);
504
+ let evidenceTruncated = candidates.length > evidence.length;
344
505
  let payload = {
345
506
  resource: 'build_agent_context',
346
- source: context.source || source || 'cloud',
347
- scope: context.scope || null,
507
+ source: normalized.source || source,
508
+ review_flow: normalized.review_flow,
509
+ scope: normalized.scope || null,
348
510
  project: {
349
- organization: context.scope?.organization?.slug || null,
350
- slug: context.scope?.project?.slug || null,
351
- name: context.scope?.project?.name || null,
352
- visibility: context.scope?.project?.visibility || null
511
+ organization: normalized.scope?.organization?.slug || null,
512
+ slug: normalized.scope?.project?.slug || null,
513
+ name: normalized.scope?.project?.name || null,
514
+ visibility: normalized.scope?.project?.visibility || null
353
515
  },
354
- build: context.build || null,
516
+ build: normalized.build || null,
355
517
  baseline: {
356
- selected: context.baseline?.selected || null,
357
- selection_reason: context.baseline?.selection_reason || null
518
+ selected: normalized.baseline?.selected || null,
519
+ selection_reason: normalized.baseline?.selection_reason || null
358
520
  },
359
- status: formatAgentStatus(context.status),
360
- summary: formatAgentSummary(context, comparisons),
521
+ status: normalized.status || null,
522
+ summary: normalized.summary || null,
523
+ signature_properties: normalized.signature_properties ?? null,
524
+ evidence_limit: evidenceLimit,
525
+ evidence_returned: evidence.length,
526
+ evidence_truncated: evidenceTruncated,
361
527
  evidence,
362
- links: context.links || {},
363
- preview: context.preview || null,
364
- next_actions: buildAgentNextActions(context, comparisonSummary)
528
+ links: normalized.links || {},
529
+ preview: normalized.preview || null,
530
+ suggested_commands: buildSuggestedCommands(normalized, evidence, evidenceTruncated)
365
531
  };
366
532
  if (includeSet.has('screenshots')) {
367
- payload.screenshots = context.screenshots || [];
533
+ payload.screenshots = normalized.screenshots || [];
368
534
  }
369
535
  if (includeSet.has('comments')) {
370
- payload.comments = context.comments || {};
536
+ payload.comments = normalized.comments || {};
371
537
  }
372
538
  return payload;
373
539
  }
@@ -396,12 +562,17 @@ function formatNeedsReview(status = {}) {
396
562
  if (!status || status.needs_review == null) {
397
563
  return null;
398
564
  }
399
- let pending = status.pending_comparisons || 0;
400
- let unresolved = status.unresolved_comments || 0;
401
565
  if (!status.needs_review) {
402
566
  return 'no';
403
567
  }
404
- return `yes · ${pending} comparisons · ${unresolved} unresolved comments`;
568
+ let details = [];
569
+ if (status.pending_comparisons != null) {
570
+ details.push(`${status.pending_comparisons} comparisons`);
571
+ }
572
+ if (status.unresolved_comments != null) {
573
+ details.push(`${status.unresolved_comments} unresolved comments`);
574
+ }
575
+ return details.length > 0 ? `yes · ${details.join(' · ')}` : 'yes';
405
576
  }
406
577
  function formatConfirmedRegionLabels(regions = []) {
407
578
  return regions.map(region => region.label).filter(Boolean).slice(0, 3).join(' · ');
@@ -436,42 +607,131 @@ function printComparisonList(output, comparisons = [], {
436
607
  }
437
608
  }
438
609
  }
610
+ function getGroupDisplayState(group = {}) {
611
+ let aggregate = group.aggregate_status || {};
612
+ if ((aggregate.failed_count ?? 0) > 0) return 'failed';
613
+ if (aggregate.has_rejected === true) return 'rejected';
614
+ if (aggregate.has_changes === true) return 'changed';
615
+ if (aggregate.has_new === true) return 'new';
616
+ if (aggregate.needs_review === true) return 'needs review';
617
+ if (aggregate.all_approved === true) return 'approved';
618
+ return null;
619
+ }
620
+ function formatViewport(viewport) {
621
+ if (!viewport) {
622
+ return null;
623
+ }
624
+ return `${viewport.width ?? '?'}×${viewport.height ?? '?'}`;
625
+ }
626
+ function printScreenshotGroups(output, groups = [], {
627
+ limit = 8
628
+ } = {}) {
629
+ let colors = output.getColors();
630
+ let visibleGroups = groups.slice(0, limit);
631
+ output.print(' Screenshot groups');
632
+ for (let group of visibleGroups) {
633
+ let displayState = getGroupDisplayState(group);
634
+ let tone = getStatusTone(colors, displayState);
635
+ let aggregate = group.aggregate_status || {};
636
+ let details = [];
637
+ if (group.variant_count != null) {
638
+ details.push(`${group.variant_count} variants`);
639
+ }
640
+ if (aggregate.needs_review_count != null) {
641
+ details.push(`${aggregate.needs_review_count} needs review`);
642
+ }
643
+ if (aggregate.failed_count != null && aggregate.failed_count > 0) {
644
+ details.push(`${aggregate.failed_count} failed`);
645
+ }
646
+ if (aggregate.max_diff_percentage != null) {
647
+ details.push(`${aggregate.max_diff_percentage}% max diff`);
648
+ }
649
+ output.print(` ${colors.bold(group.name)}${displayState ? ` ${tone(displayState.toUpperCase())}` : ''}`);
650
+ if (details.length > 0) {
651
+ output.print(` ${colors.dim(details.join(' · '))}`);
652
+ }
653
+ for (let variant of group.variants.slice(0, 3)) {
654
+ let variantDetails = [variant.review_state, variant.browser, formatViewport(variant.viewport)].filter(Boolean);
655
+ output.print(` ${getStatusTone(colors, variant.result)((variant.result || 'unknown').toUpperCase())}${variantDetails.length > 0 ? ` · ${colors.dim(variantDetails.join(' · '))}` : ''}`);
656
+ }
657
+ }
658
+ if (groups.length > visibleGroups.length) {
659
+ output.print(` ${colors.dim(`...${groups.length - visibleGroups.length} more groups`)}`);
660
+ }
661
+ }
662
+ function printFailedCaptures(output, captures = []) {
663
+ let colors = output.getColors();
664
+ output.print(' Failed captures');
665
+ for (let capture of captures.slice(0, 8)) {
666
+ let details = [capture.error_message, capture.browser, formatViewport(capture.viewport), capture.screenshot.url].filter(Boolean);
667
+ output.print(` ${colors.bold(capture.name)} ${colors.brand.error('FAILED')}`);
668
+ if (details.length > 0) {
669
+ output.print(` ${colors.dim(details.join(' · '))}`);
670
+ }
671
+ }
672
+ }
673
+ function formatReviewSummary(review = {}) {
674
+ let details = [review.pending != null ? `${review.pending} pending` : null, review.approved != null ? `${review.approved} approved` : null, review.rejected != null ? `${review.rejected} rejected` : null].filter(Boolean);
675
+ return details.length > 0 ? details.join(' · ') : null;
676
+ }
439
677
  function displayBuildContext(output, context) {
440
678
  output.header('context', 'build');
679
+ let normalizedContext = normalizeBuildContext(context);
441
680
  let colors = output.getColors();
442
- let buildTone = getStatusTone(colors, context.build.status);
443
- let comparisons = context.comparisons || [];
444
- let screenshots = context.screenshots || [];
445
- let reviewSummary = context.summary?.review || {};
446
- let commentsSummary = context.summary?.comments || {};
447
- let needsReview = formatNeedsReview(context.status);
448
- let baseline = context.baseline?.selected || null;
449
- output.print(` ${colors.bold(context.build.name || context.build.id)} ${buildTone((context.build.status || 'unknown').toUpperCase())}`);
450
- output.print(` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}`);
681
+ let build = normalizedContext.build || {};
682
+ let buildTone = getStatusTone(colors, build.status);
683
+ let comparisons = normalizedContext.comparisons;
684
+ let groups = normalizedContext.groups;
685
+ let screenshots = normalizedContext.screenshots || [];
686
+ let failedCaptures = normalizedContext.failed_captures;
687
+ let reviewSummary = formatReviewSummary(normalizedContext.summary?.review);
688
+ let commentsSummary = normalizedContext.summary?.comments || {};
689
+ let needsReview = formatNeedsReview(normalizedContext.status);
690
+ let baseline = normalizedContext.baseline?.selected || null;
691
+ let comparisonCount = normalizedContext.summary?.comparisons?.total ?? normalizedContext.total_comparisons ?? (Array.isArray(context.comparisons) ? comparisons.length : null);
692
+ let screenshotCount = normalizedContext.summary?.screenshots?.total ?? normalizedContext.screenshot_count ?? (Array.isArray(context.screenshots) ? screenshots.length : null);
693
+ output.print(` ${colors.bold(build.name || build.id || 'unknown build')} ${buildTone((build.status || 'unknown').toUpperCase())}`);
694
+ output.print(` ${colors.dim(`@${normalizedContext.scope?.organization?.slug || 'unknown'}/${normalizedContext.scope?.project?.slug || 'unknown'}`)}`);
451
695
  output.blank();
452
- output.labelValue('Comparisons', String(comparisons.length));
453
- if (screenshots.length > 0) {
454
- output.labelValue('Screenshots', String(screenshots.length));
696
+ if (comparisonCount != null) {
697
+ output.labelValue('Comparisons', String(comparisonCount));
698
+ }
699
+ if (groups.length > 0) {
700
+ output.labelValue('Screenshot Groups', String(groups.length));
701
+ }
702
+ if (screenshotCount != null) {
703
+ output.labelValue('Screenshots', String(screenshotCount));
455
704
  }
456
705
  if (baseline) {
457
- output.labelValue('Baseline', `${baseline.name || baseline.id || 'selected'}${context.baseline.selection_reason ? ` · ${context.baseline.selection_reason}` : ''}`);
706
+ output.labelValue('Baseline', `${baseline.name || baseline.id || 'selected'}${normalizedContext.baseline.selection_reason ? ` · ${normalizedContext.baseline.selection_reason}` : ''}`);
458
707
  }
459
708
  if (needsReview) {
460
709
  output.labelValue('Needs Review', needsReview);
461
710
  }
462
- output.labelValue('Review', `${reviewSummary.pending || 0} pending · ${reviewSummary.approved || 0} approved · ${reviewSummary.rejected || 0} rejected`);
463
- output.labelValue('Memory', `${commentsSummary.build ?? getBuildCommentsCount(context)} build comments · ${commentsSummary.screenshot ?? getScreenshotCommentsCount(context)} screenshot comments · ${getReviewAssignmentsCount(context)} assignments`);
464
- if (context.preview) {
465
- let previewUrl = context.preview.preview_url || context.preview.url;
466
- output.labelValue('Preview', `${context.preview.status || 'unknown'}${previewUrl ? ' · available' : ''}`);
711
+ if (reviewSummary) {
712
+ output.labelValue('Review', reviewSummary);
467
713
  }
468
- if (context.links?.build_url) {
469
- output.labelValue('Build URL', context.links.build_url);
714
+ let buildComments = commentsSummary.build ?? (Array.isArray(normalizedContext.comments?.build) ? getBuildCommentsCount(normalizedContext) : null);
715
+ let screenshotComments = commentsSummary.screenshot ?? (Number.isInteger(normalizedContext.comments?.screenshot_count) ? getScreenshotCommentsCount(normalizedContext) : null);
716
+ let assignments = Array.isArray(normalizedContext.review?.assignments) ? getReviewAssignmentsCount(normalizedContext) : null;
717
+ let memory = [buildComments != null ? `${buildComments} build comments` : null, screenshotComments != null ? `${screenshotComments} screenshot comments` : null, assignments != null ? `${assignments} assignments` : null].filter(Boolean);
718
+ if (memory.length > 0) {
719
+ output.labelValue('Memory', memory.join(' · '));
470
720
  }
471
- if (comparisons.length > 0) {
721
+ if (normalizedContext.preview) {
722
+ let previewUrl = normalizedContext.preview.preview_url || normalizedContext.preview.url;
723
+ output.labelValue('Preview', `${normalizedContext.preview.status || 'unknown'}${previewUrl ? ' · available' : ''}`);
724
+ }
725
+ if (normalizedContext.links?.build_url) {
726
+ output.labelValue('Build URL', normalizedContext.links.build_url);
727
+ }
728
+ if (groups.length > 0) {
729
+ output.blank();
730
+ printScreenshotGroups(output, groups);
731
+ }
732
+ if (failedCaptures.length > 0) {
472
733
  output.blank();
473
- output.print(' Comparisons');
474
- printComparisonList(output, comparisons);
734
+ printFailedCaptures(output, failedCaptures);
475
735
  }
476
736
  }
477
737
  function formatAgentBuildContext(context) {
@@ -631,13 +891,17 @@ export async function contextBuildCommand(buildId, options = {}, globalOptions =
631
891
  return;
632
892
  }
633
893
  let resolvedBuildId = resolveBuildContextId(buildId, runtime, deps);
894
+ let include = parseIncludeOption(options.include);
895
+ let query = globalOptions.json && options.agent && !options.full ? {
896
+ details: include.includes('diffs') ? 'diffs' : 'summary'
897
+ } : undefined;
634
898
  output.startSpinner('Fetching build context...');
635
- let context = await runtime.provider.getBuildContext(resolvedBuildId);
899
+ let context = await runtime.provider.getBuildContext(resolvedBuildId, query);
636
900
  output.stopSpinner();
637
901
  if (globalOptions.json && options.agent && !options.full) {
638
902
  output.data(buildAgentBuildPayload(context, {
639
903
  source: runtime.source,
640
- include: parseIncludeOption(options.include)
904
+ include
641
905
  }));
642
906
  output.cleanup();
643
907
  return;