@vizzly-testing/cli 0.20.0 → 0.20.1-beta.1

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 (84) hide show
  1. package/dist/api/client.js +134 -0
  2. package/dist/api/core.js +341 -0
  3. package/dist/api/endpoints.js +314 -0
  4. package/dist/api/index.js +19 -0
  5. package/dist/auth/client.js +91 -0
  6. package/dist/auth/core.js +176 -0
  7. package/dist/auth/index.js +30 -0
  8. package/dist/auth/operations.js +148 -0
  9. package/dist/cli.js +178 -3
  10. package/dist/client/index.js +144 -77
  11. package/dist/commands/doctor.js +121 -36
  12. package/dist/commands/finalize.js +49 -18
  13. package/dist/commands/init.js +13 -18
  14. package/dist/commands/login.js +49 -55
  15. package/dist/commands/logout.js +17 -9
  16. package/dist/commands/project.js +100 -71
  17. package/dist/commands/run.js +189 -95
  18. package/dist/commands/status.js +101 -66
  19. package/dist/commands/tdd-daemon.js +61 -32
  20. package/dist/commands/tdd.js +104 -98
  21. package/dist/commands/upload.js +78 -34
  22. package/dist/commands/whoami.js +44 -42
  23. package/dist/config/core.js +438 -0
  24. package/dist/config/index.js +13 -0
  25. package/dist/config/operations.js +327 -0
  26. package/dist/index.js +1 -1
  27. package/dist/project/core.js +295 -0
  28. package/dist/project/index.js +13 -0
  29. package/dist/project/operations.js +393 -0
  30. package/dist/reporter/reporter-bundle.css +1 -1
  31. package/dist/reporter/reporter-bundle.iife.js +16 -16
  32. package/dist/screenshot-server/core.js +157 -0
  33. package/dist/screenshot-server/index.js +11 -0
  34. package/dist/screenshot-server/operations.js +183 -0
  35. package/dist/sdk/index.js +3 -2
  36. package/dist/server/handlers/api-handler.js +14 -5
  37. package/dist/server/handlers/tdd-handler.js +191 -53
  38. package/dist/server/http-server.js +9 -3
  39. package/dist/server/routers/baseline.js +58 -0
  40. package/dist/server/routers/dashboard.js +10 -6
  41. package/dist/server/routers/screenshot.js +32 -0
  42. package/dist/server-manager/core.js +186 -0
  43. package/dist/server-manager/index.js +81 -0
  44. package/dist/server-manager/operations.js +209 -0
  45. package/dist/services/build-manager.js +2 -69
  46. package/dist/services/index.js +21 -48
  47. package/dist/services/screenshot-server.js +40 -74
  48. package/dist/services/server-manager.js +45 -80
  49. package/dist/services/test-runner.js +90 -250
  50. package/dist/services/uploader.js +56 -358
  51. package/dist/tdd/core/hotspot-coverage.js +112 -0
  52. package/dist/tdd/core/signature.js +101 -0
  53. package/dist/tdd/index.js +19 -0
  54. package/dist/tdd/metadata/baseline-metadata.js +103 -0
  55. package/dist/tdd/metadata/hotspot-metadata.js +93 -0
  56. package/dist/tdd/services/baseline-downloader.js +151 -0
  57. package/dist/tdd/services/baseline-manager.js +166 -0
  58. package/dist/tdd/services/comparison-service.js +230 -0
  59. package/dist/tdd/services/hotspot-service.js +71 -0
  60. package/dist/tdd/services/result-service.js +123 -0
  61. package/dist/tdd/tdd-service.js +1145 -0
  62. package/dist/test-runner/core.js +255 -0
  63. package/dist/test-runner/index.js +13 -0
  64. package/dist/test-runner/operations.js +483 -0
  65. package/dist/types/client.d.ts +25 -2
  66. package/dist/uploader/core.js +396 -0
  67. package/dist/uploader/index.js +11 -0
  68. package/dist/uploader/operations.js +412 -0
  69. package/dist/utils/colors.js +187 -39
  70. package/dist/utils/config-loader.js +3 -6
  71. package/dist/utils/context.js +228 -0
  72. package/dist/utils/output.js +449 -14
  73. package/docs/api-reference.md +173 -8
  74. package/docs/tui-elements.md +560 -0
  75. package/package.json +13 -13
  76. package/dist/services/api-service.js +0 -412
  77. package/dist/services/auth-service.js +0 -226
  78. package/dist/services/config-service.js +0 -369
  79. package/dist/services/html-report-generator.js +0 -455
  80. package/dist/services/project-service.js +0 -326
  81. package/dist/services/report-generator/report.css +0 -411
  82. package/dist/services/report-generator/viewer.js +0 -102
  83. package/dist/services/static-report-generator.js +0 -207
  84. package/dist/services/tdd-service.js +0 -1437
@@ -20,8 +20,14 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
20
20
  // Check if server already running
21
21
  if (await isServerRunning(options.port || 47392)) {
22
22
  const port = options.port || 47392;
23
- output.info(`TDD server already running at http://localhost:${port}`);
24
- output.info(`Dashboard: http://localhost:${port}/dashboard`);
23
+ let colors = output.getColors();
24
+ output.header('tdd', 'local');
25
+ output.print(` ${output.statusDot('success')} Already running`);
26
+ output.blank();
27
+ output.printBox(colors.brand.info(colors.underline(`http://localhost:${port}`)), {
28
+ title: 'Dashboard',
29
+ style: 'branded'
30
+ });
25
31
  if (options.open) {
26
32
  openDashboard(port);
27
33
  }
@@ -37,6 +43,9 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
37
43
  }
38
44
  const port = options.port || 47392;
39
45
 
46
+ // Show header first so debug messages appear below it
47
+ output.header('tdd', 'local');
48
+
40
49
  // Show loading indicator if downloading baselines (but not in verbose mode since child shows progress)
41
50
  if (options.baselineBuild && !globalOptions.verbose) {
42
51
  output.startSpinner(`Downloading baselines from build ${options.baselineBuild}...`);
@@ -109,7 +118,6 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
109
118
  output.error('Failed to start TDD server - server not responding to health checks');
110
119
  process.exit(1);
111
120
  }
112
- output.success(`TDD server started at http://localhost:${port}`);
113
121
 
114
122
  // Write server info to global location for SDK discovery (iOS/Swift can read this)
115
123
  try {
@@ -129,22 +137,29 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
129
137
  } catch {
130
138
  // Non-fatal, SDK can still use health check
131
139
  }
140
+
141
+ // Get colors for styled output
142
+ let colors = output.getColors();
143
+
144
+ // Show dashboard URL in a branded box
145
+ let dashboardUrl = `http://localhost:${port}`;
146
+ output.printBox(colors.brand.info(colors.underline(dashboardUrl)), {
147
+ title: 'Dashboard',
148
+ style: 'branded'
149
+ });
150
+
151
+ // Verbose mode: show next steps
152
+ if (globalOptions.verbose) {
153
+ output.blank();
154
+ output.print(` ${colors.brand.textTertiary('Next steps')}`);
155
+ output.print(` ${colors.brand.textMuted('1.')} Run tests in watch mode ${colors.brand.textMuted('(npm test -- --watch)')}`);
156
+ output.print(` ${colors.brand.textMuted('2.')} Review visual changes in the dashboard`);
157
+ output.print(` ${colors.brand.textMuted('3.')} Accept or reject baseline updates`);
158
+ }
159
+
160
+ // Always show stop hint
132
161
  output.blank();
133
- output.info('Dashboard:');
134
- output.info(` http://localhost:${port}/`);
135
- output.blank();
136
- output.info('Available views:');
137
- output.info(` Comparisons: http://localhost:${port}/`);
138
- output.info(` Stats: http://localhost:${port}/stats`);
139
- output.info(` Settings: http://localhost:${port}/settings`);
140
- output.info(` Projects: http://localhost:${port}/projects`);
141
- output.blank();
142
- output.info('Next steps:');
143
- output.info(' 1. Run your tests in watch mode (e.g., npm test -- --watch)');
144
- output.info(' 2. View live visual comparisons in the dashboard');
145
- output.info(' 3. Accept/reject baselines directly in the UI');
146
- output.blank();
147
- output.info('Stop server: npx vizzly dev stop');
162
+ output.hint('Stop with: vizzly tdd stop');
148
163
  if (options.open) {
149
164
  openDashboard(port);
150
165
  }
@@ -289,9 +304,11 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
289
304
  return;
290
305
  }
291
306
  try {
307
+ let _colors = output.getColors();
308
+
292
309
  // Try to kill the process gracefully
293
310
  process.kill(pid, 'SIGTERM');
294
- output.info(`Stopping TDD server (PID: ${pid})...`);
311
+ output.startSpinner('Stopping TDD server...');
295
312
 
296
313
  // Give it a moment to shut down gracefully
297
314
  await new Promise(resolve => setTimeout(resolve, 2000));
@@ -301,15 +318,16 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
301
318
  process.kill(pid, 0); // Just check if process exists
302
319
  // If we get here, process is still running, force kill it
303
320
  process.kill(pid, 'SIGKILL');
304
- output.info('Force killed TDD server');
321
+ output.stopSpinner();
322
+ output.debug('tdd', 'Force killed process');
305
323
  } catch {
306
324
  // Process is gone, which is what we want
325
+ output.stopSpinner();
307
326
  }
308
327
 
309
328
  // Clean up files
310
329
  if (existsSync(pidFile)) unlinkSync(pidFile);
311
330
  if (existsSync(serverFile)) unlinkSync(serverFile);
312
- output.success('TDD server stopped');
313
331
  } catch (error) {
314
332
  if (error.code === 'ESRCH') {
315
333
  // Process not found - clean up stale files
@@ -356,25 +374,36 @@ export async function tddStatusCommand(_options, globalOptions = {}) {
356
374
  // Try to check health endpoint
357
375
  const health = await checkServerHealth(serverInfo.port);
358
376
  if (health.running) {
359
- output.success(`TDD server running (PID: ${pid})`);
360
- output.info(`Dashboard: http://localhost:${serverInfo.port}/`);
361
- output.blank();
362
- output.info('Available views:');
363
- output.info(` Comparisons: http://localhost:${serverInfo.port}/`);
364
- output.info(` Stats: http://localhost:${serverInfo.port}/stats`);
365
- output.info(` Settings: http://localhost:${serverInfo.port}/settings`);
366
- output.info(` Projects: http://localhost:${serverInfo.port}/projects`);
377
+ let colors = output.getColors();
378
+
379
+ // Show header
380
+ output.header('tdd', 'local');
381
+
382
+ // Show running status with uptime
383
+ let uptimeStr = '';
367
384
  if (serverInfo.startTime) {
368
385
  const uptime = Math.floor((Date.now() - serverInfo.startTime) / 1000);
369
386
  const hours = Math.floor(uptime / 3600);
370
387
  const minutes = Math.floor(uptime % 3600 / 60);
371
388
  const seconds = uptime % 60;
372
- let uptimeStr = '';
373
389
  if (hours > 0) uptimeStr += `${hours}h `;
374
390
  if (minutes > 0 || hours > 0) uptimeStr += `${minutes}m `;
375
391
  uptimeStr += `${seconds}s`;
392
+ }
393
+ output.print(` ${output.statusDot('success')} Running ${uptimeStr ? colors.brand.textTertiary(`· ${uptimeStr}`) : ''}`);
394
+ output.blank();
395
+
396
+ // Show dashboard URL in a branded box
397
+ let dashboardUrl = `http://localhost:${serverInfo.port}`;
398
+ output.printBox(colors.brand.info(colors.underline(dashboardUrl)), {
399
+ title: 'Dashboard',
400
+ style: 'branded'
401
+ });
402
+
403
+ // Verbose mode: show PID
404
+ if (globalOptions.verbose) {
376
405
  output.blank();
377
- output.info(`Uptime: ${uptimeStr}`);
406
+ output.print(` ${colors.brand.textTertiary('PID:')} ${pid}`);
378
407
  }
379
408
  } else {
380
409
  output.warn('TDD server process exists but not responding to health checks');
@@ -430,7 +459,7 @@ async function checkServerHealth(port = 47392) {
430
459
  * @private
431
460
  */
432
461
  function openDashboard(port = 47392) {
433
- const url = `http://localhost:${port}/dashboard`;
462
+ const url = `http://localhost:${port}`;
434
463
 
435
464
  // Cross-platform open command
436
465
  let openCmd;
@@ -1,43 +1,73 @@
1
- import { createServices } from '../services/index.js';
2
- import { loadConfig } from '../utils/config-loader.js';
3
- import { detectBranch, detectCommit } from '../utils/git.js';
4
- import * as output from '../utils/output.js';
1
+ /**
2
+ * TDD command implementation
3
+ * Uses functional operations directly - no class wrappers needed
4
+ */
5
+
6
+ import { spawn as defaultSpawn } from 'node:child_process';
7
+ import { createBuild as defaultCreateApiBuild, createApiClient as defaultCreateApiClient, finalizeBuild as defaultFinalizeApiBuild, getBuild as defaultGetBuild } from '../api/index.js';
8
+ import { VizzlyError } from '../errors/vizzly-error.js';
9
+ import { createServerManager as defaultCreateServerManager } from '../server-manager/index.js';
10
+ import { createBuildObject as defaultCreateBuildObject } from '../services/build-manager.js';
11
+ import { initializeDaemon as defaultInitializeDaemon, runTests as defaultRunTests } from '../test-runner/index.js';
12
+ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
13
+ import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit } from '../utils/git.js';
14
+ import * as defaultOutput from '../utils/output.js';
5
15
 
6
16
  /**
7
17
  * TDD command implementation
8
18
  * @param {string} testCommand - Test command to execute
9
19
  * @param {Object} options - Command options
10
20
  * @param {Object} globalOptions - Global CLI options
21
+ * @param {Object} deps - Dependencies for testing
11
22
  * @returns {Promise<{result: Object, cleanup: Function}>} Result and cleanup function
12
23
  */
13
- export async function tddCommand(testCommand, options = {}, globalOptions = {}) {
24
+ export async function tddCommand(testCommand, options = {}, globalOptions = {}, deps = {}) {
25
+ let {
26
+ loadConfig = defaultLoadConfig,
27
+ createApiClient = defaultCreateApiClient,
28
+ createApiBuild = defaultCreateApiBuild,
29
+ finalizeApiBuild = defaultFinalizeApiBuild,
30
+ getBuild = defaultGetBuild,
31
+ createServerManager = defaultCreateServerManager,
32
+ createBuildObject = defaultCreateBuildObject,
33
+ initializeDaemon = defaultInitializeDaemon,
34
+ runTests = defaultRunTests,
35
+ detectBranch = defaultDetectBranch,
36
+ detectCommit = defaultDetectCommit,
37
+ spawn = defaultSpawn,
38
+ output = defaultOutput
39
+ } = deps;
14
40
  output.configure({
15
41
  json: globalOptions.json,
16
42
  verbose: globalOptions.verbose,
17
43
  color: !globalOptions.noColor
18
44
  });
19
- let testRunner = null;
45
+ let serverManager = null;
46
+ let testProcess = null;
20
47
  let isCleanedUp = false;
21
48
 
22
49
  // Create cleanup function that can be called by the caller
23
- const cleanup = async () => {
50
+ let cleanup = async () => {
24
51
  if (isCleanedUp) return;
25
52
  isCleanedUp = true;
26
53
  output.cleanup();
27
- if (testRunner?.cancel) {
28
- await testRunner.cancel();
54
+ if (testProcess && !testProcess.killed) {
55
+ testProcess.kill('SIGKILL');
56
+ }
57
+ if (serverManager) {
58
+ await serverManager.stop();
29
59
  }
30
60
  };
31
61
  try {
32
62
  // Load configuration with CLI overrides
33
- const allOptions = {
63
+ let allOptions = {
34
64
  ...globalOptions,
35
65
  ...options
36
66
  };
37
- const config = await loadConfig(globalOptions.config, allOptions);
67
+ let config = await loadConfig(globalOptions.config, allOptions);
38
68
 
39
69
  // Dev mode works locally by default - only needs token for baseline download
40
- const needsToken = options.baselineBuild || options.baselineComparison;
70
+ let needsToken = options.baselineBuild || options.baselineComparison;
41
71
  if (!config.apiKey && needsToken) {
42
72
  throw new Error('API token required when using --baseline-build or --baseline-comparison flags');
43
73
  }
@@ -46,97 +76,65 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {})
46
76
  config.allowNoToken = true;
47
77
 
48
78
  // Collect git metadata
49
- const branch = await detectBranch(options.branch);
50
- const commit = await detectCommit(options.commit);
79
+ let branch = await detectBranch(options.branch);
80
+ let commit = await detectCommit(options.commit);
51
81
 
52
82
  // Show header (skip in daemon mode)
53
83
  if (!options.daemon) {
54
- const mode = config.apiKey ? 'local' : 'local';
84
+ let mode = config.apiKey ? 'local' : 'local';
55
85
  output.header('tdd', mode);
56
86
 
57
87
  // Show config in verbose mode
58
- output.debug('config', 'loaded', {
59
- port: config.server.port,
60
- branch,
61
- threshold: config.comparison.threshold
62
- });
88
+ output.debug('config', `port=${config.server.port} threshold=${config.comparison.threshold}`);
63
89
  }
64
90
 
65
- // Create services
91
+ // Create functional dependencies
66
92
  output.startSpinner('Initializing TDD server...');
67
- const configWithVerbose = {
93
+ let configWithVerbose = {
68
94
  ...config,
69
95
  verbose: globalOptions.verbose
70
96
  };
71
- const services = createServices(configWithVerbose, 'tdd');
72
- testRunner = services.testRunner;
73
- output.stopSpinner();
74
97
 
75
- // Set up event handlers for user feedback
76
- testRunner.on('progress', progressData => {
77
- const {
78
- message: progressMessage
79
- } = progressData;
80
- output.progress(progressMessage || 'Running tests...');
81
- });
82
- testRunner.on('test-output', data => {
83
- // In non-JSON mode, show test output directly
84
- if (!globalOptions.json) {
85
- output.stopSpinner();
86
- output.print(data.data);
87
- }
88
- });
89
- testRunner.on('server-ready', serverInfo => {
90
- // Only show in non-daemon mode (daemon shows its own startup message)
91
- if (!options.daemon) {
92
- output.debug('server', `listening on :${serverInfo.port}`);
93
- }
94
- });
95
- testRunner.on('screenshot-captured', screenshotInfo => {
96
- output.debug('capture', screenshotInfo.name);
97
- });
98
- testRunner.on('comparison-result', comparisonInfo => {
99
- const {
100
- name,
101
- status,
102
- pixelDifference
103
- } = comparisonInfo;
104
- if (status === 'passed') {
105
- output.debug('compare', `${name} passed`);
106
- } else if (status === 'failed') {
107
- output.warn(`${name}: ${pixelDifference}% difference`);
108
- } else if (status === 'new') {
109
- output.debug('compare', `${name} (new baseline)`);
98
+ // Create server manager (functional object)
99
+ serverManager = createServerManager(configWithVerbose, {});
100
+
101
+ // Create build manager (functional object that provides the interface runTests expects)
102
+ let buildManager = {
103
+ async createBuild(buildOptions) {
104
+ return createBuildObject(buildOptions);
110
105
  }
111
- });
112
- testRunner.on('error', error => {
113
- output.error('Test runner error', error);
114
- });
115
- const runOptions = {
106
+ };
107
+ output.stopSpinner();
108
+ let runOptions = {
116
109
  testCommand,
117
110
  port: config.server.port,
118
111
  timeout: config.server.timeout,
119
112
  tdd: true,
120
113
  daemon: options.daemon || false,
121
- // Daemon mode flag
122
114
  setBaseline: options.setBaseline || false,
123
- // Pass through baseline update mode
124
115
  branch,
125
116
  commit,
126
117
  environment: config.build.environment,
127
118
  threshold: config.comparison.threshold,
128
119
  allowNoToken: config.allowNoToken || false,
129
- // Pass through the allow-no-token setting
130
120
  baselineBuildId: config.baselineBuildId,
131
121
  baselineComparisonId: config.baselineComparisonId,
132
- wait: false // No build to wait for in dev mode
122
+ wait: false
133
123
  };
134
124
 
135
125
  // In daemon mode, just start the server without running tests
136
126
  if (options.daemon) {
137
- await testRunner.initialize(runOptions);
138
-
139
- // Return immediately so daemon can set up its lifecycle
127
+ await initializeDaemon({
128
+ initOptions: runOptions,
129
+ deps: {
130
+ serverManager,
131
+ createError: (msg, code) => new VizzlyError(msg, code),
132
+ output,
133
+ onServerReady: data => {
134
+ output.debug('server', `ready on :${data.port}`);
135
+ }
136
+ }
137
+ });
140
138
  return {
141
139
  result: {
142
140
  success: true,
@@ -149,30 +147,38 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {})
149
147
 
150
148
  // Normal dev mode - run tests
151
149
  output.debug('run', testCommand);
152
- const runResult = await testRunner.run(runOptions);
153
-
154
- // Show summary
155
- const {
156
- screenshotsCaptured,
157
- comparisons
158
- } = runResult;
159
-
160
- // Determine success based on comparison results
161
- const hasFailures = runResult.failed || runResult.comparisons?.some(c => c.status === 'failed');
162
- if (comparisons && comparisons.length > 0) {
163
- const passed = comparisons.filter(c => c.status === 'passed').length;
164
- const failed = comparisons.filter(c => c.status === 'failed').length;
165
- if (hasFailures) {
166
- output.error(`${failed} visual difference${failed !== 1 ? 's' : ''} detected`);
167
- output.info(`Check .vizzly/diffs/ for diff images`);
168
- } else {
169
- output.result(`${screenshotsCaptured} screenshot${screenshotsCaptured !== 1 ? 's' : ''} · ${passed} passed`);
150
+ let runResult = await runTests({
151
+ runOptions,
152
+ config: configWithVerbose,
153
+ deps: {
154
+ serverManager,
155
+ buildManager,
156
+ spawn: (command, spawnOptions) => {
157
+ let proc = spawn(command, spawnOptions);
158
+ testProcess = proc;
159
+ return proc;
160
+ },
161
+ createApiClient,
162
+ createApiBuild,
163
+ getBuild,
164
+ finalizeApiBuild,
165
+ createError: (msg, code) => new VizzlyError(msg, code),
166
+ output,
167
+ onBuildCreated: data => {
168
+ output.debug('build', `created ${data.buildId?.substring(0, 8)}`);
169
+ },
170
+ onServerReady: data => {
171
+ output.debug('server', `ready on :${data.port}`);
172
+ },
173
+ onFinalizeFailed: data => {
174
+ output.warn(`Failed to finalize build: ${data.error}`);
175
+ }
170
176
  }
171
- } else {
172
- output.result(`${screenshotsCaptured} screenshot${screenshotsCaptured !== 1 ? 's' : ''}`);
173
- }
177
+ });
174
178
 
175
- // Return result and cleanup function
179
+ // Determine success based on comparison results
180
+ // (Summary is printed by printResults() in tdd-service.js, called from getTddResults)
181
+ let hasFailures = runResult.failed || runResult.comparisons?.some(c => c.status === 'failed');
176
182
  return {
177
183
  result: {
178
184
  success: !hasFailures,
@@ -200,24 +206,24 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {})
200
206
  * @param {Object} options - Command options
201
207
  */
202
208
  export function validateTddOptions(testCommand, options) {
203
- const errors = [];
209
+ let errors = [];
204
210
  if (!testCommand || testCommand.trim() === '') {
205
211
  errors.push('Test command is required');
206
212
  }
207
213
  if (options.port) {
208
- const port = parseInt(options.port, 10);
214
+ let port = parseInt(options.port, 10);
209
215
  if (Number.isNaN(port) || port < 1 || port > 65535) {
210
216
  errors.push('Port must be a valid number between 1 and 65535');
211
217
  }
212
218
  }
213
219
  if (options.timeout) {
214
- const timeout = parseInt(options.timeout, 10);
220
+ let timeout = parseInt(options.timeout, 10);
215
221
  if (Number.isNaN(timeout) || timeout < 1000) {
216
222
  errors.push('Timeout must be at least 1000 milliseconds');
217
223
  }
218
224
  }
219
225
  if (options.threshold !== undefined) {
220
- const threshold = parseFloat(options.threshold);
226
+ let threshold = parseFloat(options.threshold);
221
227
  if (Number.isNaN(threshold) || threshold < 0) {
222
228
  errors.push('Threshold must be a non-negative number (CIEDE2000 Delta E)');
223
229
  }
@@ -1,25 +1,31 @@
1
- import { ApiService } from '../services/api-service.js';
2
- import { createServices } from '../services/index.js';
3
- import { loadConfig } from '../utils/config-loader.js';
4
- import { detectBranch, detectCommit, detectCommitMessage, detectPullRequestNumber, generateBuildNameWithGit } from '../utils/git.js';
5
- import * as output from '../utils/output.js';
1
+ import { createApiClient as defaultCreateApiClient, finalizeBuild as defaultFinalizeBuild, getTokenContext as defaultGetTokenContext } from '../api/index.js';
2
+ import { createUploader as defaultCreateUploader } from '../services/uploader.js';
3
+ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
4
+ import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
5
+ import * as defaultOutput from '../utils/output.js';
6
6
 
7
7
  /**
8
8
  * Construct proper build URL with org/project context
9
9
  * @param {string} buildId - Build ID
10
10
  * @param {string} apiUrl - API base URL
11
11
  * @param {string} apiToken - API token
12
+ * @param {Object} deps - Dependencies
12
13
  * @returns {Promise<string>} Proper build URL
13
14
  */
14
- async function constructBuildUrl(buildId, apiUrl, apiToken) {
15
+ export async function constructBuildUrl(buildId, apiUrl, apiToken, deps = {}) {
16
+ let {
17
+ createApiClient = defaultCreateApiClient,
18
+ getTokenContext = defaultGetTokenContext,
19
+ output = defaultOutput
20
+ } = deps;
15
21
  try {
16
- const apiService = new ApiService({
22
+ let client = createApiClient({
17
23
  baseUrl: apiUrl,
18
24
  token: apiToken,
19
25
  command: 'upload'
20
26
  });
21
- const tokenContext = await apiService.getTokenContext();
22
- const baseUrl = apiUrl.replace(/\/api.*$/, '');
27
+ let tokenContext = await getTokenContext(client);
28
+ let baseUrl = apiUrl.replace(/\/api.*$/, '');
23
29
  if (tokenContext.organization?.slug && tokenContext.project?.slug) {
24
30
  return `${baseUrl}/${tokenContext.organization.slug}/${tokenContext.project.slug}/builds/${buildId}`;
25
31
  }
@@ -31,7 +37,7 @@ async function constructBuildUrl(buildId, apiUrl, apiToken) {
31
37
  }
32
38
 
33
39
  // Fallback URL construction
34
- const baseUrl = apiUrl.replace(/\/api.*$/, '');
40
+ let baseUrl = apiUrl.replace(/\/api.*$/, '');
35
41
  return `${baseUrl}/builds/${buildId}`;
36
42
  }
37
43
 
@@ -40,8 +46,23 @@ async function constructBuildUrl(buildId, apiUrl, apiToken) {
40
46
  * @param {string} screenshotsPath - Path to screenshots
41
47
  * @param {Object} options - Command options
42
48
  * @param {Object} globalOptions - Global CLI options
49
+ * @param {Object} deps - Dependencies for testing
43
50
  */
44
- export async function uploadCommand(screenshotsPath, options = {}, globalOptions = {}) {
51
+ export async function uploadCommand(screenshotsPath, options = {}, globalOptions = {}, deps = {}) {
52
+ let {
53
+ loadConfig = defaultLoadConfig,
54
+ createApiClient = defaultCreateApiClient,
55
+ finalizeBuild = defaultFinalizeBuild,
56
+ createUploader = defaultCreateUploader,
57
+ detectBranch = defaultDetectBranch,
58
+ detectCommit = defaultDetectCommit,
59
+ detectCommitMessage = defaultDetectCommitMessage,
60
+ detectPullRequestNumber = defaultDetectPullRequestNumber,
61
+ generateBuildNameWithGit = defaultGenerateBuildNameWithGit,
62
+ output = defaultOutput,
63
+ exit = code => process.exit(code),
64
+ buildUrlConstructor = constructBuildUrl
65
+ } = deps;
45
66
  output.configure({
46
67
  json: globalOptions.json,
47
68
  verbose: globalOptions.verbose,
@@ -63,7 +84,11 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
63
84
  // Validate API token
64
85
  if (!config.apiKey) {
65
86
  output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
66
- process.exit(1);
87
+ exit(1);
88
+ return {
89
+ success: false,
90
+ reason: 'no-api-key'
91
+ };
67
92
  }
68
93
 
69
94
  // Collect git metadata if not provided
@@ -83,10 +108,12 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
83
108
  });
84
109
  }
85
110
 
86
- // Get uploader service
111
+ // Create uploader
87
112
  output.startSpinner('Initializing uploader...');
88
- const services = createServices(config, 'upload');
89
- const uploader = services.uploader;
113
+ let uploader = createUploader({
114
+ ...config,
115
+ command: 'upload'
116
+ });
90
117
 
91
118
  // Prepare upload options with progress callback
92
119
  const uploadOptions = {
@@ -135,64 +162,81 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
135
162
  if (result.buildId) {
136
163
  output.progress('Finalizing build...');
137
164
  try {
138
- const apiService = new ApiService({
165
+ let client = createApiClient({
139
166
  baseUrl: config.apiUrl,
140
167
  token: config.apiKey,
141
168
  command: 'upload'
142
169
  });
143
- const executionTime = Date.now() - uploadStartTime;
144
- await apiService.finalizeBuild(result.buildId, true, executionTime);
170
+ let executionTime = Date.now() - uploadStartTime;
171
+ await finalizeBuild(client, result.buildId, true, executionTime);
145
172
  } catch (error) {
146
173
  output.warn(`Failed to finalize build: ${error.message}`);
147
174
  }
148
175
  }
149
- output.success('Upload completed successfully');
176
+ output.complete('Upload completed');
150
177
 
151
178
  // Show Vizzly summary
152
179
  if (result.buildId) {
153
- output.info(`🐻 Vizzly: Uploaded ${result.stats.uploaded} of ${result.stats.total} screenshots to build ${result.buildId}`);
180
+ output.blank();
181
+ output.keyValue({
182
+ Uploaded: `${result.stats.uploaded} of ${result.stats.total}`,
183
+ Build: result.buildId
184
+ });
154
185
  // Use API-provided URL or construct proper URL with org/project context
155
- const buildUrl = result.url || (await constructBuildUrl(result.buildId, config.apiUrl, config.apiKey));
156
- output.info(`🔗 Vizzly: View results at ${buildUrl}`);
186
+ let buildUrl = result.url || (await buildUrlConstructor(result.buildId, config.apiUrl, config.apiKey, deps));
187
+ output.blank();
188
+ output.labelValue('View', output.link('Results', buildUrl));
157
189
  }
158
190
 
159
191
  // Wait for build completion if requested
160
192
  if (options.wait && result.buildId) {
161
- output.info('Waiting for build completion...');
162
193
  output.startSpinner('Processing comparisons...');
163
- const buildResult = await uploader.waitForBuild(result.buildId);
164
- output.success('Build processing completed');
194
+ let buildResult = await uploader.waitForBuild(result.buildId);
195
+ output.stopSpinner();
196
+ output.complete('Build processing completed');
165
197
 
166
198
  // Show build processing results
199
+ let colors = output.getColors();
167
200
  if (buildResult.failedComparisons > 0) {
168
- output.warn(`${buildResult.failedComparisons} visual comparisons failed`);
201
+ output.blank();
202
+ output.print(` ${colors.brand.danger(buildResult.failedComparisons)} visual comparisons failed`);
169
203
  } else {
170
- output.success(`All ${buildResult.passedComparisons} visual comparisons passed`);
204
+ output.blank();
205
+ output.print(` ${colors.brand.success(buildResult.passedComparisons)} visual comparisons passed`);
171
206
  }
172
207
  // Use API-provided URL or construct proper URL with org/project context
173
- const buildUrl = buildResult.url || (await constructBuildUrl(result.buildId, config.apiUrl, config.apiKey));
174
- output.info(`🔗 Vizzly: View results at ${buildUrl}`);
208
+ let waitBuildUrl = buildResult.url || (await buildUrlConstructor(result.buildId, config.apiUrl, config.apiKey, deps));
209
+ output.blank();
210
+ output.labelValue('View', output.link('Results', waitBuildUrl));
175
211
  }
176
212
  output.cleanup();
213
+ return {
214
+ success: true,
215
+ result
216
+ };
177
217
  } catch (error) {
178
218
  // Mark build as failed if we have a buildId and config
179
219
  if (buildId && config) {
180
220
  try {
181
- const apiService = new ApiService({
221
+ let client = createApiClient({
182
222
  baseUrl: config.apiUrl,
183
223
  token: config.apiKey,
184
224
  command: 'upload'
185
225
  });
186
- const executionTime = Date.now() - uploadStartTime;
187
- await apiService.finalizeBuild(buildId, false, executionTime);
226
+ let executionTime = Date.now() - uploadStartTime;
227
+ await finalizeBuild(client, buildId, false, executionTime);
188
228
  } catch {
189
229
  // Silent fail on cleanup
190
230
  }
191
231
  }
192
232
  // Use user-friendly error message if available
193
- const errorMessage = error?.getUserMessage ? error.getUserMessage() : error.message;
233
+ let errorMessage = error?.getUserMessage ? error.getUserMessage() : error.message;
194
234
  output.error(errorMessage || 'Upload failed', error);
195
- process.exit(1);
235
+ exit(1);
236
+ return {
237
+ success: false,
238
+ error
239
+ };
196
240
  }
197
241
  }
198
242