test: harden daemon lifecycle and e2e reliability across app/cli/server
This commit is contained in:
@@ -2,10 +2,10 @@
|
||||
* Test Daemon Helper
|
||||
*
|
||||
* Provides utilities for launching real Paseo daemons in E2E tests.
|
||||
* Each test gets an isolated daemon on a random port with its own PASEO_HOME.
|
||||
* Each test gets an isolated daemon on an available local port with its own PASEO_HOME.
|
||||
*
|
||||
* CRITICAL RULES (from design doc):
|
||||
* 1. Port: Random port in 20000-30000 range - NEVER use 6767 (production)
|
||||
* 1. Port: Use an available ephemeral local port - NEVER use 6767 (production)
|
||||
* 2. Protocol: WebSocket ONLY - daemon has no HTTP endpoints
|
||||
* 3. Temp dirs: Create temp directories for PASEO_HOME and agent --cwd
|
||||
* 4. Model: Always use claude provider with haiku model for fast, cheap tests
|
||||
@@ -17,9 +17,10 @@ import { existsSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { ChildProcess, spawn } from 'child_process'
|
||||
import { getAvailablePort } from './network.ts'
|
||||
|
||||
export interface TestDaemonContext {
|
||||
/** Random port for test daemon (never 6767) */
|
||||
/** Available local port for test daemon (never 6767) */
|
||||
port: number
|
||||
/** WebSocket URL for connecting to daemon */
|
||||
wsUrl: string
|
||||
@@ -35,6 +36,117 @@ export interface TestDaemonContext {
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
const TEST_DAEMON_ENV_DEFAULTS: Record<string, string> = {
|
||||
PASEO_RELAY_ENABLED: 'false',
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
|
||||
}
|
||||
const TEST_DAEMON_HOST = '127.0.0.1'
|
||||
|
||||
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024
|
||||
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
|
||||
process.env.PASEO_TEST_OUTPUT_CAPTURE_BYTES ?? `${DEFAULT_OUTPUT_CAPTURE_LIMIT}`,
|
||||
10
|
||||
)
|
||||
|
||||
type OutputCapture = {
|
||||
value: string
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
function createOutputCapture(): OutputCapture {
|
||||
return { value: '', truncated: false }
|
||||
}
|
||||
|
||||
function appendOutputCapture(target: OutputCapture, chunk: Buffer): void {
|
||||
const next = target.value + chunk.toString()
|
||||
if (next.length <= TEST_OUTPUT_CAPTURE_LIMIT) {
|
||||
target.value = next
|
||||
return
|
||||
}
|
||||
target.truncated = true
|
||||
target.value = next.slice(next.length - TEST_OUTPUT_CAPTURE_LIMIT)
|
||||
}
|
||||
|
||||
function formatOutputCapture(target: OutputCapture): string {
|
||||
if (!target.truncated) {
|
||||
return target.value
|
||||
}
|
||||
return `[truncated; showing last ${TEST_OUTPUT_CAPTURE_LIMIT} chars]\n${target.value}`
|
||||
}
|
||||
|
||||
function readNodeErrnoCode(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
||||
return undefined
|
||||
}
|
||||
return typeof (error as { code?: unknown }).code === 'string'
|
||||
? ((error as { code: string }).code)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function signalProcessTree(pid: number, signal: NodeJS.Signals): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
process.kill(-pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const code = readNodeErrnoCode(error)
|
||||
if (code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const code = readNodeErrnoCode(error)
|
||||
if (code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateProcessTree(processRef: ChildProcess, timeoutMs: number): Promise<void> {
|
||||
const pid = processRef.pid
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return
|
||||
}
|
||||
if (processRef.exitCode !== null || processRef.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
signalProcessTree(pid, 'SIGTERM')
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const finish = () => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeoutId)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
signalProcessTree(pid, 'SIGKILL')
|
||||
finish()
|
||||
}, timeoutMs)
|
||||
|
||||
processRef.once('exit', () => {
|
||||
finish()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random port for test daemon
|
||||
* Uses range 20000-30000 to avoid conflicts
|
||||
@@ -73,7 +185,7 @@ async function waitForDaemonReady(
|
||||
const { exitCode } = await runPaseoCli(
|
||||
{
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}`,
|
||||
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
|
||||
paseoHome: '',
|
||||
workDir: '',
|
||||
process: null,
|
||||
@@ -110,13 +222,13 @@ export async function startTestDaemon(options?: {
|
||||
workDir?: string
|
||||
timeout?: number
|
||||
}): Promise<TestDaemonContext> {
|
||||
const port = options?.port ?? getRandomPort()
|
||||
const port = options?.port ?? await getAvailablePort()
|
||||
const { paseoHome, workDir } = options?.paseoHome && options?.workDir
|
||||
? { paseoHome: options.paseoHome, workDir: options.workDir }
|
||||
: await createTempDirs()
|
||||
const timeout = options?.timeout ?? 30000
|
||||
|
||||
const wsUrl = `ws://127.0.0.1:${port}`
|
||||
const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`
|
||||
|
||||
// Find the CLI entry point - use the source file directly with tsx
|
||||
const cliDir = join(import.meta.dirname, '..', '..')
|
||||
@@ -126,43 +238,30 @@ export async function startTestDaemon(options?: {
|
||||
const daemonProcess = spawn('npx', ['tsx', cliSrcPath, 'daemon', 'start', '--foreground'], {
|
||||
env: {
|
||||
...process.env,
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
// Disable relay for tests
|
||||
PASEO_RELAY_ENABLED: 'false',
|
||||
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
|
||||
// Force no TTY to prevent QR code output
|
||||
CI: 'true',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
detached: process.platform !== 'win32',
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const stdout = createOutputCapture()
|
||||
const stderr = createOutputCapture()
|
||||
|
||||
daemonProcess.stdout?.on('data', (data) => {
|
||||
stdout += data.toString()
|
||||
appendOutputCapture(stdout, data)
|
||||
})
|
||||
|
||||
daemonProcess.stderr?.on('data', (data) => {
|
||||
stderr += data.toString()
|
||||
appendOutputCapture(stderr, data)
|
||||
})
|
||||
|
||||
const cleanup = async () => {
|
||||
if (daemonProcess && !daemonProcess.killed) {
|
||||
daemonProcess.kill('SIGTERM')
|
||||
// Wait for process to exit
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
daemonProcess.kill('SIGKILL')
|
||||
resolve()
|
||||
}, 5000)
|
||||
|
||||
daemonProcess.on('exit', () => {
|
||||
clearTimeout(timeoutId)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
if (daemonProcess) {
|
||||
await terminateProcessTree(daemonProcess, 5000)
|
||||
}
|
||||
|
||||
// Clean up temp directories
|
||||
@@ -191,8 +290,9 @@ export async function startTestDaemon(options?: {
|
||||
daemonProcess.on('exit', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`Daemon process exited with code ${code}`)
|
||||
if (stderr) {
|
||||
console.error('Daemon stderr:', stderr)
|
||||
const stderrText = formatOutputCapture(stderr)
|
||||
if (stderrText) {
|
||||
console.error('Daemon stderr:', stderrText)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -215,7 +315,9 @@ export async function startTestDaemon(options?: {
|
||||
// Daemon failed to start - clean up and rethrow
|
||||
await cleanup()
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`Failed to start test daemon: ${message}\nStdout: ${stdout}\nStderr: ${stderr}`)
|
||||
throw new Error(
|
||||
`Failed to start test daemon: ${message}\nStdout: ${formatOutputCapture(stdout)}\nStderr: ${formatOutputCapture(stderr)}`
|
||||
)
|
||||
}
|
||||
|
||||
return ctx
|
||||
@@ -245,26 +347,30 @@ export async function runPaseoCli(
|
||||
const proc = spawn('npx', ['tsx', cliSrcPath, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOST: `localhost:${ctx.port}`,
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
|
||||
PASEO_HOME: ctx.paseoHome,
|
||||
},
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32',
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const stdout = createOutputCapture()
|
||||
const stderr = createOutputCapture()
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
stdout += data.toString()
|
||||
appendOutputCapture(stdout, data)
|
||||
})
|
||||
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data.toString()
|
||||
appendOutputCapture(stderr, data)
|
||||
})
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
proc.kill('SIGKILL')
|
||||
if (proc.pid) {
|
||||
signalProcessTree(proc.pid, 'SIGKILL')
|
||||
}
|
||||
reject(new Error(`CLI command timed out after ${timeout}ms: paseo ${args.join(' ')}`))
|
||||
}, timeout)
|
||||
|
||||
@@ -272,8 +378,8 @@ export async function runPaseoCli(
|
||||
clearTimeout(timeoutId)
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout,
|
||||
stderr,
|
||||
stdout: formatOutputCapture(stdout),
|
||||
stderr: formatOutputCapture(stderr),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user