@vizzly-testing/cli 0.33.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.
Files changed (37) hide show
  1. package/README.md +53 -3
  2. package/dist/api/core.js +2 -1
  3. package/dist/cli.js +21 -8
  4. package/dist/client/index.js +62 -21
  5. package/dist/commands/api.js +9 -3
  6. package/dist/commands/init.js +214 -2
  7. package/dist/commands/preview.js +13 -11
  8. package/dist/commands/project.js +1 -1
  9. package/dist/commands/run.js +18 -6
  10. package/dist/commands/tdd-daemon.js +8 -3
  11. package/dist/commands/tdd.js +24 -4
  12. package/dist/commands/upload.js +31 -13
  13. package/dist/reporter/reporter-bundle.iife.js +1 -1
  14. package/dist/sdk/index.js +23 -9
  15. package/dist/server/handlers/api-handler.js +3 -2
  16. package/dist/server/handlers/tdd-handler.js +5 -1
  17. package/dist/server/routers/screenshot.js +11 -4
  18. package/dist/services/build-manager.js +6 -1
  19. package/dist/test-runner/core.js +4 -1
  20. package/dist/test-runner/operations.js +2 -1
  21. package/dist/types/client.d.ts +32 -5
  22. package/dist/types/index.d.ts +33 -5
  23. package/dist/uploader/core.js +41 -9
  24. package/dist/uploader/operations.js +19 -17
  25. package/dist/utils/config-loader.js +12 -6
  26. package/dist/utils/context.js +46 -76
  27. package/dist/utils/environment-config.js +20 -0
  28. package/dist/utils/output.js +5 -5
  29. package/dist/utils/screenshot-options.js +60 -10
  30. package/package.json +2 -1
  31. package/skills/vizzly/SKILL.md +99 -0
  32. package/skills/vizzly/agents/openai.yaml +4 -0
  33. package/skills/vizzly/references/cli-context.md +42 -0
  34. package/skills/vizzly/references/dynamic-content.md +44 -0
  35. package/skills/vizzly/references/public-screenshots.md +44 -0
  36. package/skills/vizzly/references/sdks.md +62 -0
  37. package/skills/vizzly/references/setup-ci.md +42 -0
@@ -1,20 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { fileURLToPath } from 'node:url';
4
6
  import { z } from 'zod';
5
7
  import { VizzlyError } from '../errors/vizzly-error.js';
6
8
  import { loadPlugins } from '../plugin-loader.js';
7
9
  import { loadConfig } from '../utils/config-loader.js';
8
10
  import * as output from '../utils/output.js';
11
+ let commandDir = path.dirname(fileURLToPath(import.meta.url));
9
12
  let configValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(configValueSchema), z.record(z.string(), configValueSchema)]));
10
13
  let configSchemaValidator = z.record(z.string(), configValueSchema);
11
14
  function createInitDeps(deps = {}) {
12
15
  return {
13
16
  access: deps.access || fs.access,
17
+ copy: deps.copy || fs.cp,
14
18
  cwd: deps.cwd || (() => process.cwd()),
19
+ isInteractive: deps.isInteractive || (() => Boolean(process.stdin.isTTY && process.stdout.isTTY)),
15
20
  loadConfig: deps.loadConfig || loadConfig,
16
21
  loadPlugins: deps.loadPlugins || loadPlugins,
22
+ mkdir: deps.mkdir || fs.mkdir,
17
23
  output: deps.output || output,
24
+ promptAgentSkill: deps.promptAgentSkill || promptAgentSkill,
25
+ readFile: deps.readFile || fs.readFile,
26
+ skillSourcePath: deps.skillSourcePath || getPackagedAgentSkillPath(),
18
27
  writeFile: deps.writeFile || fs.writeFile
19
28
  };
20
29
  }
@@ -28,6 +37,15 @@ function configureOutput(output, options) {
28
37
  export function getInitConfigPath(cwd = process.cwd()) {
29
38
  return path.join(cwd, 'vizzly.config.js');
30
39
  }
40
+ export function getPackagedAgentSkillPath(baseDir = commandDir) {
41
+ return path.resolve(baseDir, '..', '..', 'skills', 'vizzly');
42
+ }
43
+ export function getProjectAgentSkillPath(cwd = process.cwd()) {
44
+ return path.join(cwd, '.agents', 'skills', 'vizzly');
45
+ }
46
+ export function getProjectAgentsPath(cwd = process.cwd()) {
47
+ return path.join(cwd, 'AGENTS.md');
48
+ }
31
49
  export async function fileExists(filePath, access = fs.access) {
32
50
  try {
33
51
  await access(filePath);
@@ -162,6 +180,169 @@ function showNextSteps(output) {
162
180
  output.labelValue('Next steps', '');
163
181
  output.list(['Set your API token: export VIZZLY_TOKEN="your-api-key"', 'Run your tests with Vizzly: pnpm exec vizzly run "pnpm test"', 'Upload screenshots: pnpm exec vizzly upload ./screenshots']);
164
182
  }
183
+ async function promptAgentSkill({
184
+ stdin = process.stdin,
185
+ stdout = process.stdout
186
+ } = {}) {
187
+ let rl = createInterface({
188
+ input: stdin,
189
+ output: stdout
190
+ });
191
+ try {
192
+ let answer = await rl.question('Add the Vizzly agent skill to this repo? [Y/n] ');
193
+ return !['n', 'no'].includes(answer.trim().toLowerCase());
194
+ } finally {
195
+ rl.close();
196
+ }
197
+ }
198
+ async function shouldInstallAgentSkill(options, deps) {
199
+ if (options.agentGuidance) {
200
+ return true;
201
+ }
202
+ if (options.skipAgentSkill) {
203
+ return false;
204
+ }
205
+ if (options.agentSkill) {
206
+ return true;
207
+ }
208
+ if (options.json || !deps.isInteractive()) {
209
+ return false;
210
+ }
211
+ return await deps.promptAgentSkill();
212
+ }
213
+ let agentGuidanceStart = '<!-- vizzly-agent-guidance -->';
214
+ let agentGuidanceEnd = '<!-- /vizzly-agent-guidance -->';
215
+ export function createAgentGuidanceContent() {
216
+ return `${agentGuidanceStart}
217
+ ## Visual Testing With Vizzly
218
+
219
+ Use Vizzly for user-facing UI changes when this repo has Vizzly configured.
220
+
221
+ - Read visual history before changing UI: \`npx vizzly context build current --source local --agent\`
222
+ - Inspect named screenshot history when useful: \`npx vizzly context screenshot "<screenshot-name>" --source local --json\`
223
+ - Verify visual changes through the existing user workflow that owns the surface: \`npx vizzly tdd run "<test command>" --no-open\`
224
+
225
+ Prefer existing E2E/user journeys over narrow screenshot-only specs. Treat Vizzly diffs as review evidence; do not approve or reject changes unless asked.
226
+ ${agentGuidanceEnd}
227
+ `;
228
+ }
229
+ function appendSection(content, section) {
230
+ if (!content.trim()) {
231
+ return section;
232
+ }
233
+ return `${content.replace(/\s*$/, '')}\n\n${section}`;
234
+ }
235
+ export async function upsertProjectAgentGuidance({
236
+ cwd,
237
+ access = fs.access,
238
+ readFile = fs.readFile,
239
+ writeFile = fs.writeFile
240
+ }) {
241
+ let agentsPath = getProjectAgentsPath(cwd);
242
+ let guidance = createAgentGuidanceContent();
243
+ if (!(await fileExists(agentsPath, access))) {
244
+ await writeFile(agentsPath, guidance, 'utf8');
245
+ return {
246
+ status: 'created',
247
+ agentsPath
248
+ };
249
+ }
250
+ let existingContent = await readFile(agentsPath, 'utf8');
251
+ if (existingContent.includes(agentGuidanceStart)) {
252
+ return {
253
+ status: 'exists',
254
+ agentsPath
255
+ };
256
+ }
257
+ await writeFile(agentsPath, appendSection(existingContent, guidance), 'utf8');
258
+ return {
259
+ status: 'updated',
260
+ agentsPath
261
+ };
262
+ }
263
+ export async function installProjectAgentSkill({
264
+ cwd,
265
+ sourcePath,
266
+ access = fs.access,
267
+ copy = fs.cp,
268
+ mkdir = fs.mkdir
269
+ }) {
270
+ let targetPath = getProjectAgentSkillPath(cwd);
271
+ if (!(await fileExists(path.join(sourcePath, 'SKILL.md'), access))) {
272
+ return {
273
+ status: 'missing-source',
274
+ sourcePath,
275
+ targetPath
276
+ };
277
+ }
278
+ if (await fileExists(targetPath, access)) {
279
+ return {
280
+ status: 'exists',
281
+ sourcePath,
282
+ targetPath
283
+ };
284
+ }
285
+ await mkdir(path.dirname(targetPath), {
286
+ recursive: true
287
+ });
288
+ await copy(sourcePath, targetPath, {
289
+ recursive: true,
290
+ errorOnExist: true,
291
+ force: false
292
+ });
293
+ return {
294
+ status: 'installed',
295
+ sourcePath,
296
+ targetPath
297
+ };
298
+ }
299
+ function writeAgentSkillOutput(output, result) {
300
+ if (!result) {
301
+ return;
302
+ }
303
+ if (result.status === 'installed') {
304
+ output.complete('Added Vizzly agent skill');
305
+ output.hint(`Installed at ${result.targetPath}`);
306
+ return;
307
+ }
308
+ if (result.status === 'exists') {
309
+ output.hint('Vizzly agent skill already exists in this repo');
310
+ return;
311
+ }
312
+ if (result.status === 'missing-source') {
313
+ output.warn('Vizzly agent skill was not found in this CLI package');
314
+ }
315
+ }
316
+ function writeAgentGuidanceOutput(output, result) {
317
+ if (!result) {
318
+ return;
319
+ }
320
+ if (result.status === 'created') {
321
+ output.complete('Created AGENTS.md with Vizzly guidance');
322
+ output.hint(`Wrote ${result.agentsPath}`);
323
+ return;
324
+ }
325
+ if (result.status === 'updated') {
326
+ output.complete('Added Vizzly guidance to AGENTS.md');
327
+ output.hint(`Updated ${result.agentsPath}`);
328
+ return;
329
+ }
330
+ if (result.status === 'exists') {
331
+ output.hint('Vizzly guidance already exists in AGENTS.md');
332
+ }
333
+ }
334
+ function withAgentSetupResults(payload, {
335
+ agentSkillResult,
336
+ agentGuidanceResult
337
+ }) {
338
+ if (agentSkillResult) {
339
+ payload.agentSkill = agentSkillResult;
340
+ }
341
+ if (agentGuidanceResult) {
342
+ payload.agentGuidance = agentGuidanceResult;
343
+ }
344
+ return payload;
345
+ }
165
346
  async function writeConfigFile({
166
347
  configPath,
167
348
  plugins,
@@ -197,14 +378,37 @@ export async function init(options = {}, deps = {}) {
197
378
  let plugins = await loadInitPlugins(options, resolvedDeps);
198
379
  let configPath = getInitConfigPath(resolvedDeps.cwd());
199
380
  let hasConfig = await fileExists(configPath, resolvedDeps.access);
381
+ let agentSkillResult = null;
382
+ let agentGuidanceResult = null;
383
+ if (await shouldInstallAgentSkill(options, resolvedDeps)) {
384
+ agentSkillResult = await installProjectAgentSkill({
385
+ cwd: resolvedDeps.cwd(),
386
+ sourcePath: resolvedDeps.skillSourcePath,
387
+ access: resolvedDeps.access,
388
+ copy: resolvedDeps.copy,
389
+ mkdir: resolvedDeps.mkdir
390
+ });
391
+ }
392
+ if (options.agentGuidance) {
393
+ agentGuidanceResult = await upsertProjectAgentGuidance({
394
+ cwd: resolvedDeps.cwd(),
395
+ access: resolvedDeps.access,
396
+ readFile: resolvedDeps.readFile,
397
+ writeFile: resolvedDeps.writeFile
398
+ });
399
+ }
200
400
  if (hasConfig && !options.force) {
201
401
  if (options.json) {
202
- resolvedDeps.output.data({
402
+ let payload = withAgentSetupResults({
203
403
  status: 'skipped',
204
404
  reason: 'config_exists',
205
405
  configPath,
206
406
  message: 'A vizzly.config.js file already exists. Use --force to overwrite.'
407
+ }, {
408
+ agentSkillResult,
409
+ agentGuidanceResult
207
410
  });
411
+ resolvedDeps.output.data(payload);
208
412
  return {
209
413
  status: 'skipped',
210
414
  configPath
@@ -213,6 +417,8 @@ export async function init(options = {}, deps = {}) {
213
417
  resolvedDeps.output.header('init');
214
418
  resolvedDeps.output.warn('A vizzly.config.js file already exists');
215
419
  resolvedDeps.output.hint('Use --force to overwrite');
420
+ writeAgentSkillOutput(resolvedDeps.output, agentSkillResult);
421
+ writeAgentGuidanceOutput(resolvedDeps.output, agentGuidanceResult);
216
422
  return {
217
423
  status: 'skipped',
218
424
  configPath
@@ -228,11 +434,15 @@ export async function init(options = {}, deps = {}) {
228
434
  });
229
435
  let pluginNames = getPluginConfigNames(plugins);
230
436
  if (options.json) {
231
- resolvedDeps.output.data({
437
+ let payload = withAgentSetupResults({
232
438
  status: 'created',
233
439
  configPath,
234
440
  plugins: pluginNames
441
+ }, {
442
+ agentSkillResult,
443
+ agentGuidanceResult
235
444
  });
445
+ resolvedDeps.output.data(payload);
236
446
  return {
237
447
  status: 'created',
238
448
  configPath,
@@ -240,6 +450,8 @@ export async function init(options = {}, deps = {}) {
240
450
  };
241
451
  }
242
452
  showNextSteps(resolvedDeps.output);
453
+ writeAgentSkillOutput(resolvedDeps.output, agentSkillResult);
454
+ writeAgentGuidanceOutput(resolvedDeps.output, agentGuidanceResult);
243
455
  resolvedDeps.output.blank();
244
456
  resolvedDeps.output.complete('Vizzly CLI setup complete');
245
457
  return {
@@ -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,
@@ -1,4 +1,5 @@
1
1
  import { createApiClient as defaultCreateApiClient, finalizeBuild as defaultFinalizeBuild, getTokenContext as defaultGetTokenContext } from '../api/index.js';
2
+ import { CONFIG_DEFAULTS } from '../config/core.js';
2
3
  import { createUploader as defaultCreateUploader } from '../uploader/index.js';
3
4
  import { getAppBaseUrl } from '../utils/api-url.js';
4
5
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
@@ -73,12 +74,12 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
73
74
  });
74
75
  let buildId = null;
75
76
  let config = null;
76
- const uploadStartTime = Date.now();
77
+ let uploadStartTime = Date.now();
77
78
  try {
78
79
  output.info('Starting upload process...');
79
80
 
80
81
  // Load configuration with CLI overrides
81
- const allOptions = {
82
+ let allOptions = {
82
83
  ...globalOptions,
83
84
  ...options
84
85
  };
@@ -95,11 +96,12 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
95
96
  }
96
97
 
97
98
  // Collect git metadata if not provided
98
- const branch = await detectBranch(options.branch);
99
- const commit = await detectCommit(options.commit);
100
- const message = options.message || (await detectCommitMessage());
101
- const buildName = await generateBuildNameWithGit(options.buildName);
102
- const pullRequestNumber = detectPullRequestNumber();
99
+ let configuredBuildName = config.build.name && config.build.name !== CONFIG_DEFAULTS.build.name ? config.build.name : undefined;
100
+ let branch = await detectBranch(options.branch || config.build.branch);
101
+ let commit = await detectCommit(options.commit || config.build.commit);
102
+ let message = options.message || config.build.message || (await detectCommitMessage());
103
+ let buildName = await generateBuildNameWithGit(options.buildName || configuredBuildName);
104
+ let pullRequestNumber = detectPullRequestNumber();
103
105
  output.info(`Uploading screenshots from: ${screenshotsPath}`);
104
106
  if (globalOptions.verbose) {
105
107
  output.info('Configuration loaded');
@@ -119,7 +121,7 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
119
121
  });
120
122
 
121
123
  // Prepare upload options with progress callback
122
- const uploadOptions = {
124
+ let uploadOptions = {
123
125
  screenshotsDir: screenshotsPath,
124
126
  buildName,
125
127
  branch,
@@ -127,12 +129,13 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
127
129
  message,
128
130
  environment: config.build.environment,
129
131
  threshold: config.comparison.threshold,
132
+ minClusterSize: config.comparison.minClusterSize,
130
133
  uploadAll: options.uploadAll || false,
131
134
  metadata: options.metadata ? JSON.parse(options.metadata) : {},
132
135
  pullRequestNumber,
133
136
  parallelId: config.parallelId,
134
137
  onProgress: progressData => {
135
- const {
138
+ let {
136
139
  message: progressMessage,
137
140
  current,
138
141
  total,
@@ -158,7 +161,7 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
158
161
 
159
162
  // Start upload
160
163
  output.progress('Starting upload...');
161
- const result = await uploader.upload(uploadOptions);
164
+ let result = await uploader.upload(uploadOptions);
162
165
  buildId = result.buildId; // Ensure we have the buildId
163
166
 
164
167
  // Write session for subsequent commands (like preview)
@@ -263,8 +266,10 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
263
266
  executionTimeMs
264
267
  });
265
268
  output.cleanup();
269
+ let success = buildResult.failedComparisons === 0;
266
270
  return {
267
- success: buildResult.failedComparisons === 0,
271
+ success,
272
+ exitCode: success ? 0 : 1,
268
273
  result
269
274
  };
270
275
  }
@@ -283,6 +288,14 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
283
288
  let waitBuildUrl = buildResult.url || (await buildUrlConstructor(result.buildId, config.apiUrl, config.apiKey, deps));
284
289
  output.blank();
285
290
  output.labelValue('View', output.link('Results', waitBuildUrl));
291
+ if (buildResult.failedComparisons > 0) {
292
+ output.cleanup();
293
+ return {
294
+ success: false,
295
+ exitCode: 1,
296
+ result
297
+ };
298
+ }
286
299
  }
287
300
  output.cleanup();
288
301
  return {
@@ -300,11 +313,10 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
300
313
  message: 'Vizzly API unavailable - upload skipped',
301
314
  executionTimeMs: Date.now() - uploadStartTime
302
315
  });
303
- output.cleanup();
304
316
  } else {
305
317
  output.warn('Vizzly API unavailable - upload skipped. Your tests still ran.');
306
- output.cleanup();
307
318
  }
319
+ output.cleanup();
308
320
  return {
309
321
  success: true,
310
322
  result: {
@@ -381,6 +393,12 @@ export function validateUploadOptions(screenshotsPath, options) {
381
393
  errors.push('Threshold must be a non-negative number (CIEDE2000 Delta E)');
382
394
  }
383
395
  }
396
+ if (options.minClusterSize !== undefined) {
397
+ let minClusterSize = Number(options.minClusterSize);
398
+ if (!Number.isInteger(minClusterSize) || minClusterSize < 1) {
399
+ errors.push('Min cluster size must be a positive integer');
400
+ }
401
+ }
384
402
  if (options.batchSize !== undefined) {
385
403
  let n = Number(options.batchSize);
386
404
  if (!Number.isInteger(n) || n <= 0) {