@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.
@@ -44,8 +44,23 @@ export async function resolveBuildDisplayUrl({
44
44
  }
45
45
  return undefined;
46
46
  }
47
- function buildContextCommand(buildId) {
48
- return `vizzly context build ${buildId} --agent`;
47
+
48
+ /**
49
+ * Build the follow-up command for a cloud run.
50
+ *
51
+ * JSON consumers need a self-contained command that returns structured
52
+ * evidence when executed. Human output keeps the existing readable summary.
53
+ *
54
+ * @param {string} buildId - Cloud build ID to inspect.
55
+ * @param {Object} options - Command output options.
56
+ * @param {boolean} [options.structured=false] - Include machine-readable JSON.
57
+ * @returns {string} Executable build context command.
58
+ */
59
+ function buildContextCommand(buildId, {
60
+ structured = false
61
+ } = {}) {
62
+ let jsonFlag = structured ? ' --json' : '';
63
+ return `vizzly context build ${buildId} --agent${jsonFlag} --source cloud`;
49
64
  }
50
65
 
51
66
  /**
@@ -91,6 +106,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
91
106
  let startTime = null;
92
107
  let isTddMode = false;
93
108
  let config = null;
109
+ let result = null;
94
110
 
95
111
  // Ensure cleanup on exit
96
112
  let cleanup = async () => {
@@ -254,7 +270,6 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
254
270
  output.info('Starting test execution...');
255
271
  startTime = Date.now();
256
272
  isTddMode = runOptions.tdd || false;
257
- let result;
258
273
  try {
259
274
  result = await runTests({
260
275
  runOptions,
@@ -327,7 +342,9 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
327
342
  commit,
328
343
  message
329
344
  },
330
- contextCommand: result.buildId ? buildContextCommand(result.buildId) : null,
345
+ contextCommand: result.buildId ? buildContextCommand(result.buildId, {
346
+ structured: true
347
+ }) : null,
331
348
  exitCode: 0
332
349
  };
333
350
  output.data(jsonResult);
@@ -396,7 +413,8 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
396
413
  exitCode
397
414
  };
398
415
  } else {
399
- // Setup or other error - VizzlyError.getUserMessage() provides context
416
+ // This should only be reached for failures outside the SDK paths that
417
+ // runTests handles by disabling Vizzly and running the child command.
400
418
  if (globalOptions.json) {
401
419
  let executionTimeMs = Date.now() - (startTime || Date.now());
402
420
  output.data({
@@ -443,9 +461,22 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
443
461
  getTokenContext
444
462
  });
445
463
  let exitCode = buildResult.failedComparisons > 0 ? 1 : 0;
464
+ let comparisons = {};
465
+ if (buildResult.totalComparisons !== undefined) {
466
+ comparisons.total = buildResult.totalComparisons;
467
+ }
468
+ if (buildResult.newComparisons !== undefined) {
469
+ comparisons.new = buildResult.newComparisons;
470
+ }
471
+ if (buildResult.failedComparisons !== undefined) {
472
+ comparisons.changed = buildResult.failedComparisons;
473
+ }
474
+ if (buildResult.identicalComparisons !== undefined) {
475
+ comparisons.identical = buildResult.identicalComparisons;
476
+ }
446
477
  let jsonResult = {
447
478
  buildId: result.buildId,
448
- status: buildResult.failedComparisons > 0 ? 'failed' : 'completed',
479
+ status: buildResult.status,
449
480
  url: displayUrl,
450
481
  screenshotsCaptured: result.screenshotsCaptured || 0,
451
482
  executionTimeMs,
@@ -454,16 +485,20 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
454
485
  commit,
455
486
  message
456
487
  },
457
- comparisons: {
458
- total: buildResult.totalComparisons || 0,
459
- new: buildResult.newComparisons || 0,
460
- changed: buildResult.failedComparisons || 0,
461
- identical: buildResult.identicalComparisons || 0
462
- },
463
- approvalStatus: buildResult.approvalStatus || 'pending',
464
- contextCommand: buildContextCommand(result.buildId),
488
+ contextCommand: buildContextCommand(result.buildId, {
489
+ structured: true
490
+ }),
465
491
  exitCode
466
492
  };
493
+ if (Object.keys(comparisons).length > 0) {
494
+ jsonResult.comparisons = comparisons;
495
+ }
496
+ if (buildResult.approvalStatus !== undefined) {
497
+ jsonResult.approvalStatus = buildResult.approvalStatus;
498
+ }
499
+ if (buildResult.build?.conclusion !== undefined) {
500
+ jsonResult.conclusion = buildResult.build.conclusion;
501
+ }
467
502
  output.data(jsonResult);
468
503
  output.cleanup();
469
504
  return {
@@ -492,28 +527,48 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
492
527
  } catch (error) {
493
528
  output.stopSpinner();
494
529
 
495
- // Don't fail CI for Vizzly infrastructure issues (5xx errors)
496
- let status = error.context?.status;
497
- if (status >= 500) {
498
- if (globalOptions.json) {
499
- output.data({
500
- buildId: null,
501
- status: 'skipped',
502
- message: 'Vizzly API unavailable - visual tests skipped',
503
- executionTimeMs: Date.now() - (startTime || Date.now())
530
+ // Once the user's tests have passed, no Vizzly-side error can change the
531
+ // test result. Keep that outcome while reporting that the API-backed build
532
+ // status is unavailable instead of inventing a terminal build state.
533
+ if (result) {
534
+ let apiBuild = error.context?.build;
535
+ let buildStatus = apiBuild?.status ?? null;
536
+ let exitCode = buildStatus === 'failed' ? 1 : 0;
537
+ let errorDetails = {
538
+ code: error.code || 'VIZZLY_STATUS_UNAVAILABLE',
539
+ message: error.getUserMessage ? error.getUserMessage() : error.message
540
+ };
541
+ let unavailableResult = {
542
+ buildId: result.buildId || null,
543
+ status: buildStatus,
544
+ testsPassed: true,
545
+ error: errorDetails,
546
+ executionTimeMs: Date.now() - (startTime || Date.now()),
547
+ exitCode
548
+ };
549
+ if (apiBuild?.conclusion !== undefined) {
550
+ unavailableResult.conclusion = apiBuild.conclusion;
551
+ }
552
+ if (result.buildId) {
553
+ unavailableResult.contextCommand = buildContextCommand(result.buildId, {
554
+ structured: true
504
555
  });
556
+ }
557
+ if (globalOptions.json) {
558
+ output.data(unavailableResult);
559
+ } else if (buildStatus === 'failed') {
560
+ output.error(errorDetails.message);
505
561
  } else {
506
- output.warn('Vizzly API unavailable - visual tests skipped. Your tests still ran.');
562
+ output.warn(`Tests passed, but Vizzly could not confirm the build status: ${errorDetails.message}`);
507
563
  }
508
564
  output.cleanup();
509
- output.debug('api', 'API error details:', {
565
+ output.debug('run', 'Vizzly SDK error details', {
510
566
  error: error.message
511
567
  });
512
568
  return {
513
- success: true,
514
- result: {
515
- skipped: true
516
- }
569
+ success: exitCode === 0,
570
+ exitCode,
571
+ result: unavailableResult
517
572
  };
518
573
  }
519
574
 
@@ -3,18 +3,17 @@
3
3
  * Uses functional API operations directly
4
4
  */
5
5
 
6
- import { createApiClient as defaultCreateApiClient, getBuild as defaultGetBuild, getPreviewInfo as defaultGetPreviewInfo } from '../api/index.js';
6
+ import { createApiClient as defaultCreateApiClient, getBuildStatus as defaultGetBuildStatus, getPreviewInfo as defaultGetPreviewInfo } from '../api/index.js';
7
7
  import { getAppBaseUrl } from '../utils/api-url.js';
8
8
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
9
- import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js';
10
9
  import * as defaultOutput from '../utils/output.js';
10
+ import { getVisualReviewState } from '../utils/visual-context-normalizers.js';
11
11
  function createStatusDeps(deps = {}) {
12
12
  return {
13
13
  loadConfig: deps.loadConfig || defaultLoadConfig,
14
14
  createApiClient: deps.createApiClient || defaultCreateApiClient,
15
- getBuild: deps.getBuild || defaultGetBuild,
15
+ getBuildStatus: deps.getBuildStatus || defaultGetBuildStatus,
16
16
  getPreviewInfo: deps.getPreviewInfo || defaultGetPreviewInfo,
17
- getApiUrl: deps.getApiUrl || defaultGetApiUrl,
18
17
  output: deps.output || defaultOutput,
19
18
  exit: deps.exit || (code => process.exit(code))
20
19
  };
@@ -32,7 +31,7 @@ function createStatusClient({
32
31
  }) {
33
32
  return createApiClient({
34
33
  baseUrl: config.apiUrl,
35
- token: config.apiKey,
34
+ token: config.apiKey || config.userToken,
36
35
  command: 'status'
37
36
  });
38
37
  }
@@ -52,10 +51,108 @@ async function fetchOptionalPreviewInfo(getPreviewInfo, client, buildId) {
52
51
  export function normalizeBuildStatus(buildStatus) {
53
52
  return buildStatus.build || buildStatus;
54
53
  }
55
- export function createStatusData(build, previewInfo = null) {
54
+
55
+ /**
56
+ * Read exact processing counts from canonical or legacy status responses.
57
+ *
58
+ * Legacy `pending_screenshots` is review state, not queue state, so it is
59
+ * deliberately excluded. Missing processing fields remain missing instead of
60
+ * becoming client-authored zeroes.
61
+ *
62
+ * @param {Object} status - Canonical status bundle or legacy build record.
63
+ * @returns {Object|undefined} API-provided processing facts when available.
64
+ */
65
+ export function getProcessingStatus(status = {}) {
66
+ if (status.processing) {
67
+ return status.processing;
68
+ }
69
+ let build = normalizeBuildStatus(status);
70
+ let processing = {};
71
+ let fields = [['total', build.screenshot_count], ['completed', build.completed_jobs], ['failed', build.failed_jobs], ['active', build.processing_screenshots]];
72
+ for (let [name, value] of fields) {
73
+ if (value != null) {
74
+ processing[name] = value;
75
+ }
76
+ }
77
+ return Object.keys(processing).length > 0 ? processing : undefined;
78
+ }
79
+
80
+ /**
81
+ * Read comparison totals without deriving them from screenshot counts.
82
+ *
83
+ * @param {Object} status - Canonical status bundle or legacy build record.
84
+ * @returns {Object|undefined} API-provided comparison facts when available.
85
+ */
86
+ export function getComparisonStatus(status = {}) {
87
+ if (status.comparisons) {
88
+ return status.comparisons;
89
+ }
90
+ let build = normalizeBuildStatus(status);
91
+ let comparisons = {};
92
+ let fields = [['total', build.total_comparisons], ['new', build.new_comparisons], ['changed', build.changed_comparisons], ['identical', build.identical_comparisons]];
93
+ for (let [name, value] of fields) {
94
+ if (value != null) {
95
+ comparisons[name] = value;
96
+ }
97
+ }
98
+ return Object.keys(comparisons).length > 0 ? comparisons : undefined;
99
+ }
100
+
101
+ /**
102
+ * Prefer canonical Cricket review state while retaining the legacy fallback.
103
+ *
104
+ * Processing status stays separate: a pending review never means a screenshot
105
+ * is still being processed.
106
+ *
107
+ * @param {Object} status - Canonical status bundle or legacy build record.
108
+ * @returns {string|null} API-provided review state when available.
109
+ */
110
+ export function getBuildReviewState(status = {}) {
111
+ let build = normalizeBuildStatus(status);
112
+ return getVisualReviewState(build) || getVisualReviewState(status.visualReview?.build || status.visual_review) || null;
113
+ }
114
+
115
+ /**
116
+ * Give agents concrete paths from lifecycle status into visual evidence.
117
+ *
118
+ * @param {Object} build - Build record from the status response.
119
+ * @returns {{label: string, command: string}[]} Executable CLI commands.
120
+ */
121
+ export function createStatusSuggestedCommands(build = {}) {
122
+ if (!build.id) {
123
+ return [];
124
+ }
125
+ return [{
126
+ label: 'Inspect build context',
127
+ command: `vizzly --json context build ${build.id} --agent --source cloud`
128
+ }, {
129
+ label: 'List comparisons',
130
+ command: `vizzly --json comparisons --build ${build.id}`
131
+ }];
132
+ }
133
+
134
+ /**
135
+ * Preserve the established JSON fields while exposing canonical status facts.
136
+ *
137
+ * Every lifecycle, processing, comparison, and review value comes directly
138
+ * from the API. Undefined values intentionally disappear during JSON encoding
139
+ * rather than being presented as false certainty.
140
+ *
141
+ * @param {Object} status - Canonical status bundle or legacy build record.
142
+ * @param {Object|null} previewInfo - Optional preview response.
143
+ * @returns {Object} Machine-readable status payload.
144
+ */
145
+ export function createStatusData(status, previewInfo = null) {
146
+ let build = normalizeBuildStatus(status);
147
+ let processing = getProcessingStatus(status);
148
+ let comparisons = getComparisonStatus(status);
149
+ let visualReview = build.visual_review || status.visualReview?.build || status.visual_review;
56
150
  return {
151
+ resource: status.resource,
152
+ schemaVersion: status.schema_version,
57
153
  buildId: build.id,
58
154
  status: build.status,
155
+ conclusion: status.conclusion,
59
156
  name: build.name,
60
157
  createdAt: build.created_at,
61
158
  updatedAt: build.updated_at,
@@ -64,15 +161,24 @@ export function createStatusData(build, previewInfo = null) {
64
161
  branch: build.branch,
65
162
  commit: build.commit_sha,
66
163
  commitMessage: build.commit_message,
67
- screenshotsTotal: build.screenshot_count || 0,
68
- comparisonsTotal: build.total_comparisons || 0,
69
- newComparisons: build.new_comparisons || 0,
70
- changedComparisons: build.changed_comparisons || 0,
71
- identicalComparisons: build.identical_comparisons || 0,
164
+ screenshotsTotal: processing?.total ?? build.screenshot_count,
165
+ processing,
166
+ comparisonsTotal: comparisons?.total ?? build.total_comparisons,
167
+ comparisons,
168
+ newComparisons: comparisons?.new ?? build.new_comparisons,
169
+ changedComparisons: comparisons?.changed ?? build.changed_comparisons,
170
+ identicalComparisons: comparisons?.identical ?? build.identical_comparisons,
171
+ reviewState: getBuildReviewState(status),
172
+ review: status.review,
173
+ reviewFlow: status.reviewFlow,
174
+ visualReview,
72
175
  approvalStatus: build.approval_status,
73
176
  executionTime: build.execution_time_ms,
74
177
  isBaseline: build.is_baseline,
75
178
  userAgent: build.user_agent,
179
+ scope: status.scope,
180
+ links: status.links,
181
+ suggestedCommands: createStatusSuggestedCommands(build),
76
182
  preview: previewInfo ? {
77
183
  url: previewInfo.preview_url,
78
184
  status: previewInfo.status,
@@ -95,11 +201,21 @@ export function createBuildInfo(build) {
95
201
  }
96
202
  return buildInfo;
97
203
  }
98
- export function createComparisonStats(build, colors) {
204
+
205
+ /**
206
+ * Format API comparison outcomes without filling missing buckets with zeroes.
207
+ *
208
+ * @param {Object} status - Canonical status bundle or legacy build record.
209
+ * @param {Object} colors - Output color helpers.
210
+ * @returns {string} Human-readable comparison summary.
211
+ */
212
+ export function createComparisonStats(status, colors) {
213
+ let build = normalizeBuildStatus(status);
214
+ let comparisons = getComparisonStatus(status) || {};
99
215
  let stats = [];
100
- let newCount = build.new_comparisons || 0;
101
- let changedCount = build.changed_comparisons || 0;
102
- let identicalCount = build.identical_comparisons || 0;
216
+ let newCount = comparisons.new ?? build.new_comparisons;
217
+ let changedCount = comparisons.changed ?? build.changed_comparisons;
218
+ let identicalCount = comparisons.identical ?? build.identical_comparisons;
103
219
  if (newCount > 0) {
104
220
  stats.push(`${colors.brand.info(newCount)} new`);
105
221
  }
@@ -111,27 +227,47 @@ export function createComparisonStats(build, colors) {
111
227
  }
112
228
  return stats.join(colors.brand.textMuted(' · '));
113
229
  }
114
- export function createBuildUrl(baseUrl, build) {
115
- if (!baseUrl || !build.project_id) {
230
+
231
+ /**
232
+ * Build the best available legacy link when the API did not return one.
233
+ *
234
+ * Slug routes match the current app, while the project-ID route remains as a
235
+ * compatibility fallback for older build responses.
236
+ *
237
+ * @param {string} baseUrl - API or app base URL.
238
+ * @param {Object} build - Build record.
239
+ * @param {Object|null} scope - Canonical organization and project scope.
240
+ * @returns {string|null} Build URL when the response has enough identity.
241
+ */
242
+ export function createBuildUrl(baseUrl, build, scope = null) {
243
+ if (!baseUrl) {
116
244
  return null;
117
245
  }
118
- return `${getAppBaseUrl(baseUrl)}/projects/${build.project_id}/builds/${build.id}`;
119
- }
120
- export function shouldFailStatus(build) {
121
- return build.status === 'failed' || build.failed_jobs > 0;
122
- }
123
- export function getProcessingProgress(build) {
124
- if (build.status !== 'processing' && build.status !== 'pending') {
125
- return null;
246
+ let organizationSlug = scope?.organization?.slug || build.organization_slug || build.org_slug;
247
+ let projectSlug = scope?.project?.slug || build.project_slug;
248
+ let appBaseUrl = getAppBaseUrl(baseUrl);
249
+ if (organizationSlug && projectSlug) {
250
+ return `${appBaseUrl}/${organizationSlug}/${projectSlug}/builds/${build.id}`;
126
251
  }
127
- let completedJobs = build.completed_jobs || 0;
128
- let failedJobs = build.failed_jobs || 0;
129
- let processingScreenshots = build.processing_screenshots || 0;
130
- let totalJobs = completedJobs + failedJobs + processingScreenshots;
131
- if (totalJobs <= 0) {
132
- return null;
252
+ if (build.project_id) {
253
+ return `${appBaseUrl}/projects/${build.project_id}/builds/${build.id}`;
133
254
  }
134
- return (completedJobs + failedJobs) / totalJobs * 100;
255
+ return null;
256
+ }
257
+
258
+ /**
259
+ * Preserve status failure behavior while accepting the canonical conclusion.
260
+ *
261
+ * Review-required and rejected builds keep their existing successful command
262
+ * exit behavior. Only build or processing failures produce a failing status.
263
+ *
264
+ * @param {Object} status - Canonical status bundle or legacy build record.
265
+ * @returns {boolean} Whether human status should exit non-zero.
266
+ */
267
+ export function shouldFailStatus(status) {
268
+ let build = normalizeBuildStatus(status);
269
+ let processing = getProcessingStatus(status);
270
+ return build.status === 'failed' || ['build_failed', 'processing_failed'].includes(status.conclusion) || (processing?.failed ?? 0) > 0;
135
271
  }
136
272
  function writeStatusTiming({
137
273
  build,
@@ -149,10 +285,46 @@ function writeStatusTiming({
149
285
  output.hint(`Took ${Math.round(build.execution_time_ms / 1000)}s`);
150
286
  }
151
287
  }
152
- function createVerboseInfo(build) {
288
+
289
+ /**
290
+ * Render follow-up commands after the factual status summary.
291
+ *
292
+ * Status answers lifecycle questions; these commands are the explicit bridge
293
+ * to screenshot evidence without bloating the status response itself.
294
+ *
295
+ * @param {Object} build - Build record from the status response.
296
+ * @param {Object} output - Configured output boundary.
297
+ */
298
+ function writeSuggestedCommands({
299
+ build,
300
+ output
301
+ }) {
302
+ let commands = createStatusSuggestedCommands(build);
303
+ if (commands.length === 0) {
304
+ return;
305
+ }
306
+ let colors = output.getColors();
307
+ output.blank();
308
+ output.print(' Suggested commands');
309
+ for (let item of commands) {
310
+ output.print(` ${colors.brand.textMuted(item.command)}`);
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Add optional API facts without inventing placeholders for missing metadata.
316
+ *
317
+ * @param {Object} status - Canonical status bundle or legacy build record.
318
+ * @returns {Object} Available verbose fields.
319
+ */
320
+ function createVerboseInfo(status) {
321
+ let build = normalizeBuildStatus(status);
153
322
  let verboseInfo = {};
154
- if (build.approved_screenshots > 0 || build.rejected_screenshots > 0 || build.pending_screenshots > 0) {
155
- verboseInfo.Approvals = `${build.approved_screenshots || 0} approved, ${build.rejected_screenshots || 0} rejected, ${build.pending_screenshots || 0} pending`;
323
+ if (status.review) {
324
+ let reviewParts = [['approved', status.review.approved], ['rejected', status.review.rejected], ['pending', status.review.pending]].filter(([, value]) => value != null).map(([label, value]) => `${value} ${label}`);
325
+ if (reviewParts.length > 0) {
326
+ verboseInfo.Review = reviewParts.join(', ');
327
+ }
156
328
  }
157
329
  if (build.avg_diff_percentage != null) {
158
330
  verboseInfo['Avg Diff'] = `${(build.avg_diff_percentage * 100).toFixed(2)}%`;
@@ -163,29 +335,72 @@ function createVerboseInfo(build) {
163
335
  if (build.is_baseline) {
164
336
  verboseInfo.Baseline = 'Yes';
165
337
  }
166
- verboseInfo['User Agent'] = build.user_agent || 'Unknown';
338
+ if (build.user_agent) {
339
+ verboseInfo['User Agent'] = build.user_agent;
340
+ }
167
341
  verboseInfo['Build ID'] = build.id;
168
- verboseInfo['Project ID'] = build.project_id;
342
+ if (status.scope?.project?.id || build.project_id) {
343
+ verboseInfo['Project ID'] = status.scope?.project?.id || build.project_id;
344
+ }
169
345
  return verboseInfo;
170
346
  }
347
+
348
+ /**
349
+ * Format only the processing facts supplied by the API.
350
+ *
351
+ * @param {Object|undefined} processing - Canonical processing counts.
352
+ * @returns {string} Human-readable processing summary.
353
+ */
354
+ function formatProcessingStatus(processing) {
355
+ if (!processing) {
356
+ return '';
357
+ }
358
+ let labels = {
359
+ total: 'total',
360
+ completed: 'completed',
361
+ failed: 'failed',
362
+ active: 'active',
363
+ pending: 'pending'
364
+ };
365
+ return Object.entries(labels).filter(([field]) => processing[field] != null).map(([field, label]) => `${processing[field]} ${label}`).join(' · ');
366
+ }
367
+
368
+ /**
369
+ * Present lifecycle, processing, comparisons, and review as separate facts.
370
+ *
371
+ * Keeping these lanes separate prevents review-pending counts from looking
372
+ * like unfinished processing and keeps the human view aligned with JSON.
373
+ *
374
+ * @param {Object} options - Human status presentation inputs.
375
+ */
171
376
  function writeHumanStatus({
172
377
  build,
173
378
  buildUrl,
174
379
  globalOptions,
175
380
  output,
176
- previewInfo
381
+ previewInfo,
382
+ status
177
383
  }) {
178
384
  output.header('status', build.status);
179
385
  output.keyValue(createBuildInfo(build));
180
386
  output.blank();
181
387
  let colors = output.getColors();
182
- let comparisonStats = createComparisonStats(build, colors);
183
- output.labelValue('Screenshots', String(build.screenshot_count || 0));
388
+ let comparisonStats = createComparisonStats(status, colors);
389
+ let processing = getProcessingStatus(status);
390
+ let screenshotsTotal = processing?.total ?? build.screenshot_count;
391
+ let processingSummary = formatProcessingStatus(processing);
392
+ if (screenshotsTotal != null) {
393
+ output.labelValue('Screenshots', String(screenshotsTotal));
394
+ }
395
+ if (processingSummary) {
396
+ output.labelValue('Processing', processingSummary);
397
+ }
184
398
  if (comparisonStats) {
185
399
  output.labelValue('Comparisons', comparisonStats);
186
400
  }
187
- if (build.approval_status) {
188
- output.labelValue('Approval', build.approval_status);
401
+ let reviewState = getBuildReviewState(status);
402
+ if (reviewState) {
403
+ output.labelValue('Review', reviewState);
189
404
  }
190
405
  output.blank();
191
406
  writeStatusTiming({
@@ -207,17 +422,20 @@ function writeHumanStatus({
207
422
  output.blank();
208
423
  output.divider();
209
424
  output.blank();
210
- output.keyValue(createVerboseInfo(build));
211
- }
212
- let progress = getProcessingProgress(build);
213
- if (progress !== null) {
214
- output.blank();
215
- output.print(` ${output.progressBar(progress, 100)} ${Math.round(progress)}%`);
425
+ output.keyValue(createVerboseInfo(status));
216
426
  }
427
+ writeSuggestedCommands({
428
+ build,
429
+ output
430
+ });
217
431
  }
218
432
 
219
433
  /**
220
- * Status command implementation
434
+ * Fetch and present the server-owned build status contract.
435
+ *
436
+ * Preview remains an optional adjacent resource, and established 5xx and
437
+ * build-failure exit semantics remain unchanged.
438
+ *
221
439
  * @param {string} buildId - Build ID to check status for
222
440
  * @param {Object} options - Command options
223
441
  * @param {Object} globalOptions - Global CLI options
@@ -227,9 +445,8 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}, d
227
445
  let {
228
446
  loadConfig,
229
447
  createApiClient,
230
- getBuild,
448
+ getBuildStatus,
231
449
  getPreviewInfo,
232
- getApiUrl,
233
450
  output,
234
451
  exit
235
452
  } = createStatusDeps(deps);
@@ -243,8 +460,8 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}, d
243
460
  let config = await loadConfig(globalOptions.config, allOptions);
244
461
 
245
462
  // Validate API token
246
- if (!config.apiKey) {
247
- output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
463
+ if (!config.apiKey && !config.userToken) {
464
+ output.error('Authentication required. Use --token, set VIZZLY_TOKEN, or run "vizzly login"');
248
465
  output.cleanup();
249
466
  exit(1);
250
467
  return {
@@ -261,7 +478,7 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}, d
261
478
  createApiClient,
262
479
  config
263
480
  });
264
- let buildStatus = await getBuild(client, buildId);
481
+ let buildStatus = await getBuildStatus(client, buildId);
265
482
 
266
483
  // Also fetch preview info (if exists)
267
484
  let previewInfo = await fetchOptionalPreviewInfo(getPreviewInfo, client, buildId);
@@ -272,26 +489,26 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}, d
272
489
 
273
490
  // Output in JSON mode
274
491
  if (globalOptions.json) {
275
- output.data(createStatusData(build, previewInfo));
492
+ output.data(createStatusData(buildStatus, previewInfo));
276
493
  output.cleanup();
277
494
  return;
278
495
  }
279
496
 
280
497
  // Human-readable output
281
498
  // Show build URL if we can construct it
282
- let baseUrl = config.baseUrl || getApiUrl();
283
- let buildUrl = createBuildUrl(baseUrl, build);
499
+ let buildUrl = buildStatus.links?.web || createBuildUrl(config.apiUrl, build, buildStatus.scope);
284
500
  writeHumanStatus({
285
501
  build,
286
502
  buildUrl,
287
503
  globalOptions,
288
504
  output,
289
- previewInfo
505
+ previewInfo,
506
+ status: buildStatus
290
507
  });
291
508
  output.cleanup();
292
509
 
293
510
  // Exit with appropriate code based on build status
294
- if (shouldFailStatus(build)) {
511
+ if (shouldFailStatus(buildStatus)) {
295
512
  exit(1);
296
513
  }
297
514
  } catch (error) {