@vizzly-testing/cli 0.30.0 → 0.32.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 (79) hide show
  1. package/README.md +9 -9
  2. package/dist/api/client.js +6 -3
  3. package/dist/cli.js +58 -29
  4. package/dist/client/index.js +21 -29
  5. package/dist/commands/api.js +145 -90
  6. package/dist/commands/baselines.js +78 -47
  7. package/dist/commands/builds.js +9 -8
  8. package/dist/commands/comparisons.js +7 -8
  9. package/dist/commands/config-cmd.js +15 -7
  10. package/dist/commands/context.js +329 -18
  11. package/dist/commands/doctor.js +116 -81
  12. package/dist/commands/init.js +202 -201
  13. package/dist/commands/login.js +134 -65
  14. package/dist/commands/logout.js +17 -6
  15. package/dist/commands/preview.js +29 -57
  16. package/dist/commands/project.js +120 -0
  17. package/dist/commands/projects.js +9 -2
  18. package/dist/commands/review.js +236 -202
  19. package/dist/commands/run.js +75 -72
  20. package/dist/commands/status.js +228 -150
  21. package/dist/commands/tdd-daemon-process.js +98 -0
  22. package/dist/commands/tdd-daemon.js +318 -212
  23. package/dist/commands/tdd.js +23 -8
  24. package/dist/commands/upload.js +10 -9
  25. package/dist/commands/whoami.js +63 -25
  26. package/dist/config/core.js +36 -35
  27. package/dist/context/local-workspace-provider.js +132 -12
  28. package/dist/context/provider-resolver.js +5 -1
  29. package/dist/index.js +5 -4
  30. package/dist/plugin-loader.js +55 -49
  31. package/dist/project/core.js +2 -2
  32. package/dist/project/operations.js +6 -3
  33. package/dist/reporter/reporter-bundle.css +1 -1
  34. package/dist/reporter/reporter-bundle.iife.js +7 -7
  35. package/dist/sdk/index.js +162 -107
  36. package/dist/server/handlers/tdd-handler.js +30 -54
  37. package/dist/server/routers/baseline.js +195 -178
  38. package/dist/server/routers/events.js +212 -174
  39. package/dist/server/routers/projects.js +14 -5
  40. package/dist/server/routers/screenshot.js +1 -31
  41. package/dist/services/build-manager.js +19 -97
  42. package/dist/services/config-service.js +26 -34
  43. package/dist/services/index.js +2 -4
  44. package/dist/services/static-report-generator.js +11 -12
  45. package/dist/services/test-runner.js +21 -27
  46. package/dist/tdd/core/region-coverage.js +109 -21
  47. package/dist/tdd/index.js +1 -1
  48. package/dist/tdd/metadata/region-metadata.js +0 -37
  49. package/dist/tdd/runtime-context.js +78 -0
  50. package/dist/tdd/server-registry.js +38 -17
  51. package/dist/tdd/services/baseline-manager.js +23 -0
  52. package/dist/tdd/services/comparison-service.js +10 -1
  53. package/dist/tdd/services/hotspot-service.js +0 -13
  54. package/dist/tdd/tdd-service.js +144 -138
  55. package/dist/types/client.d.ts +36 -1
  56. package/dist/types/index.d.ts +114 -22
  57. package/dist/types/sdk.d.ts +40 -0
  58. package/dist/uploader/core.js +5 -4
  59. package/dist/uploader/index.js +79 -2
  60. package/dist/uploader/operations.js +40 -5
  61. package/dist/utils/api-url.js +14 -0
  62. package/dist/utils/async-utils.js +26 -0
  63. package/dist/utils/ci-env.js +19 -12
  64. package/dist/utils/config-loader.js +39 -65
  65. package/dist/utils/config-schema.js +19 -55
  66. package/dist/utils/fetch-utils.js +12 -5
  67. package/dist/utils/git.js +35 -72
  68. package/dist/utils/global-config.js +20 -1
  69. package/dist/utils/output.js +19 -7
  70. package/dist/utils/patterns.js +14 -0
  71. package/dist/utils/project-link-store.js +105 -0
  72. package/dist/utils/screenshot-options.js +28 -0
  73. package/dist/utils/secret-store.js +65 -0
  74. package/package.json +14 -11
  75. package/dist/services/screenshot-server.js +0 -65
  76. package/dist/services/server-manager.js +0 -81
  77. package/dist/services/uploader.js +0 -100
  78. package/dist/tdd/services/region-service.js +0 -75
  79. package/dist/utils/build-history.js +0 -103
@@ -3,8 +3,247 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, join } from 'node:path';
5
5
  import { getServerRegistry } from '../tdd/server-registry.js';
6
+ import { withTimeout } from '../utils/async-utils.js';
6
7
  import * as output from '../utils/output.js';
7
8
  import { tddCommand } from './tdd.js';
9
+ import { resolveDaemonPid, waitForProcessExit } from './tdd-daemon-process.js';
10
+ export { findDaemonPidByPort, readDaemonPidFile, resolveDaemonPid, waitForProcessExit } from './tdd-daemon-process.js';
11
+ let defaultTimers = {
12
+ setTimeout,
13
+ clearTimeout
14
+ };
15
+ export function getLocalDaemonFiles(directory = process.cwd()) {
16
+ let vizzlyDir = join(directory, '.vizzly');
17
+ return {
18
+ vizzlyDir,
19
+ pidFile: join(vizzlyDir, 'server.pid'),
20
+ serverFile: join(vizzlyDir, 'server.json'),
21
+ logFile: join(vizzlyDir, 'server.log')
22
+ };
23
+ }
24
+ export function removeFileIfExists(filePath, deps = {}) {
25
+ let fileExists = deps.existsSync || existsSync;
26
+ let unlinkFile = deps.unlinkSync || unlinkSync;
27
+ if (fileExists(filePath)) {
28
+ unlinkFile(filePath);
29
+ return true;
30
+ }
31
+ return false;
32
+ }
33
+ export function cleanupLocalDaemonFiles(directory = process.cwd(), deps = {}) {
34
+ let {
35
+ pidFile,
36
+ serverFile
37
+ } = getLocalDaemonFiles(directory);
38
+ return {
39
+ pidFileRemoved: removeFileIfExists(pidFile, deps),
40
+ serverFileRemoved: removeFileIfExists(serverFile, deps)
41
+ };
42
+ }
43
+ export function buildLegacyServerInfo({
44
+ pid,
45
+ port,
46
+ now = Date.now
47
+ }) {
48
+ return {
49
+ pid,
50
+ port: port.toString(),
51
+ startTime: now()
52
+ };
53
+ }
54
+ export function writeLegacyGlobalServerFile({
55
+ pid,
56
+ port
57
+ }, {
58
+ home = homedir,
59
+ exists = existsSync,
60
+ mkdir = mkdirSync,
61
+ writeFile = writeFileSync,
62
+ now = Date.now
63
+ } = {}) {
64
+ let globalVizzlyDir = join(home(), '.vizzly');
65
+ if (!exists(globalVizzlyDir)) {
66
+ mkdir(globalVizzlyDir, {
67
+ recursive: true
68
+ });
69
+ }
70
+ let globalServerFile = join(globalVizzlyDir, 'server.json');
71
+ let serverInfo = buildLegacyServerInfo({
72
+ pid,
73
+ port,
74
+ now
75
+ });
76
+ writeFile(globalServerFile, JSON.stringify(serverInfo, null, 2));
77
+ return {
78
+ path: globalServerFile,
79
+ serverInfo
80
+ };
81
+ }
82
+ export function cleanupLegacyGlobalServerFile({
83
+ home = homedir,
84
+ exists = existsSync,
85
+ unlink = unlinkSync
86
+ } = {}) {
87
+ let globalServerFile = join(home(), '.vizzly', 'server.json');
88
+ return removeFileIfExists(globalServerFile, {
89
+ existsSync: exists,
90
+ unlinkSync: unlink
91
+ });
92
+ }
93
+ export function unregisterDaemonServer({
94
+ port,
95
+ directory = process.cwd(),
96
+ registry = getServerRegistry()
97
+ }) {
98
+ registry.unregister({
99
+ port,
100
+ directory
101
+ });
102
+ }
103
+ export function cleanupDaemonState({
104
+ port,
105
+ directory = process.cwd(),
106
+ registry = getServerRegistry(),
107
+ localFileDeps = {},
108
+ legacyFileDeps = {}
109
+ } = {}) {
110
+ let localFiles = cleanupLocalDaemonFiles(directory, localFileDeps);
111
+ let legacyGlobalServerFileRemoved = cleanupLegacyGlobalServerFile(legacyFileDeps);
112
+ try {
113
+ if (port !== undefined) {
114
+ unregisterDaemonServer({
115
+ port,
116
+ directory,
117
+ registry
118
+ });
119
+ } else {
120
+ registry.unregister({
121
+ directory
122
+ });
123
+ }
124
+ } catch {
125
+ // Non-fatal; stale file cleanup is still useful on its own.
126
+ }
127
+ return {
128
+ ...localFiles,
129
+ legacyGlobalServerFileRemoved
130
+ };
131
+ }
132
+ export function buildDashboardUrl(port = 47392) {
133
+ return `http://localhost:${port}`;
134
+ }
135
+ export function buildOpenDashboardCommand(url, platform = process.platform) {
136
+ if (platform === 'darwin') {
137
+ return {
138
+ command: 'open',
139
+ args: [url]
140
+ };
141
+ }
142
+ if (platform === 'win32') {
143
+ return {
144
+ command: 'cmd',
145
+ args: ['/c', 'start', '', url]
146
+ };
147
+ }
148
+ return {
149
+ command: 'xdg-open',
150
+ args: [url]
151
+ };
152
+ }
153
+ function wait(ms, timers = defaultTimers) {
154
+ return new Promise(resolve => {
155
+ timers.setTimeout(resolve, ms);
156
+ });
157
+ }
158
+ export function buildDaemonChildArgs({
159
+ entrypoint = process.argv[1],
160
+ port,
161
+ options = {},
162
+ globalOptions = {}
163
+ }) {
164
+ return [entrypoint, 'tdd', 'start', '--daemon-child', '--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.minClusterSize !== undefined ? ['--min-cluster-size', options.minClusterSize.toString()] : []), ...(options.timeout ? ['--timeout', options.timeout] : []), ...(options.failOnDiff ? ['--fail-on-diff'] : []), ...(options.token ? ['--token', options.token] : []), ...(globalOptions.json ? ['--json'] : []), ...(globalOptions.verbose ? ['--verbose'] : []), ...(globalOptions.noColor ? ['--no-color'] : [])];
165
+ }
166
+ export function validateTddStartOptions(options = {}) {
167
+ let errors = [];
168
+ if (options.port) {
169
+ let port = Number(options.port);
170
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
171
+ errors.push('Port must be a valid number between 1 and 65535');
172
+ }
173
+ }
174
+ if (options.timeout) {
175
+ let timeout = Number(options.timeout);
176
+ if (!Number.isInteger(timeout) || timeout < 1000) {
177
+ errors.push('Timeout must be at least 1000 milliseconds');
178
+ }
179
+ }
180
+ if (options.threshold !== undefined) {
181
+ let threshold = Number(options.threshold);
182
+ if (!Number.isFinite(threshold) || threshold < 0) {
183
+ errors.push('Threshold must be a non-negative number (CIEDE2000 Delta E)');
184
+ }
185
+ }
186
+ if (options.minClusterSize !== undefined) {
187
+ let minClusterSize = Number(options.minClusterSize);
188
+ if (!Number.isInteger(minClusterSize) || minClusterSize < 1) {
189
+ errors.push('Min cluster size must be a positive integer');
190
+ }
191
+ }
192
+ return errors;
193
+ }
194
+ export async function waitForDaemonChildInit(child, {
195
+ timeoutMs = 30000,
196
+ timers = defaultTimers
197
+ } = {}) {
198
+ let cleanup = () => {};
199
+ let initPromise = new Promise(resolve => {
200
+ let handleDisconnect = () => {
201
+ cleanup();
202
+ resolve({
203
+ ok: true
204
+ });
205
+ };
206
+ let handleExit = () => {
207
+ cleanup();
208
+ resolve({
209
+ ok: false,
210
+ reason: 'exit'
211
+ });
212
+ };
213
+ cleanup = () => {
214
+ child.off('disconnect', handleDisconnect);
215
+ child.off('exit', handleExit);
216
+ };
217
+ child.on('disconnect', handleDisconnect);
218
+ child.on('exit', handleExit);
219
+ });
220
+ try {
221
+ return await withTimeout(initPromise, timeoutMs, 'TDD server initialization timed out', timers);
222
+ } catch (error) {
223
+ cleanup();
224
+ return {
225
+ ok: false,
226
+ reason: 'timeout',
227
+ error
228
+ };
229
+ }
230
+ }
231
+ export async function waitForServerRunning(port, {
232
+ maxAttempts = 10,
233
+ delayMs = 200,
234
+ isRunning = isServerRunning,
235
+ timers = defaultTimers
236
+ } = {}) {
237
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
238
+ if (await isRunning(port)) {
239
+ return true;
240
+ }
241
+ if (attempt < maxAttempts - 1) {
242
+ await wait(delayMs * (attempt + 1), timers);
243
+ }
244
+ }
245
+ return false;
246
+ }
8
247
 
9
248
  /**
10
249
  * Start TDD server in daemon mode
@@ -33,7 +272,7 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
33
272
  status: 'already_running',
34
273
  port: existingServer.port,
35
274
  pid: existingServer.pid,
36
- dashboardUrl: `http://localhost:${existingServer.port}`
275
+ dashboardUrl: buildDashboardUrl(existingServer.port)
37
276
  });
38
277
  return;
39
278
  }
@@ -50,14 +289,10 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
50
289
  return;
51
290
  } else {
52
291
  // Stale entry - clean it up (registry and local files)
53
- registry.unregister({
54
- directory: process.cwd()
292
+ cleanupDaemonState({
293
+ directory: process.cwd(),
294
+ registry
55
295
  });
56
- let vizzlyDir = join(process.cwd(), '.vizzly');
57
- let pidFile = join(vizzlyDir, 'server.pid');
58
- let serverFile = join(vizzlyDir, 'server.json');
59
- if (existsSync(pidFile)) unlinkSync(pidFile);
60
- if (existsSync(serverFile)) unlinkSync(serverFile);
61
296
  }
62
297
  }
63
298
 
@@ -87,7 +322,9 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
87
322
  }
88
323
  try {
89
324
  // Ensure .vizzly directory exists
90
- let vizzlyDir = join(process.cwd(), '.vizzly');
325
+ let {
326
+ vizzlyDir
327
+ } = getLocalDaemonFiles();
91
328
  if (!existsSync(vizzlyDir)) {
92
329
  mkdirSync(vizzlyDir, {
93
330
  recursive: true
@@ -103,47 +340,17 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
103
340
  }
104
341
 
105
342
  // Spawn child process with stdio inherited during init for direct error visibility
106
- const child = spawn(process.execPath, [process.argv[1],
107
- // CLI entry point
108
- 'tdd', 'start', '--daemon-child',
109
- // Special flag for child process
110
- '--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.failOnDiff ? ['--fail-on-diff'] : []), ...(options.token ? ['--token', options.token] : []), ...(globalOptions.json ? ['--json'] : []), ...(globalOptions.verbose ? ['--verbose'] : []), ...(globalOptions.noColor ? ['--no-color'] : [])], {
343
+ let child = spawn(process.execPath, buildDaemonChildArgs({
344
+ port,
345
+ options,
346
+ globalOptions
347
+ }), {
111
348
  detached: true,
112
349
  stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
113
350
  cwd: process.cwd()
114
351
  });
115
-
116
- // Wait for child to signal successful init or exit with error
117
- let initComplete = false;
118
- let initFailed = false;
119
- await new Promise(resolve => {
120
- // Child disconnects IPC when initialization succeeds
121
- child.on('disconnect', () => {
122
- initComplete = true;
123
- resolve();
124
- });
125
-
126
- // Child exits before disconnecting = initialization failed
127
- child.on('exit', () => {
128
- if (!initComplete) {
129
- initFailed = true;
130
- resolve();
131
- }
132
- });
133
-
134
- // Timeout after 30 seconds to prevent indefinite wait
135
- const timeoutId = setTimeout(() => {
136
- if (!initComplete && !initFailed) {
137
- initFailed = true;
138
- resolve();
139
- }
140
- }, 30000);
141
-
142
- // Clear timeout if we resolve early
143
- child.on('disconnect', () => clearTimeout(timeoutId));
144
- child.on('exit', () => clearTimeout(timeoutId));
145
- });
146
- if (initFailed) {
352
+ let initResult = await waitForDaemonChildInit(child);
353
+ if (!initResult.ok) {
147
354
  if (options.baselineBuild && !globalOptions.verbose) {
148
355
  output.stopSpinner();
149
356
  }
@@ -155,13 +362,7 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
155
362
  child.unref();
156
363
 
157
364
  // Verify server started with retries
158
- const maxRetries = 10;
159
- const retryDelay = 200; // Start with 200ms
160
- let running = false;
161
- for (let i = 0; i < maxRetries && !running; i++) {
162
- await new Promise(resolve => setTimeout(resolve, retryDelay * (i + 1)));
163
- running = await isServerRunning(port);
164
- }
365
+ let running = await waitForServerRunning(port);
165
366
  if (options.baselineBuild && !globalOptions.verbose) {
166
367
  output.stopSpinner();
167
368
  }
@@ -178,14 +379,16 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
178
379
  registry.cleanupStale();
179
380
 
180
381
  // Register this server with log file path for menubar to read
181
- let serverLogFile = join(process.cwd(), '.vizzly', 'server.log');
382
+ let {
383
+ logFile
384
+ } = getLocalDaemonFiles();
182
385
  registry.register({
183
386
  pid: child.pid,
184
387
  port: port,
185
388
  directory: process.cwd(),
186
389
  name: basename(process.cwd()),
187
390
  startedAt: new Date().toISOString(),
188
- logFile: serverLogFile
391
+ logFile
189
392
  });
190
393
  } catch {
191
394
  // Non-fatal
@@ -193,25 +396,16 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
193
396
 
194
397
  // Also write legacy server.json for SDK discovery (backwards compatibility)
195
398
  try {
196
- const globalVizzlyDir = join(homedir(), '.vizzly');
197
- if (!existsSync(globalVizzlyDir)) {
198
- mkdirSync(globalVizzlyDir, {
199
- recursive: true
200
- });
201
- }
202
- const globalServerFile = join(globalVizzlyDir, 'server.json');
203
- const serverInfo = {
399
+ writeLegacyGlobalServerFile({
204
400
  pid: child.pid,
205
- port: port.toString(),
206
- startTime: Date.now()
207
- };
208
- writeFileSync(globalServerFile, JSON.stringify(serverInfo, null, 2));
401
+ port
402
+ });
209
403
  } catch {
210
404
  // Non-fatal, SDK can still use health check
211
405
  }
212
406
 
213
407
  // JSON output for successful start
214
- let dashboardUrl = `http://localhost:${port}`;
408
+ let dashboardUrl = buildDashboardUrl(port);
215
409
  if (globalOptions.json) {
216
410
  output.data({
217
411
  status: 'started',
@@ -264,11 +458,12 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
264
458
  * @private
265
459
  */
266
460
  export async function runDaemonChild(options = {}, globalOptions = {}) {
267
- const vizzlyDir = join(process.cwd(), '.vizzly');
268
- const port = options.port || 47392;
269
-
270
- // Set up log file for menubar app to read
271
- const logFile = join(vizzlyDir, 'server.log');
461
+ let {
462
+ pidFile,
463
+ serverFile,
464
+ logFile
465
+ } = getLocalDaemonFiles();
466
+ let port = options.port || 47392;
272
467
 
273
468
  // Configure output to write JSON logs to file (before tddCommand configures it)
274
469
  output.configure({
@@ -279,7 +474,7 @@ export async function runDaemonChild(options = {}, globalOptions = {}) {
279
474
  });
280
475
  try {
281
476
  // Use existing tddCommand but with daemon mode
282
- const {
477
+ let {
283
478
  cleanup
284
479
  } = await tddCommand(null,
285
480
  // No test command - server only
@@ -294,43 +489,22 @@ export async function runDaemonChild(options = {}, globalOptions = {}) {
294
489
  }
295
490
 
296
491
  // Store our PID for the stop command
297
- const pidFile = join(vizzlyDir, 'server.pid');
298
492
  writeFileSync(pidFile, process.pid.toString());
299
- const serverInfo = {
493
+ let serverInfo = {
300
494
  pid: process.pid,
301
495
  port: port,
302
496
  startTime: Date.now(),
303
497
  failOnDiff: options.failOnDiff || false,
304
- logFile: logFile
498
+ logFile
305
499
  };
306
- writeFileSync(join(vizzlyDir, 'server.json'), JSON.stringify(serverInfo, null, 2));
500
+ writeFileSync(serverFile, JSON.stringify(serverInfo, null, 2));
307
501
 
308
502
  // Set up graceful shutdown
309
- const handleShutdown = async () => {
503
+ let handleShutdown = async () => {
310
504
  try {
311
- // Clean up PID files
312
- if (existsSync(pidFile)) unlinkSync(pidFile);
313
- const serverFile = join(vizzlyDir, 'server.json');
314
- if (existsSync(serverFile)) unlinkSync(serverFile);
315
-
316
- // Unregister from global registry (for menubar app)
317
- try {
318
- let registry = getServerRegistry();
319
- registry.unregister({
320
- port: port,
321
- directory: process.cwd()
322
- });
323
- } catch {
324
- // Non-fatal
325
- }
326
-
327
- // Clean up legacy global server file
328
- try {
329
- const globalServerFile = join(homedir(), '.vizzly', 'server.json');
330
- if (existsSync(globalServerFile)) unlinkSync(globalServerFile);
331
- } catch {
332
- // Non-fatal
333
- }
505
+ cleanupDaemonState({
506
+ port
507
+ });
334
508
 
335
509
  // Use the cleanup function from tddCommand
336
510
  await cleanup();
@@ -364,50 +538,10 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
364
538
  verbose: globalOptions.verbose,
365
539
  color: !globalOptions.noColor
366
540
  });
367
- const vizzlyDir = join(process.cwd(), '.vizzly');
368
- const pidFile = join(vizzlyDir, 'server.pid');
369
- const serverFile = join(vizzlyDir, 'server.json');
370
-
371
- // First try to find process by PID file
372
- let pid = null;
373
- if (existsSync(pidFile)) {
374
- try {
375
- pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
376
- } catch {
377
- // Invalid PID file
378
- }
379
- }
380
-
381
- // If no PID file or invalid, try to find by port using lsof
382
- const port = options.port || 47392;
383
- if (!pid) {
384
- try {
385
- const lsofProcess = spawn('lsof', ['-ti', `:${port}`], {
386
- stdio: 'pipe'
387
- });
388
- let lsofOutput = '';
389
- lsofProcess.stdout.on('data', data => {
390
- lsofOutput += data.toString();
391
- });
392
- await new Promise(resolve => {
393
- lsofProcess.on('close', code => {
394
- if (code === 0 && lsofOutput.trim()) {
395
- const foundPid = parseInt(lsofOutput.trim().split('\n')[0], 10);
396
- if (foundPid && !Number.isNaN(foundPid)) {
397
- pid = foundPid;
398
- }
399
- }
400
- resolve();
401
- });
402
- lsofProcess.on('error', () => {
403
- // lsof not available, that's ok
404
- resolve();
405
- });
406
- });
407
- } catch {
408
- // lsof failed, that's ok too
409
- }
410
- }
541
+ let port = options.port || 47392;
542
+ let pid = await resolveDaemonPid({
543
+ port
544
+ });
411
545
  if (!pid) {
412
546
  // JSON output for not running
413
547
  if (globalOptions.json) {
@@ -420,46 +554,31 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
420
554
  }
421
555
 
422
556
  // Clean up any stale files
423
- if (existsSync(pidFile)) unlinkSync(pidFile);
424
- if (existsSync(serverFile)) unlinkSync(serverFile);
557
+ cleanupDaemonState({
558
+ port
559
+ });
425
560
  return;
426
561
  }
427
562
  try {
428
- let _colors = output.getColors();
429
-
430
563
  // Try to kill the process gracefully
431
564
  process.kill(pid, 'SIGTERM');
432
565
  output.startSpinner('Stopping TDD server...');
433
-
434
- // Give it a moment to shut down gracefully
435
- await new Promise(resolve => setTimeout(resolve, 2000));
566
+ let exited = await waitForProcessExit(pid);
436
567
 
437
568
  // Check if it's still running
438
- try {
439
- process.kill(pid, 0); // Just check if process exists
440
- // If we get here, process is still running, force kill it
569
+ if (!exited) {
441
570
  process.kill(pid, 'SIGKILL');
442
571
  output.stopSpinner();
443
572
  output.debug('tdd', 'Force killed process');
444
- } catch {
573
+ } else {
445
574
  // Process is gone, which is what we want
446
575
  output.stopSpinner();
447
576
  }
448
577
 
449
578
  // Clean up files
450
- if (existsSync(pidFile)) unlinkSync(pidFile);
451
- if (existsSync(serverFile)) unlinkSync(serverFile);
452
-
453
- // Unregister from global registry (for menubar app)
454
- try {
455
- let registry = getServerRegistry();
456
- registry.unregister({
457
- port: port,
458
- directory: process.cwd()
459
- });
460
- } catch {
461
- // Non-fatal
462
- }
579
+ cleanupDaemonState({
580
+ port
581
+ });
463
582
 
464
583
  // JSON output for successful stop
465
584
  if (globalOptions.json) {
@@ -475,19 +594,9 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
475
594
  if (error.code === 'ESRCH') {
476
595
  // Process not found - clean up stale files
477
596
  output.warn('TDD server was not running (cleaning up stale files)');
478
- if (existsSync(pidFile)) unlinkSync(pidFile);
479
- if (existsSync(serverFile)) unlinkSync(serverFile);
480
-
481
- // Still unregister from registry
482
- try {
483
- let registry = getServerRegistry();
484
- registry.unregister({
485
- port: port,
486
- directory: process.cwd()
487
- });
488
- } catch {
489
- // Non-fatal
490
- }
597
+ cleanupDaemonState({
598
+ port
599
+ });
491
600
  } else {
492
601
  output.error('Error stopping TDD server', error);
493
602
  }
@@ -505,9 +614,10 @@ export async function tddStatusCommand(_options, globalOptions = {}) {
505
614
  verbose: globalOptions.verbose,
506
615
  color: !globalOptions.noColor
507
616
  });
508
- const vizzlyDir = join(process.cwd(), '.vizzly');
509
- const pidFile = join(vizzlyDir, 'server.pid');
510
- const serverFile = join(vizzlyDir, 'server.json');
617
+ let {
618
+ pidFile,
619
+ serverFile
620
+ } = getLocalDaemonFiles();
511
621
  if (!existsSync(pidFile)) {
512
622
  // JSON output for not running
513
623
  if (globalOptions.json) {
@@ -521,7 +631,12 @@ export async function tddStatusCommand(_options, globalOptions = {}) {
521
631
  return;
522
632
  }
523
633
  try {
524
- const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
634
+ let pid = parsePositiveInteger(readFileSync(pidFile, 'utf8'));
635
+ if (!pid) {
636
+ output.warn('TDD server pid file is invalid (cleaning up stale files)');
637
+ cleanupDaemonState();
638
+ return;
639
+ }
525
640
 
526
641
  // Check if process is actually running
527
642
  process.kill(pid, 0); // Signal 0 just checks if process exists
@@ -534,22 +649,22 @@ export async function tddStatusCommand(_options, globalOptions = {}) {
534
649
  }
535
650
 
536
651
  // Try to check health endpoint
537
- const health = await checkServerHealth(serverInfo.port);
652
+ let health = await checkServerHealth(serverInfo.port);
538
653
  if (health.running) {
539
654
  // Calculate uptime
540
655
  let uptimeMs = null;
541
656
  let uptimeStr = '';
542
657
  if (serverInfo.startTime) {
543
658
  uptimeMs = Date.now() - serverInfo.startTime;
544
- const uptime = Math.floor(uptimeMs / 1000);
545
- const hours = Math.floor(uptime / 3600);
546
- const minutes = Math.floor(uptime % 3600 / 60);
547
- const seconds = uptime % 60;
659
+ let uptime = Math.floor(uptimeMs / 1000);
660
+ let hours = Math.floor(uptime / 3600);
661
+ let minutes = Math.floor(uptime % 3600 / 60);
662
+ let seconds = uptime % 60;
548
663
  if (hours > 0) uptimeStr += `${hours}h `;
549
664
  if (minutes > 0 || hours > 0) uptimeStr += `${minutes}m `;
550
665
  uptimeStr += `${seconds}s`;
551
666
  }
552
- let dashboardUrl = `http://localhost:${serverInfo.port}`;
667
+ let dashboardUrl = buildDashboardUrl(serverInfo.port);
553
668
 
554
669
  // JSON output for running status
555
670
  if (globalOptions.json) {
@@ -589,10 +704,7 @@ export async function tddStatusCommand(_options, globalOptions = {}) {
589
704
  } catch (error) {
590
705
  if (error.code === 'ESRCH') {
591
706
  output.warn('TDD server process not found (cleaning up stale files)');
592
- unlinkSync(pidFile);
593
- if (existsSync(serverFile)) {
594
- unlinkSync(serverFile);
595
- }
707
+ cleanupDaemonState();
596
708
  } else {
597
709
  output.error('Error checking TDD server status', error);
598
710
  }
@@ -605,7 +717,7 @@ export async function tddStatusCommand(_options, globalOptions = {}) {
605
717
  */
606
718
  async function isServerRunning(port = 47392) {
607
719
  try {
608
- const health = await checkServerHealth(port);
720
+ let health = await checkServerHealth(port);
609
721
  return health.running;
610
722
  } catch {
611
723
  return false;
@@ -618,8 +730,8 @@ async function isServerRunning(port = 47392) {
618
730
  */
619
731
  async function checkServerHealth(port = 47392) {
620
732
  try {
621
- const response = await fetch(`http://localhost:${port}/health`);
622
- const data = await response.json();
733
+ let response = await fetch(`http://localhost:${port}/health`);
734
+ let data = await response.json();
623
735
  return {
624
736
  running: response.ok,
625
737
  port: data.port,
@@ -637,18 +749,12 @@ async function checkServerHealth(port = 47392) {
637
749
  * @private
638
750
  */
639
751
  function openDashboard(port = 47392) {
640
- const url = `http://localhost:${port}`;
641
-
642
- // Cross-platform open command
643
- let openCmd;
644
- if (process.platform === 'darwin') {
645
- openCmd = 'open';
646
- } else if (process.platform === 'win32') {
647
- openCmd = 'start';
648
- } else {
649
- openCmd = 'xdg-open';
650
- }
651
- spawn(openCmd, [url], {
752
+ let url = buildDashboardUrl(port);
753
+ let {
754
+ command,
755
+ args
756
+ } = buildOpenDashboardCommand(url);
757
+ spawn(command, args, {
652
758
  detached: true,
653
759
  stdio: 'ignore'
654
760
  }).unref();