@vizzly-testing/cli 0.23.1 → 0.23.2

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 (36) hide show
  1. package/README.md +54 -586
  2. package/dist/api/client.js +3 -1
  3. package/dist/api/endpoints.js +6 -7
  4. package/dist/cli.js +15 -2
  5. package/dist/commands/finalize.js +12 -0
  6. package/dist/commands/preview.js +210 -28
  7. package/dist/commands/run.js +15 -0
  8. package/dist/commands/status.js +34 -8
  9. package/dist/commands/upload.js +13 -0
  10. package/package.json +1 -2
  11. package/claude-plugin/.claude-plugin/README.md +0 -270
  12. package/claude-plugin/.claude-plugin/marketplace.json +0 -28
  13. package/claude-plugin/.claude-plugin/plugin.json +0 -14
  14. package/claude-plugin/.mcp.json +0 -12
  15. package/claude-plugin/CHANGELOG.md +0 -85
  16. package/claude-plugin/commands/setup.md +0 -137
  17. package/claude-plugin/commands/suggest-screenshots.md +0 -111
  18. package/claude-plugin/mcp/vizzly-docs-server/README.md +0 -95
  19. package/claude-plugin/mcp/vizzly-docs-server/docs-fetcher.js +0 -110
  20. package/claude-plugin/mcp/vizzly-docs-server/index.js +0 -283
  21. package/claude-plugin/mcp/vizzly-server/cloud-api-provider.js +0 -399
  22. package/claude-plugin/mcp/vizzly-server/index.js +0 -927
  23. package/claude-plugin/mcp/vizzly-server/local-tdd-provider.js +0 -455
  24. package/claude-plugin/mcp/vizzly-server/token-resolver.js +0 -185
  25. package/claude-plugin/skills/check-visual-tests/SKILL.md +0 -158
  26. package/claude-plugin/skills/debug-visual-regression/SKILL.md +0 -269
  27. package/docs/api-reference.md +0 -1003
  28. package/docs/authentication.md +0 -334
  29. package/docs/doctor-command.md +0 -44
  30. package/docs/getting-started.md +0 -131
  31. package/docs/internal/SDK-API.md +0 -1018
  32. package/docs/plugins.md +0 -557
  33. package/docs/tdd-mode.md +0 -594
  34. package/docs/test-integration.md +0 -523
  35. package/docs/tui-elements.md +0 -560
  36. package/docs/upload-command.md +0 -196
@@ -78,7 +78,9 @@ export function createApiClient(options = {}) {
78
78
 
79
79
  // Other errors
80
80
  let error = parseApiError(response.status, errorBody, url);
81
- throw new VizzlyError(error.message, error.code);
81
+ throw new VizzlyError(error.message, error.code, {
82
+ status: error.status
83
+ });
82
84
  }
83
85
  return response.json();
84
86
  }
@@ -325,17 +325,16 @@ export async function finalizeParallelBuild(client, parallelId) {
325
325
  * @returns {Promise<Object>} Upload result with preview URL
326
326
  */
327
327
  export async function uploadPreviewZip(client, buildId, zipBuffer) {
328
- // Create form data with the ZIP file
329
- let FormData = (await import('form-data')).default;
328
+ // Use native FormData (Node 18+) with Blob for proper fetch compatibility
330
329
  let formData = new FormData();
331
- formData.append('file', zipBuffer, {
332
- filename: 'preview.zip',
333
- contentType: 'application/zip'
330
+ let blob = new Blob([zipBuffer], {
331
+ type: 'application/zip'
334
332
  });
333
+ formData.append('file', blob, 'preview.zip');
335
334
  return client.request(`/api/sdk/builds/${buildId}/preview/upload-zip`, {
336
335
  method: 'POST',
337
- body: formData,
338
- headers: formData.getHeaders()
336
+ body: formData
337
+ // Let fetch set the Content-Type with boundary automatically
339
338
  });
340
339
  }
341
340
 
package/dist/cli.js CHANGED
@@ -63,7 +63,7 @@ const formatHelp = (cmd, helper) => {
63
63
  key: 'core',
64
64
  icon: '▸',
65
65
  title: 'Core',
66
- names: ['run', 'tdd', 'upload', 'status', 'finalize']
66
+ names: ['run', 'tdd', 'upload', 'status', 'finalize', 'preview']
67
67
  }, {
68
68
  key: 'setup',
69
69
  icon: '▸',
@@ -412,9 +412,22 @@ 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').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('-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
+ // Show helpful error if path is missing
419
+ if (!path) {
420
+ output.error('Path to static files is required');
421
+ output.blank();
422
+ output.print(' Upload your build output directory:');
423
+ output.blank();
424
+ output.print(' vizzly preview ./dist');
425
+ output.print(' vizzly preview ./build');
426
+ output.print(' vizzly preview ./out');
427
+ output.blank();
428
+ process.exit(1);
429
+ }
430
+
418
431
  // Validate options
419
432
  const validationErrors = validatePreviewOptions(path, options);
420
433
  if (validationErrors.length > 0) {
@@ -89,6 +89,18 @@ export async function finalizeCommand(parallelId, options = {}, globalOptions =
89
89
  };
90
90
  } catch (error) {
91
91
  output.stopSpinner();
92
+
93
+ // Don't fail CI for Vizzly infrastructure issues (5xx errors)
94
+ let status = error.context?.status;
95
+ if (status >= 500) {
96
+ output.warn('Vizzly API unavailable - finalize skipped.');
97
+ return {
98
+ success: true,
99
+ result: {
100
+ skipped: true
101
+ }
102
+ };
103
+ }
92
104
  output.error('Failed to finalize parallel build', error);
93
105
  exit(1);
94
106
  return {
@@ -20,6 +20,9 @@ import * as defaultOutput from '../utils/output.js';
20
20
  import { formatSessionAge as defaultFormatSessionAge, readSession as defaultReadSession } from '../utils/session.js';
21
21
  let execAsync = promisify(exec);
22
22
 
23
+ // Maximum files to show in dry-run output (use --verbose for all)
24
+ let DRY_RUN_FILE_LIMIT = 50;
25
+
23
26
  /**
24
27
  * Validate path for shell safety - prevents command injection
25
28
  * @param {string} path - Path to validate
@@ -74,17 +77,49 @@ function getZipCommand() {
74
77
  };
75
78
  }
76
79
 
80
+ // Default directories to exclude from preview uploads
81
+ let DEFAULT_EXCLUDED_DIRS = ['node_modules', '__pycache__', '.git', '.svn', '.hg', '.vizzly', 'coverage', '.nyc_output', '.cache', '.turbo', '.next/cache', '.nuxt', '.output', '.vercel', '.netlify', 'tests', 'test', '__tests__', 'spec', '__mocks__', 'playwright-report', 'cypress', '.playwright'];
82
+
83
+ // Default file patterns to exclude from preview uploads
84
+ let DEFAULT_EXCLUDED_FILES = ['package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', '*.config.js', '*.config.ts', '*.config.mjs', '*.config.cjs', 'tsconfig.json', 'jsconfig.json', '.eslintrc*', '.prettierrc*', 'Makefile', 'Dockerfile', 'docker-compose*.yml', '*.md', '*.log', '*.map'];
85
+
86
+ /**
87
+ * Check if a filename matches any of the exclusion patterns
88
+ * @param {string} filename - Filename to check
89
+ * @param {string[]} patterns - Patterns to match against
90
+ * @returns {boolean}
91
+ */
92
+ function matchesPattern(filename, patterns) {
93
+ for (let pattern of patterns) {
94
+ if (pattern.includes('*')) {
95
+ // Simple glob matching - convert to regex
96
+ let regex = new RegExp(`^${pattern.replace(/\./g, '\\.').replace(/\*/g, '.*')}$`);
97
+ if (regex.test(filename)) return true;
98
+ } else {
99
+ if (filename === pattern) return true;
100
+ }
101
+ }
102
+ return false;
103
+ }
104
+
77
105
  /**
78
106
  * Create a ZIP file from a directory using system commands
79
107
  * @param {string} sourceDir - Directory to zip
80
108
  * @param {string} outputPath - Path for output ZIP file
109
+ * @param {Object} exclusions - Exclusion patterns
110
+ * @param {string[]} exclusions.dirs - Directory names to exclude
111
+ * @param {string[]} exclusions.files - File patterns to exclude
81
112
  * @returns {Promise<void>}
82
113
  */
83
- async function createZipWithSystem(sourceDir, outputPath) {
114
+ async function createZipWithSystem(sourceDir, outputPath, exclusions = {}) {
84
115
  let {
85
116
  command,
86
117
  available
87
118
  } = getZipCommand();
119
+ let {
120
+ dirs = [],
121
+ files = []
122
+ } = exclusions;
88
123
  if (!available) {
89
124
  throw new Error('No zip command found. Please install zip or use PowerShell on Windows.');
90
125
  }
@@ -95,22 +130,44 @@ async function createZipWithSystem(sourceDir, outputPath) {
95
130
  if (!isPathSafe(sourceDir)) {
96
131
  throw new Error('Path contains unsupported characters. Please use a path without special shell characters.');
97
132
  }
133
+
134
+ // Validate exclusion patterns to prevent command injection
135
+ // Only allow safe characters in patterns: alphanumeric, dots, asterisks, underscores, hyphens, slashes
136
+ let safePatternRegex = /^[a-zA-Z0-9.*_\-/]+$/;
137
+ for (let pattern of [...dirs, ...files]) {
138
+ if (!safePatternRegex.test(pattern)) {
139
+ throw new Error(`Exclusion pattern contains unsafe characters: ${pattern}. Only alphanumeric, ., *, _, -, / are allowed.`);
140
+ }
141
+ }
98
142
  if (command === 'zip') {
99
143
  // Standard zip command - create ZIP from directory contents
100
144
  // Using cwd option is safe as it's not part of the command string
101
- // -r: recursive, -q: quiet
102
- await execAsync(`zip -r -q "${outputPath}" .`, {
145
+ // -r: recursive, -q: quiet, -x: exclude patterns
146
+ let excludeArgs = [...dirs.map(dir => `-x "${dir}/*"`), ...files.map(pattern => `-x "${pattern}"`)].join(' ');
147
+ await execAsync(`zip -r -q "${outputPath}" . ${excludeArgs}`, {
103
148
  cwd: sourceDir,
104
149
  maxBuffer: 1024 * 1024 * 100 // 100MB buffer
105
150
  });
106
151
  } else if (command === 'powershell') {
107
- // Windows PowerShell - use -LiteralPath for safer path handling
108
- // Escape single quotes in paths by doubling them
152
+ // Windows PowerShell - Compress-Archive doesn't support exclusions,
153
+ // so we create a temp directory with only the files we want
109
154
  let safeSrcDir = sourceDir.replace(/'/g, "''");
110
155
  let safeOutPath = outputPath.replace(/'/g, "''");
111
- await execAsync(`powershell -Command "Compress-Archive -LiteralPath '${safeSrcDir}\\*' -DestinationPath '${safeOutPath}' -Force"`, {
112
- maxBuffer: 1024 * 1024 * 100
113
- });
156
+
157
+ // Build exclusion filter for PowerShell
158
+ // We use Get-ChildItem with -Exclude and pipe to Compress-Archive
159
+ let excludePatterns = [...dirs, ...files].map(p => `'${p}'`).join(',');
160
+ if (excludePatterns) {
161
+ // Use robocopy to copy files excluding patterns, then zip
162
+ // This is more reliable than PowerShell's native filtering
163
+ await execAsync(`powershell -Command "` + `$src = '${safeSrcDir}'; ` + `$dst = '${safeOutPath}'; ` + `$exclude = @(${excludePatterns}); ` + `$items = Get-ChildItem -Path $src -Recurse -File | Where-Object { ` + `$rel = $_.FullName.Substring($src.Length + 1); ` + `$dominated = $false; ` + `foreach ($ex in $exclude) { if ($rel -like $ex -or $rel -like \\"$ex/*\\" -or $_.Name -like $ex) { $dominated = $true; break } }; ` + `-not $dominated ` + `}; ` + `if ($items) { $items | Compress-Archive -DestinationPath $dst -Force }"`, {
164
+ maxBuffer: 1024 * 1024 * 100
165
+ });
166
+ } else {
167
+ await execAsync(`powershell -Command "Compress-Archive -LiteralPath '${safeSrcDir}\\*' -DestinationPath '${safeOutPath}' -Force"`, {
168
+ maxBuffer: 1024 * 1024 * 100
169
+ });
170
+ }
114
171
  }
115
172
  }
116
173
 
@@ -118,14 +175,24 @@ async function createZipWithSystem(sourceDir, outputPath) {
118
175
  * Count files in a directory recursively
119
176
  * Skips symlinks to prevent path traversal attacks
120
177
  * @param {string} dir - Directory path
121
- * @returns {Promise<{ count: number, totalSize: number }>}
178
+ * @param {Object} options - Options
179
+ * @param {boolean} options.collectPaths - Whether to collect file paths
180
+ * @param {string[]} options.excludedDirs - Directory names to exclude
181
+ * @param {string[]} options.excludedFiles - File patterns to exclude
182
+ * @returns {Promise<{ count: number, totalSize: number, files?: Array<{path: string, size: number}> }>}
122
183
  */
123
- async function countFiles(dir) {
184
+ async function countFiles(dir, options = {}) {
124
185
  let {
125
186
  readdir
126
187
  } = await import('node:fs/promises');
188
+ let {
189
+ collectPaths = false,
190
+ excludedDirs = DEFAULT_EXCLUDED_DIRS,
191
+ excludedFiles = DEFAULT_EXCLUDED_FILES
192
+ } = options;
127
193
  let count = 0;
128
194
  let totalSize = 0;
195
+ let files = collectPaths ? [] : null;
129
196
 
130
197
  // Resolve the base directory to an absolute path for traversal checks
131
198
  let baseDir = await realpath(resolve(dir));
@@ -141,8 +208,8 @@ async function countFiles(dir) {
141
208
  continue;
142
209
  }
143
210
  if (entry.isDirectory()) {
144
- // Skip hidden directories and common non-content directories
145
- if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '__pycache__') {
211
+ // Skip hidden directories and excluded directories
212
+ if (entry.name.startsWith('.') || excludedDirs.includes(entry.name)) {
146
213
  continue;
147
214
  }
148
215
 
@@ -157,16 +224,30 @@ async function countFiles(dir) {
157
224
  if (entry.name.startsWith('.')) {
158
225
  continue;
159
226
  }
227
+
228
+ // Skip excluded file patterns
229
+ if (matchesPattern(entry.name, excludedFiles)) {
230
+ continue;
231
+ }
160
232
  count++;
161
233
  let fileStat = await stat(fullPath);
162
234
  totalSize += fileStat.size;
235
+ if (files) {
236
+ // Store relative path from base directory
237
+ let relativePath = fullPath.slice(baseDir.length + 1);
238
+ files.push({
239
+ path: relativePath,
240
+ size: fileStat.size
241
+ });
242
+ }
163
243
  }
164
244
  }
165
245
  }
166
246
  await walk(baseDir);
167
247
  return {
168
248
  count,
169
- totalSize
249
+ totalSize,
250
+ files
170
251
  };
171
252
  }
172
253
 
@@ -213,8 +294,8 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
213
294
  };
214
295
  let config = await loadConfig(globalOptions.config, allOptions);
215
296
 
216
- // Validate API token
217
- if (!config.apiKey) {
297
+ // Validate API token (skip for dry-run)
298
+ if (!options.dryRun && !config.apiKey) {
218
299
  output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
219
300
  exit(1);
220
301
  return {
@@ -296,23 +377,64 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
296
377
  };
297
378
  }
298
379
 
299
- // Check for zip command availability
300
- let zipInfo = getZipCommand();
301
- if (!zipInfo.available) {
302
- output.error('No zip command found. Please install zip (macOS/Linux) or ensure PowerShell is available (Windows).');
303
- exit(1);
304
- return {
305
- success: false,
306
- reason: 'no-zip-command'
307
- };
380
+ // Check for zip command availability (skip for dry-run)
381
+ if (!options.dryRun) {
382
+ let zipInfo = getZipCommand();
383
+ if (!zipInfo.available) {
384
+ output.error('No zip command found. Please install zip (macOS/Linux) or ensure PowerShell is available (Windows).');
385
+ exit(1);
386
+ return {
387
+ success: false,
388
+ reason: 'no-zip-command'
389
+ };
390
+ }
391
+ }
392
+
393
+ // Build exclusion lists from defaults and user options
394
+ let excludedDirs = [...DEFAULT_EXCLUDED_DIRS];
395
+ let excludedFiles = [...DEFAULT_EXCLUDED_FILES];
396
+
397
+ // Add user-specified exclusions
398
+ if (options.exclude) {
399
+ let userExcludes = Array.isArray(options.exclude) ? options.exclude : [options.exclude];
400
+ for (let pattern of userExcludes) {
401
+ // If pattern ends with /, treat as directory
402
+ if (pattern.endsWith('/')) {
403
+ excludedDirs.push(pattern.slice(0, -1));
404
+ } else {
405
+ excludedFiles.push(pattern);
406
+ }
407
+ }
308
408
  }
309
409
 
410
+ // Remove patterns that user explicitly wants to include
411
+ if (options.include) {
412
+ let userIncludes = Array.isArray(options.include) ? options.include : [options.include];
413
+ for (let pattern of userIncludes) {
414
+ if (pattern.endsWith('/')) {
415
+ let dirName = pattern.slice(0, -1);
416
+ excludedDirs = excludedDirs.filter(d => d !== dirName);
417
+ } else {
418
+ excludedFiles = excludedFiles.filter(f => f !== pattern);
419
+ }
420
+ }
421
+ }
422
+ let exclusions = {
423
+ dirs: excludedDirs,
424
+ files: excludedFiles
425
+ };
426
+
310
427
  // Count files and calculate size
311
428
  output.startSpinner('Scanning files...');
312
429
  let {
313
430
  count: fileCount,
314
- totalSize
315
- } = await countFiles(path);
431
+ totalSize,
432
+ files
433
+ } = await countFiles(path, {
434
+ collectPaths: options.dryRun,
435
+ excludedDirs,
436
+ excludedFiles
437
+ });
316
438
  if (fileCount === 0) {
317
439
  output.stopSpinner();
318
440
  output.error(`No files found in ${path}`);
@@ -322,7 +444,67 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
322
444
  reason: 'no-files'
323
445
  };
324
446
  }
325
- output.updateSpinner(`Found ${fileCount} files (${formatBytes(totalSize)})`);
447
+ output.stopSpinner();
448
+
449
+ // Dry run - show what would be uploaded and exit
450
+ if (options.dryRun) {
451
+ let colors = output.getColors();
452
+ if (globalOptions.json) {
453
+ output.data({
454
+ dryRun: true,
455
+ path: resolve(path),
456
+ fileCount,
457
+ totalSize,
458
+ excludedDirs,
459
+ excludedFiles,
460
+ files: files.map(f => ({
461
+ path: f.path,
462
+ size: f.size
463
+ }))
464
+ });
465
+ } else {
466
+ output.info(`Dry run - would upload ${fileCount} files (${formatBytes(totalSize)})`);
467
+ output.blank();
468
+ output.print(` ${colors.brand.textTertiary('Source')} ${resolve(path)}`);
469
+ output.print(` ${colors.brand.textTertiary('Files')} ${fileCount}`);
470
+ output.print(` ${colors.brand.textTertiary('Total size')} ${formatBytes(totalSize)}`);
471
+ output.blank();
472
+
473
+ // Show exclusions in verbose mode
474
+ if (globalOptions.verbose) {
475
+ output.print(` ${colors.brand.textTertiary('Excluded directories:')}`);
476
+ for (let dir of excludedDirs) {
477
+ output.print(` ${colors.dim(dir)}`);
478
+ }
479
+ output.blank();
480
+ output.print(` ${colors.brand.textTertiary('Excluded file patterns:')}`);
481
+ for (let pattern of excludedFiles) {
482
+ output.print(` ${colors.dim(pattern)}`);
483
+ }
484
+ output.blank();
485
+ }
486
+
487
+ // Show files (limit in non-verbose mode)
488
+ let displayFiles = globalOptions.verbose ? files : files.slice(0, DRY_RUN_FILE_LIMIT);
489
+ let hasMore = files.length > displayFiles.length;
490
+ output.print(` ${colors.brand.textTertiary('Files to upload:')}`);
491
+ for (let file of displayFiles) {
492
+ output.print(` ${file.path} ${colors.dim(`(${formatBytes(file.size)})`)}`);
493
+ }
494
+ if (hasMore) {
495
+ output.print(` ${colors.dim(`... and ${files.length - DRY_RUN_FILE_LIMIT} more (use --verbose to see all)`)}`);
496
+ }
497
+ }
498
+ output.cleanup();
499
+ return {
500
+ success: true,
501
+ dryRun: true,
502
+ fileCount,
503
+ totalSize,
504
+ files
505
+ };
506
+ }
507
+ output.startSpinner(`Found ${fileCount} files (${formatBytes(totalSize)})`);
326
508
 
327
509
  // Create ZIP using system command
328
510
  output.updateSpinner('Compressing files...');
@@ -331,7 +513,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
331
513
  let zipPath = join(tmpdir(), `vizzly-preview-${Date.now()}-${randomSuffix}.zip`);
332
514
  let zipBuffer;
333
515
  try {
334
- await createZipWithSystem(path, zipPath);
516
+ await createZipWithSystem(path, zipPath, exclusions);
335
517
  zipBuffer = await readFile(zipPath);
336
518
  } catch (zipError) {
337
519
  output.stopSpinner();
@@ -356,6 +356,21 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
356
356
  } catch (error) {
357
357
  output.stopSpinner();
358
358
 
359
+ // Don't fail CI for Vizzly infrastructure issues (5xx errors)
360
+ let status = error.context?.status;
361
+ if (status >= 500) {
362
+ output.warn('Vizzly API unavailable - visual tests skipped. Your tests still ran.');
363
+ output.debug('api', 'API error details:', {
364
+ error: error.message
365
+ });
366
+ return {
367
+ success: true,
368
+ result: {
369
+ skipped: true
370
+ }
371
+ };
372
+ }
373
+
359
374
  // Provide more context about where the error occurred
360
375
  let errorContext = 'Test run failed';
361
376
  if (error.message?.includes('build')) {
@@ -3,18 +3,28 @@
3
3
  * Uses functional API operations directly
4
4
  */
5
5
 
6
- import { createApiClient, getBuild, getPreviewInfo } from '../api/index.js';
7
- import { loadConfig } from '../utils/config-loader.js';
8
- import { getApiUrl } from '../utils/environment-config.js';
9
- import * as output from '../utils/output.js';
6
+ import { createApiClient as defaultCreateApiClient, getBuild as defaultGetBuild, getPreviewInfo as defaultGetPreviewInfo } from '../api/index.js';
7
+ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
8
+ import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js';
9
+ import * as defaultOutput from '../utils/output.js';
10
10
 
11
11
  /**
12
12
  * Status command implementation
13
13
  * @param {string} buildId - Build ID to check status for
14
14
  * @param {Object} options - Command options
15
15
  * @param {Object} globalOptions - Global CLI options
16
+ * @param {Object} deps - Dependencies for testing
16
17
  */
17
- export async function statusCommand(buildId, options = {}, globalOptions = {}) {
18
+ export async function statusCommand(buildId, options = {}, globalOptions = {}, deps = {}) {
19
+ 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;
18
28
  output.configure({
19
29
  json: globalOptions.json,
20
30
  verbose: globalOptions.verbose,
@@ -31,7 +41,7 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) {
31
41
  // Validate API token
32
42
  if (!config.apiKey) {
33
43
  output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
34
- process.exit(1);
44
+ exit(1);
35
45
  }
36
46
 
37
47
  // Get build details via functional API
@@ -194,11 +204,27 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) {
194
204
 
195
205
  // Exit with appropriate code based on build status
196
206
  if (build.status === 'failed' || build.failed_jobs > 0) {
197
- process.exit(1);
207
+ exit(1);
198
208
  }
199
209
  } catch (error) {
210
+ // Don't fail CI for Vizzly infrastructure issues (5xx errors)
211
+ let status = error.context?.status;
212
+ if (status >= 500) {
213
+ output.warn('Vizzly API unavailable - status check skipped.');
214
+ output.cleanup();
215
+ return {
216
+ success: true,
217
+ result: {
218
+ skipped: true
219
+ }
220
+ };
221
+ }
200
222
  output.error('Failed to get build status', error);
201
- process.exit(1);
223
+ exit(1);
224
+ return {
225
+ success: false,
226
+ error
227
+ };
202
228
  }
203
229
  }
204
230
 
@@ -227,6 +227,19 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
227
227
  result
228
228
  };
229
229
  } catch (error) {
230
+ // Don't fail CI for Vizzly infrastructure issues (5xx errors)
231
+ let status = error.context?.status;
232
+ if (status >= 500) {
233
+ output.warn('Vizzly API unavailable - upload skipped. Your tests still ran.');
234
+ output.cleanup();
235
+ return {
236
+ success: true,
237
+ result: {
238
+ skipped: true
239
+ }
240
+ };
241
+ }
242
+
230
243
  // Mark build as failed if we have a buildId and config
231
244
  if (buildId && config) {
232
245
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.23.1",
3
+ "version": "0.23.2",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",
@@ -51,7 +51,6 @@
51
51
  "files": [
52
52
  "bin",
53
53
  "dist",
54
- "docs",
55
54
  "claude-plugin",
56
55
  "README.md",
57
56
  "LICENSE"