@vizzly-testing/cli 0.26.2 → 0.27.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.
@@ -271,6 +271,51 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
271
271
  if (result.buildId) {
272
272
  buildId = result.buildId;
273
273
  }
274
+
275
+ // JSON output mode - output structured data and exit
276
+ if (globalOptions.json) {
277
+ 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
+ }
299
+ let jsonResult = {
300
+ buildId: result.buildId,
301
+ status: 'completed',
302
+ url: displayUrl,
303
+ screenshotsCaptured: result.screenshotsCaptured || 0,
304
+ executionTimeMs,
305
+ git: {
306
+ branch,
307
+ commit,
308
+ message
309
+ },
310
+ exitCode: 0
311
+ };
312
+ output.data(jsonResult);
313
+ output.cleanup();
314
+ return {
315
+ success: true,
316
+ result
317
+ };
318
+ }
274
319
  output.complete('Test run completed');
275
320
 
276
321
  // Show Vizzly summary with link to results
@@ -314,14 +359,56 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
314
359
  // Extract exit code from error message if available
315
360
  let exitCodeMatch = error.message.match(/exited with code (\d+)/);
316
361
  let exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 1;
317
- output.error('Test run failed');
362
+
363
+ // JSON output for test command failure
364
+ if (globalOptions.json) {
365
+ let executionTimeMs = Date.now() - startTime;
366
+ output.data({
367
+ buildId: buildId || null,
368
+ status: 'failed',
369
+ error: {
370
+ code: error.code,
371
+ message: error.message
372
+ },
373
+ executionTimeMs,
374
+ git: {
375
+ branch,
376
+ commit,
377
+ message
378
+ },
379
+ exitCode
380
+ });
381
+ output.cleanup();
382
+ } else {
383
+ output.error('Test run failed');
384
+ }
318
385
  return {
319
386
  success: false,
320
387
  exitCode
321
388
  };
322
389
  } else {
323
390
  // Setup or other error - VizzlyError.getUserMessage() provides context
324
- output.error('Test run failed', error);
391
+ if (globalOptions.json) {
392
+ let executionTimeMs = Date.now() - (startTime || Date.now());
393
+ output.data({
394
+ buildId: buildId || null,
395
+ status: 'failed',
396
+ error: {
397
+ code: error.code || 'UNKNOWN_ERROR',
398
+ message: error.getUserMessage ? error.getUserMessage() : error.message
399
+ },
400
+ executionTimeMs,
401
+ git: {
402
+ branch,
403
+ commit,
404
+ message
405
+ },
406
+ exitCode: 1
407
+ });
408
+ output.cleanup();
409
+ } else {
410
+ output.error('Test run failed', error);
411
+ }
325
412
  return {
326
413
  success: false,
327
414
  exitCode: 1
@@ -336,6 +423,59 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
336
423
  output.info('Waiting for build completion...');
337
424
  output.startSpinner('Processing comparisons...');
338
425
  let buildResult = await uploader.waitForBuild(result.buildId);
426
+
427
+ // JSON output for --wait mode
428
+ if (globalOptions.json) {
429
+ 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
+ }
450
+ let exitCode = buildResult.failedComparisons > 0 ? 1 : 0;
451
+ let jsonResult = {
452
+ buildId: result.buildId,
453
+ status: buildResult.failedComparisons > 0 ? 'failed' : 'completed',
454
+ url: displayUrl,
455
+ screenshotsCaptured: result.screenshotsCaptured || 0,
456
+ executionTimeMs,
457
+ git: {
458
+ branch,
459
+ commit,
460
+ message
461
+ },
462
+ comparisons: {
463
+ total: buildResult.totalComparisons || 0,
464
+ new: buildResult.newComparisons || 0,
465
+ changed: buildResult.failedComparisons || 0,
466
+ identical: buildResult.identicalComparisons || 0
467
+ },
468
+ approvalStatus: buildResult.approvalStatus || 'pending',
469
+ exitCode
470
+ };
471
+ output.data(jsonResult);
472
+ output.cleanup();
473
+ return {
474
+ success: exitCode === 0,
475
+ exitCode,
476
+ result: jsonResult
477
+ };
478
+ }
339
479
  output.success('Build processing completed');
340
480
 
341
481
  // Exit with appropriate code based on comparison results
@@ -27,6 +27,16 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
27
27
  if (existingServer) {
28
28
  // Verify it's actually running
29
29
  if (await isServerRunning(existingServer.port)) {
30
+ // JSON output for already running
31
+ if (globalOptions.json) {
32
+ output.data({
33
+ status: 'already_running',
34
+ port: existingServer.port,
35
+ pid: existingServer.pid,
36
+ dashboardUrl: `http://localhost:${existingServer.port}`
37
+ });
38
+ return;
39
+ }
30
40
  output.header('tdd', 'local');
31
41
  output.print(` ${output.statusDot('success')} Already running`);
32
42
  output.blank();
@@ -200,6 +210,21 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
200
210
  // Non-fatal, SDK can still use health check
201
211
  }
202
212
 
213
+ // JSON output for successful start
214
+ let dashboardUrl = `http://localhost:${port}`;
215
+ if (globalOptions.json) {
216
+ output.data({
217
+ status: 'started',
218
+ port,
219
+ pid: child.pid,
220
+ dashboardUrl
221
+ });
222
+ if (options.open) {
223
+ openDashboard(port);
224
+ }
225
+ return;
226
+ }
227
+
203
228
  // Show auto-allocated port message if applicable
204
229
  if (autoAllocated) {
205
230
  output.print(` ${output.statusDot('info')} Auto-assigned port ${colors.brand.textTertiary(`:${port}`)}`);
@@ -207,7 +232,6 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
207
232
  }
208
233
 
209
234
  // Show dashboard URL in a branded box
210
- let dashboardUrl = `http://localhost:${port}`;
211
235
  output.printBox(colors.brand.info(colors.underline(dashboardUrl)), {
212
236
  title: 'Dashboard',
213
237
  style: 'branded'
@@ -385,7 +409,15 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
385
409
  }
386
410
  }
387
411
  if (!pid) {
388
- output.warn('No TDD server running');
412
+ // JSON output for not running
413
+ if (globalOptions.json) {
414
+ output.data({
415
+ status: 'not_running',
416
+ message: 'No TDD server running'
417
+ });
418
+ } else {
419
+ output.warn('No TDD server running');
420
+ }
389
421
 
390
422
  // Clean up any stale files
391
423
  if (existsSync(pidFile)) unlinkSync(pidFile);
@@ -428,6 +460,16 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
428
460
  } catch {
429
461
  // Non-fatal
430
462
  }
463
+
464
+ // JSON output for successful stop
465
+ if (globalOptions.json) {
466
+ output.data({
467
+ status: 'stopped',
468
+ pid,
469
+ port
470
+ });
471
+ return;
472
+ }
431
473
  output.print(` ${output.statusDot('success')} Server stopped`);
432
474
  } catch (error) {
433
475
  if (error.code === 'ESRCH') {
@@ -467,6 +509,14 @@ export async function tddStatusCommand(_options, globalOptions = {}) {
467
509
  const pidFile = join(vizzlyDir, 'server.pid');
468
510
  const serverFile = join(vizzlyDir, 'server.json');
469
511
  if (!existsSync(pidFile)) {
512
+ // JSON output for not running
513
+ if (globalOptions.json) {
514
+ output.data({
515
+ running: false,
516
+ message: 'TDD server not running'
517
+ });
518
+ return;
519
+ }
470
520
  output.info('TDD server not running');
471
521
  return;
472
522
  }
@@ -486,15 +536,12 @@ export async function tddStatusCommand(_options, globalOptions = {}) {
486
536
  // Try to check health endpoint
487
537
  const health = await checkServerHealth(serverInfo.port);
488
538
  if (health.running) {
489
- let colors = output.getColors();
490
-
491
- // Show header
492
- output.header('tdd', 'local');
493
-
494
- // Show running status with uptime
539
+ // Calculate uptime
540
+ let uptimeMs = null;
495
541
  let uptimeStr = '';
496
542
  if (serverInfo.startTime) {
497
- const uptime = Math.floor((Date.now() - serverInfo.startTime) / 1000);
543
+ uptimeMs = Date.now() - serverInfo.startTime;
544
+ const uptime = Math.floor(uptimeMs / 1000);
498
545
  const hours = Math.floor(uptime / 3600);
499
546
  const minutes = Math.floor(uptime % 3600 / 60);
500
547
  const seconds = uptime % 60;
@@ -502,11 +549,30 @@ export async function tddStatusCommand(_options, globalOptions = {}) {
502
549
  if (minutes > 0 || hours > 0) uptimeStr += `${minutes}m `;
503
550
  uptimeStr += `${seconds}s`;
504
551
  }
552
+ let dashboardUrl = `http://localhost:${serverInfo.port}`;
553
+
554
+ // JSON output for running status
555
+ if (globalOptions.json) {
556
+ output.data({
557
+ running: true,
558
+ port: serverInfo.port,
559
+ pid,
560
+ uptimeMs,
561
+ uptime: uptimeStr || null,
562
+ dashboardUrl
563
+ });
564
+ return;
565
+ }
566
+ let colors = output.getColors();
567
+
568
+ // Show header
569
+ output.header('tdd', 'local');
570
+
571
+ // Show running status with uptime
505
572
  output.print(` ${output.statusDot('success')} Running ${uptimeStr ? colors.brand.textTertiary(`· ${uptimeStr}`) : ''}`);
506
573
  output.blank();
507
574
 
508
575
  // Show dashboard URL in a branded box
509
- let dashboardUrl = `http://localhost:${serverInfo.port}`;
510
576
  output.printBox(colors.brand.info(colors.underline(dashboardUrl)), {
511
577
  title: 'Dashboard',
512
578
  style: 'branded'
@@ -610,9 +676,9 @@ export async function tddListCommand(_options, globalOptions = {}) {
610
676
 
611
677
  // JSON output
612
678
  if (globalOptions.json) {
613
- console.log(JSON.stringify({
679
+ output.data({
614
680
  servers
615
- }, null, 2));
681
+ });
616
682
  return;
617
683
  }
618
684
 
@@ -196,16 +196,75 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
196
196
  // Determine success based on comparison results
197
197
  // (Summary is printed by printResults() in tdd-service.js, called from getTddResults)
198
198
  let hasFailures = runResult.failed || runResult.comparisons?.some(c => c.status === 'failed');
199
+ let exitCode = hasFailures ? 1 : 0;
200
+
201
+ // JSON output mode
202
+ if (globalOptions.json) {
203
+ // Build comparison data for JSON output
204
+ let comparisons = (runResult.comparisons || []).map(c => ({
205
+ name: c.name,
206
+ status: c.status,
207
+ signature: c.signature,
208
+ diffPercentage: c.diffPercentage ?? c.diff_percentage ?? null,
209
+ threshold: c.threshold ?? config.comparison.threshold,
210
+ paths: {
211
+ baseline: c.baselinePath || c.baseline_path || null,
212
+ current: c.currentPath || c.current_path || null,
213
+ diff: c.diffPath || c.diff_path || null
214
+ },
215
+ viewport: c.viewport || {
216
+ width: c.viewportWidth || c.viewport_width,
217
+ height: c.viewportHeight || c.viewport_height
218
+ },
219
+ browser: c.browser || null
220
+ }));
221
+
222
+ // Calculate summary
223
+ let summary = runResult.summary || {
224
+ total: comparisons.length,
225
+ passed: comparisons.filter(c => c.status === 'passed').length,
226
+ failed: comparisons.filter(c => c.status === 'failed').length,
227
+ new: comparisons.filter(c => c.status === 'new').length
228
+ };
229
+ output.data({
230
+ status: hasFailures ? 'failed' : 'completed',
231
+ exitCode,
232
+ comparisons,
233
+ summary,
234
+ reportPath: runResult.reportPath || '.vizzly/report/index.html'
235
+ });
236
+ output.cleanup();
237
+ }
199
238
  return {
200
239
  result: {
201
240
  success: !hasFailures,
202
- exitCode: hasFailures ? 1 : 0,
241
+ exitCode,
203
242
  ...runResult
204
243
  },
205
244
  cleanup
206
245
  };
207
246
  } catch (error) {
208
- output.error('Test failed', error);
247
+ // JSON output for errors
248
+ if (globalOptions.json) {
249
+ output.data({
250
+ status: 'failed',
251
+ exitCode: 1,
252
+ error: {
253
+ message: error.message,
254
+ code: error.code || 'UNKNOWN_ERROR'
255
+ },
256
+ comparisons: [],
257
+ summary: {
258
+ total: 0,
259
+ passed: 0,
260
+ failed: 0,
261
+ new: 0
262
+ }
263
+ });
264
+ output.cleanup();
265
+ } else {
266
+ output.error('Test failed', error);
267
+ }
209
268
  return {
210
269
  result: {
211
270
  success: false,
@@ -185,6 +185,33 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
185
185
  output.warn(`Failed to finalize build: ${error.message}`);
186
186
  }
187
187
  }
188
+
189
+ // JSON output mode
190
+ if (globalOptions.json) {
191
+ let executionTimeMs = Date.now() - uploadStartTime;
192
+ let buildUrl = result.url || (result.buildId ? await buildUrlConstructor(result.buildId, config.apiUrl, config.apiKey, deps) : null);
193
+ output.data({
194
+ buildId: result.buildId,
195
+ url: buildUrl,
196
+ stats: {
197
+ total: result.stats?.total || 0,
198
+ uploaded: result.stats?.uploaded || 0,
199
+ skipped: result.stats?.skipped || 0,
200
+ bytes: result.stats?.bytes || 0
201
+ },
202
+ git: {
203
+ branch,
204
+ commit,
205
+ message
206
+ },
207
+ executionTimeMs
208
+ });
209
+ output.cleanup();
210
+ return {
211
+ success: true,
212
+ result
213
+ };
214
+ }
188
215
  output.complete('Upload completed');
189
216
 
190
217
  // Show Vizzly summary
@@ -205,6 +232,41 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
205
232
  output.startSpinner('Processing comparisons...');
206
233
  let buildResult = await uploader.waitForBuild(result.buildId);
207
234
  output.stopSpinner();
235
+
236
+ // JSON output for --wait mode
237
+ if (globalOptions.json) {
238
+ let executionTimeMs = Date.now() - uploadStartTime;
239
+ let waitBuildUrl = buildResult.url || (await buildUrlConstructor(result.buildId, config.apiUrl, config.apiKey, deps));
240
+ output.data({
241
+ buildId: result.buildId,
242
+ status: buildResult.failedComparisons > 0 ? 'failed' : 'completed',
243
+ url: waitBuildUrl,
244
+ stats: {
245
+ total: result.stats?.total || 0,
246
+ uploaded: result.stats?.uploaded || 0,
247
+ skipped: result.stats?.skipped || 0,
248
+ bytes: result.stats?.bytes || 0
249
+ },
250
+ git: {
251
+ branch,
252
+ commit,
253
+ message
254
+ },
255
+ comparisons: {
256
+ total: buildResult.totalComparisons || 0,
257
+ passed: buildResult.passedComparisons || 0,
258
+ failed: buildResult.failedComparisons || 0,
259
+ new: buildResult.newComparisons || 0
260
+ },
261
+ approvalStatus: buildResult.approvalStatus || 'pending',
262
+ executionTimeMs
263
+ });
264
+ output.cleanup();
265
+ return {
266
+ success: buildResult.failedComparisons === 0,
267
+ result
268
+ };
269
+ }
208
270
  output.complete('Build processing completed');
209
271
 
210
272
  // Show build processing results
@@ -230,8 +292,18 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
230
292
  // Don't fail CI for Vizzly infrastructure issues (5xx errors)
231
293
  let status = error.context?.status;
232
294
  if (status >= 500) {
233
- output.warn('Vizzly API unavailable - upload skipped. Your tests still ran.');
234
- output.cleanup();
295
+ if (globalOptions.json) {
296
+ output.data({
297
+ buildId: null,
298
+ status: 'skipped',
299
+ message: 'Vizzly API unavailable - upload skipped',
300
+ executionTimeMs: Date.now() - uploadStartTime
301
+ });
302
+ output.cleanup();
303
+ } else {
304
+ output.warn('Vizzly API unavailable - upload skipped. Your tests still ran.');
305
+ output.cleanup();
306
+ }
235
307
  return {
236
308
  success: true,
237
309
  result: {
@@ -254,6 +326,26 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
254
326
  // Silent fail on cleanup
255
327
  }
256
328
  }
329
+
330
+ // JSON output for errors
331
+ if (globalOptions.json) {
332
+ output.data({
333
+ buildId: buildId || null,
334
+ status: 'failed',
335
+ error: {
336
+ code: error.code || 'UPLOAD_FAILED',
337
+ message: error?.getUserMessage ? error.getUserMessage() : error.message
338
+ },
339
+ executionTimeMs: Date.now() - uploadStartTime
340
+ });
341
+ output.cleanup();
342
+ exit(1);
343
+ return {
344
+ success: false,
345
+ error
346
+ };
347
+ }
348
+
257
349
  // Use user-friendly error message if available
258
350
  let errorMessage = error?.getUserMessage ? error.getUserMessage() : error.message;
259
351
  output.error(errorMessage || 'Upload failed', error);
@@ -2,7 +2,7 @@ import { resolve } from 'node:path';
2
2
  import { cosmiconfigSync } from 'cosmiconfig';
3
3
  import { validateVizzlyConfigWithDefaults } from './config-schema.js';
4
4
  import { getApiToken, getApiUrl, getBuildName, getParallelId } from './environment-config.js';
5
- import { getProjectMapping } from './global-config.js';
5
+ import { getAccessToken, getProjectMapping } from './global-config.js';
6
6
  import * as output from './output.js';
7
7
  const DEFAULT_CONFIG = {
8
8
  // API Configuration
@@ -116,6 +116,18 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
116
116
  output.debug('config', 'using token from --token flag');
117
117
  }
118
118
  applyCLIOverrides(config, cliOverrides);
119
+
120
+ // 6. Fall back to user auth token if no other token found
121
+ // This enables interactive commands (builds, comparisons, approve, etc.)
122
+ // to work without a project token when the user is logged in
123
+ if (!config.apiKey) {
124
+ let userToken = await getAccessToken();
125
+ if (userToken) {
126
+ config.apiKey = userToken;
127
+ config.isUserAuth = true; // Flag to indicate this is user auth, not project token
128
+ output.debug('config', 'using token from user login');
129
+ }
130
+ }
119
131
  return config;
120
132
  }
121
133