@vizzly-testing/cli 0.30.0 → 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 (77) hide show
  1. package/README.md +9 -9
  2. package/dist/api/client.js +6 -3
  3. package/dist/cli.js +58 -29
  4. package/dist/client/index.js +21 -29
  5. package/dist/commands/api.js +145 -90
  6. package/dist/commands/baselines.js +78 -47
  7. package/dist/commands/builds.js +9 -8
  8. package/dist/commands/comparisons.js +7 -8
  9. package/dist/commands/config-cmd.js +15 -7
  10. package/dist/commands/context.js +329 -18
  11. package/dist/commands/doctor.js +116 -81
  12. package/dist/commands/init.js +202 -201
  13. package/dist/commands/login.js +134 -65
  14. package/dist/commands/logout.js +17 -6
  15. package/dist/commands/preview.js +29 -57
  16. package/dist/commands/project.js +120 -0
  17. package/dist/commands/projects.js +9 -2
  18. package/dist/commands/review.js +236 -202
  19. package/dist/commands/run.js +75 -72
  20. package/dist/commands/status.js +228 -150
  21. package/dist/commands/tdd-daemon-process.js +98 -0
  22. package/dist/commands/tdd-daemon.js +318 -212
  23. package/dist/commands/tdd.js +23 -8
  24. package/dist/commands/upload.js +10 -9
  25. package/dist/commands/whoami.js +63 -25
  26. package/dist/config/core.js +36 -35
  27. package/dist/context/local-workspace-provider.js +132 -12
  28. package/dist/context/provider-resolver.js +5 -1
  29. package/dist/index.js +5 -4
  30. package/dist/plugin-loader.js +55 -49
  31. package/dist/project/core.js +2 -2
  32. package/dist/project/operations.js +6 -3
  33. package/dist/reporter/reporter-bundle.css +1 -1
  34. package/dist/reporter/reporter-bundle.iife.js +7 -7
  35. package/dist/sdk/index.js +162 -107
  36. package/dist/server/handlers/tdd-handler.js +30 -54
  37. package/dist/server/routers/baseline.js +195 -178
  38. package/dist/server/routers/events.js +212 -174
  39. package/dist/server/routers/projects.js +14 -5
  40. package/dist/server/routers/screenshot.js +1 -31
  41. package/dist/services/build-manager.js +19 -97
  42. package/dist/services/config-service.js +26 -34
  43. package/dist/services/index.js +2 -4
  44. package/dist/services/static-report-generator.js +11 -12
  45. package/dist/services/test-runner.js +21 -27
  46. package/dist/tdd/index.js +1 -1
  47. package/dist/tdd/metadata/region-metadata.js +0 -37
  48. package/dist/tdd/runtime-context.js +78 -0
  49. package/dist/tdd/server-registry.js +38 -17
  50. package/dist/tdd/services/baseline-manager.js +23 -0
  51. package/dist/tdd/services/hotspot-service.js +0 -13
  52. package/dist/tdd/tdd-service.js +82 -113
  53. package/dist/types/client.d.ts +36 -1
  54. package/dist/types/index.d.ts +114 -22
  55. package/dist/types/sdk.d.ts +40 -0
  56. package/dist/uploader/core.js +5 -4
  57. package/dist/uploader/index.js +79 -2
  58. package/dist/uploader/operations.js +40 -5
  59. package/dist/utils/api-url.js +14 -0
  60. package/dist/utils/async-utils.js +26 -0
  61. package/dist/utils/ci-env.js +19 -12
  62. package/dist/utils/config-loader.js +39 -65
  63. package/dist/utils/config-schema.js +19 -55
  64. package/dist/utils/fetch-utils.js +12 -5
  65. package/dist/utils/git.js +35 -72
  66. package/dist/utils/global-config.js +20 -1
  67. package/dist/utils/output.js +19 -7
  68. package/dist/utils/patterns.js +14 -0
  69. package/dist/utils/project-link-store.js +105 -0
  70. package/dist/utils/screenshot-options.js +28 -0
  71. package/dist/utils/secret-store.js +65 -0
  72. package/package.json +11 -8
  73. package/dist/services/screenshot-server.js +0 -65
  74. package/dist/services/server-manager.js +0 -81
  75. package/dist/services/uploader.js +0 -100
  76. package/dist/tdd/services/region-service.js +0 -75
  77. package/dist/utils/build-history.js +0 -103
package/README.md CHANGED
@@ -1,23 +1,23 @@
1
1
  # Vizzly CLI
2
2
 
3
- > Visual proof that your UI works
3
+ > Visual review for agents and teams
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@vizzly-testing/cli.svg)](https://www.npmjs.com/package/@vizzly-testing/cli)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
- Visual bugs slip through code review. They hide in pixel-perfect mockups, sneak past unit tests, and show up right when you're about to ship. Vizzly catches them first.
8
+ Vizzly keeps the visual truth behind your UI: approved baselines, meaningful diffs, review state, comments, and preview links in one place. That makes it useful for humans reviewing product changes and for LLM agents that need to understand what the UI is supposed to look like before they edit it.
9
9
 
10
- Unlike tools that re-render components in isolation, Vizzly captures screenshots directly from your functional tests—the *real thing*. Whether you're validating AI-generated code or testing manual changes, you get visual proof before anything hits production.
10
+ Unlike tools that re-render components in isolation, Vizzly captures screenshots directly from your functional tests: the real thing. Whether you're validating AI-generated code or testing manual changes, you get reviewed visual evidence before anything hits production.
11
11
 
12
12
  ## Why Vizzly?
13
13
 
14
- **Local TDD workflow.** See changes as you type, not after CI. The `vizzly tdd` command runs a local dashboard that compares screenshots instantly—no cloud roundtrip, no waiting.
14
+ **Local TDD workflow.** See changes as you type, not after CI. The `vizzly tdd` command runs a local dashboard that compares screenshots instantly and exposes the current workspace as local context.
15
15
 
16
- **Smart diffing with Honeydiff.** Our Rust-based comparison engine is 12x faster than alternatives and ignores the noise: timestamps, ads, font rendering differences. It finds real changes.
16
+ **Meaningful diff metadata.** Vizzly stores rich diff evidence: changed regions, cluster metadata, fingerprints, hotspots, confirmed regions, and image URLs. Agents can inspect what changed instead of guessing from a pass/fail label.
17
17
 
18
18
  **Any screenshot source.** Playwright, Cypress, Puppeteer, Selenium, native mobile apps, or even design mockups. If you can capture it, Vizzly can compare it.
19
19
 
20
- **Team-based pricing.** Pay for your team, not your screenshots. Test everything without budget anxiety.
20
+ **Approved baselines as truth.** Cloud context carries human review state. Local context carries the downloaded or generated baseline metadata. That is the bridge between TDD locally and collaborative review in Vizzly.
21
21
 
22
22
  ## Quick Start
23
23
 
@@ -60,7 +60,7 @@ vizzly run "npm test" --wait
60
60
 
61
61
  ### Visual Context For Agents
62
62
 
63
- Use `vizzly context` when you want Vizzly to act more like visual context than a test runner.
63
+ Use `vizzly context` when you want Vizzly to act like a visual context store, not just a test runner.
64
64
 
65
65
  This is especially useful for LLM agents, automation, and quick debugging loops. Instead of
66
66
  making a bunch of narrow API calls, you can ask for one build, comparison, screenshot, or review
@@ -73,11 +73,11 @@ vizzly context comparison def456 --json
73
73
 
74
74
  # Local workspace context from .vizzly/
75
75
  vizzly context build current --source local
76
+ vizzly context build current --source local --agent
76
77
  vizzly context screenshot build-detail-screenshots --source local --json
77
78
  ```
78
79
 
79
- `--json` is the main automation path. Human-readable output is there for quick terminal use, but
80
- JSON is what you want for scripts, agents, and prompt assembly.
80
+ `--json` is the durable automation path. `--agent` gives a compact handoff for prompt assembly and local dogfooding. Add `--full` when an agent really needs the complete build context payload, or `--include screenshots,diffs,comments` when the compact JSON needs selected detail.
81
81
 
82
82
  Local context is read-only and file-backed. It reads your existing `.vizzly` workspace state from
83
83
  TDD runs, including screenshots, diffs, and any saved hotspot or region metadata.
@@ -14,6 +14,9 @@ import { buildApiUrl, buildRequestHeaders, buildUserAgent, extractErrorBody, isA
14
14
  * Default API URL
15
15
  */
16
16
  export const DEFAULT_API_URL = 'https://app.vizzly.dev';
17
+ function isProjectToken(token) {
18
+ return typeof token === 'string' && token.startsWith('vzt_');
19
+ }
17
20
 
18
21
  /**
19
22
  * Create an API client with the given configuration
@@ -63,7 +66,7 @@ export function createApiClient(options = {}) {
63
66
  let errorBody = await extractErrorBody(response);
64
67
 
65
68
  // Handle 401 with token refresh
66
- if (shouldRetryWithRefresh(response.status, isRetry, await hasRefreshToken())) {
69
+ if (shouldRetryWithRefresh(response.status, isRetry, !isProjectToken(token) && (await hasRefreshToken()))) {
67
70
  let refreshed = await attemptTokenRefresh();
68
71
  if (refreshed) {
69
72
  token = refreshed;
@@ -73,7 +76,7 @@ export function createApiClient(options = {}) {
73
76
 
74
77
  // Auth error
75
78
  if (isAuthError(response.status)) {
76
- throw new AuthError('Invalid or expired API token. Link a project via "vizzly project:select" or set VIZZLY_TOKEN.');
79
+ throw new AuthError('Invalid or expired API token. Run "vizzly project link <org>/<project>" or set VIZZLY_TOKEN.');
77
80
  }
78
81
 
79
82
  // Other errors
@@ -119,7 +122,7 @@ export function createApiClient(options = {}) {
119
122
  await saveAuthTokens({
120
123
  accessToken: data.accessToken,
121
124
  refreshToken: data.refreshToken,
122
- expiresAt: data.expiresAt,
125
+ expiresAt: data.expiresAt || new Date(Date.now() + data.expiresIn * 1000).toISOString(),
123
126
  user: auth.user
124
127
  });
125
128
  return data.accessToken;
package/dist/cli.js CHANGED
@@ -14,18 +14,20 @@ import { loginCommand, validateLoginOptions } from './commands/login.js';
14
14
  import { logoutCommand, validateLogoutOptions } from './commands/logout.js';
15
15
  import { orgsCommand, validateOrgsOptions } from './commands/orgs.js';
16
16
  import { previewCommand, validatePreviewOptions } from './commands/preview.js';
17
+ import { projectLinkCommand, validateProjectLinkOptions } from './commands/project.js';
17
18
  import { projectsCommand, validateProjectsOptions } from './commands/projects.js';
18
19
  import { approveCommand, commentCommand, rejectCommand, validateApproveOptions, validateCommentOptions, validateRejectOptions } from './commands/review.js';
19
20
  import { runCommand, validateRunOptions } from './commands/run.js';
20
21
  import { statusCommand, validateStatusOptions } from './commands/status.js';
21
22
  import { tddCommand, validateTddOptions } from './commands/tdd.js';
22
- import { runDaemonChild, tddListCommand, tddStartCommand, tddStatusCommand, tddStopCommand } from './commands/tdd-daemon.js';
23
+ import { runDaemonChild, tddListCommand, tddStartCommand, tddStatusCommand, tddStopCommand, validateTddStartOptions } from './commands/tdd-daemon.js';
23
24
  import { uploadCommand, validateUploadOptions } from './commands/upload.js';
24
25
  import { validateWhoamiOptions, whoamiCommand } from './commands/whoami.js';
25
26
  import { createPluginServices } from './plugin-api.js';
26
27
  import { loadPlugins } from './plugin-loader.js';
27
28
  import { createServices } from './services/index.js';
28
29
  import { generateStaticReport, getReportFileUrl } from './services/static-report-generator.js';
30
+ import { withTimeout } from './utils/async-utils.js';
29
31
  import { openBrowser } from './utils/browser.js';
30
32
  import { colors } from './utils/colors.js';
31
33
  import { loadConfig } from './utils/config-loader.js';
@@ -47,7 +49,7 @@ const formatHelp = (cmd, helper) => {
47
49
  // Cute grizzly bear mascot with square eyes (like the Vizzly logo!)
48
50
  lines.push(c.brand.amber(' ʕ□ᴥ□ʔ'));
49
51
  lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.dim(`v${version}`)}`);
50
- lines.push(` ${c.gray('Visual regression testing for UI teams')}`);
52
+ lines.push(` ${c.gray('Visual review for agents and teams')}`);
51
53
  } else {
52
54
  // Compact header for subcommands
53
55
  lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.white(cmd.name())}`);
@@ -171,7 +173,7 @@ const formatHelp = (cmd, helper) => {
171
173
  if (isRootCommand) {
172
174
  lines.push(` ${c.brand.amber('▸')} ${c.bold('Quick Start')}`);
173
175
  lines.push('');
174
- lines.push(` ${c.dim('# Local visual testing')}`);
176
+ lines.push(` ${c.dim('# Local visual review')}`);
175
177
  lines.push(` ${c.gray('$')} ${c.white('vizzly tdd start')}`);
176
178
  lines.push('');
177
179
  lines.push(` ${c.dim('# CI pipeline')}`);
@@ -250,7 +252,7 @@ function normalizeJsonArgv(argv, commandNames) {
250
252
  }
251
253
  return normalizedArgv;
252
254
  }
253
- 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({
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({
254
256
  formatHelp
255
257
  });
256
258
 
@@ -284,18 +286,15 @@ const pluginServices = createPluginServices(services);
284
286
  let plugins = [];
285
287
  try {
286
288
  plugins = await loadPlugins(configPath, config);
287
- for (const plugin of plugins) {
289
+ for (let plugin of plugins) {
288
290
  try {
289
291
  // Add timeout protection for plugin registration (5 seconds)
290
- const registerPromise = plugin.register(program, {
292
+ let registerPromise = plugin.register(program, {
291
293
  config,
292
294
  services: pluginServices,
293
- output,
294
- // Backwards compatibility alias for plugins using old API
295
- logger: output
295
+ output
296
296
  });
297
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Plugin registration timeout (5s)')), 5000));
298
- await Promise.race([registerPromise, timeoutPromise]);
297
+ await withTimeout(registerPromise, 5000, 'Plugin registration timeout (5s)');
299
298
  output.debug(`Registered plugin: ${plugin.name}`);
300
299
  } catch (error) {
301
300
  output.warn(`Failed to register plugin ${plugin.name}: ${error.message}`);
@@ -312,7 +311,7 @@ program.command('init').description('Initialize Vizzly in your project').option(
312
311
  plugins
313
312
  });
314
313
  });
315
- 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) => {
316
315
  const globalOptions = program.opts();
317
316
 
318
317
  // Validate options
@@ -327,11 +326,11 @@ program.command('upload').description('Upload screenshots to Vizzly').argument('
327
326
  await uploadCommand(path, options, globalOptions);
328
327
  });
329
328
 
330
- // TDD command with subcommands - Local visual testing with interactive dashboard
331
- 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');
332
331
 
333
332
  // TDD Start - Background server
334
- 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 => {
335
334
  const globalOptions = program.opts();
336
335
 
337
336
  // If this is a daemon child process, run the server directly
@@ -339,6 +338,14 @@ tddCmd.command('start').description('Start background TDD server with dashboard'
339
338
  await runDaemonChild(options, globalOptions);
340
339
  return;
341
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
+ }
342
349
  await tddStartCommand(options, globalOptions);
343
350
  });
344
351
 
@@ -361,7 +368,7 @@ tddCmd.command('list').description('List all running TDD servers').action(async
361
368
  });
362
369
 
363
370
  // TDD Run - One-off test run with ephemeral server (generates static report)
364
- 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) => {
365
372
  const globalOptions = program.opts();
366
373
 
367
374
  // Validate options
@@ -422,7 +429,7 @@ tddCmd.command('run <command>').description('Run tests once in TDD mode with loc
422
429
  await handleCleanup();
423
430
  process.exit(result?.exitCode || 0);
424
431
  });
425
- 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) => {
426
433
  const globalOptions = program.opts();
427
434
 
428
435
  // Validate options
@@ -458,7 +465,7 @@ program.command('status').description('Check the status of a build').argument('<
458
465
  }
459
466
  await statusCommand(buildId, options, globalOptions);
460
467
  });
461
- 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', `
462
469
  Examples:
463
470
  $ vizzly builds # List recent builds
464
471
  $ vizzly builds --branch main # Filter by branch
@@ -482,7 +489,7 @@ Examples:
482
489
  }
483
490
  await buildsCommand(options, globalOptions);
484
491
  });
485
- 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', `
486
493
  Examples:
487
494
  $ vizzly comparisons -b abc123 # List comparisons for a build
488
495
  $ vizzly comparisons --id def456 # Get specific comparison by ID
@@ -505,13 +512,15 @@ Examples:
505
512
  }
506
513
  await comparisonsCommand(options, globalOptions);
507
514
  });
508
- let contextCmd = program.command('context').description('Fetch visual context bundles for agents and reviewers');
509
- contextCmd.command('build').description('Fetch a build context bundle').argument('<build-id>', 'Build ID to fetch context for').option('--source <source>', 'Context source: auto, cloud, or local', 'auto').addHelpText('after', `
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', `
510
517
  Examples:
511
518
  $ vizzly context build abc123
512
519
  $ vizzly context build current --source local
513
- $ vizzly context build abc123 --json
514
- $ vizzly context build abc123 --json build.id,summary.comparisons
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
515
524
  `).action(async (buildId, options) => {
516
525
  const globalOptions = program.opts();
517
526
  const validationErrors = validateContextBuildOptions(options);
@@ -524,7 +533,7 @@ Examples:
524
533
  }
525
534
  await contextBuildCommand(buildId, options, globalOptions);
526
535
  });
527
- 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)', val => parseInt(val, 10)).option('--recent-limit <n>', 'Maximum recent same-name comparisons to return (1-50)', val => parseInt(val, 10)).option('--window-size <n>', 'Historical hotspot analysis window size (1-50)', val => parseInt(val, 10)).addHelpText('after', `
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', `
528
537
  Examples:
529
538
  $ vizzly context comparison def456
530
539
  $ vizzly context comparison def456 --source local
@@ -542,7 +551,7 @@ Examples:
542
551
  }
543
552
  await contextComparisonCommand(comparisonId, options, globalOptions);
544
553
  });
545
- 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)', val => parseInt(val, 10)).option('--window-size <n>', 'Historical hotspot analysis window size (1-50)', val => parseInt(val, 10)).addHelpText('after', `
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', `
546
555
  Examples:
547
556
  $ vizzly context screenshot Dashboard
548
557
  $ vizzly context screenshot Dashboard --source local
@@ -560,7 +569,7 @@ Examples:
560
569
  }
561
570
  await contextScreenshotCommand(name, options, globalOptions);
562
571
  });
563
- 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)', val => parseInt(val, 10)).addHelpText('after', `
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', `
564
573
  Examples:
565
574
  $ vizzly context similar fp-dashboard
566
575
  $ vizzly context similar fp-dashboard --project storybook --org acme
@@ -577,7 +586,7 @@ Examples:
577
586
  }
578
587
  await contextSimilarCommand(fingerprintHash, options, globalOptions);
579
588
  });
580
- 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)', val => parseInt(val, 10)).option('--offset <n>', 'Skip first N comparisons', val => parseInt(val, 10)).addHelpText('after', `
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', `
581
590
  Examples:
582
591
  $ vizzly context review-queue --project storybook --org acme
583
592
  $ vizzly context review-queue --source local
@@ -740,7 +749,7 @@ or the single organization for a project token.
740
749
  }
741
750
  await orgsCommand(options, globalOptions);
742
751
  });
743
- 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', `
744
753
  Examples:
745
754
  $ vizzly projects # List all projects
746
755
  $ vizzly projects --org my-company # Filter by organization
@@ -762,6 +771,26 @@ Workflow:
762
771
  }
763
772
  await projectsCommand(options, globalOptions);
764
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
+ });
765
794
  program.command('finalize').description('Finalize a parallel build after all shards complete').argument('<parallel-id>', 'Parallel ID to finalize').action(async (parallelId, options) => {
766
795
  const globalOptions = program.opts();
767
796
 
@@ -776,7 +805,7 @@ program.command('finalize').description('Finalize a parallel build after all sha
776
805
  }
777
806
  await finalizeCommand(parallelId, options, globalOptions);
778
807
  });
779
- 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) => {
780
809
  const globalOptions = program.opts();
781
810
 
782
811
  // Show helpful error if path is missing
@@ -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(),