@vizzly-testing/cli 0.25.0 → 0.25.1

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.
package/dist/cli.js CHANGED
@@ -188,7 +188,7 @@ const formatHelp = (cmd, helper) => {
188
188
  lines.push('');
189
189
  return lines.join('\n');
190
190
  };
191
- program.name('vizzly').description('Vizzly CLI for visual regression testing').version(getPackageVersion()).option('-c, --config <path>', 'Config file path').option('--token <token>', 'Vizzly API token').option('-v, --verbose', 'Verbose output (shorthand for --log-level debug)').option('--log-level <level>', 'Log level: debug, info, warn, error (default: info, or VIZZLY_LOG_LEVEL env var)').option('--json', 'Machine-readable output').option('--color', 'Force colored output (even in non-TTY)').option('--no-color', 'Disable colored output').configureHelp({
191
+ program.name('vizzly').description('Vizzly CLI for visual regression testing').version(getPackageVersion()).option('-c, --config <path>', 'Config file path').option('--token <token>', 'Vizzly API token').option('-v, --verbose', 'Verbose output (shorthand for --log-level debug)').option('--log-level <level>', 'Log level: debug, info, warn, error (default: info, or VIZZLY_LOG_LEVEL env var)').option('--json', 'Machine-readable output').option('--color', 'Force colored output (even in non-TTY)').option('--no-color', 'Disable colored output').option('--strict', 'Fail on any error (default: be resilient, warn on non-critical issues)').configureHelp({
192
192
  formatHelp
193
193
  });
194
194
 
@@ -412,7 +412,7 @@ program.command('finalize').description('Finalize a parallel build after all sha
412
412
  }
413
413
  await finalizeCommand(parallelId, options, globalOptions);
414
414
  });
415
- program.command('preview').description('Upload static files as a preview for a build').argument('[path]', 'Path to static files (dist/, build/, out/)').option('-b, --build <id>', 'Build ID to attach preview to').option('-p, --parallel-id <id>', 'Look up build by parallel ID').option('--base <path>', 'Override auto-detected base path').option('--open', 'Open preview URL in browser after upload').option('--dry-run', 'Show what would be uploaded without uploading').option('-x, --exclude <pattern>', 'Exclude files/dirs (repeatable, e.g. -x "*.log" -x "temp/")', (val, prev) => prev ? [...prev, val] : [val]).option('-i, --include <pattern>', 'Override default exclusions (repeatable, e.g. -i package.json -i tests/)', (val, prev) => prev ? [...prev, val] : [val]).action(async (path, options) => {
415
+ program.command('preview').description('Upload static files as a preview for a build').argument('[path]', 'Path to static files (dist/, build/, out/)').option('-b, --build <id>', 'Build ID to attach preview to').option('-p, --parallel-id <id>', 'Look up build by parallel ID').option('--base <path>', 'Override auto-detected base path').option('--open', 'Open preview URL in browser after upload').option('--dry-run', 'Show what would be uploaded without uploading').option('--public-link', 'Acknowledge that preview URL grants access to anyone with the link (required for private projects)').option('-x, --exclude <pattern>', 'Exclude files/dirs (repeatable, e.g. -x "*.log" -x "temp/")', (val, prev) => prev ? [...prev, val] : [val]).option('-i, --include <pattern>', 'Override default exclusions (repeatable, e.g. -i package.json -i tests/)', (val, prev) => prev ? [...prev, val] : [val]).action(async (path, options) => {
416
416
  const globalOptions = program.opts();
417
417
 
418
418
  // Show helpful error if path is missing
@@ -7,6 +7,7 @@ import { createApiClient as defaultCreateApiClient, finalizeParallelBuild as def
7
7
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
8
8
  import * as defaultOutput from '../utils/output.js';
9
9
  import { writeSession as defaultWriteSession } from '../utils/session.js';
10
+ let MISSING_BUILD_HINTS = [' • No screenshots were uploaded with this parallel-id', ' • Tests were skipped or failed before capturing screenshots', ' • The parallel-id does not match what was used during test runs'];
10
11
 
11
12
  /**
12
13
  * Finalize command implementation
@@ -89,15 +90,54 @@ export async function finalizeCommand(parallelId, options = {}, globalOptions =
89
90
  };
90
91
  } catch (error) {
91
92
  output.stopSpinner();
93
+ let status = error.context?.status;
92
94
 
93
95
  // Don't fail CI for Vizzly infrastructure issues (5xx errors)
94
- let status = error.context?.status;
96
+ // Note: --strict does NOT affect 5xx handling - infrastructure issues are out of user's control
95
97
  if (status >= 500) {
96
98
  output.warn('Vizzly API unavailable - finalize skipped.');
97
99
  return {
98
100
  success: true,
99
101
  result: {
100
- skipped: true
102
+ skipped: true,
103
+ reason: 'api-unavailable'
104
+ }
105
+ };
106
+ }
107
+
108
+ // Handle missing builds gracefully (404 errors)
109
+ // This happens when: no screenshots were uploaded, tests were skipped, or parallel-id doesn't exist
110
+ if (status === 404) {
111
+ let isStrict = globalOptions.strict;
112
+ if (isStrict) {
113
+ output.error(`No build found for parallel ID: ${parallelId}`);
114
+ output.blank();
115
+ output.info('This can happen when:');
116
+ for (let hint of MISSING_BUILD_HINTS) {
117
+ output.info(hint);
118
+ }
119
+ exit(1);
120
+ return {
121
+ success: false,
122
+ reason: 'no-build-found',
123
+ error
124
+ };
125
+ }
126
+
127
+ // Non-strict mode: warn but don't fail CI
128
+ output.warn(`No build found for parallel ID: ${parallelId} - finalize skipped.`);
129
+ if (globalOptions.verbose) {
130
+ output.info('Possible reasons:');
131
+ for (let hint of MISSING_BUILD_HINTS) {
132
+ output.info(hint);
133
+ }
134
+ output.info('Use --strict flag to fail CI when no build is found.');
135
+ }
136
+ return {
137
+ success: true,
138
+ result: {
139
+ skipped: true,
140
+ reason: 'no-build-found'
101
141
  }
102
142
  };
103
143
  }
@@ -12,7 +12,7 @@ import { readFile, realpath, stat, unlink } from 'node:fs/promises';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { join, resolve } from 'node:path';
14
14
  import { promisify } from 'node:util';
15
- import { createApiClient as defaultCreateApiClient, uploadPreviewZip as defaultUploadPreviewZip } from '../api/index.js';
15
+ import { createApiClient as defaultCreateApiClient, getBuild as defaultGetBuild, uploadPreviewZip as defaultUploadPreviewZip } from '../api/index.js';
16
16
  import { openBrowser as defaultOpenBrowser } from '../utils/browser.js';
17
17
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
18
18
  import { detectBranch as defaultDetectBranch } from '../utils/git.js';
@@ -273,6 +273,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
273
273
  let {
274
274
  loadConfig = defaultLoadConfig,
275
275
  createApiClient = defaultCreateApiClient,
276
+ getBuild = defaultGetBuild,
276
277
  uploadPreviewZip = defaultUploadPreviewZip,
277
278
  readSession = defaultReadSession,
278
279
  formatSessionAge = defaultFormatSessionAge,
@@ -377,6 +378,59 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
377
378
  };
378
379
  }
379
380
 
381
+ // Create API client for non-dry-run operations (reused for visibility check and upload)
382
+ let client;
383
+ if (!options.dryRun) {
384
+ client = createApiClient({
385
+ baseUrl: config.apiUrl,
386
+ token: config.apiKey,
387
+ command: 'preview'
388
+ });
389
+
390
+ // Check project visibility for private projects
391
+ let buildResponse;
392
+ try {
393
+ buildResponse = await getBuild(client, buildId);
394
+ } catch (error) {
395
+ if (error.status === 404) {
396
+ output.error(`Build not found: ${buildId}`);
397
+ } else {
398
+ output.error('Failed to verify project visibility', error);
399
+ }
400
+ exit(1);
401
+ // Return is for testing (exit is mocked in tests)
402
+ return {
403
+ success: false,
404
+ reason: 'build-fetch-failed',
405
+ error
406
+ };
407
+ }
408
+
409
+ // Check if project is private and user hasn't acknowledged public link access
410
+ // Note: API returns { build, project } at top level, not nested
411
+ // Use === false to handle undefined/missing isPublic defensively
412
+ let project = buildResponse.project;
413
+ let isPrivate = project && project.isPublic === false;
414
+ if (isPrivate && !options.publicLink) {
415
+ output.error('This project is private.');
416
+ output.blank();
417
+ output.print(' Preview URLs grant access to anyone with the link (until expiration).');
418
+ output.blank();
419
+ output.print(' To proceed, acknowledge this by using:');
420
+ output.blank();
421
+ output.print(' vizzly preview ./dist --public-link');
422
+ output.blank();
423
+ output.print(' Or set your project to public in Vizzly settings.');
424
+ output.blank();
425
+ exit(1);
426
+ // Return is for testing (exit is mocked in tests)
427
+ return {
428
+ success: false,
429
+ reason: 'private-project-no-flag'
430
+ };
431
+ }
432
+ }
433
+
380
434
  // Check for zip command availability (skip for dry-run)
381
435
  if (!options.dryRun) {
382
436
  let zipInfo = getZipCommand();
@@ -532,13 +586,8 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
532
586
  let compressionRatio = ((1 - zipBuffer.length / totalSize) * 100).toFixed(0);
533
587
  output.updateSpinner(`Compressed to ${formatBytes(zipBuffer.length)} (${compressionRatio}% smaller)`);
534
588
 
535
- // Upload
589
+ // Upload (reuse client created earlier)
536
590
  output.updateSpinner('Uploading preview...');
537
- let client = createApiClient({
538
- baseUrl: config.apiUrl,
539
- token: config.apiKey,
540
- command: 'preview'
541
- });
542
591
  let result = await uploadPreviewZip(client, buildId, zipBuffer);
543
592
  output.stopSpinner();
544
593