@vizzly-testing/cli 0.30.0 → 0.32.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.
Files changed (79) hide show
  1. package/README.md +9 -9
  2. package/dist/api/client.js +6 -3
  3. package/dist/cli.js +58 -29
  4. package/dist/client/index.js +21 -29
  5. package/dist/commands/api.js +145 -90
  6. package/dist/commands/baselines.js +78 -47
  7. package/dist/commands/builds.js +9 -8
  8. package/dist/commands/comparisons.js +7 -8
  9. package/dist/commands/config-cmd.js +15 -7
  10. package/dist/commands/context.js +329 -18
  11. package/dist/commands/doctor.js +116 -81
  12. package/dist/commands/init.js +202 -201
  13. package/dist/commands/login.js +134 -65
  14. package/dist/commands/logout.js +17 -6
  15. package/dist/commands/preview.js +29 -57
  16. package/dist/commands/project.js +120 -0
  17. package/dist/commands/projects.js +9 -2
  18. package/dist/commands/review.js +236 -202
  19. package/dist/commands/run.js +75 -72
  20. package/dist/commands/status.js +228 -150
  21. package/dist/commands/tdd-daemon-process.js +98 -0
  22. package/dist/commands/tdd-daemon.js +318 -212
  23. package/dist/commands/tdd.js +23 -8
  24. package/dist/commands/upload.js +10 -9
  25. package/dist/commands/whoami.js +63 -25
  26. package/dist/config/core.js +36 -35
  27. package/dist/context/local-workspace-provider.js +132 -12
  28. package/dist/context/provider-resolver.js +5 -1
  29. package/dist/index.js +5 -4
  30. package/dist/plugin-loader.js +55 -49
  31. package/dist/project/core.js +2 -2
  32. package/dist/project/operations.js +6 -3
  33. package/dist/reporter/reporter-bundle.css +1 -1
  34. package/dist/reporter/reporter-bundle.iife.js +7 -7
  35. package/dist/sdk/index.js +162 -107
  36. package/dist/server/handlers/tdd-handler.js +30 -54
  37. package/dist/server/routers/baseline.js +195 -178
  38. package/dist/server/routers/events.js +212 -174
  39. package/dist/server/routers/projects.js +14 -5
  40. package/dist/server/routers/screenshot.js +1 -31
  41. package/dist/services/build-manager.js +19 -97
  42. package/dist/services/config-service.js +26 -34
  43. package/dist/services/index.js +2 -4
  44. package/dist/services/static-report-generator.js +11 -12
  45. package/dist/services/test-runner.js +21 -27
  46. package/dist/tdd/core/region-coverage.js +109 -21
  47. package/dist/tdd/index.js +1 -1
  48. package/dist/tdd/metadata/region-metadata.js +0 -37
  49. package/dist/tdd/runtime-context.js +78 -0
  50. package/dist/tdd/server-registry.js +38 -17
  51. package/dist/tdd/services/baseline-manager.js +23 -0
  52. package/dist/tdd/services/comparison-service.js +10 -1
  53. package/dist/tdd/services/hotspot-service.js +0 -13
  54. package/dist/tdd/tdd-service.js +144 -138
  55. package/dist/types/client.d.ts +36 -1
  56. package/dist/types/index.d.ts +114 -22
  57. package/dist/types/sdk.d.ts +40 -0
  58. package/dist/uploader/core.js +5 -4
  59. package/dist/uploader/index.js +79 -2
  60. package/dist/uploader/operations.js +40 -5
  61. package/dist/utils/api-url.js +14 -0
  62. package/dist/utils/async-utils.js +26 -0
  63. package/dist/utils/ci-env.js +19 -12
  64. package/dist/utils/config-loader.js +39 -65
  65. package/dist/utils/config-schema.js +19 -55
  66. package/dist/utils/fetch-utils.js +12 -5
  67. package/dist/utils/git.js +35 -72
  68. package/dist/utils/global-config.js +20 -1
  69. package/dist/utils/output.js +19 -7
  70. package/dist/utils/patterns.js +14 -0
  71. package/dist/utils/project-link-store.js +105 -0
  72. package/dist/utils/screenshot-options.js +28 -0
  73. package/dist/utils/secret-store.js +65 -0
  74. package/package.json +14 -11
  75. package/dist/services/screenshot-server.js +0 -65
  76. package/dist/services/server-manager.js +0 -81
  77. package/dist/services/uploader.js +0 -100
  78. package/dist/tdd/services/region-service.js +0 -75
  79. package/dist/utils/build-history.js +0 -103
@@ -8,12 +8,44 @@ import { createBuild as defaultCreateApiBuild, createApiClient as defaultCreateA
8
8
  import { VizzlyError } from '../errors/vizzly-error.js';
9
9
  import { createServerManager as defaultCreateServerManager } from '../server-manager/index.js';
10
10
  import { createBuildObject as defaultCreateBuildObject } from '../services/build-manager.js';
11
- import { createUploader as defaultCreateUploader } from '../services/uploader.js';
12
11
  import { finalizeBuild as defaultFinalizeBuild, runTests as defaultRunTests } from '../test-runner/index.js';
12
+ import { createUploader as defaultCreateUploader } from '../uploader/index.js';
13
+ import { getAppBaseUrl } from '../utils/api-url.js';
13
14
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
14
15
  import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
15
16
  import * as defaultOutput from '../utils/output.js';
16
17
  import { writeSession as defaultWriteSession } from '../utils/session.js';
18
+ export async function resolveBuildDisplayUrl({
19
+ result,
20
+ config,
21
+ createApiClient = defaultCreateApiClient,
22
+ getTokenContext = defaultGetTokenContext
23
+ }) {
24
+ if (result.url) {
25
+ return result.url;
26
+ }
27
+ if (!config.apiKey) {
28
+ return undefined;
29
+ }
30
+ let baseUrl = getAppBaseUrl(config.apiUrl);
31
+ try {
32
+ let client = createApiClient({
33
+ baseUrl: config.apiUrl,
34
+ token: config.apiKey,
35
+ command: 'run'
36
+ });
37
+ let tokenContext = await getTokenContext(client);
38
+ if (tokenContext.organization?.slug && tokenContext.project?.slug) {
39
+ return `${baseUrl}/${tokenContext.organization.slug}/${tokenContext.project.slug}/builds/${result.buildId}`;
40
+ }
41
+ } catch {
42
+ return `${baseUrl}/builds/${result.buildId}`;
43
+ }
44
+ return undefined;
45
+ }
46
+ function buildContextCommand(buildId) {
47
+ return `vizzly context build ${buildId} --agent`;
48
+ }
17
49
 
18
50
  /**
19
51
  * Run command implementation
@@ -275,27 +307,12 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
275
307
  // JSON output mode - output structured data and exit
276
308
  if (globalOptions.json) {
277
309
  let executionTimeMs = Date.now() - startTime;
278
-
279
- // Get URL from result, or construct one as fallback
280
- let displayUrl = result.url;
281
- if (!displayUrl && config.apiKey) {
282
- try {
283
- let client = createApiClient({
284
- baseUrl: config.apiUrl,
285
- token: config.apiKey,
286
- command: 'run'
287
- });
288
- let tokenContext = await getTokenContext(client);
289
- let baseUrl = config.apiUrl.replace(/\/api.*$/, '');
290
- if (tokenContext.organization?.slug && tokenContext.project?.slug) {
291
- displayUrl = `${baseUrl}/${tokenContext.organization.slug}/${tokenContext.project.slug}/builds/${result.buildId}`;
292
- }
293
- } catch {
294
- // Fallback to simple URL if context fetch fails
295
- let baseUrl = config.apiUrl.replace(/\/api.*$/, '');
296
- displayUrl = `${baseUrl}/builds/${result.buildId}`;
297
- }
298
- }
310
+ let displayUrl = await resolveBuildDisplayUrl({
311
+ result,
312
+ config,
313
+ createApiClient,
314
+ getTokenContext
315
+ });
299
316
  let jsonResult = {
300
317
  buildId: result.buildId,
301
318
  status: 'completed',
@@ -307,6 +324,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
307
324
  commit,
308
325
  message
309
326
  },
327
+ contextCommand: result.buildId ? buildContextCommand(result.buildId) : null,
310
328
  exitCode: 0
311
329
  };
312
330
  output.data(jsonResult);
@@ -323,32 +341,18 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
323
341
  output.blank();
324
342
  let colors = output.getColors();
325
343
  output.print(` ${colors.brand.textTertiary('Screenshots')} ${colors.white(result.screenshotsCaptured)}`);
326
-
327
- // Get URL from result, or construct one as fallback
328
- let displayUrl = result.url;
329
- if (!displayUrl && config.apiKey) {
330
- try {
331
- let client = createApiClient({
332
- baseUrl: config.apiUrl,
333
- token: config.apiKey,
334
- command: 'run'
335
- });
336
- let tokenContext = await getTokenContext(client);
337
- let baseUrl = config.apiUrl.replace(/\/api.*$/, '');
338
- if (tokenContext.organization?.slug && tokenContext.project?.slug) {
339
- displayUrl = `${baseUrl}/${tokenContext.organization.slug}/${tokenContext.project.slug}/builds/${result.buildId}`;
340
- }
341
- } catch {
342
- // Fallback to simple URL if context fetch fails
343
- let baseUrl = config.apiUrl.replace(/\/api.*$/, '');
344
- displayUrl = `${baseUrl}/builds/${result.buildId}`;
345
- }
346
- }
344
+ let displayUrl = await resolveBuildDisplayUrl({
345
+ result,
346
+ config,
347
+ createApiClient,
348
+ getTokenContext
349
+ });
347
350
  if (displayUrl) {
348
351
  output.print(` ${colors.brand.textTertiary('Results')} ${colors.cyan(colors.underline(displayUrl))}`);
349
352
  } else {
350
353
  output.print(` ${colors.brand.textTertiary('Build')} ${colors.dim(result.buildId)}`);
351
354
  }
355
+ output.print(` ${colors.brand.textTertiary('Context')} ${colors.dim(buildContextCommand(result.buildId))}`);
352
356
  }
353
357
  } catch (error) {
354
358
  // Test execution failed - build should already be finalized by test runner
@@ -358,7 +362,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
358
362
  if (error.code === 'TEST_COMMAND_FAILED' || error.code === 'TEST_COMMAND_INTERRUPTED') {
359
363
  // Extract exit code from error message if available
360
364
  let exitCodeMatch = error.message.match(/exited with code (\d+)/);
361
- let exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 1;
365
+ let exitCode = exitCodeMatch ? Number(exitCodeMatch[1]) : 1;
362
366
 
363
367
  // JSON output for test command failure
364
368
  if (globalOptions.json) {
@@ -427,26 +431,12 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
427
431
  // JSON output for --wait mode
428
432
  if (globalOptions.json) {
429
433
  let executionTimeMs = Date.now() - startTime;
430
-
431
- // Get URL from result, or construct one as fallback
432
- let displayUrl = result.url;
433
- if (!displayUrl && config.apiKey) {
434
- try {
435
- let client = createApiClient({
436
- baseUrl: config.apiUrl,
437
- token: config.apiKey,
438
- command: 'run'
439
- });
440
- let tokenContext = await getTokenContext(client);
441
- let baseUrl = config.apiUrl.replace(/\/api.*$/, '');
442
- if (tokenContext.organization?.slug && tokenContext.project?.slug) {
443
- displayUrl = `${baseUrl}/${tokenContext.organization.slug}/${tokenContext.project.slug}/builds/${result.buildId}`;
444
- }
445
- } catch {
446
- let baseUrl = config.apiUrl.replace(/\/api.*$/, '');
447
- displayUrl = `${baseUrl}/builds/${result.buildId}`;
448
- }
449
- }
434
+ let displayUrl = await resolveBuildDisplayUrl({
435
+ result,
436
+ config,
437
+ createApiClient,
438
+ getTokenContext
439
+ });
450
440
  let exitCode = buildResult.failedComparisons > 0 ? 1 : 0;
451
441
  let jsonResult = {
452
442
  buildId: result.buildId,
@@ -466,6 +456,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
466
456
  identical: buildResult.identicalComparisons || 0
467
457
  },
468
458
  approvalStatus: buildResult.approvalStatus || 'pending',
459
+ contextCommand: buildContextCommand(result.buildId),
469
460
  exitCode
470
461
  };
471
462
  output.data(jsonResult);
@@ -544,28 +535,40 @@ export function validateRunOptions(testCommand, options) {
544
535
  errors.push('Test command is required');
545
536
  }
546
537
  if (options.port) {
547
- let port = parseInt(options.port, 10);
548
- if (Number.isNaN(port) || port < 1 || port > 65535) {
538
+ let port = Number(options.port);
539
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
549
540
  errors.push('Port must be a valid number between 1 and 65535');
550
541
  }
551
542
  }
552
543
  if (options.timeout) {
553
- let timeout = parseInt(options.timeout, 10);
554
- if (Number.isNaN(timeout) || timeout < 1000) {
544
+ let timeout = Number(options.timeout);
545
+ if (!Number.isInteger(timeout) || timeout < 1000) {
555
546
  errors.push('Timeout must be at least 1000 milliseconds');
556
547
  }
557
548
  }
558
549
  if (options.batchSize !== undefined) {
559
- let n = parseInt(options.batchSize, 10);
560
- if (!Number.isFinite(n) || n <= 0) {
550
+ let n = Number(options.batchSize);
551
+ if (!Number.isInteger(n) || n <= 0) {
561
552
  errors.push('Batch size must be a positive integer');
562
553
  }
563
554
  }
564
555
  if (options.uploadTimeout !== undefined) {
565
- let n = parseInt(options.uploadTimeout, 10);
566
- if (!Number.isFinite(n) || n <= 0) {
556
+ let n = Number(options.uploadTimeout);
557
+ if (!Number.isInteger(n) || n <= 0) {
567
558
  errors.push('Upload timeout must be a positive integer (milliseconds)');
568
559
  }
569
560
  }
561
+ if (options.threshold !== undefined) {
562
+ let threshold = Number(options.threshold);
563
+ if (!Number.isFinite(threshold) || threshold < 0) {
564
+ errors.push('Threshold must be a non-negative number (CIEDE2000 Delta E)');
565
+ }
566
+ }
567
+ if (options.minClusterSize !== undefined) {
568
+ let minClusterSize = Number(options.minClusterSize);
569
+ if (!Number.isInteger(minClusterSize) || minClusterSize < 1) {
570
+ errors.push('Min cluster size must be a positive integer');
571
+ }
572
+ }
570
573
  return errors;
571
574
  }
@@ -4,9 +4,204 @@
4
4
  */
5
5
 
6
6
  import { createApiClient as defaultCreateApiClient, getBuild as defaultGetBuild, getPreviewInfo as defaultGetPreviewInfo } from '../api/index.js';
7
+ import { getAppBaseUrl } from '../utils/api-url.js';
7
8
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
8
9
  import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js';
9
10
  import * as defaultOutput from '../utils/output.js';
11
+ function createStatusDeps(deps = {}) {
12
+ return {
13
+ loadConfig: deps.loadConfig || defaultLoadConfig,
14
+ createApiClient: deps.createApiClient || defaultCreateApiClient,
15
+ getBuild: deps.getBuild || defaultGetBuild,
16
+ getPreviewInfo: deps.getPreviewInfo || defaultGetPreviewInfo,
17
+ getApiUrl: deps.getApiUrl || defaultGetApiUrl,
18
+ output: deps.output || defaultOutput,
19
+ exit: deps.exit || (code => process.exit(code))
20
+ };
21
+ }
22
+ function configureOutput(output, globalOptions) {
23
+ output.configure({
24
+ json: globalOptions.json,
25
+ verbose: globalOptions.verbose,
26
+ color: !globalOptions.noColor
27
+ });
28
+ }
29
+ function createStatusClient({
30
+ createApiClient,
31
+ config
32
+ }) {
33
+ return createApiClient({
34
+ baseUrl: config.apiUrl,
35
+ token: config.apiKey,
36
+ command: 'status'
37
+ });
38
+ }
39
+ export function normalizeBuildStatus(buildStatus) {
40
+ return buildStatus.build || buildStatus;
41
+ }
42
+ export function createStatusData(build, previewInfo = null) {
43
+ return {
44
+ buildId: build.id,
45
+ status: build.status,
46
+ name: build.name,
47
+ createdAt: build.created_at,
48
+ updatedAt: build.updated_at,
49
+ completedAt: build.completed_at,
50
+ environment: build.environment,
51
+ branch: build.branch,
52
+ commit: build.commit_sha,
53
+ commitMessage: build.commit_message,
54
+ screenshotsTotal: build.screenshot_count || 0,
55
+ comparisonsTotal: build.total_comparisons || 0,
56
+ newComparisons: build.new_comparisons || 0,
57
+ changedComparisons: build.changed_comparisons || 0,
58
+ identicalComparisons: build.identical_comparisons || 0,
59
+ approvalStatus: build.approval_status,
60
+ executionTime: build.execution_time_ms,
61
+ isBaseline: build.is_baseline,
62
+ userAgent: build.user_agent,
63
+ preview: previewInfo ? {
64
+ url: previewInfo.preview_url,
65
+ status: previewInfo.status,
66
+ fileCount: previewInfo.file_count,
67
+ expiresAt: previewInfo.expires_at
68
+ } : null
69
+ };
70
+ }
71
+ export function createBuildInfo(build) {
72
+ let buildInfo = {
73
+ Name: build.name || build.id,
74
+ Status: build.status.toUpperCase(),
75
+ Environment: build.environment
76
+ };
77
+ if (build.branch) {
78
+ buildInfo.Branch = build.branch;
79
+ }
80
+ if (build.commit_sha) {
81
+ buildInfo.Commit = `${build.commit_sha.substring(0, 8)} - ${build.commit_message || 'No message'}`;
82
+ }
83
+ return buildInfo;
84
+ }
85
+ export function createComparisonStats(build, colors) {
86
+ let stats = [];
87
+ let newCount = build.new_comparisons || 0;
88
+ let changedCount = build.changed_comparisons || 0;
89
+ let identicalCount = build.identical_comparisons || 0;
90
+ if (newCount > 0) {
91
+ stats.push(`${colors.brand.info(newCount)} new`);
92
+ }
93
+ if (changedCount > 0) {
94
+ stats.push(`${colors.brand.warning(changedCount)} changed`);
95
+ }
96
+ if (identicalCount > 0) {
97
+ stats.push(`${colors.brand.success(identicalCount)} identical`);
98
+ }
99
+ return stats.join(colors.brand.textMuted(' · '));
100
+ }
101
+ export function createBuildUrl(baseUrl, build) {
102
+ if (!baseUrl || !build.project_id) {
103
+ return null;
104
+ }
105
+ return `${getAppBaseUrl(baseUrl)}/projects/${build.project_id}/builds/${build.id}`;
106
+ }
107
+ export function shouldFailStatus(build) {
108
+ return build.status === 'failed' || build.failed_jobs > 0;
109
+ }
110
+ export function getProcessingProgress(build) {
111
+ if (build.status !== 'processing' && build.status !== 'pending') {
112
+ return null;
113
+ }
114
+ let completedJobs = build.completed_jobs || 0;
115
+ let failedJobs = build.failed_jobs || 0;
116
+ let processingScreenshots = build.processing_screenshots || 0;
117
+ let totalJobs = completedJobs + failedJobs + processingScreenshots;
118
+ if (totalJobs <= 0) {
119
+ return null;
120
+ }
121
+ return (completedJobs + failedJobs) / totalJobs * 100;
122
+ }
123
+ function writeStatusTiming({
124
+ build,
125
+ output
126
+ }) {
127
+ if (build.created_at) {
128
+ output.hint(`Created ${new Date(build.created_at).toLocaleString()}`);
129
+ }
130
+ if (build.completed_at) {
131
+ output.hint(`Completed ${new Date(build.completed_at).toLocaleString()}`);
132
+ } else if (build.status !== 'completed' && build.status !== 'failed') {
133
+ output.hint(`Started ${new Date(build.started_at || build.created_at).toLocaleString()}`);
134
+ }
135
+ if (build.execution_time_ms) {
136
+ output.hint(`Took ${Math.round(build.execution_time_ms / 1000)}s`);
137
+ }
138
+ }
139
+ function createVerboseInfo(build) {
140
+ let verboseInfo = {};
141
+ if (build.approved_screenshots > 0 || build.rejected_screenshots > 0 || build.pending_screenshots > 0) {
142
+ verboseInfo.Approvals = `${build.approved_screenshots || 0} approved, ${build.rejected_screenshots || 0} rejected, ${build.pending_screenshots || 0} pending`;
143
+ }
144
+ if (build.avg_diff_percentage != null) {
145
+ verboseInfo['Avg Diff'] = `${(build.avg_diff_percentage * 100).toFixed(2)}%`;
146
+ }
147
+ if (build.github_pull_request_number) {
148
+ verboseInfo['GitHub PR'] = `#${build.github_pull_request_number}`;
149
+ }
150
+ if (build.is_baseline) {
151
+ verboseInfo.Baseline = 'Yes';
152
+ }
153
+ verboseInfo['User Agent'] = build.user_agent || 'Unknown';
154
+ verboseInfo['Build ID'] = build.id;
155
+ verboseInfo['Project ID'] = build.project_id;
156
+ return verboseInfo;
157
+ }
158
+ function writeHumanStatus({
159
+ build,
160
+ buildUrl,
161
+ globalOptions,
162
+ output,
163
+ previewInfo
164
+ }) {
165
+ output.header('status', build.status);
166
+ output.keyValue(createBuildInfo(build));
167
+ output.blank();
168
+ let colors = output.getColors();
169
+ let comparisonStats = createComparisonStats(build, colors);
170
+ output.labelValue('Screenshots', String(build.screenshot_count || 0));
171
+ if (comparisonStats) {
172
+ output.labelValue('Comparisons', comparisonStats);
173
+ }
174
+ if (build.approval_status) {
175
+ output.labelValue('Approval', build.approval_status);
176
+ }
177
+ output.blank();
178
+ writeStatusTiming({
179
+ build,
180
+ output
181
+ });
182
+ if (buildUrl) {
183
+ output.blank();
184
+ output.labelValue('View', output.link('Build', buildUrl));
185
+ }
186
+ if (previewInfo?.preview_url) {
187
+ output.labelValue('Preview', output.link('Preview', previewInfo.preview_url));
188
+ if (previewInfo.expires_at) {
189
+ let expiresDate = new Date(previewInfo.expires_at);
190
+ output.hint(`Preview expires ${expiresDate.toLocaleDateString()}`);
191
+ }
192
+ }
193
+ if (globalOptions.verbose) {
194
+ output.blank();
195
+ output.divider();
196
+ output.blank();
197
+ output.keyValue(createVerboseInfo(build));
198
+ }
199
+ let progress = getProcessingProgress(build);
200
+ if (progress !== null) {
201
+ output.blank();
202
+ output.print(` ${output.progressBar(progress, 100)} ${Math.round(progress)}%`);
203
+ }
204
+ }
10
205
 
11
206
  /**
12
207
  * Status command implementation
@@ -17,19 +212,15 @@ import * as defaultOutput from '../utils/output.js';
17
212
  */
18
213
  export async function statusCommand(buildId, options = {}, globalOptions = {}, deps = {}) {
19
214
  let {
20
- loadConfig = defaultLoadConfig,
21
- createApiClient = defaultCreateApiClient,
22
- getBuild = defaultGetBuild,
23
- getPreviewInfo = defaultGetPreviewInfo,
24
- getApiUrl = defaultGetApiUrl,
25
- output = defaultOutput,
26
- exit = code => process.exit(code)
27
- } = deps;
28
- output.configure({
29
- json: globalOptions.json,
30
- verbose: globalOptions.verbose,
31
- color: !globalOptions.noColor
32
- });
215
+ loadConfig,
216
+ createApiClient,
217
+ getBuild,
218
+ getPreviewInfo,
219
+ getApiUrl,
220
+ output,
221
+ exit
222
+ } = createStatusDeps(deps);
223
+ configureOutput(output, globalOptions);
33
224
  try {
34
225
  // Load configuration with CLI overrides
35
226
  let allOptions = {
@@ -41,15 +232,21 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}, d
41
232
  // Validate API token
42
233
  if (!config.apiKey) {
43
234
  output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
235
+ output.cleanup();
44
236
  exit(1);
237
+ return {
238
+ success: false,
239
+ result: {
240
+ reason: 'missing_token'
241
+ }
242
+ };
45
243
  }
46
244
 
47
245
  // Get build details via functional API
48
246
  output.startSpinner('Fetching build status...');
49
- let client = createApiClient({
50
- baseUrl: config.apiUrl,
51
- token: config.apiKey,
52
- command: 'status'
247
+ let client = createStatusClient({
248
+ createApiClient,
249
+ config
53
250
  });
54
251
  let buildStatus = await getBuild(client, buildId);
55
252
 
@@ -58,155 +255,35 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}, d
58
255
  output.stopSpinner();
59
256
 
60
257
  // Extract build data from API response
61
- let build = buildStatus.build || buildStatus;
258
+ let build = normalizeBuildStatus(buildStatus);
62
259
 
63
260
  // Output in JSON mode
64
261
  if (globalOptions.json) {
65
- let statusData = {
66
- buildId: build.id,
67
- status: build.status,
68
- name: build.name,
69
- createdAt: build.created_at,
70
- updatedAt: build.updated_at,
71
- completedAt: build.completed_at,
72
- environment: build.environment,
73
- branch: build.branch,
74
- commit: build.commit_sha,
75
- commitMessage: build.commit_message,
76
- screenshotsTotal: build.screenshot_count || 0,
77
- comparisonsTotal: build.total_comparisons || 0,
78
- newComparisons: build.new_comparisons || 0,
79
- changedComparisons: build.changed_comparisons || 0,
80
- identicalComparisons: build.identical_comparisons || 0,
81
- approvalStatus: build.approval_status,
82
- executionTime: build.execution_time_ms,
83
- isBaseline: build.is_baseline,
84
- userAgent: build.user_agent,
85
- preview: previewInfo ? {
86
- url: previewInfo.preview_url,
87
- status: previewInfo.status,
88
- fileCount: previewInfo.file_count,
89
- expiresAt: previewInfo.expires_at
90
- } : null
91
- };
92
- output.data(statusData);
262
+ output.data(createStatusData(build, previewInfo));
93
263
  output.cleanup();
94
264
  return;
95
265
  }
96
266
 
97
267
  // Human-readable output
98
- output.header('status', build.status);
99
-
100
- // Build info section
101
- let buildInfo = {
102
- Name: build.name || build.id,
103
- Status: build.status.toUpperCase(),
104
- Environment: build.environment
105
- };
106
- if (build.branch) {
107
- buildInfo.Branch = build.branch;
108
- }
109
- if (build.commit_sha) {
110
- buildInfo.Commit = `${build.commit_sha.substring(0, 8)} - ${build.commit_message || 'No message'}`;
111
- }
112
- output.keyValue(buildInfo);
113
- output.blank();
114
-
115
- // Comparison stats with visual indicators
116
- let colors = output.getColors();
117
- let stats = [];
118
- let newCount = build.new_comparisons || 0;
119
- let changedCount = build.changed_comparisons || 0;
120
- let identicalCount = build.identical_comparisons || 0;
121
- let screenshotCount = build.screenshot_count || 0;
122
- output.labelValue('Screenshots', String(screenshotCount));
123
- if (newCount > 0) {
124
- stats.push(`${colors.brand.info(newCount)} new`);
125
- }
126
- if (changedCount > 0) {
127
- stats.push(`${colors.brand.warning(changedCount)} changed`);
128
- }
129
- if (identicalCount > 0) {
130
- stats.push(`${colors.brand.success(identicalCount)} identical`);
131
- }
132
- if (stats.length > 0) {
133
- output.labelValue('Comparisons', stats.join(colors.brand.textMuted(' · ')));
134
- }
135
- if (build.approval_status) {
136
- output.labelValue('Approval', build.approval_status);
137
- }
138
- output.blank();
139
-
140
- // Timing info
141
- if (build.created_at) {
142
- output.hint(`Created ${new Date(build.created_at).toLocaleString()}`);
143
- }
144
- if (build.completed_at) {
145
- output.hint(`Completed ${new Date(build.completed_at).toLocaleString()}`);
146
- } else if (build.status !== 'completed' && build.status !== 'failed') {
147
- output.hint(`Started ${new Date(build.started_at || build.created_at).toLocaleString()}`);
148
- }
149
- if (build.execution_time_ms) {
150
- output.hint(`Took ${Math.round(build.execution_time_ms / 1000)}s`);
151
- }
152
-
153
268
  // Show build URL if we can construct it
154
269
  let baseUrl = config.baseUrl || getApiUrl();
155
- if (baseUrl && build.project_id) {
156
- let buildUrl = baseUrl.replace('/api', '') + `/projects/${build.project_id}/builds/${build.id}`;
157
- output.blank();
158
- output.labelValue('View', output.link('Build', buildUrl));
159
- }
160
-
161
- // Show preview URL if available
162
- if (previewInfo?.preview_url) {
163
- output.labelValue('Preview', output.link('Preview', previewInfo.preview_url));
164
- if (previewInfo.expires_at) {
165
- let expiresDate = new Date(previewInfo.expires_at);
166
- output.hint(`Preview expires ${expiresDate.toLocaleDateString()}`);
167
- }
168
- }
169
-
170
- // Show additional info in verbose mode
171
- if (globalOptions.verbose) {
172
- output.blank();
173
- output.divider();
174
- output.blank();
175
- let verboseInfo = {};
176
- if (build.approved_screenshots > 0 || build.rejected_screenshots > 0 || build.pending_screenshots > 0) {
177
- verboseInfo.Approvals = `${build.approved_screenshots || 0} approved, ${build.rejected_screenshots || 0} rejected, ${build.pending_screenshots || 0} pending`;
178
- }
179
- if (build.avg_diff_percentage !== null) {
180
- verboseInfo['Avg Diff'] = `${(build.avg_diff_percentage * 100).toFixed(2)}%`;
181
- }
182
- if (build.github_pull_request_number) {
183
- verboseInfo['GitHub PR'] = `#${build.github_pull_request_number}`;
184
- }
185
- if (build.is_baseline) {
186
- verboseInfo.Baseline = 'Yes';
187
- }
188
- verboseInfo['User Agent'] = build.user_agent || 'Unknown';
189
- verboseInfo['Build ID'] = build.id;
190
- verboseInfo['Project ID'] = build.project_id;
191
- output.keyValue(verboseInfo);
192
- }
193
-
194
- // Show progress if build is still processing
195
- if (build.status === 'processing' || build.status === 'pending') {
196
- let totalJobs = build.completed_jobs + build.failed_jobs + build.processing_screenshots;
197
- if (totalJobs > 0) {
198
- let progress = (build.completed_jobs + build.failed_jobs) / totalJobs;
199
- output.blank();
200
- output.print(` ${output.progressBar(progress * 100, 100)} ${Math.round(progress * 100)}%`);
201
- }
202
- }
270
+ let buildUrl = createBuildUrl(baseUrl, build);
271
+ writeHumanStatus({
272
+ build,
273
+ buildUrl,
274
+ globalOptions,
275
+ output,
276
+ previewInfo
277
+ });
203
278
  output.cleanup();
204
279
 
205
280
  // Exit with appropriate code based on build status
206
- if (build.status === 'failed' || build.failed_jobs > 0) {
281
+ if (shouldFailStatus(build)) {
207
282
  exit(1);
208
283
  }
209
284
  } catch (error) {
285
+ output.stopSpinner();
286
+
210
287
  // Don't fail CI for Vizzly infrastructure issues (5xx errors)
211
288
  let status = error.context?.status;
212
289
  if (status >= 500) {
@@ -220,6 +297,7 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}, d
220
297
  };
221
298
  }
222
299
  output.error('Failed to get build status', error);
300
+ output.cleanup();
223
301
  exit(1);
224
302
  return {
225
303
  success: false,