@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
478 lines (434 loc) • 14.9 kB
JavaScript
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import * as output from '../utils/output.js';
import { tddCommand } from './tdd.js';
/**
* Start TDD server in daemon mode
* @param {Object} options - Command options
* @param {Object} globalOptions - Global CLI options
*/
export async function tddStartCommand(options = {}, globalOptions = {}) {
output.configure({
json: globalOptions.json,
verbose: globalOptions.verbose,
color: !globalOptions.noColor
});
// Check if server already running
if (await isServerRunning(options.port || 47392)) {
const port = options.port || 47392;
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'
});
if (options.open) {
openDashboard(port);
}
return;
}
try {
// Ensure .vizzly directory exists
const 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');
// Show loading indicator if downloading baselines (but not in verbose mode since child shows progress)
if (options.baselineBuild && !globalOptions.verbose) {
output.startSpinner(`Downloading baselines from build ${options.baselineBuild}...`);
}
// Spawn child process with stdio inherited during init for direct error visibility
const child = spawn(process.execPath, [process.argv[1],
// CLI entry point
'tdd', 'start', '--daemon-child',
// Special flag for child process
'--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'] : [])], {
detached: true,
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
cwd: process.cwd()
});
// Wait for child to signal successful init or exit with error
let initComplete = false;
let initFailed = false;
await new Promise(resolve => {
// Child disconnects IPC when initialization succeeds
child.on('disconnect', () => {
initComplete = true;
resolve();
});
// Child exits before disconnecting = initialization failed
child.on('exit', () => {
if (!initComplete) {
initFailed = true;
resolve();
}
});
// Timeout after 30 seconds to prevent indefinite wait
const timeoutId = setTimeout(() => {
if (!initComplete && !initFailed) {
initFailed = true;
resolve();
}
}, 30000);
// Clear timeout if we resolve early
child.on('disconnect', () => clearTimeout(timeoutId));
child.on('exit', () => clearTimeout(timeoutId));
});
if (initFailed) {
if (options.baselineBuild && !globalOptions.verbose) {
output.stopSpinner();
}
output.error('TDD server failed to start');
process.exit(1);
}
// Unref so parent can exit
child.unref();
// Verify server started with retries
const maxRetries = 10;
const retryDelay = 200; // Start with 200ms
let running = false;
for (let i = 0; i < maxRetries && !running; i++) {
await new Promise(resolve => setTimeout(resolve, retryDelay * (i + 1)));
running = await isServerRunning(port);
}
if (options.baselineBuild && !globalOptions.verbose) {
output.stopSpinner();
}
if (!running) {
output.error('Failed to start TDD server - server not responding to health checks');
process.exit(1);
}
// Write server info to global location for SDK discovery (iOS/Swift can read this)
try {
const globalVizzlyDir = join(homedir(), '.vizzly');
if (!existsSync(globalVizzlyDir)) {
mkdirSync(globalVizzlyDir, {
recursive: true
});
}
const globalServerFile = join(globalVizzlyDir, 'server.json');
const serverInfo = {
pid: child.pid,
port: port.toString(),
startTime: Date.now()
};
writeFileSync(globalServerFile, JSON.stringify(serverInfo, null, 2));
} catch {
// Non-fatal, SDK can still use health check
}
// Get colors for styled output
let colors = output.getColors();
// Show dashboard URL in a branded box
let dashboardUrl = `http://localhost:${port}`;
output.printBox(colors.brand.info(colors.underline(dashboardUrl)), {
title: 'Dashboard',
style: 'branded'
});
// Verbose mode: show next steps
if (globalOptions.verbose) {
output.blank();
output.print(` ${colors.brand.textTertiary('Next steps')}`);
output.print(` ${colors.brand.textMuted('1.')} Run tests in watch mode ${colors.brand.textMuted('(npm test -- --watch)')}`);
output.print(` ${colors.brand.textMuted('2.')} Review visual changes in the dashboard`);
output.print(` ${colors.brand.textMuted('3.')} Accept or reject baseline updates`);
}
// Always show stop hint
output.blank();
output.hint('Stop with: vizzly tdd stop');
if (options.open) {
openDashboard(port);
}
} catch (error) {
output.error('Failed to start TDD daemon', error);
process.exit(1);
}
}
/**
* Internal function to run server in child process
* This is called when --daemon-child flag is present
* @private
*/
export async function runDaemonChild(options = {}, globalOptions = {}) {
const vizzlyDir = join(process.cwd(), '.vizzly');
const port = options.port || 47392;
try {
// Use existing tddCommand but with daemon mode
const {
cleanup
} = await tddCommand(null,
// No test command - server only
{
...options,
daemon: true
}, globalOptions);
// Disconnect IPC after successful initialization to signal parent
if (process.send) {
process.disconnect();
}
// Store our PID for the stop command
const pidFile = join(vizzlyDir, 'server.pid');
writeFileSync(pidFile, process.pid.toString());
const serverInfo = {
pid: process.pid,
port: port,
startTime: Date.now(),
failOnDiff: options.failOnDiff || false
};
writeFileSync(join(vizzlyDir, 'server.json'), JSON.stringify(serverInfo, null, 2));
// Set up graceful shutdown
const handleShutdown = async () => {
try {
// Clean up PID files
if (existsSync(pidFile)) unlinkSync(pidFile);
const serverFile = join(vizzlyDir, 'server.json');
if (existsSync(serverFile)) unlinkSync(serverFile);
// Clean up global server file
try {
const globalServerFile = join(homedir(), '.vizzly', 'server.json');
if (existsSync(globalServerFile)) unlinkSync(globalServerFile);
} catch {
// Non-fatal
}
// Use the cleanup function from tddCommand
await cleanup();
} catch {
// Silent cleanup in daemon
}
process.exit(0);
};
// Register signal handlers
process.on('SIGINT', () => handleShutdown());
process.on('SIGTERM', () => handleShutdown());
// Keep process alive
process.stdin.resume();
} catch (error) {
// Most errors shown via inherited stdio, but catch any that weren't
console.error(`Fatal error: ${error.message}`);
process.exit(1);
}
}
/**
* Stop TDD daemon server
* @param {Object} options - Command options
* @param {Object} globalOptions - Global CLI options
*/
export async function tddStopCommand(options = {}, globalOptions = {}) {
output.configure({
json: globalOptions.json,
verbose: globalOptions.verbose,
color: !globalOptions.noColor
});
const vizzlyDir = join(process.cwd(), '.vizzly');
const pidFile = join(vizzlyDir, 'server.pid');
const serverFile = join(vizzlyDir, 'server.json');
// First try to find process by PID file
let pid = null;
if (existsSync(pidFile)) {
try {
pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
} catch {
// Invalid PID file
}
}
// If no PID file or invalid, try to find by port using lsof
const port = options.port || 47392;
if (!pid) {
try {
const lsofProcess = spawn('lsof', ['-ti', `:${port}`], {
stdio: 'pipe'
});
let lsofOutput = '';
lsofProcess.stdout.on('data', data => {
lsofOutput += data.toString();
});
await new Promise(resolve => {
lsofProcess.on('close', code => {
if (code === 0 && lsofOutput.trim()) {
const foundPid = parseInt(lsofOutput.trim().split('\n')[0], 10);
if (foundPid && !Number.isNaN(foundPid)) {
pid = foundPid;
}
}
resolve();
});
lsofProcess.on('error', () => {
// lsof not available, that's ok
resolve();
});
});
} catch {
// lsof failed, that's ok too
}
}
if (!pid) {
output.warn('No TDD server running');
// Clean up any stale files
if (existsSync(pidFile)) unlinkSync(pidFile);
if (existsSync(serverFile)) unlinkSync(serverFile);
return;
}
try {
let _colors = output.getColors();
// Try to kill the process gracefully
process.kill(pid, 'SIGTERM');
output.startSpinner('Stopping TDD server...');
// Give it a moment to shut down gracefully
await new Promise(resolve => setTimeout(resolve, 2000));
// Check if it's still running
try {
process.kill(pid, 0); // Just check if process exists
// If we get here, process is still running, force kill it
process.kill(pid, 'SIGKILL');
output.stopSpinner();
output.debug('tdd', 'Force killed process');
} catch {
// Process is gone, which is what we want
output.stopSpinner();
}
// Clean up files
if (existsSync(pidFile)) unlinkSync(pidFile);
if (existsSync(serverFile)) unlinkSync(serverFile);
} 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);
} else {
output.error('Error stopping TDD server', error);
}
}
}
/**
* Check TDD daemon server status
* @param {Object} options - Command options
* @param {Object} globalOptions - Global CLI options
*/
export async function tddStatusCommand(_options, globalOptions = {}) {
output.configure({
json: globalOptions.json,
verbose: globalOptions.verbose,
color: !globalOptions.noColor
});
const vizzlyDir = join(process.cwd(), '.vizzly');
const pidFile = join(vizzlyDir, 'server.pid');
const serverFile = join(vizzlyDir, 'server.json');
if (!existsSync(pidFile)) {
output.info('TDD server not running');
return;
}
try {
const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
// Check if process is actually running
process.kill(pid, 0); // Signal 0 just checks if process exists
let serverInfo = {
port: 47392
};
if (existsSync(serverFile)) {
serverInfo = JSON.parse(readFileSync(serverFile, 'utf8'));
}
// Try to check health endpoint
const health = await checkServerHealth(serverInfo.port);
if (health.running) {
let colors = output.getColors();
// Show header
output.header('tdd', 'local');
// Show running status with uptime
let uptimeStr = '';
if (serverInfo.startTime) {
const uptime = Math.floor((Date.now() - serverInfo.startTime) / 1000);
const hours = Math.floor(uptime / 3600);
const minutes = Math.floor(uptime % 3600 / 60);
const seconds = uptime % 60;
if (hours > 0) uptimeStr += `${hours}h `;
if (minutes > 0 || hours > 0) uptimeStr += `${minutes}m `;
uptimeStr += `${seconds}s`;
}
output.print(` ${output.statusDot('success')} Running ${uptimeStr ? colors.brand.textTertiary(`· ${uptimeStr}`) : ''}`);
output.blank();
// Show dashboard URL in a branded box
let dashboardUrl = `http://localhost:${serverInfo.port}`;
output.printBox(colors.brand.info(colors.underline(dashboardUrl)), {
title: 'Dashboard',
style: 'branded'
});
// Verbose mode: show PID
if (globalOptions.verbose) {
output.blank();
output.print(` ${colors.brand.textTertiary('PID:')} ${pid}`);
}
} else {
output.warn('TDD server process exists but not responding to health checks');
}
} catch (error) {
if (error.code === 'ESRCH') {
output.warn('TDD server process not found (cleaning up stale files)');
unlinkSync(pidFile);
if (existsSync(serverFile)) {
unlinkSync(serverFile);
}
} else {
output.error('Error checking TDD server status', error);
}
}
}
/**
* Check if server is running on given port
* @private
*/
async function isServerRunning(port = 47392) {
try {
const health = await checkServerHealth(port);
return health.running;
} catch {
return false;
}
}
/**
* Check server health endpoint
* @private
*/
async function checkServerHealth(port = 47392) {
try {
const response = await fetch(`http://localhost:${port}/health`);
const data = await response.json();
return {
running: response.ok,
port: data.port,
uptime: data.uptime
};
} catch {
return {
running: false
};
}
}
/**
* Open dashboard in default browser
* @private
*/
function openDashboard(port = 47392) {
const url = `http://localhost:${port}`;
// Cross-platform open command
let openCmd;
if (process.platform === 'darwin') {
openCmd = 'open';
} else if (process.platform === 'win32') {
openCmd = 'start';
} else {
openCmd = 'xdg-open';
}
spawn(openCmd, [url], {
detached: true,
stdio: 'ignore'
}).unref();
}