@vizzly-testing/cli 0.35.0 → 0.35.2

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.
@@ -1,12 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
3
  import { basename, join } from 'node:path';
5
4
  import { getServerRegistry } from '../tdd/server-registry.js';
6
5
  import { withTimeout } from '../utils/async-utils.js';
7
6
  import * as output from '../utils/output.js';
8
7
  import { tddCommand } from './tdd.js';
9
- import { resolveDaemonPid, waitForProcessExit } from './tdd-daemon-process.js';
8
+ import { parsePositiveInteger, readDaemonPidFile, resolveDaemonPid, waitForProcessExit } from './tdd-daemon-process.js';
10
9
  export { findDaemonPidByPort, readDaemonPidFile, resolveDaemonPid, waitForProcessExit } from './tdd-daemon-process.js';
11
10
  let defaultTimers = {
12
11
  setTimeout,
@@ -40,59 +39,30 @@ export function cleanupLocalDaemonFiles(directory = process.cwd(), deps = {}) {
40
39
  serverFileRemoved: removeFileIfExists(serverFile, deps)
41
40
  };
42
41
  }
43
- export function buildLegacyServerInfo({
44
- pid,
45
- port,
46
- failOnDiff = false,
47
- now = Date.now
48
- }) {
49
- return {
50
- pid,
51
- port: port.toString(),
52
- startTime: now(),
53
- failOnDiff
54
- };
55
- }
56
- export function writeLegacyGlobalServerFile({
57
- pid,
58
- port,
59
- failOnDiff = false
60
- }, {
61
- home = homedir,
62
- exists = existsSync,
63
- mkdir = mkdirSync,
64
- writeFile = writeFileSync,
65
- now = Date.now
66
- } = {}) {
67
- let globalVizzlyDir = join(home(), '.vizzly');
68
- if (!exists(globalVizzlyDir)) {
69
- mkdir(globalVizzlyDir, {
70
- recursive: true
71
- });
42
+ export function readLocalDaemonInfo(directory = process.cwd(), deps = {}) {
43
+ let {
44
+ serverFile
45
+ } = getLocalDaemonFiles(directory);
46
+ let fileExists = deps.existsSync || existsSync;
47
+ let readFile = deps.readFileSync || readFileSync;
48
+ if (!fileExists(serverFile)) {
49
+ return null;
50
+ }
51
+ try {
52
+ let info = JSON.parse(readFile(serverFile, 'utf8'));
53
+ let port = parsePositiveInteger(info.port);
54
+ let pid = parsePositiveInteger(info.pid);
55
+ if (!port) {
56
+ return null;
57
+ }
58
+ return {
59
+ ...info,
60
+ port,
61
+ pid
62
+ };
63
+ } catch {
64
+ return null;
72
65
  }
73
- let globalServerFile = join(globalVizzlyDir, 'server.json');
74
- let serverInfo = buildLegacyServerInfo({
75
- pid,
76
- port,
77
- failOnDiff,
78
- now
79
- });
80
- writeFile(globalServerFile, JSON.stringify(serverInfo, null, 2));
81
- return {
82
- path: globalServerFile,
83
- serverInfo
84
- };
85
- }
86
- export function cleanupLegacyGlobalServerFile({
87
- home = homedir,
88
- exists = existsSync,
89
- unlink = unlinkSync
90
- } = {}) {
91
- let globalServerFile = join(home(), '.vizzly', 'server.json');
92
- return removeFileIfExists(globalServerFile, {
93
- existsSync: exists,
94
- unlinkSync: unlink
95
- });
96
66
  }
97
67
  export function unregisterDaemonServer({
98
68
  port,
@@ -109,10 +79,12 @@ export function cleanupDaemonState({
109
79
  directory = process.cwd(),
110
80
  registry = getServerRegistry(),
111
81
  localFileDeps = {},
112
- legacyFileDeps = {}
82
+ cleanLocalFiles = true
113
83
  } = {}) {
114
- let localFiles = cleanupLocalDaemonFiles(directory, localFileDeps);
115
- let legacyGlobalServerFileRemoved = cleanupLegacyGlobalServerFile(legacyFileDeps);
84
+ let cleanupResult = cleanLocalFiles ? cleanupLocalDaemonFiles(directory, localFileDeps) : {
85
+ pidFileRemoved: false,
86
+ serverFileRemoved: false
87
+ };
116
88
  try {
117
89
  if (port !== undefined) {
118
90
  unregisterDaemonServer({
@@ -128,11 +100,42 @@ export function cleanupDaemonState({
128
100
  } catch {
129
101
  // Non-fatal; stale file cleanup is still useful on its own.
130
102
  }
103
+ return cleanupResult;
104
+ }
105
+ function resolveDaemonTarget(options = {}, directory = process.cwd()) {
106
+ let registry = getServerRegistry();
107
+ let requestedPort = options.port ? Number(options.port) : null;
108
+ let localInfo = readLocalDaemonInfo(directory);
109
+ let localInfoMatches = !requestedPort || localInfo?.port === requestedPort ? localInfo : null;
110
+ let registryServer = requestedPort ? registry.find({
111
+ port: requestedPort
112
+ }) : registry.find({
113
+ directory
114
+ });
115
+ let serverInfo = localInfoMatches || registryServer || (requestedPort ? {
116
+ port: requestedPort
117
+ } : null);
118
+ let port = requestedPort || serverInfo?.port || 47392;
131
119
  return {
132
- ...localFiles,
133
- legacyGlobalServerFileRemoved
120
+ registry,
121
+ requestedPort,
122
+ localInfo: localInfoMatches,
123
+ registryServer,
124
+ serverInfo,
125
+ port,
126
+ directory: registryServer?.directory || directory,
127
+ useLocalPidFile: !requestedPort || Boolean(localInfoMatches),
128
+ cleanLocalFiles: !requestedPort || Boolean(localInfoMatches || registryServer)
134
129
  };
135
130
  }
131
+ function cleanupDaemonTarget(target) {
132
+ return cleanupDaemonState({
133
+ port: target.port,
134
+ directory: target.directory,
135
+ registry: target.registry,
136
+ cleanLocalFiles: target.cleanLocalFiles
137
+ });
138
+ }
136
139
  export function buildDashboardUrl(port = 47392) {
137
140
  return `http://localhost:${port}`;
138
141
  }
@@ -200,29 +203,62 @@ export async function waitForDaemonChildInit(child, {
200
203
  timers = defaultTimers
201
204
  } = {}) {
202
205
  let cleanup = () => {};
206
+ let stderrOutput = '';
203
207
  let initPromise = new Promise(resolve => {
208
+ let handleStderr = data => {
209
+ stderrOutput += data.toString();
210
+ };
211
+ let handleMessage = message => {
212
+ if (message?.type !== 'error') {
213
+ return;
214
+ }
215
+ let error = new Error(message.message || 'Daemon child failed to start');
216
+ if (message.code) {
217
+ error.code = message.code;
218
+ }
219
+ if (message.stack) {
220
+ error.stack = message.stack;
221
+ }
222
+ cleanup();
223
+ resolve({
224
+ ok: false,
225
+ reason: 'error',
226
+ error
227
+ });
228
+ };
204
229
  let handleDisconnect = () => {
205
230
  cleanup();
206
231
  resolve({
207
232
  ok: true
208
233
  });
209
234
  };
210
- let handleExit = () => {
235
+ let handleExit = (code, signal) => {
236
+ let error = new Error(stderrOutput.trim() || 'Daemon child exited before startup');
237
+ error.code = code ?? signal ?? null;
211
238
  cleanup();
212
239
  resolve({
213
240
  ok: false,
214
- reason: 'exit'
241
+ reason: 'exit',
242
+ error
215
243
  });
216
244
  };
217
245
  cleanup = () => {
246
+ child.stderr?.off?.('data', handleStderr);
247
+ child.off('message', handleMessage);
218
248
  child.off('disconnect', handleDisconnect);
219
249
  child.off('exit', handleExit);
220
250
  };
251
+ child.stderr?.on?.('data', handleStderr);
252
+ child.on('message', handleMessage);
221
253
  child.on('disconnect', handleDisconnect);
222
254
  child.on('exit', handleExit);
223
255
  });
224
256
  try {
225
- return await withTimeout(initPromise, timeoutMs, 'TDD server initialization timed out', timers);
257
+ let result = await withTimeout(initPromise, timeoutMs, 'TDD server initialization timed out', timers);
258
+ if (result.ok) {
259
+ child.stderr?.unref?.();
260
+ }
261
+ return result;
226
262
  } catch (error) {
227
263
  cleanup();
228
264
  return {
@@ -305,7 +341,7 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
305
341
  let autoAllocated = false;
306
342
  if (options.port) {
307
343
  // User specified a port - use it (will fail if busy)
308
- port = options.port;
344
+ port = Number(options.port);
309
345
 
310
346
  // Check if user-specified port is in use
311
347
  if (await isServerRunning(port)) {
@@ -343,14 +379,15 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
343
379
  output.startSpinner(`Downloading baselines from build ${options.baselineBuild}...`);
344
380
  }
345
381
 
346
- // Spawn child process with stdio inherited during init for direct error visibility
382
+ // Keep daemon stdio detached so callers that capture output do not hang
383
+ // waiting for the long-lived child to close inherited pipes.
347
384
  let child = spawn(process.execPath, buildDaemonChildArgs({
348
385
  port,
349
386
  options,
350
387
  globalOptions
351
388
  }), {
352
389
  detached: true,
353
- stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
390
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
354
391
  cwd: process.cwd()
355
392
  });
356
393
  let initResult = await waitForDaemonChildInit(child);
@@ -358,7 +395,7 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
358
395
  if (options.baselineBuild && !globalOptions.verbose) {
359
396
  output.stopSpinner();
360
397
  }
361
- output.error('TDD server failed to start');
398
+ output.error(initResult.error?.message || 'TDD server failed to start');
362
399
  process.exit(1);
363
400
  }
364
401
 
@@ -398,17 +435,6 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
398
435
  // Non-fatal
399
436
  }
400
437
 
401
- // Also write legacy server.json for SDK discovery (backwards compatibility)
402
- try {
403
- writeLegacyGlobalServerFile({
404
- pid: child.pid,
405
- port,
406
- failOnDiff: options.failOnDiff || false
407
- });
408
- } catch {
409
- // Non-fatal, SDK can still use health check
410
- }
411
-
412
438
  // JSON output for successful start
413
439
  let dashboardUrl = buildDashboardUrl(port);
414
440
  if (globalOptions.json) {
@@ -447,7 +473,8 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
447
473
 
448
474
  // Always show stop hint
449
475
  output.blank();
450
- output.hint('Stop with: vizzly tdd stop');
476
+ let stopCommand = port === 47392 ? 'vizzly tdd stop' : `vizzly tdd stop --port ${port}`;
477
+ output.hint(`Stop with: ${stopCommand}`);
451
478
  if (options.open) {
452
479
  openDashboard(port);
453
480
  }
@@ -480,6 +507,7 @@ export async function runDaemonChild(options = {}, globalOptions = {}) {
480
507
  try {
481
508
  // Use existing tddCommand but with daemon mode
482
509
  let {
510
+ result,
483
511
  cleanup
484
512
  } = await tddCommand(null,
485
513
  // No test command - server only
@@ -487,6 +515,9 @@ export async function runDaemonChild(options = {}, globalOptions = {}) {
487
515
  ...options,
488
516
  daemon: true
489
517
  }, globalOptions);
518
+ if (result?.success === false) {
519
+ throw new Error(result.error || 'TDD server failed to start');
520
+ }
490
521
 
491
522
  // Disconnect IPC after successful initialization to signal parent
492
523
  if (process.send) {
@@ -526,11 +557,29 @@ export async function runDaemonChild(options = {}, globalOptions = {}) {
526
557
  // Keep process alive
527
558
  process.stdin.resume();
528
559
  } catch (error) {
529
- // Most errors shown via inherited stdio, but catch any that weren't
560
+ await sendDaemonChildError(error);
530
561
  console.error(`Fatal error: ${error.message}`);
531
562
  process.exit(1);
532
563
  }
533
564
  }
565
+ async function sendDaemonChildError(error) {
566
+ if (!process.send) {
567
+ return;
568
+ }
569
+ let message = {
570
+ type: 'error',
571
+ message: error.message,
572
+ code: error.code || null,
573
+ stack: error.stack || null
574
+ };
575
+ await new Promise(resolve => {
576
+ try {
577
+ process.send(message, () => resolve());
578
+ } catch {
579
+ resolve();
580
+ }
581
+ });
582
+ }
534
583
 
535
584
  /**
536
585
  * Stop TDD daemon server
@@ -543,10 +592,16 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
543
592
  verbose: globalOptions.verbose,
544
593
  color: !globalOptions.noColor
545
594
  });
546
- let port = options.port || 47392;
547
- let pid = await resolveDaemonPid({
548
- port
549
- });
595
+ let target = resolveDaemonTarget(options);
596
+ let pid = target.useLocalPidFile ? await resolveDaemonPid({
597
+ port: target.port
598
+ }) : null;
599
+
600
+ // Stop is intentionally file/registry-driven. Do not rediscover arbitrary
601
+ // processes by port here; a non-Vizzly server may be using that port.
602
+ if (!pid) {
603
+ pid = target.serverInfo?.pid || null;
604
+ }
550
605
  if (!pid) {
551
606
  // JSON output for not running
552
607
  if (globalOptions.json) {
@@ -559,9 +614,7 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
559
614
  }
560
615
 
561
616
  // Clean up any stale files
562
- cleanupDaemonState({
563
- port
564
- });
617
+ cleanupDaemonTarget(target);
565
618
  return;
566
619
  }
567
620
  try {
@@ -581,16 +634,14 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
581
634
  }
582
635
 
583
636
  // Clean up files
584
- cleanupDaemonState({
585
- port
586
- });
637
+ cleanupDaemonTarget(target);
587
638
 
588
639
  // JSON output for successful stop
589
640
  if (globalOptions.json) {
590
641
  output.data({
591
642
  status: 'stopped',
592
643
  pid,
593
- port
644
+ port: target.port
594
645
  });
595
646
  return;
596
647
  }
@@ -598,10 +649,16 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
598
649
  } catch (error) {
599
650
  if (error.code === 'ESRCH') {
600
651
  // Process not found - clean up stale files
652
+ cleanupDaemonTarget(target);
653
+ if (globalOptions.json) {
654
+ output.data({
655
+ status: 'stale',
656
+ stopped: false,
657
+ message: 'TDD server was not running; cleaned up stale files'
658
+ });
659
+ return;
660
+ }
601
661
  output.warn('TDD server was not running (cleaning up stale files)');
602
- cleanupDaemonState({
603
- port
604
- });
605
662
  } else {
606
663
  output.error('Error stopping TDD server', error);
607
664
  }
@@ -613,17 +670,53 @@ export async function tddStopCommand(options = {}, globalOptions = {}) {
613
670
  * @param {Object} options - Command options
614
671
  * @param {Object} globalOptions - Global CLI options
615
672
  */
616
- export async function tddStatusCommand(_options, globalOptions = {}) {
673
+ function printRunningStatus({
674
+ serverInfo,
675
+ port,
676
+ pid,
677
+ uptimeMs = null,
678
+ uptimeStr = '',
679
+ dashboardUrl = buildDashboardUrl(port),
680
+ globalOptions = {}
681
+ }) {
682
+ if (globalOptions.json) {
683
+ output.data({
684
+ running: true,
685
+ port,
686
+ pid,
687
+ uptimeMs,
688
+ uptime: uptimeStr || null,
689
+ dashboardUrl
690
+ });
691
+ return;
692
+ }
693
+ let colors = output.getColors();
694
+ output.header('tdd', 'local');
695
+ output.print(` ${output.statusDot('success')} Running ${uptimeStr ? colors.brand.textTertiary(`· ${uptimeStr}`) : ''}`);
696
+ output.blank();
697
+ output.printBox(colors.brand.info(colors.underline(dashboardUrl)), {
698
+ title: 'Dashboard',
699
+ style: 'branded'
700
+ });
701
+ if (globalOptions.verbose && pid) {
702
+ output.blank();
703
+ output.print(` ${colors.brand.textTertiary('PID:')} ${pid}`);
704
+ }
705
+ if (globalOptions.verbose && serverInfo?.logFile) {
706
+ output.print(` ${colors.brand.textTertiary('Log:')} ${serverInfo.logFile}`);
707
+ }
708
+ }
709
+ export async function tddStatusCommand(options = {}, globalOptions = {}) {
617
710
  output.configure({
618
711
  json: globalOptions.json,
619
712
  verbose: globalOptions.verbose,
620
713
  color: !globalOptions.noColor
621
714
  });
622
715
  let {
623
- pidFile,
624
- serverFile
716
+ pidFile
625
717
  } = getLocalDaemonFiles();
626
- if (!existsSync(pidFile)) {
718
+ let target = resolveDaemonTarget(options);
719
+ if (!target.serverInfo && !existsSync(pidFile)) {
627
720
  // JSON output for not running
628
721
  if (globalOptions.json) {
629
722
  output.data({
@@ -636,31 +729,27 @@ export async function tddStatusCommand(_options, globalOptions = {}) {
636
729
  return;
637
730
  }
638
731
  try {
639
- let pid = parsePositiveInteger(readFileSync(pidFile, 'utf8'));
732
+ let pid = target.useLocalPidFile ? readDaemonPidFile(pidFile) : null;
733
+ if (!pid) {
734
+ pid = target.serverInfo?.pid || null;
735
+ }
640
736
  if (!pid) {
641
- output.warn('TDD server pid file is invalid (cleaning up stale files)');
642
- cleanupDaemonState();
737
+ output.warn('TDD server not running (cleaning up stale files)');
738
+ cleanupDaemonTarget(target);
643
739
  return;
644
740
  }
645
741
 
646
742
  // Check if process is actually running
647
743
  process.kill(pid, 0); // Signal 0 just checks if process exists
648
744
 
649
- let serverInfo = {
650
- port: 47392
651
- };
652
- if (existsSync(serverFile)) {
653
- serverInfo = JSON.parse(readFileSync(serverFile, 'utf8'));
654
- }
655
-
656
745
  // Try to check health endpoint
657
- let health = await checkServerHealth(serverInfo.port);
746
+ let health = await checkServerHealth(target.port);
658
747
  if (health.running) {
659
748
  // Calculate uptime
660
749
  let uptimeMs = null;
661
750
  let uptimeStr = '';
662
- if (serverInfo.startTime) {
663
- uptimeMs = Date.now() - serverInfo.startTime;
751
+ if (target.serverInfo?.startTime) {
752
+ uptimeMs = Date.now() - target.serverInfo.startTime;
664
753
  let uptime = Math.floor(uptimeMs / 1000);
665
754
  let hours = Math.floor(uptime / 3600);
666
755
  let minutes = Math.floor(uptime % 3600 / 60);
@@ -669,47 +758,31 @@ export async function tddStatusCommand(_options, globalOptions = {}) {
669
758
  if (minutes > 0 || hours > 0) uptimeStr += `${minutes}m `;
670
759
  uptimeStr += `${seconds}s`;
671
760
  }
672
- let dashboardUrl = buildDashboardUrl(serverInfo.port);
673
-
674
- // JSON output for running status
675
- if (globalOptions.json) {
676
- output.data({
677
- running: true,
678
- port: serverInfo.port,
679
- pid,
680
- uptimeMs,
681
- uptime: uptimeStr || null,
682
- dashboardUrl
683
- });
684
- return;
685
- }
686
- let colors = output.getColors();
687
-
688
- // Show header
689
- output.header('tdd', 'local');
690
-
691
- // Show running status with uptime
692
- output.print(` ${output.statusDot('success')} Running ${uptimeStr ? colors.brand.textTertiary(`· ${uptimeStr}`) : ''}`);
693
- output.blank();
694
-
695
- // Show dashboard URL in a branded box
696
- output.printBox(colors.brand.info(colors.underline(dashboardUrl)), {
697
- title: 'Dashboard',
698
- style: 'branded'
761
+ let dashboardUrl = buildDashboardUrl(target.port);
762
+ printRunningStatus({
763
+ serverInfo: target.serverInfo,
764
+ port: target.port,
765
+ pid,
766
+ uptimeMs,
767
+ uptimeStr,
768
+ dashboardUrl,
769
+ globalOptions
699
770
  });
700
-
701
- // Verbose mode: show PID
702
- if (globalOptions.verbose) {
703
- output.blank();
704
- output.print(` ${colors.brand.textTertiary('PID:')} ${pid}`);
705
- }
706
771
  } else {
707
772
  output.warn('TDD server process exists but not responding to health checks');
708
773
  }
709
774
  } catch (error) {
710
775
  if (error.code === 'ESRCH') {
776
+ cleanupDaemonTarget(target);
777
+ if (globalOptions.json) {
778
+ output.data({
779
+ status: 'stale',
780
+ running: false,
781
+ message: 'TDD server process not found; cleaned up stale files'
782
+ });
783
+ return;
784
+ }
711
785
  output.warn('TDD server process not found (cleaning up stale files)');
712
- cleanupDaemonState();
713
786
  } else {
714
787
  output.error('Error checking TDD server status', error);
715
788
  }
@@ -160,7 +160,8 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
160
160
  allowNoToken: config.allowNoToken || false,
161
161
  baselineBuildId: config.baselineBuildId,
162
162
  baselineComparisonId: config.baselineComparisonId,
163
- wait: false
163
+ wait: false,
164
+ json: globalOptions.json
164
165
  };
165
166
 
166
167
  // In daemon mode, just start the server without running tests