@vizzly-testing/cli 0.8.0 → 0.9.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 (70) hide show
  1. package/README.md +26 -13
  2. package/dist/cli.js +31 -1
  3. package/dist/client/index.js +77 -11
  4. package/dist/commands/init.js +23 -17
  5. package/dist/commands/tdd-daemon.js +362 -0
  6. package/dist/commands/tdd.js +45 -14
  7. package/dist/reporter/reporter-bundle.css +1 -0
  8. package/dist/reporter/reporter-bundle.iife.js +57 -0
  9. package/dist/server/handlers/api-handler.js +98 -30
  10. package/dist/server/handlers/tdd-handler.js +264 -77
  11. package/dist/server/http-server.js +358 -15
  12. package/dist/services/html-report-generator.js +77 -0
  13. package/dist/services/report-generator/report.css +56 -0
  14. package/dist/services/screenshot-server.js +6 -3
  15. package/dist/services/server-manager.js +2 -9
  16. package/dist/services/tdd-service.js +188 -25
  17. package/dist/services/test-runner.js +43 -1
  18. package/dist/types/commands/tdd-daemon.d.ts +24 -0
  19. package/dist/types/container/index.d.ts +1 -3
  20. package/dist/types/reporter/src/components/app-router.d.ts +3 -0
  21. package/dist/types/reporter/src/components/comparison/comparison-actions.d.ts +5 -0
  22. package/dist/types/reporter/src/components/comparison/comparison-card.d.ts +6 -0
  23. package/dist/types/reporter/src/components/comparison/comparison-list.d.ts +6 -0
  24. package/dist/types/reporter/src/components/comparison/comparison-viewer.d.ts +4 -0
  25. package/dist/types/reporter/src/components/comparison/view-mode-selector.d.ts +4 -0
  26. package/dist/types/reporter/src/components/comparison/viewer-modes/onion-viewer.d.ts +3 -0
  27. package/dist/types/reporter/src/components/comparison/viewer-modes/overlay-viewer.d.ts +3 -0
  28. package/dist/types/reporter/src/components/comparison/viewer-modes/side-by-side-viewer.d.ts +3 -0
  29. package/dist/types/reporter/src/components/comparison/viewer-modes/toggle-viewer.d.ts +3 -0
  30. package/dist/types/reporter/src/components/dashboard/dashboard-filters.d.ts +16 -0
  31. package/dist/types/reporter/src/components/dashboard/dashboard-header.d.ts +5 -0
  32. package/dist/types/reporter/src/components/dashboard/dashboard-stats.d.ts +4 -0
  33. package/dist/types/reporter/src/components/dashboard/empty-state.d.ts +8 -0
  34. package/dist/types/reporter/src/components/ui/smart-image.d.ts +7 -0
  35. package/dist/types/reporter/src/components/ui/status-badge.d.ts +5 -0
  36. package/dist/types/reporter/src/components/ui/toast.d.ts +4 -0
  37. package/dist/types/reporter/src/components/views/comparisons-view.d.ts +6 -0
  38. package/dist/types/reporter/src/components/views/stats-view.d.ts +6 -0
  39. package/dist/types/reporter/src/hooks/use-baseline-actions.d.ts +5 -0
  40. package/dist/types/reporter/src/hooks/use-comparison-filters.d.ts +20 -0
  41. package/dist/types/reporter/src/hooks/use-image-loader.d.ts +1 -0
  42. package/dist/types/reporter/src/hooks/use-report-data.d.ts +7 -0
  43. package/dist/types/reporter/src/hooks/use-vizzly-api.d.ts +9 -0
  44. package/dist/types/reporter/src/main.d.ts +1 -0
  45. package/dist/types/reporter/src/services/api-client.d.ts +4 -0
  46. package/dist/types/reporter/src/utils/comparison-helpers.d.ts +16 -0
  47. package/dist/types/reporter/src/utils/constants.d.ts +37 -0
  48. package/dist/types/reporter/vite.config.d.ts +2 -0
  49. package/dist/types/reporter/vite.dev.config.d.ts +2 -0
  50. package/dist/types/sdk/index.d.ts +1 -2
  51. package/dist/types/server/handlers/api-handler.d.ts +5 -14
  52. package/dist/types/server/handlers/tdd-handler.d.ts +18 -17
  53. package/dist/types/server/http-server.d.ts +2 -1
  54. package/dist/types/services/base-service.d.ts +1 -2
  55. package/dist/types/services/html-report-generator.d.ts +3 -3
  56. package/dist/types/services/screenshot-server.d.ts +1 -1
  57. package/dist/types/services/server-manager.d.ts +25 -35
  58. package/dist/types/services/tdd-service.d.ts +7 -1
  59. package/dist/types/services/test-runner.d.ts +6 -1
  60. package/dist/types/utils/build-history.d.ts +16 -0
  61. package/dist/types/utils/config-loader.d.ts +1 -1
  62. package/dist/types/utils/console-ui.d.ts +1 -1
  63. package/dist/types/utils/git.d.ts +4 -4
  64. package/dist/types/utils/security.d.ts +2 -1
  65. package/dist/utils/build-history.js +103 -0
  66. package/dist/utils/security.js +14 -5
  67. package/docs/api-reference.md +1 -3
  68. package/docs/getting-started.md +1 -1
  69. package/docs/tdd-mode.md +178 -112
  70. package/package.json +20 -4
package/README.md CHANGED
@@ -55,7 +55,7 @@ vizzly upload ./screenshots --build-name "Release v1.2.3"
55
55
  vizzly run "npm test"
56
56
 
57
57
  # Use TDD mode for local development
58
- vizzly tdd "npm test"
58
+ vizzly tdd run "npm test"
59
59
  ```
60
60
 
61
61
  ### In your test code
@@ -125,29 +125,42 @@ vizzly run "npm test" --parallel-id "ci-run-123" # For parallel CI builds
125
125
  For local visual testing with immediate feedback, use the dedicated `tdd` command:
126
126
 
127
127
  ```bash
128
- # First run - creates local baselines
129
- vizzly tdd "npm test"
128
+ # Start interactive TDD dashboard (runs in background)
129
+ vizzly tdd start
130
130
 
131
- # Make changes and test - fails if visual differences detected
132
- vizzly tdd "npm test"
131
+ # Run your tests in watch mode (same terminal or new one)
132
+ npm test -- --watch
133
133
 
134
- # Accept changes as new baseline
135
- vizzly tdd "npm test" --set-baseline
134
+ # View the dashboard at http://localhost:47392
136
135
  ```
137
136
 
138
- **Interactive HTML Report:** Each TDD run generates a detailed HTML report with visual comparison tools:
139
- - **Overlay mode** - Toggle between baseline and current screenshots
140
- - **Side-by-side mode** - Compare baseline and current images horizontally
141
- - **Onion skin mode** - Drag to reveal differences interactively
142
- - **Toggle mode** - Click to switch between baseline and current
137
+ **Interactive Dashboard:** The TDD dashboard provides real-time visual feedback:
138
+ - **Live Updates** - See comparisons as tests run
139
+ - **Visual Diff Modes** - Overlay, side-by-side, onion skin, and toggle views
140
+ - **Baseline Management** - Accept/reject changes directly from the UI
141
+ - **Test Statistics** - Real-time pass/fail metrics
142
+ - **Dark Theme** - Easy on the eyes during long sessions
143
+
144
+ **TDD Subcommands:**
145
+
146
+ ```bash
147
+ # Start the TDD dashboard server
148
+ vizzly tdd start [options]
149
+
150
+ # Run tests in single-shot mode
151
+ vizzly tdd run "npm test" [options]
152
+
153
+ # Stop a running TDD server
154
+ vizzly tdd stop
155
+ ```
143
156
 
144
157
  **TDD Command Options:**
145
158
  - `--set-baseline` - Accept current screenshots as new baseline
146
159
  - `--baseline-build <id>` - Use specific build as baseline (requires API token)
147
- - `--baseline-comparison <id>` - Use specific comparison as baseline (requires API token)
148
160
  - `--threshold <number>` - Comparison threshold (0-1, default: 0.1)
149
161
  - `--port <port>` - Server port (default: 47392)
150
162
  - `--timeout <ms>` - Server timeout (default: 30000)
163
+ - `--open` - Auto-open dashboard in browser (start command only)
151
164
 
152
165
  ### Setup and Status Commands
153
166
  ```bash
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { init } from './commands/init.js';
5
5
  import { uploadCommand, validateUploadOptions } from './commands/upload.js';
6
6
  import { runCommand, validateRunOptions } from './commands/run.js';
7
7
  import { tddCommand, validateTddOptions } from './commands/tdd.js';
8
+ import { tddStartCommand, tddStopCommand, tddStatusCommand, runDaemonChild } from './commands/tdd-daemon.js';
8
9
  import { statusCommand, validateStatusOptions } from './commands/status.js';
9
10
  import { finalizeCommand, validateFinalizeOptions } from './commands/finalize.js';
10
11
  import { doctorCommand, validateDoctorOptions } from './commands/doctor.js';
@@ -29,7 +30,36 @@ program.command('upload').description('Upload screenshots to Vizzly').argument('
29
30
  }
30
31
  await uploadCommand(path, options, globalOptions);
31
32
  });
32
- program.command('tdd').description('Run tests in TDD mode with local visual comparisons').argument('<command>', 'Test command to run').option('--port <port>', 'Port for screenshot server', '47392').option('--branch <branch>', 'Git branch override').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--token <token>', 'API token override').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--set-baseline', 'Accept current screenshots as new baseline (overwrites existing)').option('--allow-no-token', 'Allow running without API token (no baselines)').action(async (command, options) => {
33
+
34
+ // TDD command with subcommands
35
+ const tddCmd = program.command('tdd').description('Run tests in TDD mode with local visual comparisons');
36
+
37
+ // TDD Start - Background server
38
+ tddCmd.command('start').description('Start background TDD server').option('--port <port>', 'Port for screenshot server', '47392').option('--open', 'Open dashboard in browser').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--token <token>', 'API token override').option('--daemon-child', 'Internal: run as daemon child process').action(async options => {
39
+ const globalOptions = program.opts();
40
+
41
+ // If this is a daemon child process, run the server directly
42
+ if (options.daemonChild) {
43
+ await runDaemonChild(options, globalOptions);
44
+ return;
45
+ }
46
+ await tddStartCommand(options, globalOptions);
47
+ });
48
+
49
+ // TDD Stop - Kill background server
50
+ tddCmd.command('stop').description('Stop background TDD server').action(async options => {
51
+ const globalOptions = program.opts();
52
+ await tddStopCommand(options, globalOptions);
53
+ });
54
+
55
+ // TDD Status - Check server status
56
+ tddCmd.command('status').description('Check TDD server status').action(async options => {
57
+ const globalOptions = program.opts();
58
+ await tddStatusCommand(options, globalOptions);
59
+ });
60
+
61
+ // TDD Run - One-off test run (primary workflow)
62
+ tddCmd.command('run <command>').description('Run tests once in TDD mode with local visual comparisons').option('--port <port>', 'Port for screenshot server', '47392').option('--branch <branch>', 'Git branch override').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--token <token>', 'API token override').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--set-baseline', 'Accept current screenshots as new baseline (overwrites existing)').action(async (command, options) => {
33
63
  const globalOptions = program.opts();
34
64
 
35
65
  // Validate options
@@ -3,11 +3,14 @@
3
3
  * @description Thin client for test runners - minimal API for taking screenshots
4
4
  */
5
5
 
6
- import { isVizzlyEnabled, getServerUrl, getBuildId, isTddMode, setVizzlyEnabled } from '../utils/environment-config.js';
6
+ import { getServerUrl, getBuildId, isTddMode, setVizzlyEnabled } from '../utils/environment-config.js';
7
+ import { existsSync, readFileSync } from 'fs';
8
+ import { join, parse, dirname } from 'path';
7
9
 
8
10
  // Internal client state
9
11
  let currentClient = null;
10
12
  let isDisabled = false;
13
+ let hasLoggedWarning = false;
11
14
 
12
15
  /**
13
16
  * Check if Vizzly is currently disabled
@@ -15,7 +18,8 @@ let isDisabled = false;
15
18
  * @returns {boolean} True if disabled via environment variable or auto-disabled due to failure
16
19
  */
17
20
  function isVizzlyDisabled() {
18
- return !isVizzlyEnabled() || isDisabled;
21
+ // Don't check isVizzlyEnabled() here - let auto-discovery happen first
22
+ return isDisabled;
19
23
  }
20
24
 
21
25
  /**
@@ -31,6 +35,37 @@ function disableVizzly(reason = 'disabled') {
31
35
  }
32
36
  }
33
37
 
38
+ /**
39
+ * Auto-discover local TDD server by checking for server.json
40
+ * @private
41
+ * @returns {string|null} Server URL if found
42
+ */
43
+ function autoDiscoverTddServer() {
44
+ try {
45
+ // Look for .vizzly/server.json in current directory and parent directories
46
+ let currentDir = process.cwd();
47
+ const root = parse(currentDir).root;
48
+ while (currentDir !== root) {
49
+ const serverJsonPath = join(currentDir, '.vizzly', 'server.json');
50
+ if (existsSync(serverJsonPath)) {
51
+ try {
52
+ const serverInfo = JSON.parse(readFileSync(serverJsonPath, 'utf8'));
53
+ if (serverInfo.port) {
54
+ const url = `http://localhost:${serverInfo.port}`;
55
+ return url;
56
+ }
57
+ } catch {
58
+ // Invalid JSON, continue searching
59
+ }
60
+ }
61
+ currentDir = dirname(currentDir);
62
+ }
63
+ return null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
34
69
  /**
35
70
  * Get the current client instance
36
71
  * @private
@@ -40,9 +75,19 @@ function getClient() {
40
75
  return null;
41
76
  }
42
77
  if (!currentClient) {
43
- // Only try to initialize if VIZZLY_ENABLED is explicitly true
44
- const serverUrl = getServerUrl();
45
- if (serverUrl && isVizzlyEnabled()) {
78
+ let serverUrl = getServerUrl();
79
+
80
+ // Auto-detect local TDD server and enable Vizzly if TDD server is found
81
+ if (!serverUrl) {
82
+ serverUrl = autoDiscoverTddServer();
83
+ if (serverUrl) {
84
+ // Automatically enable Vizzly when TDD server is detected
85
+ setVizzlyEnabled(true);
86
+ }
87
+ }
88
+
89
+ // If we have a server URL, create the client (regardless of initial enabled state)
90
+ if (serverUrl) {
46
91
  currentClient = createSimpleClient(serverUrl);
47
92
  }
48
93
  }
@@ -80,22 +125,39 @@ function createSimpleClient(serverUrl) {
80
125
  };
81
126
  });
82
127
 
83
- // In TDD mode, if we get 422 (visual difference), throw with clean message
128
+ // In TDD mode, if we get 422 (visual difference), log but DON'T throw
129
+ // This allows all screenshots in the test to be captured and compared
84
130
  if (response.status === 422 && errorData.tddMode && errorData.comparison) {
85
131
  const comp = errorData.comparison;
86
- throw new Error(`Visual difference detected in "${name}"\n` + ` Baseline: ${comp.baseline}\n` + ` Current: ${comp.current}\n` + ` Diff: ${comp.diff}`);
132
+ const diffPercent = comp.diffPercentage ? comp.diffPercentage.toFixed(2) : '0.00';
133
+
134
+ // Extract port from serverUrl (e.g., "http://localhost:47392" -> "47392")
135
+ const urlMatch = serverUrl.match(/:(\d+)/);
136
+ const port = urlMatch ? urlMatch[1] : '47392';
137
+ const dashboardUrl = `http://localhost:${port}/dashboard`;
138
+
139
+ // Just log warning - don't throw by default in TDD mode
140
+ // This allows all screenshots to be captured
141
+ console.warn(`⚠️ Visual diff: ${comp.name} (${diffPercent}%) → ${dashboardUrl}`);
142
+
143
+ // Return success so test continues and captures remaining screenshots
144
+ return {
145
+ success: true,
146
+ status: 'failed',
147
+ name: comp.name,
148
+ diffPercentage: comp.diffPercentage
149
+ };
87
150
  }
88
151
  throw new Error(`Screenshot failed: ${response.status} ${response.statusText} - ${errorData.error || 'Unknown error'}`);
89
152
  }
90
153
  return await response.json();
91
154
  } catch (error) {
92
155
  // In TDD mode with visual differences, throw the error to fail the test
93
- if (error.message.includes('Visual difference detected')) {
156
+ if (error.message.toLowerCase().includes('visual diff')) {
94
157
  // Clean output for TDD mode - don't spam with additional logs
95
158
  throw error;
96
159
  }
97
- console.error(`Failed to save screenshot "${name}":`, error.message);
98
- console.error(`Vizzly screenshot failed for ${name}: ${error.message}`);
160
+ console.error(`Vizzly screenshot failed for ${name}:`, error.message);
99
161
  if (error.message.includes('fetch') || error.code === 'ECONNREFUSED') {
100
162
  console.error(`Server URL: ${serverUrl}/screenshot`);
101
163
  console.error('This usually means the Vizzly server is not running or not accessible');
@@ -157,7 +219,11 @@ export async function vizzlyScreenshot(name, imageBuffer, options = {}) {
157
219
  }
158
220
  const client = getClient();
159
221
  if (!client) {
160
- console.warn('Vizzly client not initialized. Screenshots will be skipped.');
222
+ if (!hasLoggedWarning) {
223
+ console.warn('Vizzly client not initialized. Screenshots will be skipped.');
224
+ hasLoggedWarning = true;
225
+ disableVizzly();
226
+ }
161
227
  return;
162
228
  }
163
229
  return client.screenshot(name, imageBuffer, options);
@@ -40,30 +40,36 @@ export class InitCommand {
40
40
  const configContent = `export default {
41
41
  // API configuration
42
42
  // Set VIZZLY_TOKEN environment variable or uncomment and set here:
43
- // apiToken: 'your-token-here',
44
-
45
- // Screenshot configuration
46
- screenshots: {
47
- directory: './screenshots',
48
- formats: ['png']
49
- },
50
-
51
- // Server configuration
43
+ // apiKey: 'your-token-here',
44
+
45
+ // Server configuration (for run command)
52
46
  server: {
53
47
  port: 47392,
48
+ timeout: 30000,
54
49
  screenshotPath: '/screenshot'
55
50
  },
56
-
57
- // Comparison configuration
58
- comparison: {
59
- threshold: 0.1,
60
- ignoreAntialiasing: true
51
+
52
+ // Build configuration
53
+ build: {
54
+ name: 'Build {timestamp}',
55
+ environment: 'test'
61
56
  },
62
-
63
- // Upload configuration
57
+
58
+ // Upload configuration (for upload command)
64
59
  upload: {
65
- concurrency: 5,
60
+ screenshotsDir: './screenshots',
61
+ batchSize: 10,
66
62
  timeout: 30000
63
+ },
64
+
65
+ // Comparison configuration
66
+ comparison: {
67
+ threshold: 0.1
68
+ },
69
+
70
+ // TDD configuration
71
+ tdd: {
72
+ openReport: false // Whether to auto-open HTML report in browser
67
73
  }
68
74
  };
69
75
  `;
@@ -0,0 +1,362 @@
1
+ import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync, openSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { spawn } from 'child_process';
4
+ import { ConsoleUI } from '../utils/console-ui.js';
5
+ import { tddCommand } from './tdd.js';
6
+
7
+ /**
8
+ * Start TDD server in daemon mode
9
+ * @param {Object} options - Command options
10
+ * @param {Object} globalOptions - Global CLI options
11
+ */
12
+ export async function tddStartCommand(options = {}, globalOptions = {}) {
13
+ const ui = new ConsoleUI({
14
+ json: globalOptions.json,
15
+ verbose: globalOptions.verbose,
16
+ color: !globalOptions.noColor
17
+ });
18
+
19
+ // Check if server already running
20
+ if (await isServerRunning(options.port || 47392)) {
21
+ const port = options.port || 47392;
22
+ ui.info(`TDD server already running at http://localhost:${port}`);
23
+ ui.info(`Dashboard: http://localhost:${port}/dashboard`);
24
+ if (options.open) {
25
+ openDashboard(port);
26
+ }
27
+ return;
28
+ }
29
+ try {
30
+ // Ensure .vizzly directory exists
31
+ const vizzlyDir = join(process.cwd(), '.vizzly');
32
+ if (!existsSync(vizzlyDir)) {
33
+ mkdirSync(vizzlyDir, {
34
+ recursive: true
35
+ });
36
+ }
37
+ const port = options.port || 47392;
38
+
39
+ // Prepare log files for daemon output
40
+ const logFile = join(vizzlyDir, 'daemon.log');
41
+ const errorFile = join(vizzlyDir, 'daemon-error.log');
42
+
43
+ // Spawn detached child process to run the server
44
+ const child = spawn(process.execPath, [process.argv[1],
45
+ // CLI entry point
46
+ 'tdd', 'start', '--daemon-child',
47
+ // Special flag for child process
48
+ '--port', port.toString(), ...(options.open ? ['--open'] : []), ...(options.baselineBuild ? ['--baseline-build', options.baselineBuild] : []), ...(options.baselineComparison ? ['--baseline-comparison', options.baselineComparison] : []), ...(options.environment ? ['--environment', options.environment] : []), ...(options.threshold !== undefined ? ['--threshold', options.threshold.toString()] : []), ...(options.timeout ? ['--timeout', options.timeout] : []), ...(options.token ? ['--token', options.token] : []), ...(globalOptions.json ? ['--json'] : []), ...(globalOptions.verbose ? ['--verbose'] : []), ...(globalOptions.noColor ? ['--no-color'] : [])], {
49
+ detached: true,
50
+ stdio: ['ignore', openSync(logFile, 'a'), openSync(errorFile, 'a')],
51
+ cwd: process.cwd()
52
+ });
53
+
54
+ // Unref so parent can exit
55
+ child.unref();
56
+
57
+ // Verify server started with retries
58
+ const maxRetries = 10;
59
+ const retryDelay = 200; // Start with 200ms
60
+ let running = false;
61
+ for (let i = 0; i < maxRetries && !running; i++) {
62
+ await new Promise(resolve => setTimeout(resolve, retryDelay * (i + 1)));
63
+ running = await isServerRunning(port);
64
+ }
65
+ if (!running) {
66
+ ui.error('Failed to start TDD server - server not responding to health checks');
67
+ process.exit(1);
68
+ }
69
+ ui.success(`TDD server started at http://localhost:${port}`);
70
+ ui.info('');
71
+ ui.info('Dashboard URLs:');
72
+ ui.info(` Comparisons: http://localhost:${port}/`);
73
+ ui.info(` Stats: http://localhost:${port}/stats`);
74
+ ui.info('');
75
+ ui.info('Next steps:');
76
+ ui.info(' 1. Run your tests (any test runner)');
77
+ ui.info(' 2. Open the dashboard in your browser');
78
+ ui.info(' 3. Manage baselines in the Stats view');
79
+ ui.info('');
80
+ ui.info('Stop server: npx vizzly tdd stop');
81
+ if (options.open) {
82
+ openDashboard(port);
83
+ }
84
+ } catch (error) {
85
+ ui.error('Failed to start TDD daemon', error);
86
+ process.exit(1);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Internal function to run server in child process
92
+ * This is called when --daemon-child flag is present
93
+ * @private
94
+ */
95
+ export async function runDaemonChild(options = {}, globalOptions = {}) {
96
+ const vizzlyDir = join(process.cwd(), '.vizzly');
97
+ const port = options.port || 47392;
98
+ try {
99
+ // Use existing tddCommand but with daemon mode
100
+ const {
101
+ cleanup
102
+ } = await tddCommand(null,
103
+ // No test command - server only
104
+ {
105
+ ...options,
106
+ daemon: true
107
+ }, globalOptions);
108
+
109
+ // Store our PID for the stop command
110
+ const pidFile = join(vizzlyDir, 'server.pid');
111
+ writeFileSync(pidFile, process.pid.toString());
112
+ const serverInfo = {
113
+ pid: process.pid,
114
+ port: port,
115
+ startTime: Date.now()
116
+ };
117
+ writeFileSync(join(vizzlyDir, 'server.json'), JSON.stringify(serverInfo, null, 2));
118
+
119
+ // Set up graceful shutdown
120
+ const handleShutdown = async () => {
121
+ try {
122
+ // Clean up PID files
123
+ if (existsSync(pidFile)) unlinkSync(pidFile);
124
+ const serverFile = join(vizzlyDir, 'server.json');
125
+ if (existsSync(serverFile)) unlinkSync(serverFile);
126
+
127
+ // Use the cleanup function from tddCommand
128
+ await cleanup();
129
+ } catch {
130
+ // Silent cleanup in daemon
131
+ }
132
+ process.exit(0);
133
+ };
134
+
135
+ // Register signal handlers
136
+ process.on('SIGINT', () => handleShutdown());
137
+ process.on('SIGTERM', () => handleShutdown());
138
+
139
+ // Keep process alive
140
+ process.stdin.resume();
141
+ } catch (error) {
142
+ // Log error to file for debugging
143
+ const logFile = join(vizzlyDir, 'daemon-error.log');
144
+ try {
145
+ writeFileSync(logFile, `[${new Date().toISOString()}] ${error.stack || error}\n`, {
146
+ flag: 'a'
147
+ });
148
+ } catch {
149
+ // Silent failure if we can't write log
150
+ }
151
+ process.exit(1);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Stop TDD daemon server
157
+ * @param {Object} options - Command options
158
+ * @param {Object} globalOptions - Global CLI options
159
+ */
160
+ export async function tddStopCommand(options = {}, globalOptions = {}) {
161
+ const ui = new ConsoleUI({
162
+ json: globalOptions.json,
163
+ verbose: globalOptions.verbose,
164
+ color: !globalOptions.noColor
165
+ });
166
+ const vizzlyDir = join(process.cwd(), '.vizzly');
167
+ const pidFile = join(vizzlyDir, 'server.pid');
168
+ const serverFile = join(vizzlyDir, 'server.json');
169
+
170
+ // First try to find process by PID file
171
+ let pid = null;
172
+ if (existsSync(pidFile)) {
173
+ try {
174
+ pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
175
+ } catch {
176
+ // Invalid PID file
177
+ }
178
+ }
179
+
180
+ // If no PID file or invalid, try to find by port using lsof
181
+ const port = options.port || 47392;
182
+ if (!pid) {
183
+ try {
184
+ const lsofProcess = spawn('lsof', ['-ti', `:${port}`], {
185
+ stdio: 'pipe'
186
+ });
187
+ let lsofOutput = '';
188
+ lsofProcess.stdout.on('data', data => {
189
+ lsofOutput += data.toString();
190
+ });
191
+ await new Promise(resolve => {
192
+ lsofProcess.on('close', code => {
193
+ if (code === 0 && lsofOutput.trim()) {
194
+ const foundPid = parseInt(lsofOutput.trim().split('\n')[0], 10);
195
+ if (foundPid && !isNaN(foundPid)) {
196
+ pid = foundPid;
197
+ }
198
+ }
199
+ resolve();
200
+ });
201
+ lsofProcess.on('error', () => {
202
+ // lsof not available, that's ok
203
+ resolve();
204
+ });
205
+ });
206
+ } catch {
207
+ // lsof failed, that's ok too
208
+ }
209
+ }
210
+ if (!pid) {
211
+ ui.warning('No TDD server running');
212
+
213
+ // Clean up any stale files
214
+ if (existsSync(pidFile)) unlinkSync(pidFile);
215
+ if (existsSync(serverFile)) unlinkSync(serverFile);
216
+ return;
217
+ }
218
+ try {
219
+ // Try to kill the process gracefully
220
+ process.kill(pid, 'SIGTERM');
221
+ ui.info(`Stopping TDD server (PID: ${pid})...`);
222
+
223
+ // Give it a moment to shut down gracefully
224
+ await new Promise(resolve => setTimeout(resolve, 2000));
225
+
226
+ // Check if it's still running
227
+ try {
228
+ process.kill(pid, 0); // Just check if process exists
229
+ // If we get here, process is still running, force kill it
230
+ process.kill(pid, 'SIGKILL');
231
+ ui.info('Force killed TDD server');
232
+ } catch {
233
+ // Process is gone, which is what we want
234
+ }
235
+
236
+ // Clean up files
237
+ if (existsSync(pidFile)) unlinkSync(pidFile);
238
+ if (existsSync(serverFile)) unlinkSync(serverFile);
239
+ ui.success('TDD server stopped');
240
+ } catch (error) {
241
+ if (error.code === 'ESRCH') {
242
+ // Process not found - clean up stale files
243
+ ui.warning('TDD server was not running (cleaning up stale files)');
244
+ if (existsSync(pidFile)) unlinkSync(pidFile);
245
+ if (existsSync(serverFile)) unlinkSync(serverFile);
246
+ } else {
247
+ ui.error('Error stopping TDD server', error);
248
+ }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Check TDD daemon server status
254
+ * @param {Object} options - Command options
255
+ * @param {Object} globalOptions - Global CLI options
256
+ */
257
+ export async function tddStatusCommand(options, globalOptions = {}) {
258
+ const ui = new ConsoleUI({
259
+ json: globalOptions.json,
260
+ verbose: globalOptions.verbose,
261
+ color: !globalOptions.noColor
262
+ });
263
+ const vizzlyDir = join(process.cwd(), '.vizzly');
264
+ const pidFile = join(vizzlyDir, 'server.pid');
265
+ const serverFile = join(vizzlyDir, 'server.json');
266
+ if (!existsSync(pidFile)) {
267
+ ui.info('TDD server not running');
268
+ return;
269
+ }
270
+ try {
271
+ const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
272
+
273
+ // Check if process is actually running
274
+ process.kill(pid, 0); // Signal 0 just checks if process exists
275
+
276
+ let serverInfo = {
277
+ port: 47392
278
+ };
279
+ if (existsSync(serverFile)) {
280
+ serverInfo = JSON.parse(readFileSync(serverFile, 'utf8'));
281
+ }
282
+
283
+ // Try to check health endpoint
284
+ const health = await checkServerHealth(serverInfo.port);
285
+ if (health.running) {
286
+ ui.success(`TDD server running (PID: ${pid})`);
287
+ ui.info(`Server: http://localhost:${serverInfo.port}`);
288
+ ui.info(`Dashboard: http://localhost:${serverInfo.port}/dashboard`);
289
+ if (serverInfo.startTime) {
290
+ const uptime = Math.floor((Date.now() - serverInfo.startTime) / 1000);
291
+ ui.info(`Uptime: ${uptime} seconds`);
292
+ }
293
+ } else {
294
+ ui.warning('TDD server process exists but not responding to health checks');
295
+ }
296
+ } catch (error) {
297
+ if (error.code === 'ESRCH') {
298
+ ui.warning('TDD server process not found (cleaning up stale files)');
299
+ unlinkSync(pidFile);
300
+ if (existsSync(serverFile)) {
301
+ unlinkSync(serverFile);
302
+ }
303
+ } else {
304
+ ui.error('Error checking TDD server status', error);
305
+ }
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Check if server is running on given port
311
+ * @private
312
+ */
313
+ async function isServerRunning(port = 47392) {
314
+ try {
315
+ const health = await checkServerHealth(port);
316
+ return health.running;
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Check server health endpoint
324
+ * @private
325
+ */
326
+ async function checkServerHealth(port = 47392) {
327
+ try {
328
+ const response = await fetch(`http://localhost:${port}/health`);
329
+ const data = await response.json();
330
+ return {
331
+ running: response.ok,
332
+ port: data.port,
333
+ uptime: data.uptime
334
+ };
335
+ } catch {
336
+ return {
337
+ running: false
338
+ };
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Open dashboard in default browser
344
+ * @private
345
+ */
346
+ function openDashboard(port = 47392) {
347
+ const url = `http://localhost:${port}/dashboard`;
348
+
349
+ // Cross-platform open command
350
+ let openCmd;
351
+ if (process.platform === 'darwin') {
352
+ openCmd = 'open';
353
+ } else if (process.platform === 'win32') {
354
+ openCmd = 'start';
355
+ } else {
356
+ openCmd = 'xdg-open';
357
+ }
358
+ spawn(openCmd, [url], {
359
+ detached: true,
360
+ stdio: 'ignore'
361
+ }).unref();
362
+ }