mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
20
package-lock.json
generated
20
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
packages/cli/src/commands/daemon/index.ts
Normal file
17
packages/cli/src/commands/daemon/index.ts
Normal 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
|
||||
}
|
||||
51
packages/cli/src/commands/daemon/restart.ts
Normal file
51
packages/cli/src/commands/daemon/restart.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
94
packages/cli/src/commands/daemon/start.ts
Normal file
94
packages/cli/src/commands/daemon/start.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
54
packages/cli/src/commands/daemon/status.ts
Normal file
54
packages/cli/src/commands/daemon/status.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
53
packages/cli/src/commands/daemon/stop.ts
Normal file
53
packages/cli/src/commands/daemon/stop.ts
Normal 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
packages/cli/src/utils/client.ts
Normal file
75
packages/cli/src/utils/client.ts
Normal 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
|
||||
}
|
||||
}
|
||||
53
packages/cli/tests/01-foundation.test.mts
Normal file
53
packages/cli/tests/01-foundation.test.mts
Normal 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')
|
||||
92
packages/cli/tests/run-all.mts
Normal file
92
packages/cli/tests/run-all.mts
Normal 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
138
packages/cli/tests/setup.ts
Normal 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)
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
7
packages/server/src/server/exports.ts
Normal file
7
packages/server/src/server/exports.ts
Normal 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";
|
||||
Reference in New Issue
Block a user