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

20
package-lock.json generated
View File

@@ -26240,6 +26240,19 @@
}
}
},
"node_modules/zx": {
"version": "8.8.5",
"resolved": "https://registry.npmjs.org/zx/-/zx-8.8.5.tgz",
"integrity": "sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"zx": "build/cli.js"
},
"engines": {
"node": ">= 12.17.0"
}
},
"packages/app": {
"name": "@paseo/app",
"version": "1.0.0",
@@ -26355,14 +26368,17 @@
"dependencies": {
"@paseo/server": "*",
"chalk": "^5.3.0",
"commander": "^12.0.0"
"commander": "^12.0.0",
"ws": "^8.14.2"
},
"bin": {
"paseo": "bin/paseo"
},
"devDependencies": {
"@types/ws": "^8.5.8",
"tsx": "^4.6.0",
"typescript": "^5.2.2"
"typescript": "^5.2.2",
"zx": "^8.8.5"
}
},
"packages/cli/node_modules/chalk": {

View File

@@ -7,15 +7,19 @@
"paseo": "./bin/paseo"
},
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test:e2e": "npx zx tests/run-all.mts"
},
"dependencies": {
"@paseo/server": "*",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"chalk": "^5.3.0"
"ws": "^8.14.2"
},
"devDependencies": {
"@types/ws": "^8.5.8",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"tsx": "^4.6.0"
"zx": "^8.8.5"
}
}

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')

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
}

View File

@@ -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)
}
}

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)
}
}

View File

@@ -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)
}
}

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)
}
}

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
}
}

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env npx zx
/**
* Phase 1: Foundation Tests
*
* Tests basic CLI functionality that doesn't require a daemon:
* - paseo --version outputs version
* - paseo --help shows commands
*/
import { $ } from 'zx'
$.verbose = false
console.log('📋 Phase 1: Foundation Tests\n')
// Test 1.1: --version outputs version
console.log(' Testing paseo --version...')
const versionResult = await $`paseo --version`.nothrow()
if (versionResult.exitCode !== 0) {
console.error(' ❌ paseo --version failed with exit code', versionResult.exitCode)
console.error(' stderr:', versionResult.stderr)
process.exit(1)
}
const versionOutput = versionResult.stdout.trim()
if (!versionOutput.match(/\d+\.\d+\.\d+/)) {
console.error(' ❌ paseo --version output does not contain version number')
console.error(' output:', versionOutput)
process.exit(1)
}
console.log(' ✅ paseo --version outputs:', versionOutput)
// Test 1.2: --help shows commands
console.log(' Testing paseo --help...')
const helpResult = await $`paseo --help`.nothrow()
if (helpResult.exitCode !== 0) {
console.error(' ❌ paseo --help failed with exit code', helpResult.exitCode)
console.error(' stderr:', helpResult.stderr)
process.exit(1)
}
const helpOutput = helpResult.stdout
// Check for expected sections in help output
const expectedTerms = ['agent', 'daemon', 'Usage', 'Options', 'Commands']
const missingTerms = expectedTerms.filter(term => !helpOutput.includes(term))
if (missingTerms.length > 0) {
console.error(' ❌ paseo --help missing expected terms:', missingTerms.join(', '))
console.error(' output:', helpOutput)
process.exit(1)
}
console.log(' ✅ paseo --help shows commands')
console.log('\n✅ Phase 1: Foundation Tests PASSED')

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env npx zx
/**
* Test runner for Paseo CLI E2E tests
*
* Runs all test phases in sequence and reports results.
* Each test is a separate .mts file that can also be run independently.
*/
import { $ } from 'zx'
import { readdir } from 'fs/promises'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
$.verbose = false
console.log('🧪 Paseo CLI E2E Test Runner\n')
console.log('='.repeat(50))
// Discover all test files
const files = await readdir(__dirname)
const testFiles = files
.filter(f => f.match(/^\d{2}-.*\.test\.mts$/))
.sort()
if (testFiles.length === 0) {
console.log('⚠️ No test files found')
process.exit(0)
}
console.log(`Found ${testFiles.length} test file(s):\n`)
for (const file of testFiles) {
console.log(` - ${file}`)
}
console.log()
let passed = 0
let failed = 0
const failures: { test: string; error: string }[] = []
for (const testFile of testFiles) {
const testPath = join(__dirname, testFile)
const testName = testFile.replace(/\.test\.mts$/, '')
console.log(`\n${'─'.repeat(50)}`)
console.log(`📋 Running ${testName}...`)
console.log('─'.repeat(50))
try {
const result = await $`npx zx ${testPath}`.nothrow()
if (result.exitCode === 0) {
console.log(`\n✅ ${testName} PASSED`)
passed++
} else {
console.log(`\n❌ ${testName} FAILED (exit code: ${result.exitCode})`)
if (result.stderr) {
console.log('stderr:', result.stderr)
}
failed++
failures.push({ test: testName, error: result.stderr || `Exit code: ${result.exitCode}` })
}
} catch (e) {
const error = e instanceof Error ? e.message : String(e)
console.log(`\n❌ ${testName} FAILED`)
console.log('Error:', error)
failed++
failures.push({ test: testName, error })
}
}
// Summary
console.log('\n' + '='.repeat(50))
console.log('📊 Test Results')
console.log('='.repeat(50))
console.log(` ✅ Passed: ${passed}`)
console.log(` ❌ Failed: ${failed}`)
console.log(` 📝 Total: ${passed + failed}`)
if (failures.length > 0) {
console.log('\n❌ Failed tests:')
for (const { test, error } of failures) {
console.log(` - ${test}`)
if (error) {
console.log(` ${error.split('\n')[0]}`)
}
}
}
console.log()
process.exit(failed > 0 ? 1 : 0)

138
packages/cli/tests/setup.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* Test setup utilities for Paseo CLI E2E tests
*
* Critical rules from design doc:
* 1. Port: Random port via 10000 + Math.floor(Math.random() * 50000) - NEVER 6767
* 2. Protocol: WebSocket ONLY - daemon has no HTTP endpoints
* 3. Temp dirs: Create temp directories for PASEO_HOME and agent --cwd
* 4. Model: Always --provider claude with haiku model for agent tests
* 5. Cleanup: Kill daemon and remove temp dirs after each test
*/
import { $, ProcessPromise, sleep } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
export interface TestContext {
/** Random port for test daemon (never 6767) */
port: number
/** Temp directory for PASEO_HOME */
paseoHome: string
/** Temp directory for agent working directory */
workDir: string
/** Running daemon process */
daemon: ProcessPromise | null
/** Run a paseo CLI command against the test daemon */
paseo: (args: string[]) => ProcessPromise
/** Clean up all resources */
cleanup: () => Promise<void>
}
/**
* Generate a random port for test daemon
* NEVER uses 6767 (user's running daemon)
*/
export function getRandomPort(): number {
return 10000 + Math.floor(Math.random() * 50000)
}
/**
* Create isolated temp directories for testing
*/
export async function createTempDirs(): Promise<{ paseoHome: string; workDir: string }> {
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const workDir = await mkdtemp(join(tmpdir(), 'paseo-test-work-'))
return { paseoHome, workDir }
}
/**
* Wait for daemon to be ready by testing WebSocket connection
* Uses `paseo agent ps` which connects via WebSocket
*/
export async function waitForDaemon(port: number, timeout = 30000): Promise<void> {
const start = Date.now()
while (Date.now() - start < timeout) {
try {
const result = await $`PASEO_HOST=localhost:${port} paseo agent ps`.nothrow()
if (result.exitCode === 0) return
} catch {
// Connection failed, keep trying
}
await sleep(100)
}
throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`)
}
/**
* Start an isolated test daemon
*/
export async function startDaemon(
port: number,
paseoHome: string
): Promise<ProcessPromise> {
$.verbose = false
const daemon = $`PASEO_HOME=${paseoHome} PASEO_PORT=${port} paseo daemon start --foreground`.nothrow()
return daemon
}
/**
* Create a full test context with daemon, temp dirs, and helpers
*/
export async function createTestContext(): Promise<TestContext> {
const port = getRandomPort()
const { paseoHome, workDir } = await createTempDirs()
// Helper to run CLI commands against test daemon
const paseo = (args: string[]): ProcessPromise => {
$.verbose = false
return $`PASEO_HOST=localhost:${port} paseo ${args}`.nothrow()
}
// Cleanup function
const cleanup = async (): Promise<void> => {
if (ctx.daemon) {
ctx.daemon.kill()
}
await rm(paseoHome, { recursive: true, force: true })
await rm(workDir, { recursive: true, force: true })
}
const ctx: TestContext = {
port,
paseoHome,
workDir,
daemon: null,
paseo,
cleanup,
}
return ctx
}
/**
* Create a test context and start the daemon
* Use this for tests that need a running daemon
*/
export async function createTestContextWithDaemon(): Promise<TestContext> {
const ctx = await createTestContext()
ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome)
await waitForDaemon(ctx.port)
return ctx
}
/**
* Register cleanup handlers for process exit
*/
export function registerCleanupHandlers(cleanup: () => Promise<void>): void {
const handler = async () => {
await cleanup()
process.exit(0)
}
process.on('exit', () => {
// Can't await in exit handler, but at least try to kill daemon
})
process.on('SIGINT', handler)
process.on('SIGTERM', handler)
}

View File

@@ -4,6 +4,7 @@
"description": "Paseo backend server",
"type": "module",
"exports": {
".": "./src/server/exports.ts",
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts"
},
"scripts": {

View File

@@ -0,0 +1,7 @@
// CLI exports for @paseo/server
export { createPaseoDaemon, type PaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js";
export { loadConfig } from "./config.js";
export { resolvePaseoHome } from "./paseo-home.js";
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
export { DaemonClientV2, type DaemonClientV2Config, type ConnectionState, type DaemonEvent } from "../client/daemon-client-v2.js";