@vizzly-testing/cli 0.29.6 → 0.31.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 (87) hide show
  1. package/README.md +34 -6
  2. package/dist/api/client.js +6 -3
  3. package/dist/api/endpoints.js +59 -0
  4. package/dist/api/index.js +1 -1
  5. package/dist/auth/core.js +1 -1
  6. package/dist/auth/index.js +2 -1
  7. package/dist/cli.js +206 -51
  8. package/dist/client/index.js +21 -29
  9. package/dist/commands/api.js +145 -90
  10. package/dist/commands/baselines.js +78 -47
  11. package/dist/commands/builds.js +9 -8
  12. package/dist/commands/comparisons.js +7 -8
  13. package/dist/commands/config-cmd.js +15 -7
  14. package/dist/commands/context.js +870 -0
  15. package/dist/commands/doctor.js +116 -81
  16. package/dist/commands/init.js +202 -201
  17. package/dist/commands/login.js +134 -65
  18. package/dist/commands/logout.js +18 -7
  19. package/dist/commands/preview.js +29 -57
  20. package/dist/commands/project.js +120 -0
  21. package/dist/commands/projects.js +9 -2
  22. package/dist/commands/review.js +236 -202
  23. package/dist/commands/run.js +75 -72
  24. package/dist/commands/status.js +228 -150
  25. package/dist/commands/tdd-daemon-process.js +98 -0
  26. package/dist/commands/tdd-daemon.js +318 -212
  27. package/dist/commands/tdd.js +23 -8
  28. package/dist/commands/upload.js +10 -9
  29. package/dist/commands/whoami.js +64 -26
  30. package/dist/config/core.js +37 -36
  31. package/dist/context/local-workspace-provider.js +532 -0
  32. package/dist/context/provider-resolver.js +36 -0
  33. package/dist/index.js +5 -4
  34. package/dist/plugin-loader.js +55 -49
  35. package/dist/project/core.js +2 -2
  36. package/dist/project/operations.js +6 -3
  37. package/dist/reporter/reporter-bundle.css +2 -1
  38. package/dist/reporter/reporter-bundle.iife.js +119 -119
  39. package/dist/reporter-ssr/ssr-entry.js +605 -509
  40. package/dist/sdk/index.js +162 -107
  41. package/dist/server/handlers/tdd-handler.js +30 -54
  42. package/dist/server/routers/assets.js +4 -0
  43. package/dist/server/routers/baseline.js +195 -178
  44. package/dist/server/routers/events.js +226 -148
  45. package/dist/server/routers/projects.js +14 -5
  46. package/dist/server/routers/screenshot.js +1 -31
  47. package/dist/services/build-manager.js +19 -97
  48. package/dist/services/config-service.js +26 -34
  49. package/dist/services/index.js +2 -4
  50. package/dist/services/static-report-generator.js +11 -12
  51. package/dist/services/test-runner.js +21 -27
  52. package/dist/tdd/core/hotspot-coverage.js +1 -1
  53. package/dist/tdd/index.js +1 -1
  54. package/dist/tdd/metadata/region-metadata.js +0 -37
  55. package/dist/tdd/runtime-context.js +78 -0
  56. package/dist/tdd/server-registry.js +38 -17
  57. package/dist/tdd/services/baseline-manager.js +23 -0
  58. package/dist/tdd/services/hotspot-service.js +1 -14
  59. package/dist/tdd/tdd-service.js +82 -113
  60. package/dist/types/client.d.ts +36 -1
  61. package/dist/types/index.d.ts +114 -22
  62. package/dist/types/sdk.d.ts +47 -7
  63. package/dist/uploader/core.js +5 -4
  64. package/dist/uploader/index.js +79 -2
  65. package/dist/uploader/operations.js +40 -5
  66. package/dist/utils/api-url.js +14 -0
  67. package/dist/utils/async-utils.js +26 -0
  68. package/dist/utils/ci-env.js +19 -12
  69. package/dist/utils/colors.js +46 -44
  70. package/dist/utils/config-loader.js +39 -65
  71. package/dist/utils/config-schema.js +19 -55
  72. package/dist/utils/fetch-utils.js +12 -5
  73. package/dist/utils/git.js +35 -72
  74. package/dist/utils/global-config.js +22 -3
  75. package/dist/utils/output.js +19 -7
  76. package/dist/utils/patterns.js +14 -0
  77. package/dist/utils/project-link-store.js +105 -0
  78. package/dist/utils/report-data.js +31 -0
  79. package/dist/utils/screenshot-options.js +28 -0
  80. package/dist/utils/secret-store.js +65 -0
  81. package/dist/utils/security.js +3 -2
  82. package/package.json +14 -11
  83. package/dist/services/screenshot-server.js +0 -65
  84. package/dist/services/server-manager.js +0 -81
  85. package/dist/services/uploader.js +0 -100
  86. package/dist/tdd/services/region-service.js +0 -75
  87. package/dist/utils/build-history.js +0 -103
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { baselinesCommand, validateBaselinesOptions } from './commands/baselines
6
6
  import { buildsCommand, validateBuildsOptions } from './commands/builds.js';
7
7
  import { comparisonsCommand, validateComparisonsOptions } from './commands/comparisons.js';
8
8
  import { configCommand, validateConfigOptions } from './commands/config-cmd.js';
9
+ import { contextBuildCommand, contextComparisonCommand, contextReviewQueueCommand, contextScreenshotCommand, contextSimilarCommand, validateContextBuildOptions, validateContextComparisonOptions, validateContextReviewQueueOptions, validateContextScreenshotOptions, validateContextSimilarOptions } from './commands/context.js';
9
10
  import { doctorCommand, validateDoctorOptions } from './commands/doctor.js';
10
11
  import { finalizeCommand, validateFinalizeOptions } from './commands/finalize.js';
11
12
  import { init } from './commands/init.js';
@@ -13,18 +14,20 @@ import { loginCommand, validateLoginOptions } from './commands/login.js';
13
14
  import { logoutCommand, validateLogoutOptions } from './commands/logout.js';
14
15
  import { orgsCommand, validateOrgsOptions } from './commands/orgs.js';
15
16
  import { previewCommand, validatePreviewOptions } from './commands/preview.js';
17
+ import { projectLinkCommand, validateProjectLinkOptions } from './commands/project.js';
16
18
  import { projectsCommand, validateProjectsOptions } from './commands/projects.js';
17
19
  import { approveCommand, commentCommand, rejectCommand, validateApproveOptions, validateCommentOptions, validateRejectOptions } from './commands/review.js';
18
20
  import { runCommand, validateRunOptions } from './commands/run.js';
19
21
  import { statusCommand, validateStatusOptions } from './commands/status.js';
20
22
  import { tddCommand, validateTddOptions } from './commands/tdd.js';
21
- import { runDaemonChild, tddListCommand, tddStartCommand, tddStatusCommand, tddStopCommand } from './commands/tdd-daemon.js';
23
+ import { runDaemonChild, tddListCommand, tddStartCommand, tddStatusCommand, tddStopCommand, validateTddStartOptions } from './commands/tdd-daemon.js';
22
24
  import { uploadCommand, validateUploadOptions } from './commands/upload.js';
23
25
  import { validateWhoamiOptions, whoamiCommand } from './commands/whoami.js';
24
26
  import { createPluginServices } from './plugin-api.js';
25
27
  import { loadPlugins } from './plugin-loader.js';
26
28
  import { createServices } from './services/index.js';
27
29
  import { generateStaticReport, getReportFileUrl } from './services/static-report-generator.js';
30
+ import { withTimeout } from './utils/async-utils.js';
28
31
  import { openBrowser } from './utils/browser.js';
29
32
  import { colors } from './utils/colors.js';
30
33
  import { loadConfig } from './utils/config-loader.js';
@@ -46,7 +49,7 @@ const formatHelp = (cmd, helper) => {
46
49
  // Cute grizzly bear mascot with square eyes (like the Vizzly logo!)
47
50
  lines.push(c.brand.amber(' ʕ□ᴥ□ʔ'));
48
51
  lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.dim(`v${version}`)}`);
49
- lines.push(` ${c.gray('Visual regression testing for UI teams')}`);
52
+ lines.push(` ${c.gray('Visual review for agents and teams')}`);
50
53
  } else {
51
54
  // Compact header for subcommands
52
55
  lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.white(cmd.name())}`);
@@ -71,7 +74,7 @@ const formatHelp = (cmd, helper) => {
71
74
  key: 'core',
72
75
  icon: '▸',
73
76
  title: 'Core',
74
- names: ['run', 'tdd', 'upload', 'status', 'finalize', 'preview', 'builds', 'comparisons']
77
+ names: ['run', 'tdd', 'upload', 'status', 'context', 'finalize', 'preview', 'builds', 'comparisons']
75
78
  }, {
76
79
  key: 'review',
77
80
  icon: '▸',
@@ -170,7 +173,7 @@ const formatHelp = (cmd, helper) => {
170
173
  if (isRootCommand) {
171
174
  lines.push(` ${c.brand.amber('▸')} ${c.bold('Quick Start')}`);
172
175
  lines.push('');
173
- lines.push(` ${c.dim('# Local visual testing')}`);
176
+ lines.push(` ${c.dim('# Local visual review')}`);
174
177
  lines.push(` ${c.gray('$')} ${c.white('vizzly tdd start')}`);
175
178
  lines.push('');
176
179
  lines.push(` ${c.dim('# CI pipeline')}`);
@@ -202,40 +205,65 @@ const formatHelp = (cmd, helper) => {
202
205
  lines.push('');
203
206
  return lines.join('\n');
204
207
  };
205
- 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 [fields]', 'JSON output, optionally specify fields (e.g., --json id,status,branch)').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({
208
+ function extractGlobalOptionsFromArgv(argv, commandNames = null) {
209
+ let configPath = null;
210
+ let verboseMode = false;
211
+ let logLevelArg = null;
212
+ let jsonArg = null;
213
+ for (let i = 0; i < argv.length; i++) {
214
+ if ((argv[i] === '-c' || argv[i] === '--config') && argv[i + 1]) {
215
+ configPath = argv[i + 1];
216
+ }
217
+ if (argv[i] === '-v' || argv[i] === '--verbose') {
218
+ verboseMode = true;
219
+ }
220
+ if (argv[i] === '--log-level' && argv[i + 1]) {
221
+ logLevelArg = argv[i + 1];
222
+ }
223
+ if (argv[i] === '--json') {
224
+ let nextArg = argv[i + 1];
225
+ let nextArgIsCommand = commandNames?.has(nextArg);
226
+ if (nextArg && !nextArg.startsWith('-') && !nextArgIsCommand) {
227
+ jsonArg = nextArg;
228
+ } else {
229
+ jsonArg = true;
230
+ }
231
+ } else if (argv[i].startsWith('--json=')) {
232
+ jsonArg = argv[i].substring('--json='.length);
233
+ }
234
+ }
235
+ return {
236
+ configPath,
237
+ verboseMode,
238
+ logLevelArg,
239
+ jsonArg
240
+ };
241
+ }
242
+ function normalizeJsonArgv(argv, commandNames) {
243
+ let normalizedArgv = [...argv];
244
+ for (let i = 0; i < normalizedArgv.length; i++) {
245
+ if (normalizedArgv[i] !== '--json') {
246
+ continue;
247
+ }
248
+ let nextArg = normalizedArgv[i + 1];
249
+ if (nextArg && !nextArg.startsWith('-') && commandNames.has(nextArg)) {
250
+ normalizedArgv[i] = '--json=true';
251
+ }
252
+ }
253
+ return normalizedArgv;
254
+ }
255
+ program.name('vizzly').description('Vizzly CLI for visual review').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 [fields]', 'JSON output, optionally specify fields (e.g., --json id,status,branch)').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({
206
256
  formatHelp
207
257
  });
208
258
 
209
259
  // Load plugins before defining commands
210
260
  // We need to manually parse to get the config option early
211
- let configPath = null;
212
- let verboseMode = false;
213
- let logLevelArg = null;
214
- let jsonArg = null;
215
- for (let i = 0; i < process.argv.length; i++) {
216
- if ((process.argv[i] === '-c' || process.argv[i] === '--config') && process.argv[i + 1]) {
217
- configPath = process.argv[i + 1];
218
- }
219
- if (process.argv[i] === '-v' || process.argv[i] === '--verbose') {
220
- verboseMode = true;
221
- }
222
- if (process.argv[i] === '--log-level' && process.argv[i + 1]) {
223
- logLevelArg = process.argv[i + 1];
224
- }
225
- // Handle --json with optional field selection
226
- // --json (no value) = true, --json=fields or --json fields = "fields"
227
- if (process.argv[i] === '--json') {
228
- let nextArg = process.argv[i + 1];
229
- // If next arg exists and doesn't start with -, it's the fields value
230
- if (nextArg && !nextArg.startsWith('-')) {
231
- jsonArg = nextArg;
232
- } else {
233
- jsonArg = true;
234
- }
235
- } else if (process.argv[i].startsWith('--json=')) {
236
- jsonArg = process.argv[i].substring('--json='.length);
237
- }
238
- }
261
+ let {
262
+ configPath,
263
+ verboseMode,
264
+ logLevelArg,
265
+ jsonArg
266
+ } = extractGlobalOptionsFromArgv(process.argv);
239
267
 
240
268
  // Configure output early
241
269
  // Priority: --log-level > --verbose > VIZZLY_LOG_LEVEL env var > default ('info')
@@ -258,18 +286,15 @@ const pluginServices = createPluginServices(services);
258
286
  let plugins = [];
259
287
  try {
260
288
  plugins = await loadPlugins(configPath, config);
261
- for (const plugin of plugins) {
289
+ for (let plugin of plugins) {
262
290
  try {
263
291
  // Add timeout protection for plugin registration (5 seconds)
264
- const registerPromise = plugin.register(program, {
292
+ let registerPromise = plugin.register(program, {
265
293
  config,
266
294
  services: pluginServices,
267
- output,
268
- // Backwards compatibility alias for plugins using old API
269
- logger: output
295
+ output
270
296
  });
271
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Plugin registration timeout (5s)')), 5000));
272
- await Promise.race([registerPromise, timeoutPromise]);
297
+ await withTimeout(registerPromise, 5000, 'Plugin registration timeout (5s)');
273
298
  output.debug(`Registered plugin: ${plugin.name}`);
274
299
  } catch (error) {
275
300
  output.warn(`Failed to register plugin ${plugin.name}: ${error.message}`);
@@ -286,7 +311,7 @@ program.command('init').description('Initialize Vizzly in your project').option(
286
311
  plugins
287
312
  });
288
313
  });
289
- program.command('upload').description('Upload screenshots to Vizzly').argument('<path>', 'Path to screenshots directory or file').option('-b, --build-name <name>', 'Build name for grouping').option('-m, --metadata <json>', 'Additional metadata as JSON').option('--batch-size <n>', 'Upload batch size', v => parseInt(v, 10)).option('--upload-timeout <ms>', 'Upload timeout in milliseconds', v => parseInt(v, 10)).option('--branch <branch>', 'Git branch').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--upload-all', 'Upload all screenshots without SHA deduplication').option('--parallel-id <id>', 'Unique identifier for parallel test execution').action(async (path, options) => {
314
+ program.command('upload').description('Upload screenshots to Vizzly').argument('<path>', 'Path to screenshots directory or file').option('-b, --build-name <name>', 'Build name for grouping').option('-m, --metadata <json>', 'Additional metadata as JSON').option('--batch-size <n>', 'Upload batch size', Number).option('--upload-timeout <ms>', 'Upload timeout in milliseconds', Number).option('--branch <branch>', 'Git branch').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', Number).option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--upload-all', 'Upload all screenshots without SHA deduplication').option('--parallel-id <id>', 'Unique identifier for parallel test execution').action(async (path, options) => {
290
315
  const globalOptions = program.opts();
291
316
 
292
317
  // Validate options
@@ -301,11 +326,11 @@ program.command('upload').description('Upload screenshots to Vizzly').argument('
301
326
  await uploadCommand(path, options, globalOptions);
302
327
  });
303
328
 
304
- // TDD command with subcommands - Local visual testing with interactive dashboard
305
- const tddCmd = program.command('tdd').description('Run tests in TDD mode with local visual comparisons');
329
+ // TDD command with subcommands - local visual review with an interactive dashboard
330
+ const tddCmd = program.command('tdd').description('Run tests in TDD mode with local visual review');
306
331
 
307
332
  // TDD Start - Background server
308
- tddCmd.command('start').description('Start background TDD server with dashboard').option('--port <port>', 'Port for TDD server', '47392').option('--open', 'Open dashboard in browser').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--fail-on-diff', 'Fail tests when visual differences are detected').option('--token <token>', 'API token override').option('--daemon-child', 'Internal: run as daemon child process').action(async options => {
333
+ tddCmd.command('start').description('Start background TDD server with dashboard').option('--port <port>', 'Port for TDD server', '47392').option('--open', 'Open dashboard in browser').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', Number).option('--min-cluster-size <pixels>', 'Minimum changed-pixel cluster size', Number).option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--fail-on-diff', 'Fail tests when visual differences are detected').option('--token <token>', 'API token override').option('--daemon-child', 'Internal: run as daemon child process').action(async options => {
309
334
  const globalOptions = program.opts();
310
335
 
311
336
  // If this is a daemon child process, run the server directly
@@ -313,6 +338,14 @@ tddCmd.command('start').description('Start background TDD server with dashboard'
313
338
  await runDaemonChild(options, globalOptions);
314
339
  return;
315
340
  }
341
+ let validationErrors = validateTddStartOptions(options);
342
+ if (validationErrors.length > 0) {
343
+ output.error('Validation errors:');
344
+ for (let error of validationErrors) {
345
+ output.printErr(` - ${error}`);
346
+ }
347
+ process.exit(1);
348
+ }
316
349
  await tddStartCommand(options, globalOptions);
317
350
  });
318
351
 
@@ -335,7 +368,7 @@ tddCmd.command('list').description('List all running TDD servers').action(async
335
368
  });
336
369
 
337
370
  // TDD Run - One-off test run with ephemeral server (generates static report)
338
- tddCmd.command('run <command>').description('Run tests once in TDD mode with local visual comparisons').option('--port <port>', 'Port for TDD server', '47392').option('--branch <branch>', 'Git branch override').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--token <token>', 'API token override').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--set-baseline', 'Accept current screenshots as new baseline (overwrites existing)').option('--fail-on-diff', 'Fail tests when visual differences are detected').option('--no-open', 'Skip opening report in browser').action(async (command, options) => {
371
+ tddCmd.command('run <command>').description('Run tests once in TDD mode with local visual review').option('--port <port>', 'Port for TDD server', '47392').option('--branch <branch>', 'Git branch override').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', Number).option('--min-cluster-size <pixels>', 'Minimum changed-pixel cluster size', Number).option('--token <token>', 'API token override').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--set-baseline', 'Accept current screenshots as new baseline (overwrites existing)').option('--fail-on-diff', 'Fail tests when visual differences are detected').option('--no-open', 'Skip opening report in browser').action(async (command, options) => {
339
372
  const globalOptions = program.opts();
340
373
 
341
374
  // Validate options
@@ -396,7 +429,7 @@ tddCmd.command('run <command>').description('Run tests once in TDD mode with loc
396
429
  await handleCleanup();
397
430
  process.exit(result?.exitCode || 0);
398
431
  });
399
- program.command('run').description('Run tests with Vizzly integration').argument('<command>', 'Test command to run').option('--port <port>', 'Port for screenshot server', '47392').option('-b, --build-name <name>', 'Custom build name').option('--branch <branch>', 'Git branch override').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--allow-no-token', 'Allow running without API token').option('--upload-all', 'Upload all screenshots without SHA deduplication').option('--parallel-id <id>', 'Unique identifier for parallel test execution').action(async (command, options) => {
432
+ program.command('run').description('Run tests with Vizzly integration').argument('<command>', 'Test command to run').option('--port <port>', 'Port for screenshot server', '47392').option('-b, --build-name <name>', 'Custom build name').option('--branch <branch>', 'Git branch override').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', Number).option('--min-cluster-size <pixels>', 'Minimum changed-pixel cluster size', Number).option('--batch-size <n>', 'Upload batch size', Number).option('--upload-timeout <ms>', 'Upload timeout in milliseconds', Number).option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--allow-no-token', 'Allow running without API token').option('--upload-all', 'Upload all screenshots without SHA deduplication').option('--parallel-id <id>', 'Unique identifier for parallel test execution').action(async (command, options) => {
400
433
  const globalOptions = program.opts();
401
434
 
402
435
  // Validate options
@@ -432,7 +465,7 @@ program.command('status').description('Check the status of a build').argument('<
432
465
  }
433
466
  await statusCommand(buildId, options, globalOptions);
434
467
  });
435
- program.command('builds').description('List and query builds').option('-b, --build <id>', 'Get a specific build by ID').option('--branch <branch>', 'Filter by branch').option('--status <status>', 'Filter by status (created, pending, processing, completed, failed)').option('--environment <env>', 'Filter by environment').option('-p, --project <slug>', 'Filter by project slug').option('--org <slug>', 'Filter by organization slug').option('--limit <n>', 'Maximum results to return (1-250)', val => parseInt(val, 10), 20).option('--offset <n>', 'Skip first N results', val => parseInt(val, 10), 0).option('--comparisons', 'Include comparisons when fetching a specific build').addHelpText('after', `
468
+ program.command('builds').description('List and query builds').option('-b, --build <id>', 'Get a specific build by ID').option('--branch <branch>', 'Filter by branch').option('--status <status>', 'Filter by status (created, pending, processing, completed, failed)').option('--environment <env>', 'Filter by environment').option('-p, --project <slug>', 'Filter by project slug').option('--org <slug>', 'Filter by organization slug').option('--limit <n>', 'Maximum results to return (1-250)', Number, 20).option('--offset <n>', 'Skip first N results', Number, 0).option('--comparisons', 'Include comparisons when fetching a specific build').addHelpText('after', `
436
469
  Examples:
437
470
  $ vizzly builds # List recent builds
438
471
  $ vizzly builds --branch main # Filter by branch
@@ -456,7 +489,7 @@ Examples:
456
489
  }
457
490
  await buildsCommand(options, globalOptions);
458
491
  });
459
- program.command('comparisons').description('Query and search comparisons').option('-b, --build <id>', 'Get comparisons for a specific build').option('--id <id>', 'Get a specific comparison by ID').option('--name <pattern>', 'Search comparisons by name (supports wildcards)').option('--status <status>', 'Filter by status (identical, new, changed)').option('--branch <branch>', 'Filter by branch (for name search)').option('--limit <n>', 'Maximum results to return (1-250)', val => parseInt(val, 10), 50).option('--offset <n>', 'Skip first N results', val => parseInt(val, 10), 0).option('-p, --project <slug>', 'Filter by project slug').option('--org <slug>', 'Filter by organization slug').addHelpText('after', `
492
+ program.command('comparisons').description('Query and search comparisons').option('-b, --build <id>', 'Get comparisons for a specific build').option('--id <id>', 'Get a specific comparison by ID').option('--name <pattern>', 'Search comparisons by name (supports wildcards)').option('--status <status>', 'Filter by status (identical, new, changed)').option('--branch <branch>', 'Filter by branch (for name search)').option('--limit <n>', 'Maximum results to return (1-250)', Number, 50).option('--offset <n>', 'Skip first N results', Number, 0).option('-p, --project <slug>', 'Filter by project slug').option('--org <slug>', 'Filter by organization slug').addHelpText('after', `
460
493
  Examples:
461
494
  $ vizzly comparisons -b abc123 # List comparisons for a build
462
495
  $ vizzly comparisons --id def456 # Get specific comparison by ID
@@ -479,6 +512,98 @@ Examples:
479
512
  }
480
513
  await comparisonsCommand(options, globalOptions);
481
514
  });
515
+ let contextCmd = program.command('context').description('Fetch build context for agents and reviewers');
516
+ contextCmd.command('build').description('Fetch build context for agents and reviewers').argument('<build-id>', 'Build ID to fetch context for').option('--source <source>', 'Context source: auto, cloud, or local', 'auto').option('--agent', 'Output compact context for LLM agents').option('--full', 'Return the full build context payload with --agent --json').option('--include <items>', 'Add detail to compact agent JSON: screenshots,diffs,comments').addHelpText('after', `
517
+ Examples:
518
+ $ vizzly context build abc123
519
+ $ vizzly context build current --source local
520
+ $ vizzly context build current --source local --agent
521
+ $ vizzly context build abc123 --agent --json
522
+ $ vizzly context build abc123 --agent --json --include diffs,comments
523
+ $ vizzly context build abc123 --agent --json --full
524
+ `).action(async (buildId, options) => {
525
+ const globalOptions = program.opts();
526
+ const validationErrors = validateContextBuildOptions(options);
527
+ if (validationErrors.length > 0) {
528
+ output.error('Validation errors:');
529
+ for (let error of validationErrors) {
530
+ output.printErr(` - ${error}`);
531
+ }
532
+ process.exit(1);
533
+ }
534
+ await contextBuildCommand(buildId, options, globalOptions);
535
+ });
536
+ contextCmd.command('comparison').description('Fetch a comparison context bundle').argument('<comparison-id>', 'Comparison ID to fetch context for').option('--source <source>', 'Context source: auto, cloud, or local', 'auto').option('--similar-limit <n>', 'Maximum similar fingerprint matches to return (1-50)', Number).option('--recent-limit <n>', 'Maximum recent same-name comparisons to return (1-50)', Number).option('--window-size <n>', 'Historical hotspot analysis window size (1-50)', Number).addHelpText('after', `
537
+ Examples:
538
+ $ vizzly context comparison def456
539
+ $ vizzly context comparison def456 --source local
540
+ $ vizzly context comparison def456 --similar-limit 5 --recent-limit 5
541
+ $ vizzly context comparison def456 --json
542
+ `).action(async (comparisonId, options) => {
543
+ const globalOptions = program.opts();
544
+ const validationErrors = validateContextComparisonOptions(options);
545
+ if (validationErrors.length > 0) {
546
+ output.error('Validation errors:');
547
+ for (let error of validationErrors) {
548
+ output.printErr(` - ${error}`);
549
+ }
550
+ process.exit(1);
551
+ }
552
+ await contextComparisonCommand(comparisonId, options, globalOptions);
553
+ });
554
+ contextCmd.command('screenshot').description('Fetch screenshot context and historical memory').argument('<name>', 'Screenshot name').option('--source <source>', 'Context source: auto, cloud, or local', 'auto').option('-p, --project <slug-or-id>', 'Project scope for user auth lookups').option('--org <slug>', 'Organization slug when project slug is ambiguous').option('--recent-limit <n>', 'Maximum recent comparisons to return (1-50)', Number).option('--window-size <n>', 'Historical hotspot analysis window size (1-50)', Number).addHelpText('after', `
555
+ Examples:
556
+ $ vizzly context screenshot Dashboard
557
+ $ vizzly context screenshot Dashboard --source local
558
+ $ vizzly context screenshot Dashboard --project storybook --org acme
559
+ $ vizzly context screenshot Dashboard --json
560
+ `).action(async (name, options) => {
561
+ const globalOptions = program.opts();
562
+ const validationErrors = validateContextScreenshotOptions(options);
563
+ if (validationErrors.length > 0) {
564
+ output.error('Validation errors:');
565
+ for (let error of validationErrors) {
566
+ output.printErr(` - ${error}`);
567
+ }
568
+ process.exit(1);
569
+ }
570
+ await contextScreenshotCommand(name, options, globalOptions);
571
+ });
572
+ contextCmd.command('similar').description('Fetch project-scoped matches for a fingerprint hash').argument('<fingerprint-hash>', 'Fingerprint hash to search for').option('--source <source>', 'Context source: auto, cloud, or local', 'auto').option('-p, --project <slug-or-id>', 'Project scope for user auth lookups').option('--org <slug>', 'Organization slug when project slug is ambiguous').option('--limit <n>', 'Maximum matches to return (1-50)', Number).addHelpText('after', `
573
+ Examples:
574
+ $ vizzly context similar fp-dashboard
575
+ $ vizzly context similar fp-dashboard --project storybook --org acme
576
+ $ vizzly context similar fp-dashboard --json
577
+ `).action(async (fingerprintHash, options) => {
578
+ const globalOptions = program.opts();
579
+ const validationErrors = validateContextSimilarOptions(options);
580
+ if (validationErrors.length > 0) {
581
+ output.error('Validation errors:');
582
+ for (let error of validationErrors) {
583
+ output.printErr(` - ${error}`);
584
+ }
585
+ process.exit(1);
586
+ }
587
+ await contextSimilarCommand(fingerprintHash, options, globalOptions);
588
+ });
589
+ contextCmd.command('review-queue').description('Fetch pending review context for a project').option('--source <source>', 'Context source: auto, cloud, or local', 'auto').option('-p, --project <slug-or-id>', 'Project scope for user auth lookups').option('--org <slug>', 'Organization slug when project slug is ambiguous').option('--limit <n>', 'Maximum comparisons to return (1-100)', Number).option('--offset <n>', 'Skip first N comparisons', Number).addHelpText('after', `
590
+ Examples:
591
+ $ vizzly context review-queue --project storybook --org acme
592
+ $ vizzly context review-queue --source local
593
+ $ vizzly context review-queue --limit 10
594
+ $ vizzly context review-queue --json
595
+ `).action(async options => {
596
+ const globalOptions = program.opts();
597
+ const validationErrors = validateContextReviewQueueOptions(options);
598
+ if (validationErrors.length > 0) {
599
+ output.error('Validation errors:');
600
+ for (let error of validationErrors) {
601
+ output.printErr(` - ${error}`);
602
+ }
603
+ process.exit(1);
604
+ }
605
+ await contextReviewQueueCommand(options, globalOptions);
606
+ });
482
607
  program.command('config').description('Display current configuration').argument('[key]', 'Specific config key to get (dot notation, e.g., comparison.threshold)').action(async (key, options) => {
483
608
  const globalOptions = program.opts();
484
609
 
@@ -624,7 +749,7 @@ or the single organization for a project token.
624
749
  }
625
750
  await orgsCommand(options, globalOptions);
626
751
  });
627
- program.command('projects').description('List projects you have access to').option('--org <slug>', 'Filter by organization slug').option('--limit <n>', 'Maximum results to return (1-250)', val => parseInt(val, 10), 50).option('--offset <n>', 'Skip first N results', val => parseInt(val, 10), 0).addHelpText('after', `
752
+ program.command('projects').description('List projects you have access to').option('--org <slug>', 'Filter by organization slug').option('--limit <n>', 'Maximum results to return (1-250)', Number, 50).option('--offset <n>', 'Skip first N results', Number, 0).addHelpText('after', `
628
753
  Examples:
629
754
  $ vizzly projects # List all projects
630
755
  $ vizzly projects --org my-company # Filter by organization
@@ -646,6 +771,26 @@ Workflow:
646
771
  }
647
772
  await projectsCommand(options, globalOptions);
648
773
  });
774
+ let projectCommand = program.command('project').description('Manage the project linked to this local checkout');
775
+ projectCommand.command('link [selector]').description('Link a Vizzly project for cloud uploads').option('--org <slug>', 'Organization slug').option('--project <slug>', 'Project slug').option('--name <name>', 'Credential name shown in Vizzly').option('--expires-at <iso>', 'Optional token expiration timestamp').addHelpText('after', `
776
+ Examples:
777
+ $ vizzly project link vizzly/storybook
778
+ $ vizzly project link --org vizzly --project storybook
779
+
780
+ Note: run "vizzly login" first. The linked credential is project-scoped and is
781
+ used for cloud uploads; your user login remains separate for review actions.
782
+ `).action(async (selector, options) => {
783
+ const globalOptions = program.opts();
784
+ const validationErrors = validateProjectLinkOptions(selector, options);
785
+ if (validationErrors.length > 0) {
786
+ output.error('Validation errors:');
787
+ for (let error of validationErrors) {
788
+ output.printErr(` - ${error}`);
789
+ }
790
+ process.exit(1);
791
+ }
792
+ await projectLinkCommand(selector, options, globalOptions);
793
+ });
649
794
  program.command('finalize').description('Finalize a parallel build after all shards complete').argument('<parallel-id>', 'Parallel ID to finalize').action(async (parallelId, options) => {
650
795
  const globalOptions = program.opts();
651
796
 
@@ -660,7 +805,7 @@ program.command('finalize').description('Finalize a parallel build after all sha
660
805
  }
661
806
  await finalizeCommand(parallelId, options, globalOptions);
662
807
  });
663
- 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) => {
808
+ 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('--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) => {
664
809
  const globalOptions = program.opts();
665
810
 
666
811
  // Show helpful error if path is missing
@@ -747,4 +892,14 @@ program.command('whoami').description('Show current authentication status and us
747
892
  // Save user's PATH for menubar app (non-blocking, runs in background)
748
893
  // This auto-configures the menubar app so it can find npx/node
749
894
  saveUserPath().catch(() => {});
750
- program.parse();
895
+ let commandNames = new Set(program.commands.map(command => command.name()));
896
+ let normalizedArgv = normalizeJsonArgv(process.argv, commandNames);
897
+ let normalizedGlobals = extractGlobalOptionsFromArgv(normalizedArgv, commandNames);
898
+ output.configure({
899
+ logLevel: normalizedGlobals.logLevelArg,
900
+ verbose: normalizedGlobals.verboseMode,
901
+ color: colorOverride,
902
+ json: normalizedGlobals.jsonArg,
903
+ resetTimer: false
904
+ });
905
+ program.parse(normalizedArgv);
@@ -8,16 +8,17 @@ import { request as httpRequest } from 'node:http';
8
8
  import { request as httpsRequest } from 'node:https';
9
9
  import { dirname, join, parse } from 'node:path';
10
10
  import { getBuildId, getServerUrl, isTddMode, setVizzlyEnabled } from '../utils/environment-config.js';
11
+ import { createScreenshotProperties } from '../utils/screenshot-options.js';
11
12
 
12
13
  // Internal client state
13
14
  let currentClient = null;
14
15
  let isDisabled = false;
15
16
 
16
17
  // Default timeout for screenshot requests (30 seconds)
17
- const DEFAULT_TIMEOUT_MS = 30000;
18
+ let DEFAULT_TIMEOUT_MS = 30000;
18
19
 
19
20
  // Log levels for client SDK output control
20
- export const LOG_LEVELS = {
21
+ export let LOG_LEVELS = {
21
22
  debug: 0,
22
23
  info: 1,
23
24
  warn: 2,
@@ -71,14 +72,14 @@ export function autoDiscoverTddServer(startDir, deps = {}) {
71
72
  try {
72
73
  // Look for .vizzly/server.json in current directory and parent directories
73
74
  let currentDir = startDir || process.cwd();
74
- const root = parse(currentDir).root;
75
+ let root = parse(currentDir).root;
75
76
  while (currentDir !== root) {
76
- const serverJsonPath = join(currentDir, '.vizzly', 'server.json');
77
+ let serverJsonPath = join(currentDir, '.vizzly', 'server.json');
77
78
  if (exists(serverJsonPath)) {
78
79
  try {
79
- const serverInfo = JSON.parse(readFile(serverJsonPath, 'utf8'));
80
+ let serverInfo = JSON.parse(readFile(serverJsonPath, 'utf8'));
80
81
  if (serverInfo.port) {
81
- const url = `http://localhost:${serverInfo.port}`;
82
+ let url = `http://localhost:${serverInfo.port}`;
82
83
  return url;
83
84
  }
84
85
  } catch {
@@ -191,14 +192,9 @@ function createSimpleClient(serverUrl) {
191
192
  let isFilePath = typeof imageBuffer === 'string';
192
193
  let image = isFilePath ? imageBuffer : imageBuffer.toString('base64');
193
194
  let type = isFilePath ? 'file-path' : 'base64';
194
- let {
195
- fullPage,
196
- threshold,
197
- properties: userProperties,
198
- ...rest
199
- } = options;
195
+ let properties = createScreenshotProperties(options);
200
196
  let httpStart = Date.now();
201
- const {
197
+ let {
202
198
  status,
203
199
  json
204
200
  } = await httpPost(`${serverUrl}/screenshot`, {
@@ -206,11 +202,7 @@ function createSimpleClient(serverUrl) {
206
202
  name,
207
203
  image,
208
204
  type,
209
- properties: {
210
- ...rest,
211
- ...userProperties
212
- },
213
- fullPage: fullPage || false
205
+ properties
214
206
  }, DEFAULT_TIMEOUT_MS);
215
207
  let httpMs = Date.now() - httpStart;
216
208
  if (shouldLogClient('debug')) {
@@ -293,10 +285,11 @@ function createSimpleClient(serverUrl) {
293
285
  * @param {Buffer|string} imageBuffer - PNG image data as a Buffer, or a file path to an image
294
286
  * @param {Object} [options] - Optional configuration
295
287
  * @param {Record<string, any>} [options.properties] - Additional properties to attach to the screenshot
296
- * @param {number} [options.threshold=0] - Pixel difference threshold (0-100)
297
- * @param {boolean} [options.fullPage=false] - Whether this is a full page screenshot
288
+ * @param {number} [options.threshold] - CIEDE2000 Delta E threshold for this screenshot
289
+ * @param {number} [options.minClusterSize] - Minimum changed cluster size for this screenshot
290
+ * @param {boolean} [options.fullPage] - Whether this is a full page screenshot
298
291
  *
299
- * @returns {Promise<void>}
292
+ * @returns {Promise<Object|null>} Screenshot result from the server, or null when capture is skipped
300
293
  *
301
294
  * @example
302
295
  * // Basic usage with Buffer
@@ -319,19 +312,17 @@ function createSimpleClient(serverUrl) {
319
312
  * threshold: 5
320
313
  * });
321
314
  *
322
- * @throws {VizzlyError} When screenshot capture fails or client is not initialized
323
- * @throws {VizzlyError} When file path is provided but file doesn't exist
324
- * @throws {VizzlyError} When file cannot be read due to permissions or I/O errors
315
+ * Capture failures are logged and return null so test suites can continue.
325
316
  */
326
317
  export async function vizzlyScreenshot(name, imageBuffer, options = {}) {
327
318
  if (isVizzlyDisabled()) {
328
- return; // Silently skip when disabled
319
+ return null; // Silently skip when disabled
329
320
  }
330
321
  let client = getClient();
331
322
  if (!client) {
332
323
  // Silently disable - no server running, nothing to do
333
324
  disableVizzly();
334
- return;
325
+ return null;
335
326
  }
336
327
 
337
328
  // Pass through the original value (Buffer or file path)
@@ -342,7 +333,7 @@ export async function vizzlyScreenshot(name, imageBuffer, options = {}) {
342
333
  /**
343
334
  * Wait for all queued screenshots to be processed
344
335
  *
345
- * @returns {Promise<void>}
336
+ * @returns {Promise<Object|null>} Flush summary, or null if no server is connected
346
337
  *
347
338
  * @example
348
339
  * afterAll(async () => {
@@ -350,10 +341,11 @@ export async function vizzlyScreenshot(name, imageBuffer, options = {}) {
350
341
  * });
351
342
  */
352
343
  export async function vizzlyFlush() {
353
- const client = getClient();
344
+ let client = getClient();
354
345
  if (client) {
355
346
  return client.flush();
356
347
  }
348
+ return null;
357
349
  }
358
350
 
359
351
  /**
@@ -401,7 +393,7 @@ export function setEnabled(enabled) {
401
393
  * @returns {Object} Client information
402
394
  */
403
395
  export function getVizzlyInfo() {
404
- const client = getClient();
396
+ let client = getClient();
405
397
  return {
406
398
  enabled: !isVizzlyDisabled(),
407
399
  serverUrl: getServerUrl(),