From 2975c0951c490a650876400880093edab46c7916 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Wed, 28 Jan 2026 16:08:01 -0600 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20Add=20menubar=20app=20support?= =?UTF-8?q?=20with=20global=20server=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ServerRegistry class to manage running servers at ~/.vizzly/servers.json - Register/unregister servers on tdd start/stop - Add `tdd list` command to show running servers - Save user's PATH to config.json for menubar app to use - Notify menubar via DistributedNotification when servers change --- src/cli.js | 15 ++++ src/commands/tdd-daemon.js | 122 ++++++++++++++++++++++++- src/tdd/server-registry.js | 180 +++++++++++++++++++++++++++++++++++++ src/utils/global-config.js | 27 ++++++ 4 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 src/tdd/server-registry.js diff --git a/src/cli.js b/src/cli.js index 86c91502..6c2c564f 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import 'dotenv/config'; import { program } from 'commander'; +import { saveUserPath } from './utils/global-config.js'; import { doctorCommand, validateDoctorOptions } from './commands/doctor.js'; import { finalizeCommand, @@ -22,6 +23,7 @@ import { statusCommand, validateStatusOptions } from './commands/status.js'; import { tddCommand, validateTddOptions } from './commands/tdd.js'; import { runDaemonChild, + tddListCommand, tddStartCommand, tddStatusCommand, tddStopCommand, @@ -406,6 +408,15 @@ tddCmd await tddStatusCommand(options, globalOptions); }); +// TDD List - List all running servers (for menubar app integration) +tddCmd + .command('list') + .description('List all running TDD servers') + .action(async options => { + const globalOptions = program.opts(); + await tddListCommand(options, globalOptions); + }); + // TDD Run - One-off test run with ephemeral server (generates static report) tddCmd .command('run ') @@ -752,4 +763,8 @@ program await projectRemoveCommand(options, globalOptions); }); +// Save user's PATH for menubar app before parsing commands +// This auto-configures the menubar app so it can find npx/node +await saveUserPath().catch(() => {}); + program.parse(); diff --git a/src/commands/tdd-daemon.js b/src/commands/tdd-daemon.js index 931570d4..fec53f05 100644 --- a/src/commands/tdd-daemon.js +++ b/src/commands/tdd-daemon.js @@ -7,9 +7,10 @@ import { writeFileSync, } from 'node:fs'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import * as output from '../utils/output.js'; import { tddCommand } from './tdd.js'; +import { getServerRegistry } from '../tdd/server-registry.js'; /** * Start TDD server in daemon mode @@ -163,7 +164,26 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { process.exit(1); } - // Write server info to global location for SDK discovery (iOS/Swift can read this) + // Register server in global registry (for menubar app) + try { + let registry = getServerRegistry() + + // Clean up any stale servers first + registry.cleanupStale() + + // Register this server + registry.register({ + pid: child.pid, + port: port, + directory: process.cwd(), + name: basename(process.cwd()), + startedAt: new Date().toISOString(), + }) + } catch { + // Non-fatal + } + + // Also write legacy server.json for SDK discovery (backwards compatibility) try { const globalVizzlyDir = join(homedir(), '.vizzly'); if (!existsSync(globalVizzlyDir)) { @@ -266,7 +286,15 @@ export async function runDaemonChild(options = {}, globalOptions = {}) { const serverFile = join(vizzlyDir, 'server.json'); if (existsSync(serverFile)) unlinkSync(serverFile); - // Clean up global server file + // Unregister from global registry (for menubar app) + try { + let registry = getServerRegistry() + registry.unregister({ port: port, directory: process.cwd() }) + } catch { + // Non-fatal + } + + // Clean up legacy global server file try { const globalServerFile = join(homedir(), '.vizzly', 'server.json'); if (existsSync(globalServerFile)) unlinkSync(globalServerFile); @@ -389,12 +417,30 @@ export async function tddStopCommand(options = {}, globalOptions = {}) { // Clean up files if (existsSync(pidFile)) unlinkSync(pidFile); if (existsSync(serverFile)) unlinkSync(serverFile); + + // Unregister from global registry (for menubar app) + try { + let registry = getServerRegistry() + registry.unregister({ port: port, directory: process.cwd() }) + } catch { + // Non-fatal + } + + output.print(` ${output.statusDot('success')} Server stopped`); } catch (error) { if (error.code === 'ESRCH') { // Process not found - clean up stale files output.warn('TDD server was not running (cleaning up stale files)'); if (existsSync(pidFile)) unlinkSync(pidFile); if (existsSync(serverFile)) unlinkSync(serverFile); + + // Still unregister from registry + try { + let registry = getServerRegistry() + registry.unregister({ port: port, directory: process.cwd() }) + } catch { + // Non-fatal + } } else { output.error('Error stopping TDD server', error); } @@ -542,3 +588,73 @@ function openDashboard(port = 47392) { stdio: 'ignore', }).unref(); } + +/** + * List all running TDD servers from the global registry + * @param {Object} options - Command options + * @param {Object} globalOptions - Global CLI options + */ +export async function tddListCommand(_options, globalOptions = {}) { + output.configure({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + let registry = getServerRegistry() + + // Clean up stale servers first + let cleaned = registry.cleanupStale() + if (cleaned > 0 && globalOptions.verbose) { + output.debug('tdd', `Cleaned up ${cleaned} stale server(s)`) + } + + let servers = registry.list() + + // JSON output + if (globalOptions.json) { + console.log(JSON.stringify({ servers }, null, 2)) + return + } + + // No servers + if (servers.length === 0) { + output.info('No TDD servers running') + output.hint('Start one with: vizzly tdd start') + return + } + + // Table output + let colors = output.getColors() + + output.header('tdd', 'servers') + output.blank() + + for (let server of servers) { + let uptimeStr = '' + if (server.startedAt) { + let startTime = new Date(server.startedAt).getTime() + let uptime = Math.floor((Date.now() - startTime) / 1000) + let hours = Math.floor(uptime / 3600) + let minutes = Math.floor((uptime % 3600) / 60) + if (hours > 0) uptimeStr += `${hours}h ` + if (minutes > 0 || hours > 0) uptimeStr += `${minutes}m` + else uptimeStr = '<1m' + } + + let name = server.name || basename(server.directory) + let portStr = colors.brand.textTertiary(`:${server.port}`) + let uptimeLabel = uptimeStr ? colors.brand.textMuted(` · ${uptimeStr}`) : '' + + output.print(` ${output.statusDot('success')} ${name}${portStr}${uptimeLabel}`) + output.print(` ${colors.brand.textMuted(server.directory)}`) + + if (globalOptions.verbose) { + output.print(` ${colors.brand.textMuted(`PID: ${server.pid}`)}`) + } + + output.blank() + } + + output.print(` ${colors.brand.textTertiary(`${servers.length} server(s) running`)}`) +} diff --git a/src/tdd/server-registry.js b/src/tdd/server-registry.js new file mode 100644 index 00000000..1bf975de --- /dev/null +++ b/src/tdd/server-registry.js @@ -0,0 +1,180 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' +import { execSync } from 'child_process' +import { randomBytes } from 'crypto' + +/** + * Manages a global registry of running TDD servers at ~/.vizzly/servers.json + * Enables the menubar app to discover and manage multiple concurrent servers. + */ +export class ServerRegistry { + constructor() { + this.vizzlyHome = process.env.VIZZLY_HOME || join(homedir(), '.vizzly') + this.registryPath = join(this.vizzlyHome, 'servers.json') + } + + /** + * Ensure the registry directory exists + */ + ensureDirectory() { + if (!existsSync(this.vizzlyHome)) { + mkdirSync(this.vizzlyHome, { recursive: true }) + } + } + + /** + * Read the current registry, returning empty if it doesn't exist + */ + read() { + try { + if (existsSync(this.registryPath)) { + let data = JSON.parse(readFileSync(this.registryPath, 'utf8')) + return { + version: data.version || 1, + servers: data.servers || [], + } + } + } catch (err) { + // Corrupted file, start fresh + console.warn('Warning: Could not read server registry, starting fresh') + } + return { version: 1, servers: [] } + } + + /** + * Write the registry to disk + */ + write(registry) { + this.ensureDirectory() + writeFileSync(this.registryPath, JSON.stringify(registry, null, 2)) + } + + /** + * Register a new server in the registry + */ + register(serverInfo) { + let registry = this.read() + + // Remove any existing entry for this port or directory (shouldn't happen, but be safe) + registry.servers = registry.servers.filter( + (s) => s.port !== serverInfo.port && s.directory !== serverInfo.directory + ) + + // Add the new server + registry.servers.push({ + id: serverInfo.id || randomBytes(8).toString('hex'), + port: Number(serverInfo.port), + pid: Number(serverInfo.pid), + directory: serverInfo.directory, + startedAt: serverInfo.startedAt || new Date().toISOString(), + configPath: serverInfo.configPath || null, + name: serverInfo.name || null, + logFile: serverInfo.logFile || null, + }) + + this.write(registry) + this.notifyMenubar() + + return registry + } + + /** + * Unregister a server by port or directory + */ + unregister({ port, directory }) { + let registry = this.read() + let initialCount = registry.servers.length + + if (port) { + registry.servers = registry.servers.filter((s) => s.port !== port) + } + if (directory) { + registry.servers = registry.servers.filter((s) => s.directory !== directory) + } + + if (registry.servers.length !== initialCount) { + this.write(registry) + this.notifyMenubar() + } + + return registry + } + + /** + * Find a server by port or directory + */ + find({ port, directory }) { + let registry = this.read() + + if (port) { + return registry.servers.find((s) => s.port === port) + } + if (directory) { + return registry.servers.find((s) => s.directory === directory) + } + return null + } + + /** + * Get all registered servers + */ + list() { + return this.read().servers + } + + /** + * Remove servers whose PIDs no longer exist (stale entries) + */ + cleanupStale() { + let registry = this.read() + let initialCount = registry.servers.length + + registry.servers = registry.servers.filter((server) => { + try { + // Signal 0 doesn't kill, just checks if process exists + process.kill(server.pid, 0) + return true + } catch (err) { + // ESRCH = process doesn't exist, EPERM = exists but no permission (still valid) + return err.code === 'EPERM' + } + }) + + if (registry.servers.length !== initialCount) { + this.write(registry) + this.notifyMenubar() + return initialCount - registry.servers.length + } + + return 0 + } + + /** + * Notify the menubar app that the registry changed + * Uses macOS DistributedNotificationCenter via osascript + */ + notifyMenubar() { + if (process.platform !== 'darwin') return + + try { + // Post a distributed notification that the menubar app listens for + execSync( + `osascript -e 'tell application "System Events" to post notification with name "dev.vizzly.serverChanged"'`, + { stdio: 'ignore', timeout: 1000 } + ) + } catch (err) { + // Non-fatal - menubar might not be running or osascript might fail + } + } +} + +// Singleton instance +let registryInstance = null + +export function getServerRegistry() { + if (!registryInstance) { + registryInstance = new ServerRegistry() + } + return registryInstance +} diff --git a/src/utils/global-config.js b/src/utils/global-config.js index 1a38b56c..09455a71 100644 --- a/src/utils/global-config.js +++ b/src/utils/global-config.js @@ -99,6 +99,33 @@ export async function clearGlobalConfig() { await saveGlobalConfig({}); } +/** + * Save user's PATH for menubar app to use + * This auto-configures the menubar app so it can find npx/node + * @returns {Promise} + */ +export async function saveUserPath() { + let config = await loadGlobalConfig(); + let userPath = process.env.PATH; + + // Only update if PATH has changed + if (config.userPath === userPath) { + return; + } + + config.userPath = userPath; + await saveGlobalConfig(config); +} + +/** + * Get stored user PATH for external tools (like menubar app) + * @returns {Promise} PATH string or null if not configured + */ +export async function getUserPath() { + let config = await loadGlobalConfig(); + return config.userPath || null; +} + /** * Get authentication tokens from global config * @returns {Promise} Token object with accessToken, refreshToken, expiresAt, user, or null if not found From 54ce7cee27d0e18679747e9ec1e9d2c7e9e2d4e6 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Mon, 2 Feb 2026 22:06:30 -0600 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20Add=20auto-port=20allocation=20?= =?UTF-8?q?for=20multiple=20TDD=20servers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When starting a TDD server without --port, automatically find an available port instead of failing if 47392 is busy. This enables running multiple projects simultaneously without manual port config. - Add findAvailablePort() to ServerRegistry - Check both registry and actual TCP binding - Show "Auto-assigned port :47393" when using non-default - Add 17 tests for server registry functionality --- src/cli.js | 2 +- src/commands/tdd-daemon.js | 161 +++++++++++------- src/tdd/server-registry.js | 169 +++++++++++++------ tests/tdd/server-registry.test.js | 264 ++++++++++++++++++++++++++++++ 4 files changed, 485 insertions(+), 111 deletions(-) create mode 100644 tests/tdd/server-registry.test.js diff --git a/src/cli.js b/src/cli.js index 6c2c564f..1923452d 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,7 +1,6 @@ #!/usr/bin/env node import 'dotenv/config'; import { program } from 'commander'; -import { saveUserPath } from './utils/global-config.js'; import { doctorCommand, validateDoctorOptions } from './commands/doctor.js'; import { finalizeCommand, @@ -41,6 +40,7 @@ import { openBrowser } from './utils/browser.js'; import { colors } from './utils/colors.js'; import { loadConfig } from './utils/config-loader.js'; import { getContext } from './utils/context.js'; +import { saveUserPath } from './utils/global-config.js'; import * as output from './utils/output.js'; import { getPackageVersion } from './utils/package-info.js'; diff --git a/src/commands/tdd-daemon.js b/src/commands/tdd-daemon.js index fec53f05..5eeafb7f 100644 --- a/src/commands/tdd-daemon.js +++ b/src/commands/tdd-daemon.js @@ -8,9 +8,9 @@ import { } from 'node:fs'; import { homedir } from 'node:os'; import { basename, join } from 'node:path'; +import { getServerRegistry } from '../tdd/server-registry.js'; import * as output from '../utils/output.js'; import { tddCommand } from './tdd.js'; -import { getServerRegistry } from '../tdd/server-registry.js'; /** * Start TDD server in daemon mode @@ -24,37 +24,69 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { color: !globalOptions.noColor, }); - // Check if server already running - if (await isServerRunning(options.port || 47392)) { - const port = options.port || 47392; - let colors = output.getColors(); + let registry = getServerRegistry(); + let colors = output.getColors(); - output.header('tdd', 'local'); - output.print(` ${output.statusDot('success')} Already running`); - output.blank(); - output.printBox( - colors.brand.info(colors.underline(`http://localhost:${port}`)), - { - title: 'Dashboard', - style: 'branded', - } - ); + // Check if THIS directory already has a server running + let existingServer = registry.find({ directory: process.cwd() }); + if (existingServer) { + // Verify it's actually running + if (await isServerRunning(existingServer.port)) { + output.header('tdd', 'local'); + output.print(` ${output.statusDot('success')} Already running`); + output.blank(); + output.printBox( + colors.brand.info( + colors.underline(`http://localhost:${existingServer.port}`) + ), + { + title: 'Dashboard', + style: 'branded', + } + ); - if (options.open) { - openDashboard(port); + if (options.open) { + openDashboard(existingServer.port); + } + return; + } else { + // Stale entry - clean it up + registry.unregister({ directory: process.cwd() }); } + } + + // Determine port: user-specified or auto-allocate + let port; + let autoAllocated = false; + + if (options.port) { + // User specified a port - use it (will fail if busy) + port = options.port; + } else { + // Auto-allocate an available port + port = await registry.findAvailablePort(); + autoAllocated = port !== 47392; + } + + // If user specified a port, check if it's in use + if (options.port && (await isServerRunning(port))) { + output.header('tdd', 'local'); + output.print( + ` ${output.statusDot('error')} Port ${port} is already in use` + ); + output.blank(); + output.hint('Try a different port: vizzly tdd start --port 47393'); + output.hint('Or let Vizzly auto-allocate: vizzly tdd start'); return; } try { // Ensure .vizzly directory exists - const vizzlyDir = join(process.cwd(), '.vizzly'); + let vizzlyDir = join(process.cwd(), '.vizzly'); if (!existsSync(vizzlyDir)) { mkdirSync(vizzlyDir, { recursive: true }); } - const port = options.port || 47392; - // Show header first so debug messages appear below it output.header('tdd', 'local'); @@ -166,10 +198,10 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { // Register server in global registry (for menubar app) try { - let registry = getServerRegistry() + let registry = getServerRegistry(); // Clean up any stale servers first - registry.cleanupStale() + registry.cleanupStale(); // Register this server registry.register({ @@ -178,7 +210,7 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { directory: process.cwd(), name: basename(process.cwd()), startedAt: new Date().toISOString(), - }) + }); } catch { // Non-fatal } @@ -200,8 +232,13 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { // Non-fatal, SDK can still use health check } - // Get colors for styled output - let colors = output.getColors(); + // Show auto-allocated port message if applicable + if (autoAllocated) { + output.print( + ` ${output.statusDot('info')} Auto-assigned port ${colors.brand.textTertiary(`:${port}`)}` + ); + output.blank(); + } // Show dashboard URL in a branded box let dashboardUrl = `http://localhost:${port}`; @@ -288,8 +325,8 @@ export async function runDaemonChild(options = {}, globalOptions = {}) { // Unregister from global registry (for menubar app) try { - let registry = getServerRegistry() - registry.unregister({ port: port, directory: process.cwd() }) + let registry = getServerRegistry(); + registry.unregister({ port: port, directory: process.cwd() }); } catch { // Non-fatal } @@ -420,8 +457,8 @@ export async function tddStopCommand(options = {}, globalOptions = {}) { // Unregister from global registry (for menubar app) try { - let registry = getServerRegistry() - registry.unregister({ port: port, directory: process.cwd() }) + let registry = getServerRegistry(); + registry.unregister({ port: port, directory: process.cwd() }); } catch { // Non-fatal } @@ -436,8 +473,8 @@ export async function tddStopCommand(options = {}, globalOptions = {}) { // Still unregister from registry try { - let registry = getServerRegistry() - registry.unregister({ port: port, directory: process.cwd() }) + let registry = getServerRegistry(); + registry.unregister({ port: port, directory: process.cwd() }); } catch { // Non-fatal } @@ -601,60 +638,66 @@ export async function tddListCommand(_options, globalOptions = {}) { color: !globalOptions.noColor, }); - let registry = getServerRegistry() + let registry = getServerRegistry(); // Clean up stale servers first - let cleaned = registry.cleanupStale() + let cleaned = registry.cleanupStale(); if (cleaned > 0 && globalOptions.verbose) { - output.debug('tdd', `Cleaned up ${cleaned} stale server(s)`) + output.debug('tdd', `Cleaned up ${cleaned} stale server(s)`); } - let servers = registry.list() + let servers = registry.list(); // JSON output if (globalOptions.json) { - console.log(JSON.stringify({ servers }, null, 2)) - return + console.log(JSON.stringify({ servers }, null, 2)); + return; } // No servers if (servers.length === 0) { - output.info('No TDD servers running') - output.hint('Start one with: vizzly tdd start') - return + output.info('No TDD servers running'); + output.hint('Start one with: vizzly tdd start'); + return; } // Table output - let colors = output.getColors() + let colors = output.getColors(); - output.header('tdd', 'servers') - output.blank() + output.header('tdd', 'servers'); + output.blank(); for (let server of servers) { - let uptimeStr = '' + let uptimeStr = ''; if (server.startedAt) { - let startTime = new Date(server.startedAt).getTime() - let uptime = Math.floor((Date.now() - startTime) / 1000) - let hours = Math.floor(uptime / 3600) - let minutes = Math.floor((uptime % 3600) / 60) - if (hours > 0) uptimeStr += `${hours}h ` - if (minutes > 0 || hours > 0) uptimeStr += `${minutes}m` - else uptimeStr = '<1m' + let startTime = new Date(server.startedAt).getTime(); + let uptime = Math.floor((Date.now() - startTime) / 1000); + let hours = Math.floor(uptime / 3600); + let minutes = Math.floor((uptime % 3600) / 60); + if (hours > 0) uptimeStr += `${hours}h `; + if (minutes > 0 || hours > 0) uptimeStr += `${minutes}m`; + else uptimeStr = '<1m'; } - let name = server.name || basename(server.directory) - let portStr = colors.brand.textTertiary(`:${server.port}`) - let uptimeLabel = uptimeStr ? colors.brand.textMuted(` · ${uptimeStr}`) : '' + let name = server.name || basename(server.directory); + let portStr = colors.brand.textTertiary(`:${server.port}`); + let uptimeLabel = uptimeStr + ? colors.brand.textMuted(` · ${uptimeStr}`) + : ''; - output.print(` ${output.statusDot('success')} ${name}${portStr}${uptimeLabel}`) - output.print(` ${colors.brand.textMuted(server.directory)}`) + output.print( + ` ${output.statusDot('success')} ${name}${portStr}${uptimeLabel}` + ); + output.print(` ${colors.brand.textMuted(server.directory)}`); if (globalOptions.verbose) { - output.print(` ${colors.brand.textMuted(`PID: ${server.pid}`)}`) + output.print(` ${colors.brand.textMuted(`PID: ${server.pid}`)}`); } - output.blank() + output.blank(); } - output.print(` ${colors.brand.textTertiary(`${servers.length} server(s) running`)}`) + output.print( + ` ${colors.brand.textTertiary(`${servers.length} server(s) running`)}` + ); } diff --git a/src/tdd/server-registry.js b/src/tdd/server-registry.js index 1bf975de..19106179 100644 --- a/src/tdd/server-registry.js +++ b/src/tdd/server-registry.js @@ -1,8 +1,9 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' -import { join } from 'path' -import { homedir } from 'os' -import { execSync } from 'child_process' -import { randomBytes } from 'crypto' +import { execSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; /** * Manages a global registry of running TDD servers at ~/.vizzly/servers.json @@ -10,8 +11,8 @@ import { randomBytes } from 'crypto' */ export class ServerRegistry { constructor() { - this.vizzlyHome = process.env.VIZZLY_HOME || join(homedir(), '.vizzly') - this.registryPath = join(this.vizzlyHome, 'servers.json') + this.vizzlyHome = process.env.VIZZLY_HOME || join(homedir(), '.vizzly'); + this.registryPath = join(this.vizzlyHome, 'servers.json'); } /** @@ -19,7 +20,7 @@ export class ServerRegistry { */ ensureDirectory() { if (!existsSync(this.vizzlyHome)) { - mkdirSync(this.vizzlyHome, { recursive: true }) + mkdirSync(this.vizzlyHome, { recursive: true }); } } @@ -29,37 +30,37 @@ export class ServerRegistry { read() { try { if (existsSync(this.registryPath)) { - let data = JSON.parse(readFileSync(this.registryPath, 'utf8')) + let data = JSON.parse(readFileSync(this.registryPath, 'utf8')); return { version: data.version || 1, servers: data.servers || [], - } + }; } - } catch (err) { + } catch (_err) { // Corrupted file, start fresh - console.warn('Warning: Could not read server registry, starting fresh') + console.warn('Warning: Could not read server registry, starting fresh'); } - return { version: 1, servers: [] } + return { version: 1, servers: [] }; } /** * Write the registry to disk */ write(registry) { - this.ensureDirectory() - writeFileSync(this.registryPath, JSON.stringify(registry, null, 2)) + this.ensureDirectory(); + writeFileSync(this.registryPath, JSON.stringify(registry, null, 2)); } /** * Register a new server in the registry */ register(serverInfo) { - let registry = this.read() + let registry = this.read(); // Remove any existing entry for this port or directory (shouldn't happen, but be safe) registry.servers = registry.servers.filter( - (s) => s.port !== serverInfo.port && s.directory !== serverInfo.directory - ) + s => s.port !== serverInfo.port && s.directory !== serverInfo.directory + ); // Add the new server registry.servers.push({ @@ -71,83 +72,85 @@ export class ServerRegistry { configPath: serverInfo.configPath || null, name: serverInfo.name || null, logFile: serverInfo.logFile || null, - }) + }); - this.write(registry) - this.notifyMenubar() + this.write(registry); + this.notifyMenubar(); - return registry + return registry; } /** * Unregister a server by port or directory */ unregister({ port, directory }) { - let registry = this.read() - let initialCount = registry.servers.length + let registry = this.read(); + let initialCount = registry.servers.length; if (port) { - registry.servers = registry.servers.filter((s) => s.port !== port) + registry.servers = registry.servers.filter(s => s.port !== port); } if (directory) { - registry.servers = registry.servers.filter((s) => s.directory !== directory) + registry.servers = registry.servers.filter( + s => s.directory !== directory + ); } if (registry.servers.length !== initialCount) { - this.write(registry) - this.notifyMenubar() + this.write(registry); + this.notifyMenubar(); } - return registry + return registry; } /** * Find a server by port or directory */ find({ port, directory }) { - let registry = this.read() + let registry = this.read(); if (port) { - return registry.servers.find((s) => s.port === port) + return registry.servers.find(s => s.port === port); } if (directory) { - return registry.servers.find((s) => s.directory === directory) + return registry.servers.find(s => s.directory === directory); } - return null + return null; } /** * Get all registered servers */ list() { - return this.read().servers + return this.read().servers; } /** * Remove servers whose PIDs no longer exist (stale entries) */ cleanupStale() { - let registry = this.read() - let initialCount = registry.servers.length + let registry = this.read(); + let initialCount = registry.servers.length; - registry.servers = registry.servers.filter((server) => { + registry.servers = registry.servers.filter(server => { try { // Signal 0 doesn't kill, just checks if process exists - process.kill(server.pid, 0) - return true + process.kill(server.pid, 0); + return true; } catch (err) { // ESRCH = process doesn't exist, EPERM = exists but no permission (still valid) - return err.code === 'EPERM' + return err.code === 'EPERM'; } - }) + }); if (registry.servers.length !== initialCount) { - this.write(registry) - this.notifyMenubar() - return initialCount - registry.servers.length + this.write(registry); + this.notifyMenubar(); + return initialCount - registry.servers.length; } - return 0 + return 0; } /** @@ -155,26 +158,90 @@ export class ServerRegistry { * Uses macOS DistributedNotificationCenter via osascript */ notifyMenubar() { - if (process.platform !== 'darwin') return + if (process.platform !== 'darwin') return; try { // Post a distributed notification that the menubar app listens for execSync( `osascript -e 'tell application "System Events" to post notification with name "dev.vizzly.serverChanged"'`, { stdio: 'ignore', timeout: 1000 } - ) - } catch (err) { + ); + } catch (_err) { // Non-fatal - menubar might not be running or osascript might fail } } + + /** + * Get all ports currently in use by registered servers + * @returns {Set} Set of ports in use + */ + getUsedPorts() { + let registry = this.read(); + return new Set(registry.servers.map(s => s.port)); + } + + /** + * Find an available port starting from the default + * @param {number} startPort - Port to start searching from (default: 47392) + * @param {number} maxAttempts - Maximum ports to try (default: 100) + * @returns {Promise} Available port + */ + async findAvailablePort(startPort = 47392, maxAttempts = 100) { + // Clean up stale entries first + this.cleanupStale(); + + let usedPorts = this.getUsedPorts(); + + for (let i = 0; i < maxAttempts; i++) { + let port = startPort + i; + + // Skip if registered in our registry + if (usedPorts.has(port)) continue; + + // Check if port is actually free (not used by other apps) + let isFree = await isPortFree(port); + if (isFree) { + return port; + } + } + + // Fallback to default if nothing found (will fail later with clear error) + return startPort; + } +} + +/** + * Check if a port is free (not in use by any process) + * @param {number} port - Port to check + * @returns {Promise} True if port is free + */ +async function isPortFree(port) { + return new Promise(resolve => { + let server = createServer(); + + server.once('error', err => { + if (err.code === 'EADDRINUSE') { + resolve(false); + } else { + // Other errors - assume port is free + resolve(true); + } + }); + + server.once('listening', () => { + server.close(() => resolve(true)); + }); + + server.listen(port, '127.0.0.1'); + }); } // Singleton instance -let registryInstance = null +let registryInstance = null; export function getServerRegistry() { if (!registryInstance) { - registryInstance = new ServerRegistry() + registryInstance = new ServerRegistry(); } - return registryInstance + return registryInstance; } diff --git a/tests/tdd/server-registry.test.js b/tests/tdd/server-registry.test.js new file mode 100644 index 00000000..475b1c81 --- /dev/null +++ b/tests/tdd/server-registry.test.js @@ -0,0 +1,264 @@ +import assert from 'node:assert'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { ServerRegistry } from '../../src/tdd/server-registry.js'; + +/** + * Create an isolated registry using a temp directory + */ +function createTestRegistry(testDir) { + let registry = new ServerRegistry(); + registry.vizzlyHome = testDir; + registry.registryPath = join(testDir, 'servers.json'); + // Disable menubar notifications in tests + registry.notifyMenubar = () => {}; + return registry; +} + +/** + * Start a TCP server on a port (to simulate a port being in use) + */ +function occupyPort(port) { + return new Promise((resolve, reject) => { + let server = createServer(); + server.once('error', reject); + server.once('listening', () => resolve(server)); + server.listen(port, '127.0.0.1'); + }); +} + +describe('tdd/server-registry', () => { + let testDir; + let registry; + + beforeEach(() => { + testDir = join( + tmpdir(), + `vizzly-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + registry = createTestRegistry(testDir); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('register and find', () => { + it('can register a server and find it by directory', () => { + registry.register({ + pid: 12345, + port: 47392, + directory: '/projects/my-app', + name: 'my-app', + }); + + let found = registry.find({ directory: '/projects/my-app' }); + assert.strictEqual(found.port, 47392); + assert.strictEqual(found.pid, 12345); + assert.strictEqual(found.name, 'my-app'); + }); + + it('can register a server and find it by port', () => { + registry.register({ + pid: 12345, + port: 47393, + directory: '/projects/my-app', + }); + + let found = registry.find({ port: 47393 }); + assert.strictEqual(found.directory, '/projects/my-app'); + }); + + it('returns null when server not found', () => { + let found = registry.find({ directory: '/nonexistent' }); + assert.strictEqual(found, undefined); + }); + }); + + describe('unregister', () => { + it('removes server by directory', () => { + registry.register({ + pid: 12345, + port: 47392, + directory: '/projects/app-a', + }); + registry.register({ + pid: 12346, + port: 47393, + directory: '/projects/app-b', + }); + + registry.unregister({ directory: '/projects/app-a' }); + + assert.strictEqual( + registry.find({ directory: '/projects/app-a' }), + undefined + ); + assert.notStrictEqual( + registry.find({ directory: '/projects/app-b' }), + undefined + ); + }); + + it('removes server by port', () => { + registry.register({ + pid: 12345, + port: 47392, + directory: '/projects/app-a', + }); + + registry.unregister({ port: 47392 }); + + assert.strictEqual(registry.find({ port: 47392 }), undefined); + }); + }); + + describe('list', () => { + it('returns all registered servers', () => { + registry.register({ pid: 1, port: 47392, directory: '/a' }); + registry.register({ pid: 2, port: 47393, directory: '/b' }); + registry.register({ pid: 3, port: 47394, directory: '/c' }); + + let servers = registry.list(); + assert.strictEqual(servers.length, 3); + }); + + it('returns empty array when no servers', () => { + let servers = registry.list(); + assert.deepStrictEqual(servers, []); + }); + }); + + describe('getUsedPorts', () => { + it('returns set of all registered ports', () => { + registry.register({ pid: 1, port: 47392, directory: '/a' }); + registry.register({ pid: 2, port: 47395, directory: '/b' }); + + let usedPorts = registry.getUsedPorts(); + assert.strictEqual(usedPorts.has(47392), true); + assert.strictEqual(usedPorts.has(47395), true); + assert.strictEqual(usedPorts.has(47393), false); + }); + }); + + describe('cleanupStale', () => { + it('removes servers with non-existent PIDs', () => { + // Register a server with a PID that definitely doesn't exist + registry.register({ + pid: 999999999, + port: 47392, + directory: '/projects/dead-app', + }); + + let cleaned = registry.cleanupStale(); + + assert.strictEqual(cleaned, 1); + assert.strictEqual( + registry.find({ directory: '/projects/dead-app' }), + undefined + ); + }); + + it('keeps servers with existing PIDs', () => { + // Use current process PID - definitely exists + registry.register({ + pid: process.pid, + port: 47392, + directory: '/projects/live-app', + }); + + let cleaned = registry.cleanupStale(); + + assert.strictEqual(cleaned, 0); + assert.notStrictEqual( + registry.find({ directory: '/projects/live-app' }), + null + ); + }); + }); + + describe('findAvailablePort', () => { + it('returns default port when nothing is using it', async () => { + let port = await registry.findAvailablePort(47392); + assert.strictEqual(port, 47392); + }); + + it('skips ports registered in the registry', async () => { + // Register servers on 47392 and 47393 with current PID so they're not cleaned up + registry.register({ pid: process.pid, port: 47392, directory: '/a' }); + registry.register({ pid: process.pid, port: 47393, directory: '/b' }); + + let port = await registry.findAvailablePort(47392); + assert.strictEqual(port, 47394); + }); + + it('skips ports actually in use by other processes', async () => { + // Actually occupy port 47500 with a real TCP server + let occupyingServer = await occupyPort(47500); + + try { + let port = await registry.findAvailablePort(47500); + assert.strictEqual(port, 47501); + } finally { + occupyingServer.close(); + } + }); + + it('handles combination of registered and occupied ports', async () => { + // Register 47600 in registry + registry.register({ pid: process.pid, port: 47600, directory: '/a' }); + + // Actually occupy 47601 + let occupyingServer = await occupyPort(47601); + + try { + let port = await registry.findAvailablePort(47600); + // Should skip 47600 (registered) and 47601 (occupied), return 47602 + assert.strictEqual(port, 47602); + } finally { + occupyingServer.close(); + } + }); + }); + + describe('corrupted registry file', () => { + it('recovers from corrupted JSON', () => { + writeFileSync(registry.registryPath, 'not valid json{{{'); + + // Should not throw, returns empty + let servers = registry.list(); + assert.deepStrictEqual(servers, []); + + // Can still register new servers + registry.register({ pid: 1, port: 47392, directory: '/a' }); + assert.strictEqual(registry.list().length, 1); + }); + }); + + describe('replaces existing entries', () => { + it('replaces server when same directory is registered again', () => { + registry.register({ pid: 1, port: 47392, directory: '/projects/app' }); + registry.register({ pid: 2, port: 47393, directory: '/projects/app' }); + + let servers = registry.list(); + assert.strictEqual(servers.length, 1); + assert.strictEqual(servers[0].port, 47393); + assert.strictEqual(servers[0].pid, 2); + }); + + it('replaces server when same port is registered again', () => { + registry.register({ pid: 1, port: 47392, directory: '/projects/app-a' }); + registry.register({ pid: 2, port: 47392, directory: '/projects/app-b' }); + + let servers = registry.list(); + assert.strictEqual(servers.length, 1); + assert.strictEqual(servers[0].directory, '/projects/app-b'); + }); + }); +}); From 3497d08ffd1864c56f2502a5e1912ca515033c30 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Mon, 2 Feb 2026 22:36:01 -0600 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=A8=20Add=20JSON=20log=20file=20outpu?= =?UTF-8?q?t=20for=20menubar=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD daemon now writes JSON logs to .vizzly/server.log that the menubar app can read and display. Log format is NDJSON with timestamp, level, message, and contextual data. - Configure output.logFile in daemon child process - Register logFile path in server registry - Include logFile in server.json for local discovery --- src/commands/tdd-daemon.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/commands/tdd-daemon.js b/src/commands/tdd-daemon.js index 5eeafb7f..b7b9884b 100644 --- a/src/commands/tdd-daemon.js +++ b/src/commands/tdd-daemon.js @@ -203,13 +203,15 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { // Clean up any stale servers first registry.cleanupStale(); - // Register this server + // Register this server with log file path for menubar to read + let serverLogFile = join(process.cwd(), '.vizzly', 'server.log'); registry.register({ pid: child.pid, port: port, directory: process.cwd(), name: basename(process.cwd()), startedAt: new Date().toISOString(), + logFile: serverLogFile, }); } catch { // Non-fatal @@ -284,6 +286,17 @@ export async function runDaemonChild(options = {}, globalOptions = {}) { const vizzlyDir = join(process.cwd(), '.vizzly'); const port = options.port || 47392; + // Set up log file for menubar app to read + const logFile = join(vizzlyDir, 'server.log'); + + // Configure output to write JSON logs to file (before tddCommand configures it) + output.configure({ + logFile, + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + try { // Use existing tddCommand but with daemon mode const { cleanup } = await tddCommand( @@ -309,6 +322,7 @@ export async function runDaemonChild(options = {}, globalOptions = {}) { port: port, startTime: Date.now(), failOnDiff: options.failOnDiff || false, + logFile: logFile, }; writeFileSync( join(vizzlyDir, 'server.json'), From 8fc16bd755246363c807afa1bc154bef308b8fd6 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Mon, 2 Feb 2026 23:55:56 -0600 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20Fix=20code=20review=20issues?= =?UTF-8?q?=20for=20menubar=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses feedback from PR #200 code review: **Critical fixes:** - Make saveUserPath() non-blocking to avoid CLI startup delays - Add comment documenting the race condition mitigation strategy **Bug fixes:** - Remove broken osascript notification (file watching is primary) - Clean up local .vizzly files when detecting stale registry entries **Code quality:** - Add input validation to register() (required fields, number checks) - Fix unregister() to use AND logic when both port and directory provided - Fix flaky test by using high port range (48500+) to avoid conflicts --- src/cli.js | 4 +-- src/commands/tdd-daemon.js | 35 +++++++++++++-------- src/tdd/server-registry.js | 51 +++++++++++++++++++------------ tests/tdd/server-registry.test.js | 17 ++++++----- 4 files changed, 65 insertions(+), 42 deletions(-) diff --git a/src/cli.js b/src/cli.js index 1923452d..34cdf767 100644 --- a/src/cli.js +++ b/src/cli.js @@ -763,8 +763,8 @@ program await projectRemoveCommand(options, globalOptions); }); -// Save user's PATH for menubar app before parsing commands +// Save user's PATH for menubar app (non-blocking, runs in background) // This auto-configures the menubar app so it can find npx/node -await saveUserPath().catch(() => {}); +saveUserPath().catch(() => {}); program.parse(); diff --git a/src/commands/tdd-daemon.js b/src/commands/tdd-daemon.js index b7b9884b..608e4c62 100644 --- a/src/commands/tdd-daemon.js +++ b/src/commands/tdd-daemon.js @@ -50,8 +50,14 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { } return; } else { - // Stale entry - clean it up + // Stale entry - clean it up (registry and local files) registry.unregister({ directory: process.cwd() }); + + let vizzlyDir = join(process.cwd(), '.vizzly'); + let pidFile = join(vizzlyDir, 'server.pid'); + let serverFile = join(vizzlyDir, 'server.json'); + if (existsSync(pidFile)) unlinkSync(pidFile); + if (existsSync(serverFile)) unlinkSync(serverFile); } } @@ -62,24 +68,27 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { if (options.port) { // User specified a port - use it (will fail if busy) port = options.port; + + // Check if user-specified port is in use + if (await isServerRunning(port)) { + output.header('tdd', 'local'); + output.print( + ` ${output.statusDot('error')} Port ${port} is already in use` + ); + output.blank(); + output.hint('Try a different port: vizzly tdd start --port 47393'); + output.hint('Or let Vizzly auto-allocate: vizzly tdd start'); + return; + } } else { // Auto-allocate an available port + // Note: There's a small race window between finding a port and binding. + // The registry acts as a soft reservation, and findAvailablePort does + // an actual TCP bind test to minimize this window. port = await registry.findAvailablePort(); autoAllocated = port !== 47392; } - // If user specified a port, check if it's in use - if (options.port && (await isServerRunning(port))) { - output.header('tdd', 'local'); - output.print( - ` ${output.statusDot('error')} Port ${port} is already in use` - ); - output.blank(); - output.hint('Try a different port: vizzly tdd start --port 47393'); - output.hint('Or let Vizzly auto-allocate: vizzly tdd start'); - return; - } - try { // Ensure .vizzly directory exists let vizzlyDir = join(process.cwd(), '.vizzly'); diff --git a/src/tdd/server-registry.js b/src/tdd/server-registry.js index 19106179..96ecdaa0 100644 --- a/src/tdd/server-registry.js +++ b/src/tdd/server-registry.js @@ -1,4 +1,3 @@ -import { execSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:net'; @@ -55,18 +54,30 @@ export class ServerRegistry { * Register a new server in the registry */ register(serverInfo) { + // Validate required fields + if (!serverInfo.pid || !serverInfo.port || !serverInfo.directory) { + throw new Error('Missing required fields: pid, port, directory'); + } + + let port = Number(serverInfo.port); + let pid = Number(serverInfo.pid); + + if (Number.isNaN(port) || Number.isNaN(pid)) { + throw new Error('Invalid port or pid - must be numbers'); + } + let registry = this.read(); // Remove any existing entry for this port or directory (shouldn't happen, but be safe) registry.servers = registry.servers.filter( - s => s.port !== serverInfo.port && s.directory !== serverInfo.directory + s => s.port !== port && s.directory !== serverInfo.directory ); // Add the new server registry.servers.push({ id: serverInfo.id || randomBytes(8).toString('hex'), - port: Number(serverInfo.port), - pid: Number(serverInfo.pid), + port, + pid, directory: serverInfo.directory, startedAt: serverInfo.startedAt || new Date().toISOString(), configPath: serverInfo.configPath || null, @@ -81,16 +92,22 @@ export class ServerRegistry { } /** - * Unregister a server by port or directory + * Unregister a server by port and/or directory + * When both are provided, matches servers with BOTH criteria (AND logic) + * When only one is provided, matches servers with that criteria */ unregister({ port, directory }) { let registry = this.read(); let initialCount = registry.servers.length; - if (port) { + if (port && directory) { + // Both specified - match servers with both port AND directory + registry.servers = registry.servers.filter( + s => !(s.port === port && s.directory === directory) + ); + } else if (port) { registry.servers = registry.servers.filter(s => s.port !== port); - } - if (directory) { + } else if (directory) { registry.servers = registry.servers.filter( s => s.directory !== directory ); @@ -155,20 +172,14 @@ export class ServerRegistry { /** * Notify the menubar app that the registry changed - * Uses macOS DistributedNotificationCenter via osascript + * + * NOTE: The menubar app primarily uses FSEvents file watching on servers.json. + * This method is a placeholder for future notification mechanisms (e.g., XPC). + * For now, file watching provides reliable, immediate updates. */ notifyMenubar() { - if (process.platform !== 'darwin') return; - - try { - // Post a distributed notification that the menubar app listens for - execSync( - `osascript -e 'tell application "System Events" to post notification with name "dev.vizzly.serverChanged"'`, - { stdio: 'ignore', timeout: 1000 } - ); - } catch (_err) { - // Non-fatal - menubar might not be running or osascript might fail - } + // File watching on servers.json is the primary notification mechanism. + // This method exists for future enhancements (XPC, etc.) but is currently a no-op. } /** diff --git a/tests/tdd/server-registry.test.js b/tests/tdd/server-registry.test.js index 475b1c81..c01d0cae 100644 --- a/tests/tdd/server-registry.test.js +++ b/tests/tdd/server-registry.test.js @@ -184,18 +184,21 @@ describe('tdd/server-registry', () => { }); describe('findAvailablePort', () => { + // Use high port range (48500+) to avoid conflicts with running TDD servers + let testBasePort = 48500; + it('returns default port when nothing is using it', async () => { - let port = await registry.findAvailablePort(47392); - assert.strictEqual(port, 47392); + let port = await registry.findAvailablePort(testBasePort); + assert.strictEqual(port, testBasePort); }); it('skips ports registered in the registry', async () => { - // Register servers on 47392 and 47393 with current PID so they're not cleaned up - registry.register({ pid: process.pid, port: 47392, directory: '/a' }); - registry.register({ pid: process.pid, port: 47393, directory: '/b' }); + // Register servers on testBasePort and testBasePort+1 with current PID so they're not cleaned up + registry.register({ pid: process.pid, port: testBasePort, directory: '/a' }); + registry.register({ pid: process.pid, port: testBasePort + 1, directory: '/b' }); - let port = await registry.findAvailablePort(47392); - assert.strictEqual(port, 47394); + let port = await registry.findAvailablePort(testBasePort); + assert.strictEqual(port, testBasePort + 2); }); it('skips ports actually in use by other processes', async () => { From 76f7649a5e9ffaabb1baf3362f9c6b48aaa08405 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Mon, 2 Feb 2026 23:55:56 -0600 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=90=9B=20Fix=20code=20review=20issues?= =?UTF-8?q?=20for=20menubar=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses feedback from PR #200 code review: **Critical fixes:** - Make saveUserPath() non-blocking to avoid CLI startup delays - Add comment documenting the race condition mitigation strategy **Bug fixes:** - Remove broken osascript notification (file watching is primary) - Clean up local .vizzly files when detecting stale registry entries **Code quality:** - Add input validation to register() (required fields, number checks) - Fix unregister() to use AND logic when both port and directory provided - Fix flaky test by using high port range (48500+) to avoid conflicts --- tests/tdd/server-registry.test.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/tdd/server-registry.test.js b/tests/tdd/server-registry.test.js index c01d0cae..ffb35d59 100644 --- a/tests/tdd/server-registry.test.js +++ b/tests/tdd/server-registry.test.js @@ -194,8 +194,16 @@ describe('tdd/server-registry', () => { it('skips ports registered in the registry', async () => { // Register servers on testBasePort and testBasePort+1 with current PID so they're not cleaned up - registry.register({ pid: process.pid, port: testBasePort, directory: '/a' }); - registry.register({ pid: process.pid, port: testBasePort + 1, directory: '/b' }); + registry.register({ + pid: process.pid, + port: testBasePort, + directory: '/a', + }); + registry.register({ + pid: process.pid, + port: testBasePort + 1, + directory: '/b', + }); let port = await registry.findAvailablePort(testBasePort); assert.strictEqual(port, testBasePort + 2);