@vizzly-testing/cli 0.34.0 → 0.35.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.
package/README.md CHANGED
@@ -35,6 +35,9 @@ For agent-friendly repos, install the Vizzly skill and add a short project
35
35
  vizzly init --agent-guidance
36
36
  ```
37
37
 
38
+ Use `vizzly init --agent-skill` to install only the local skill, or
39
+ `vizzly init --skip-agent-skill` when you want config without the agent prompt.
40
+
38
41
  ### Start Local TDD
39
42
 
40
43
  Start the TDD server, run your tests, and open the dashboard at
@@ -54,9 +57,14 @@ Use cloud builds when you want shared baselines, team review, and CI status:
54
57
 
55
58
  ```bash
56
59
  vizzly login
60
+ vizzly project link your-org/your-project
57
61
  vizzly run "pnpm test" --wait
58
62
  ```
59
63
 
64
+ `vizzly login` authenticates your user account. `vizzly project link` creates a
65
+ project-scoped upload credential for this checkout, which `vizzly run` uses for
66
+ cloud uploads.
67
+
60
68
  `--wait` blocks until Vizzly finishes processing the build. It exits with code
61
69
  `1` when visual differences need review.
62
70
 
@@ -111,12 +119,23 @@ test('homepage looks correct', async ({ page }) => {
111
119
 
112
120
  let screenshot = await page.screenshot();
113
121
  await vizzlyScreenshot('homepage', screenshot, {
114
- browser: 'chrome',
115
- viewport: '1920x1080'
122
+ fullPage: true,
123
+ requestTimeout: 5000,
124
+ properties: {
125
+ browser: 'chrome',
126
+ viewport: { width: 1920, height: 1080 },
127
+ },
116
128
  });
117
129
  });
118
130
  ```
119
131
 
132
+ `properties` is your metadata bag for baseline grouping, filtering, and
133
+ debugging. SDK options such as `threshold`, `minClusterSize`, `fullPage`,
134
+ `requestTimeout`, and `buildId` stay at the top level. If one of those reserved
135
+ SDK options is accidentally placed inside `properties`, Vizzly removes it from
136
+ metadata, applies it as an option when possible, and warns so the call site can
137
+ be cleaned up.
138
+
120
139
  The client SDK is lightweight. It posts screenshots to the local Vizzly server
121
140
  or the cloud build wrapper. It works with any test runner.
122
141
 
@@ -136,9 +155,26 @@ await vizzlyScreenshot('homepage', './screenshots/homepage.png');
136
155
  Or upload an existing folder of screenshots:
137
156
 
138
157
  ```bash
139
- vizzly upload ./screenshots
158
+ vizzly upload ./screenshots --threshold 2 --min-cluster-size 4 --batch-size 10 --upload-timeout 60000
140
159
  ```
141
160
 
161
+ `--batch-size` controls how many screenshots are uploaded per request.
162
+ `--upload-timeout` controls the upload client's timeout, including how long
163
+ `--wait` polls for build processing.
164
+
165
+ CI workflows can force every screenshot through even when the SHA cache says it
166
+ already uploaded. Parallel jobs should use the same stable ID, then finalize
167
+ that ID after every shard completes:
168
+
169
+ ```bash
170
+ vizzly run "pnpm test" --upload-all --parallel-id "$GITHUB_RUN_ID"
171
+ vizzly finalize "$GITHUB_RUN_ID"
172
+ ```
173
+
174
+ For smoke jobs where cloud credentials are intentionally unavailable, add
175
+ `--allow-no-token` to `vizzly run` and Vizzly will keep the local screenshot
176
+ server path working without creating a cloud build.
177
+
142
178
  ## Configuration
143
179
 
144
180
  Generate a config file:
package/dist/api/core.js CHANGED
@@ -172,7 +172,8 @@ export function buildScreenshotCheckObject(sha256, name, metadata = {}) {
172
172
  name,
173
173
  browser: meta.browser || 'chrome',
174
174
  viewport_width: meta.viewport?.width || meta.viewport_width || 1920,
175
- viewport_height: meta.viewport?.height || meta.viewport_height || 1080
175
+ viewport_height: meta.viewport?.height || meta.viewport_height || 1080,
176
+ properties: meta
176
177
  };
177
178
  }
178
179
 
package/dist/cli.js CHANGED
@@ -207,6 +207,7 @@ const formatHelp = (cmd, helper) => {
207
207
  };
208
208
  function extractGlobalOptionsFromArgv(argv, commandNames = null) {
209
209
  let configPath = null;
210
+ let tokenArg = null;
210
211
  let verboseMode = false;
211
212
  let logLevelArg = null;
212
213
  let jsonArg = null;
@@ -214,6 +215,11 @@ function extractGlobalOptionsFromArgv(argv, commandNames = null) {
214
215
  if ((argv[i] === '-c' || argv[i] === '--config') && argv[i + 1]) {
215
216
  configPath = argv[i + 1];
216
217
  }
218
+ if (argv[i] === '--token' && argv[i + 1]) {
219
+ tokenArg = argv[i + 1];
220
+ } else if (argv[i].startsWith('--token=')) {
221
+ tokenArg = argv[i].substring('--token='.length);
222
+ }
217
223
  if (argv[i] === '-v' || argv[i] === '--verbose') {
218
224
  verboseMode = true;
219
225
  }
@@ -234,6 +240,7 @@ function extractGlobalOptionsFromArgv(argv, commandNames = null) {
234
240
  }
235
241
  return {
236
242
  configPath,
243
+ tokenArg,
237
244
  verboseMode,
238
245
  logLevelArg,
239
246
  jsonArg
@@ -260,6 +267,7 @@ program.name('vizzly').description('Vizzly CLI for visual regression testing').v
260
267
  // We need to manually parse to get the config option early
261
268
  let {
262
269
  configPath,
270
+ tokenArg,
263
271
  verboseMode,
264
272
  logLevelArg,
265
273
  jsonArg
@@ -280,9 +288,11 @@ output.configure({
280
288
  color: colorOverride,
281
289
  json: jsonArg
282
290
  });
283
- const config = await loadConfig(configPath, {});
284
- const services = createServices(config);
285
- const pluginServices = createPluginServices(services);
291
+ let config = await loadConfig(configPath, {
292
+ token: tokenArg
293
+ });
294
+ let services = createServices(config);
295
+ let pluginServices = createPluginServices(services);
286
296
  let plugins = [];
287
297
  try {
288
298
  plugins = await loadPlugins(configPath, config);
@@ -311,8 +321,8 @@ program.command('init').description('Initialize Vizzly in your project').option(
311
321
  plugins
312
322
  });
313
323
  });
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) => {
315
- const globalOptions = program.opts();
324
+ program.command('upload').description('Upload screenshots to Vizzly').argument('<path>', 'Path to screenshots directory').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('--min-cluster-size <pixels>', 'Minimum changed-pixel cluster size', 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) => {
325
+ let globalOptions = program.opts();
316
326
 
317
327
  // Validate options
318
328
  const validationErrors = validateUploadOptions(path, options);
@@ -323,7 +333,10 @@ program.command('upload').description('Upload screenshots to Vizzly').argument('
323
333
  }
324
334
  process.exit(1);
325
335
  }
326
- await uploadCommand(path, options, globalOptions);
336
+ let result = await uploadCommand(path, options, globalOptions);
337
+ if (result?.exitCode) {
338
+ process.exit(result.exitCode);
339
+ }
327
340
  });
328
341
 
329
342
  // TDD command with subcommands - local visual review with an interactive dashboard
@@ -8,22 +8,27 @@ 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
+ import { normalizeScreenshotOptions } from '../utils/screenshot-options.js';
12
12
 
13
13
  // Internal client state
14
14
  let currentClient = null;
15
+ let currentServerUrl = null;
15
16
  let isDisabled = false;
17
+ let currentFailOnDiff = false;
16
18
 
17
19
  // Default timeout for screenshot requests (30 seconds)
18
20
  let DEFAULT_TIMEOUT_MS = 30000;
19
21
 
20
22
  // Log levels for client SDK output control
21
- export let LOG_LEVELS = {
23
+ export let LOG_LEVELS = Object.freeze({
22
24
  debug: 0,
23
25
  info: 1,
24
26
  warn: 2,
25
27
  error: 3
26
- };
28
+ });
29
+ function getEnvFailOnDiff(env = process.env) {
30
+ return env.VIZZLY_FAIL_ON_DIFF === 'true' || env.VIZZLY_FAIL_ON_DIFF === '1';
31
+ }
27
32
 
28
33
  /**
29
34
  * Check if client should log at the given level
@@ -56,6 +61,7 @@ function isVizzlyDisabled() {
56
61
  function disableVizzly() {
57
62
  isDisabled = true;
58
63
  currentClient = null;
64
+ currentServerUrl = null;
59
65
  }
60
66
 
61
67
  /**
@@ -67,7 +73,8 @@ function disableVizzly() {
67
73
  export function autoDiscoverTddServer(startDir, deps = {}) {
68
74
  let {
69
75
  exists = existsSync,
70
- readFile = readFileSync
76
+ readFile = readFileSync,
77
+ env = process.env
71
78
  } = deps;
72
79
  try {
73
80
  // Look for .vizzly/server.json in current directory and parent directories
@@ -80,6 +87,7 @@ export function autoDiscoverTddServer(startDir, deps = {}) {
80
87
  let serverInfo = JSON.parse(readFile(serverJsonPath, 'utf8'));
81
88
  if (serverInfo.port) {
82
89
  let url = `http://localhost:${serverInfo.port}`;
90
+ currentFailOnDiff = getEnvFailOnDiff(env) || Boolean(serverInfo.failOnDiff);
83
91
  return url;
84
92
  }
85
93
  } catch {
@@ -104,6 +112,7 @@ function getClient() {
104
112
  }
105
113
  if (!currentClient) {
106
114
  let serverUrl = getServerUrl();
115
+ currentFailOnDiff = getEnvFailOnDiff();
107
116
 
108
117
  // Auto-detect local TDD server and enable Vizzly if TDD server is found
109
118
  if (!serverUrl) {
@@ -116,7 +125,10 @@ function getClient() {
116
125
 
117
126
  // If we have a server URL, create the client (regardless of initial enabled state)
118
127
  if (serverUrl) {
119
- currentClient = createSimpleClient(serverUrl);
128
+ currentServerUrl = serverUrl;
129
+ currentClient = createSimpleClient(serverUrl, {
130
+ failOnDiff: currentFailOnDiff
131
+ });
120
132
  }
121
133
  }
122
134
  return currentClient;
@@ -183,28 +195,38 @@ function httpPost(url, body, timeoutMs) {
183
195
  * Create a simple HTTP client for screenshots
184
196
  * @private
185
197
  */
186
- function createSimpleClient(serverUrl) {
198
+ function createSimpleClient(serverUrl, clientOptions = {}) {
199
+ let {
200
+ failOnDiff = false
201
+ } = clientOptions;
187
202
  return {
188
203
  async screenshot(name, imageBuffer, options = {}) {
204
+ let normalizedOptions = normalizeScreenshotOptions(options);
205
+ let requestTimeout = normalizedOptions.requestTimeout || DEFAULT_TIMEOUT_MS;
206
+ for (let warning of normalizedOptions.warnings) {
207
+ console.warn(`[vizzly] ${warning.message}`);
208
+ }
189
209
  try {
190
210
  // If it's a string, assume it's a file path and send directly
191
211
  // Otherwise it's a Buffer, so convert to base64
192
212
  let isFilePath = typeof imageBuffer === 'string';
193
213
  let image = isFilePath ? imageBuffer : imageBuffer.toString('base64');
194
214
  let type = isFilePath ? 'file-path' : 'base64';
195
- let requestTimeout = options.requestTimeout || DEFAULT_TIMEOUT_MS;
196
- let properties = createScreenshotProperties(options);
215
+ let screenshotData = {
216
+ buildId: normalizedOptions.buildId ?? getBuildId(),
217
+ name,
218
+ image,
219
+ type,
220
+ properties: normalizedOptions.properties
221
+ };
222
+ if (normalizedOptions.warnings.length > 0) {
223
+ screenshotData.warnings = normalizedOptions.warnings;
224
+ }
197
225
  let httpStart = Date.now();
198
226
  let {
199
227
  status,
200
228
  json
201
- } = await httpPost(`${serverUrl}/screenshot`, {
202
- buildId: getBuildId(),
203
- name,
204
- image,
205
- type,
206
- properties
207
- }, requestTimeout);
229
+ } = await httpPost(`${serverUrl}/screenshot`, screenshotData, requestTimeout);
208
230
  let httpMs = Date.now() - httpStart;
209
231
  if (shouldLogClient('debug')) {
210
232
  console.debug(`[vizzly-client] ${name} HTTP completed in ${httpMs}ms`);
@@ -215,6 +237,9 @@ function createSimpleClient(serverUrl) {
215
237
  // The summary will show all failures at the end
216
238
  if (status === 422 && json.tddMode && json.comparison) {
217
239
  let comp = json.comparison;
240
+ if (failOnDiff) {
241
+ throw new Error(`Visual diff detected for "${comp.name}" (${comp.diffPercentage ?? 0}% difference)`);
242
+ }
218
243
 
219
244
  // Return success so test continues and captures remaining screenshots
220
245
  // Visual diff details will be shown in the summary after tests complete
@@ -227,12 +252,15 @@ function createSimpleClient(serverUrl) {
227
252
  }
228
253
  throw new Error(`Screenshot failed: ${status} - ${json.error || 'Unknown error'}`);
229
254
  }
255
+ if (failOnDiff && json?.tddMode && ['diff', 'failed'].includes(json.status)) {
256
+ throw new Error(`Visual diff detected for "${json.name || name}" (${json.diffPercentage ?? 0}% difference)`);
257
+ }
230
258
  return json;
231
259
  } catch (error) {
232
260
  // Handle timeout
233
261
  if (error.message === 'Request timeout') {
234
262
  if (shouldLogClient('error')) {
235
- console.error(`[vizzly] Screenshot timed out for "${name}" after ${DEFAULT_TIMEOUT_MS / 1000}s`);
263
+ console.error(`[vizzly] Screenshot timed out for "${name}" after ${requestTimeout / 1000}s`);
236
264
  }
237
265
  disableVizzly();
238
266
  return null;
@@ -308,7 +336,7 @@ function createSimpleClient(serverUrl) {
308
336
  * await vizzlyScreenshot('checkout-form', screenshot, {
309
337
  * properties: {
310
338
  * browser: 'chrome',
311
- * viewport: '1920x1080'
339
+ * viewport: { width: 1920, height: 1080 }
312
340
  * },
313
341
  * threshold: 5
314
342
  * });
@@ -366,8 +394,20 @@ export function isVizzlyReady() {
366
394
  * @param {boolean} [config.enabled] - Enable/disable screenshots
367
395
  */
368
396
  export function configure(config = {}) {
397
+ if ('failOnDiff' in config) {
398
+ currentFailOnDiff = config.failOnDiff === true;
399
+ } else if ('serverUrl' in config) {
400
+ currentFailOnDiff = getEnvFailOnDiff();
401
+ }
369
402
  if ('serverUrl' in config) {
370
- currentClient = config.serverUrl ? createSimpleClient(config.serverUrl) : null;
403
+ currentServerUrl = config.serverUrl || null;
404
+ currentClient = config.serverUrl ? createSimpleClient(config.serverUrl, {
405
+ failOnDiff: currentFailOnDiff
406
+ }) : null;
407
+ } else if ('failOnDiff' in config && currentClient && currentServerUrl) {
408
+ currentClient = createSimpleClient(currentServerUrl, {
409
+ failOnDiff: currentFailOnDiff
410
+ });
371
411
  }
372
412
  if (typeof config.enabled === 'boolean') {
373
413
  setVizzlyEnabled(config.enabled);
@@ -397,10 +437,11 @@ export function getVizzlyInfo() {
397
437
  let client = getClient();
398
438
  return {
399
439
  enabled: !isVizzlyDisabled(),
400
- serverUrl: getServerUrl(),
440
+ serverUrl: currentServerUrl || getServerUrl() || null,
401
441
  ready: !isVizzlyDisabled() && client !== null,
402
- buildId: getBuildId(),
442
+ buildId: getBuildId() || null,
403
443
  tddMode: isTddMode(),
404
- disabled: isVizzlyDisabled()
444
+ disabled: isVizzlyDisabled(),
445
+ failOnDiff: currentFailOnDiff
405
446
  };
406
447
  }
@@ -65,7 +65,8 @@ export function appendApiQuery(endpoint, queryOption) {
65
65
  }
66
66
  export function validateApiRequest({
67
67
  endpoint,
68
- method
68
+ method,
69
+ hasData = false
69
70
  }) {
70
71
  let errors = [];
71
72
  if (method !== 'GET' && method !== 'POST') {
@@ -75,6 +76,9 @@ export function validateApiRequest({
75
76
  if (method === 'POST' && !isAllowedPostEndpoint(endpoint)) {
76
77
  errors.push(`POST not allowed for ${endpoint}. Only approve, reject, and comment endpoints support POST.`);
77
78
  }
79
+ if (hasData && method !== 'POST') {
80
+ errors.push('Request data requires --method POST.');
81
+ }
78
82
  return errors;
79
83
  }
80
84
  export function buildApiRequest({
@@ -85,7 +89,8 @@ export function buildApiRequest({
85
89
  let method = normalizeApiMethod(options.method || 'GET');
86
90
  let errors = validateApiRequest({
87
91
  endpoint: normalizedEndpoint,
88
- method
92
+ method,
93
+ hasData: options.data !== undefined
89
94
  });
90
95
  if (errors.length > 0) {
91
96
  return {
@@ -239,7 +244,8 @@ export function validateApiOptions(endpoint, options = {}) {
239
244
  let method = normalizeApiMethod(options.method || 'GET');
240
245
  errors.push(...validateApiRequest({
241
246
  endpoint: normalizedEndpoint,
242
- method
247
+ method,
248
+ hasData: options.data !== undefined
243
249
  }));
244
250
  return errors;
245
251
  }
@@ -259,6 +259,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
259
259
  formatSessionAge = defaultFormatSessionAge,
260
260
  detectBranch = defaultDetectBranch,
261
261
  openBrowser = defaultOpenBrowser,
262
+ createZipWithSystem: createZip = createZipWithSystem,
262
263
  output = defaultOutput,
263
264
  exit = code => process.exit(code)
264
265
  } = deps;
@@ -284,19 +285,20 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
284
285
  reason: 'no-api-key'
285
286
  };
286
287
  }
288
+ let uploadPath = options.base ? resolve(path, options.base) : path;
287
289
 
288
290
  // Validate path exists and is a directory
289
- if (!existsSync(path)) {
290
- output.error(`Path does not exist: ${path}`);
291
+ if (!existsSync(uploadPath)) {
292
+ output.error(`Path does not exist: ${uploadPath}`);
291
293
  exit(1);
292
294
  return {
293
295
  success: false,
294
296
  reason: 'path-not-found'
295
297
  };
296
298
  }
297
- let pathStat = statSync(path);
299
+ let pathStat = statSync(uploadPath);
298
300
  if (!pathStat.isDirectory()) {
299
- output.error(`Path is not a directory: ${path}`);
301
+ output.error(`Path is not a directory: ${uploadPath}`);
300
302
  exit(1);
301
303
  return {
302
304
  success: false,
@@ -455,14 +457,14 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
455
457
  count: fileCount,
456
458
  totalSize,
457
459
  files
458
- } = await countFiles(path, {
460
+ } = await countFiles(uploadPath, {
459
461
  collectPaths: options.dryRun,
460
462
  excludedDirs,
461
463
  excludedFiles
462
464
  });
463
465
  if (fileCount === 0) {
464
466
  output.stopSpinner();
465
- output.error(`No files found in ${path}`);
467
+ output.error(`No files found in ${uploadPath}`);
466
468
  exit(1);
467
469
  return {
468
470
  success: false,
@@ -477,7 +479,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
477
479
  if (globalOptions.json) {
478
480
  output.data({
479
481
  dryRun: true,
480
- path: resolve(path),
482
+ path: resolve(uploadPath),
481
483
  fileCount,
482
484
  totalSize,
483
485
  excludedDirs,
@@ -490,7 +492,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
490
492
  } else {
491
493
  output.info(`Dry run - would upload ${fileCount} files (${formatBytes(totalSize)})`);
492
494
  output.blank();
493
- output.print(` ${colors.brand.textTertiary('Source')} ${resolve(path)}`);
495
+ output.print(` ${colors.brand.textTertiary('Source')} ${resolve(uploadPath)}`);
494
496
  output.print(` ${colors.brand.textTertiary('Files')} ${fileCount}`);
495
497
  output.print(` ${colors.brand.textTertiary('Total size')} ${formatBytes(totalSize)}`);
496
498
  output.blank();
@@ -538,7 +540,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
538
540
  let zipPath = join(tmpdir(), `vizzly-preview-${Date.now()}-${randomSuffix}.zip`);
539
541
  let zipBuffer;
540
542
  try {
541
- await createZipWithSystem(path, zipPath, exclusions);
543
+ await createZip(uploadPath, zipPath, exclusions);
542
544
  zipBuffer = await readFile(zipPath);
543
545
  } catch (zipError) {
544
546
  output.stopSpinner();
@@ -569,10 +571,10 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
569
571
  success: true,
570
572
  buildId,
571
573
  previewUrl: result.previewUrl,
572
- files: result.uploaded || fileCount,
574
+ files: result.uploaded ?? fileCount,
573
575
  bytes: totalSize,
574
576
  compressedBytes: zipBuffer.length,
575
- compressionRatio,
577
+ compressionRatio: `${compressionPercent}%`,
576
578
  newBytes: result.newBytes,
577
579
  reusedBlobs: result.reusedBlobs || 0,
578
580
  deduplicationRatio: result.deduplicationRatio,
@@ -56,7 +56,7 @@ export async function projectLinkCommand(selector, options = {}, globalOptions =
56
56
  } = parseProjectSelector(selector, options);
57
57
  try {
58
58
  let config = await loadConfig(globalOptions.config, globalOptions);
59
- let userToken = config.userToken || (await getAccessToken());
59
+ let userToken = config.userToken || globalOptions.token || (await getAccessToken());
60
60
  if (!userToken) {
61
61
  output.error('Login required before linking a project');
62
62
  output.hint('Run "vizzly login" first, then try project link again');
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { spawn as defaultSpawn } from 'node:child_process';
7
7
  import { createBuild as defaultCreateApiBuild, createApiClient as defaultCreateApiClient, finalizeBuild as defaultFinalizeApiBuild, getBuild as defaultGetBuild, getTokenContext as defaultGetTokenContext } from '../api/index.js';
8
+ import { CONFIG_DEFAULTS } from '../config/core.js';
8
9
  import { VizzlyError } from '../errors/vizzly-error.js';
9
10
  import { createServerManager as defaultCreateServerManager } from '../server-manager/index.js';
10
11
  import { createBuildObject as defaultCreateBuildObject } from '../services/build-manager.js';
@@ -174,10 +175,11 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
174
175
  }
175
176
 
176
177
  // Collect git metadata and build info
177
- let branch = await detectBranch(options.branch);
178
- let commit = await detectCommit(options.commit);
179
- let message = options.message || (await detectCommitMessage());
180
- let buildName = await generateBuildNameWithGit(options.buildName);
178
+ let configuredBuildName = config.build.name && config.build.name !== CONFIG_DEFAULTS.build.name ? config.build.name : undefined;
179
+ let branch = await detectBranch(options.branch || config.build.branch);
180
+ let commit = await detectCommit(options.commit || config.build.commit);
181
+ let message = options.message || config.build.message || (await detectCommitMessage());
182
+ let buildName = await generateBuildNameWithGit(options.buildName || configuredBuildName);
181
183
  let pullRequestNumber = detectPullRequestNumber();
182
184
  if (globalOptions.verbose) {
183
185
  output.info('Configuration loaded');
@@ -305,7 +307,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
305
307
  }
306
308
 
307
309
  // JSON output mode - output structured data and exit
308
- if (globalOptions.json) {
310
+ if (globalOptions.json && !runOptions.wait) {
309
311
  let executionTimeMs = Date.now() - startTime;
310
312
  let displayUrl = await resolveBuildDisplayUrl({
311
313
  result,
@@ -490,7 +492,17 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
490
492
  // Don't fail CI for Vizzly infrastructure issues (5xx errors)
491
493
  let status = error.context?.status;
492
494
  if (status >= 500) {
493
- output.warn('Vizzly API unavailable - visual tests skipped. Your tests still ran.');
495
+ if (globalOptions.json) {
496
+ output.data({
497
+ buildId: null,
498
+ status: 'skipped',
499
+ message: 'Vizzly API unavailable - visual tests skipped',
500
+ executionTimeMs: Date.now() - (startTime || Date.now())
501
+ });
502
+ } else {
503
+ output.warn('Vizzly API unavailable - visual tests skipped. Your tests still ran.');
504
+ }
505
+ output.cleanup();
494
506
  output.debug('api', 'API error details:', {
495
507
  error: error.message
496
508
  });
@@ -43,17 +43,20 @@ export function cleanupLocalDaemonFiles(directory = process.cwd(), deps = {}) {
43
43
  export function buildLegacyServerInfo({
44
44
  pid,
45
45
  port,
46
+ failOnDiff = false,
46
47
  now = Date.now
47
48
  }) {
48
49
  return {
49
50
  pid,
50
51
  port: port.toString(),
51
- startTime: now()
52
+ startTime: now(),
53
+ failOnDiff
52
54
  };
53
55
  }
54
56
  export function writeLegacyGlobalServerFile({
55
57
  pid,
56
- port
58
+ port,
59
+ failOnDiff = false
57
60
  }, {
58
61
  home = homedir,
59
62
  exists = existsSync,
@@ -71,6 +74,7 @@ export function writeLegacyGlobalServerFile({
71
74
  let serverInfo = buildLegacyServerInfo({
72
75
  pid,
73
76
  port,
77
+ failOnDiff,
74
78
  now
75
79
  });
76
80
  writeFile(globalServerFile, JSON.stringify(serverInfo, null, 2));
@@ -398,7 +402,8 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
398
402
  try {
399
403
  writeLegacyGlobalServerFile({
400
404
  pid: child.pid,
401
- port
405
+ port,
406
+ failOnDiff: options.failOnDiff || false
402
407
  });
403
408
  } catch {
404
409
  // Non-fatal, SDK can still use health check
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { spawn as defaultSpawn } from 'node:child_process';
7
7
  import { createBuild as defaultCreateApiBuild, createApiClient as defaultCreateApiClient, finalizeBuild as defaultFinalizeApiBuild, getBuild as defaultGetBuild } from '../api/index.js';
8
+ import { CONFIG_DEFAULTS } from '../config/core.js';
8
9
  import { VizzlyError } from '../errors/vizzly-error.js';
9
10
  import { createServerManager as defaultCreateServerManager } from '../server-manager/index.js';
10
11
  import { createAuthService as defaultCreateAuthService } from '../services/auth-service.js';
@@ -13,7 +14,7 @@ import { createConfigService as defaultCreateConfigService } from '../services/c
13
14
  import { createProjectService as defaultCreateProjectService } from '../services/project-service.js';
14
15
  import { initializeDaemon as defaultInitializeDaemon, runTests as defaultRunTests } from '../test-runner/index.js';
15
16
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
16
- import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit } from '../utils/git.js';
17
+ import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
17
18
  import * as defaultOutput from '../utils/output.js';
18
19
  function buildLocalContextCommand() {
19
20
  return 'vizzly context build current --source local --agent';
@@ -43,6 +44,9 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
43
44
  runTests = defaultRunTests,
44
45
  detectBranch = defaultDetectBranch,
45
46
  detectCommit = defaultDetectCommit,
47
+ detectCommitMessage = defaultDetectCommitMessage,
48
+ detectPullRequestNumber = defaultDetectPullRequestNumber,
49
+ generateBuildNameWithGit = defaultGenerateBuildNameWithGit,
46
50
  spawn = defaultSpawn,
47
51
  output = defaultOutput
48
52
  } = deps;
@@ -76,7 +80,7 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
76
80
  let config = await loadConfig(globalOptions.config, allOptions);
77
81
 
78
82
  // Dev mode works locally by default - only needs token for baseline download
79
- let needsToken = options.baselineBuild || options.baselineComparison;
83
+ let needsToken = options.baselineBuild || options.baselineComparison || config.baselineBuildId || config.baselineComparisonId;
80
84
  if (!config.apiKey && needsToken) {
81
85
  throw new Error('API token required when using --baseline-build or --baseline-comparison flags');
82
86
  }
@@ -85,8 +89,12 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
85
89
  config.allowNoToken = true;
86
90
 
87
91
  // Collect git metadata
88
- let branch = await detectBranch(options.branch);
89
- let commit = await detectCommit(options.commit);
92
+ let configuredBuildName = config.build.name && config.build.name !== CONFIG_DEFAULTS.build.name ? config.build.name : undefined;
93
+ let branch = await detectBranch(options.branch || config.build.branch);
94
+ let commit = await detectCommit(options.commit || config.build.commit);
95
+ let message = options.message || config.build.message || (await detectCommitMessage());
96
+ let buildName = await generateBuildNameWithGit(options.buildName || configuredBuildName);
97
+ let pullRequestNumber = detectPullRequestNumber();
90
98
 
91
99
  // Show header (skip in daemon mode)
92
100
  if (!options.daemon) {
@@ -132,9 +140,21 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
132
140
  tdd: true,
133
141
  daemon: options.daemon || false,
134
142
  setBaseline: options.setBaseline || false,
143
+ failOnDiff: options.failOnDiff || false,
144
+ name: buildName,
145
+ buildName,
135
146
  branch,
136
147
  commit,
148
+ message,
137
149
  environment: config.build.environment,
150
+ pullRequestNumber,
151
+ parallelId: config.parallelId,
152
+ metadata: {
153
+ comparison: {
154
+ threshold: config.comparison.threshold,
155
+ minClusterSize: config.comparison.minClusterSize
156
+ }
157
+ },
138
158
  threshold: config.comparison.threshold,
139
159
  minClusterSize: config.comparison.minClusterSize,
140
160
  allowNoToken: config.allowNoToken || false,