test(cli): add zx TDD infrastructure and phase 1 tests

- Install zx as devDependency for E2E testing
- Create tests/ directory with setup utilities
- Add setup.ts with test context helpers:
  - Random port generation (never 6767)
  - Temp directory creation for PASEO_HOME and workDir
  - Daemon startup/waitForDaemon helper (WebSocket only)
  - Cleanup function
  - paseo() helper to run CLI commands against test daemon
- Add Phase 1 foundation tests (01-foundation.test.mts):
  - Test paseo --version outputs version
  - Test paseo --help shows commands
- Add test runner (run-all.mts) that discovers and runs all tests
- Add test:e2e script to package.json
This commit is contained in:
Mohamed Boudra
2026-01-28 22:47:08 +07:00
parent 6781c1b888
commit 6fd4214466
14 changed files with 664 additions and 12 deletions
+4 -7
View File
@@ -1,4 +1,5 @@
import { Command } from 'commander'
import { createDaemonCommand } from './commands/daemon/index.js'
const VERSION = '0.1.0'
@@ -10,7 +11,7 @@ export function createCli(): Command {
.description('Paseo CLI - control your AI coding agents from the command line')
.version(VERSION, '-v, --version', 'output the version number')
// Placeholder subcommands for Phase 1
// Placeholder subcommands
program
.command('agent')
.description('Manage agents')
@@ -18,12 +19,8 @@ export function createCli(): Command {
console.log('agent command (not yet implemented)')
})
program
.command('daemon')
.description('Manage the Paseo daemon')
.action(() => {
console.log('daemon command (not yet implemented)')
})
// Real daemon command
program.addCommand(createDaemonCommand())
program
.command('permit')
+17
View File
@@ -0,0 +1,17 @@
import { Command } from 'commander'
import { startCommand } from './start.js'
import { statusCommand } from './status.js'
import { stopCommand } from './stop.js'
import { restartCommand } from './restart.js'
export function createDaemonCommand(): Command {
const daemon = new Command('daemon')
.description('Manage the Paseo daemon')
daemon.addCommand(startCommand())
daemon.addCommand(statusCommand())
daemon.addCommand(stopCommand())
daemon.addCommand(restartCommand())
return daemon
}
@@ -0,0 +1,51 @@
import { Command } from 'commander'
import chalk from 'chalk'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
export function restartCommand(): Command {
return new Command('restart')
.description('Restart the daemon')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(async (options: { host?: string }) => {
await runRestart(options)
})
}
async function runRestart(options: { host?: string }): Promise<void> {
const host = getDaemonHost(options)
let client
try {
client = await connectToDaemon(options)
} catch {
console.log(chalk.yellow('Daemon is not running'))
console.log(chalk.dim(`Tried to connect to ${host}`))
console.log()
console.log(chalk.dim('Start the daemon with:'))
console.log(chalk.dim(' paseo daemon start'))
process.exit(1)
}
try {
console.log(chalk.dim('Restarting daemon...'))
// Request server restart
await client.restartServer('cli_restart')
console.log(chalk.green('Daemon restart requested'))
await client.close()
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
// If connection was closed, the daemon is restarting
if (message.includes('closed') || message.includes('disconnected')) {
console.log(chalk.green('Daemon is restarting'))
process.exit(0)
}
console.error(chalk.red(`Failed to restart daemon: ${message}`))
process.exit(1)
}
}
+94
View File
@@ -0,0 +1,94 @@
import { Command } from 'commander'
import chalk from 'chalk'
import {
createPaseoDaemon,
loadConfig,
resolvePaseoHome,
createRootLogger,
loadPersistedConfig,
} from '@paseo/server'
interface StartOptions {
port?: string
home?: string
foreground?: boolean
noRelay?: boolean
}
export function startCommand(): Command {
return new Command('start')
.description('Start the Paseo daemon')
.option('--port <port>', 'Port to listen on (default: 6767)')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--foreground', 'Run in foreground (don\'t daemonize)')
.option('--no-relay', 'Disable relay connection')
.action(async (options: StartOptions) => {
await runStart(options)
})
}
async function runStart(options: StartOptions): Promise<void> {
// Set environment variables based on CLI options
if (options.home) {
process.env.PASEO_HOME = options.home
}
if (options.port) {
process.env.PASEO_LISTEN = `127.0.0.1:${options.port}`
}
const paseoHome = resolvePaseoHome()
const persistedConfig = loadPersistedConfig(paseoHome)
const logger = createRootLogger(persistedConfig)
const config = loadConfig(paseoHome)
// Apply CLI overrides
if (options.noRelay) {
config.relayEnabled = false
}
// For now, only foreground mode is supported
// TODO: Implement daemonization in a future phase
if (!options.foreground) {
console.log(chalk.yellow('Note: Background daemon mode not yet implemented. Running in foreground.'))
}
const daemon = await createPaseoDaemon(config, logger)
// Handle graceful shutdown
let shuttingDown = false
const handleShutdown = async (signal: string) => {
if (shuttingDown) {
logger.info('Forcing exit...')
process.exit(1)
}
shuttingDown = true
logger.info(`${signal} received, shutting down gracefully... (press Ctrl+C again to force exit)`)
const forceExit = setTimeout(() => {
logger.warn('Forcing shutdown - HTTP server didn\'t close in time')
process.exit(1)
}, 10000)
try {
await daemon.stop()
clearTimeout(forceExit)
logger.info('Server closed')
process.exit(0)
} catch (err) {
clearTimeout(forceExit)
logger.error({ err }, 'Shutdown failed')
process.exit(1)
}
}
process.on('SIGTERM', () => handleShutdown('SIGTERM'))
process.on('SIGINT', () => handleShutdown('SIGINT'))
try {
await daemon.start()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(chalk.red(`Failed to start daemon: ${message}`))
process.exit(1)
}
}
@@ -0,0 +1,54 @@
import { Command } from 'commander'
import chalk from 'chalk'
import { resolvePaseoHome } from '@paseo/server'
import { tryConnectToDaemon, getDaemonHost } from '../../utils/client.js'
export function statusCommand(): Command {
return new Command('status')
.description('Show daemon status')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(async (options: { host?: string }) => {
await runStatus(options)
})
}
async function runStatus(options: { host?: string }): Promise<void> {
const host = getDaemonHost(options)
const client = await tryConnectToDaemon(options)
if (!client) {
console.log(chalk.red('Status:'), 'not running')
console.log(chalk.dim(`Tried to connect to ${host}`))
console.log()
console.log(chalk.dim('Start the daemon with:'))
console.log(chalk.dim(' paseo daemon start'))
process.exit(1)
}
try {
// Request session state to get agent information
client.requestSessionState()
// Wait a moment for the session state to be populated
await new Promise(resolve => setTimeout(resolve, 500))
const agents = client.listAgents()
const runningAgents = agents.filter(a => a.status === 'running')
const idleAgents = agents.filter(a => a.status === 'idle')
// Get paseo home for display
const paseoHome = resolvePaseoHome()
console.log(chalk.green('Status:'), 'running')
console.log(chalk.dim('Host:'), host)
console.log(chalk.dim('Home:'), paseoHome)
console.log(chalk.dim('Agents:'), `${runningAgents.length} running, ${idleAgents.length} idle`)
await client.close()
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
console.error(chalk.red(`Failed to get status: ${message}`))
process.exit(1)
}
}
+53
View File
@@ -0,0 +1,53 @@
import { Command } from 'commander'
import chalk from 'chalk'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
export function stopCommand(): Command {
return new Command('stop')
.description('Stop the daemon')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(async (options: { host?: string }) => {
await runStop(options)
})
}
async function runStop(options: { host?: string }): Promise<void> {
const host = getDaemonHost(options)
let client
try {
client = await connectToDaemon(options)
} catch {
console.log(chalk.yellow('Daemon is not running'))
console.log(chalk.dim(`Tried to connect to ${host}`))
process.exit(0)
}
try {
console.log(chalk.dim('Stopping daemon...'))
// Request server restart with "shutdown" reason
// This signals the daemon to shut down gracefully
await client.restartServer('cli_shutdown')
// Give the daemon a moment to acknowledge
await new Promise(resolve => setTimeout(resolve, 500))
console.log(chalk.green('Daemon stop requested'))
console.log(chalk.dim('The daemon will shut down gracefully'))
await client.close()
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
// If connection was closed, the daemon is stopping
if (message.includes('closed') || message.includes('disconnected')) {
console.log(chalk.green('Daemon is stopping'))
process.exit(0)
}
console.error(chalk.red(`Failed to stop daemon: ${message}`))
process.exit(1)
}
}
+75
View File
@@ -0,0 +1,75 @@
import { DaemonClientV2 } from '@paseo/server'
import WebSocket from 'ws'
export interface ConnectOptions {
host?: string
timeout?: number
}
const DEFAULT_HOST = 'localhost:6767'
const DEFAULT_TIMEOUT = 5000
/**
* Get the daemon host from environment or options
*/
export function getDaemonHost(options?: ConnectOptions): string {
return options?.host ?? process.env.PASEO_HOST ?? DEFAULT_HOST
}
/**
* Create a WebSocket factory that works in Node.js
*/
function createNodeWebSocketFactory() {
return (url: string, options?: { headers?: Record<string, string> }) => {
return new WebSocket(url, { headers: options?.headers }) as unknown as {
readyState: number
send: (data: string) => void
close: (code?: number, reason?: string) => void
on: (event: string, listener: (...args: unknown[]) => void) => void
off: (event: string, listener: (...args: unknown[]) => void) => void
}
}
}
/**
* Create and connect a daemon client
* Returns the connected client or throws if connection fails
*/
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2> {
const host = getDaemonHost(options)
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const url = `ws://${host}/ws`
const client = new DaemonClientV2({
url,
webSocketFactory: createNodeWebSocketFactory(),
reconnect: { enabled: false },
})
// Connect with timeout
const connectPromise = client.connect()
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Connection timeout after ${timeout}ms`))
}, timeout)
})
try {
await Promise.race([connectPromise, timeoutPromise])
return client
} catch (err) {
await client.close().catch(() => {})
throw err
}
}
/**
* Try to connect to the daemon, returns null if connection fails
*/
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2 | null> {
try {
return await connectToDaemon(options)
} catch {
return null
}
}