/** * Test Daemon Helper * * Provides utilities for launching real Paseo daemons in E2E tests. * Each test gets an isolated daemon on an available local port with its own PASEO_HOME. * * CRITICAL RULES (from design doc): * 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 * 5. Cleanup: Kill daemon and remove temp dirs after each test */ import { mkdtemp, rm, mkdir } from "fs/promises"; 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 { /** Available local port for test daemon (never 6767) */ port: number; /** WebSocket URL for connecting to daemon */ wsUrl: string; /** Temp directory for PASEO_HOME */ paseoHome: string; /** Temp directory for agent working directory */ workDir: string; /** Running daemon process */ process: ChildProcess | null; /** Whether the daemon is ready to accept connections */ isReady: boolean; /** Stop the daemon and clean up resources */ stop: () => Promise; } const TEST_DAEMON_ENV_DEFAULTS: Record = { 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 { 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((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 * NEVER uses 6767 (user's running daemon) */ export function getRandomPort(): number { return 20000 + Math.floor(Math.random() * 10000); } /** * Create isolated temp directories for testing */ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: string }> { const paseoHome = await mkdtemp(join(tmpdir(), "paseo-e2e-home-")); const workDir = await mkdtemp(join(tmpdir(), "paseo-e2e-work-")); // Create the agents directory that the daemon expects const agentsDir = join(paseoHome, "agents"); await mkdir(agentsDir, { recursive: true }); return { paseoHome, workDir }; } /** * Wait for daemon to be ready by running `paseo agent ls` * This connects via WebSocket and ensures the daemon is responsive */ async function waitForDaemonReady(port: number, timeout = 30000): Promise { const start = Date.now(); while (Date.now() - start < timeout) { try { const { exitCode } = await runPaseoCli( { port, wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`, paseoHome: "", workDir: "", process: null, isReady: false, stop: async () => {}, }, ["agent", "ls"], ); if (exitCode === 0) { return; // Daemon is ready } } catch { // Connection failed, keep trying } await sleep(100); } throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Start a test daemon programmatically using the server's bootstrap API * * This starts the daemon in a separate process using the CLI's daemon start command * with isolated PASEO_HOME and PASEO_LISTEN environment variables. */ export async function startTestDaemon(options?: { port?: number; paseoHome?: string; workDir?: string; timeout?: number; }): Promise { 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://${TEST_DAEMON_HOST}:${port}`; // Find the CLI entry point - use the source file directly with tsx const cliDir = join(import.meta.dirname, "..", ".."); const cliSrcPath = join(cliDir, "src", "index.ts"); // Start daemon process using tsx to run TypeScript directly const daemonProcess = spawn("npx", ["tsx", cliSrcPath, "daemon", "start", "--foreground"], { env: { ...process.env, ...TEST_DAEMON_ENV_DEFAULTS, PASEO_HOME: paseoHome, PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, // Force no TTY to prevent QR code output CI: "true", }, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", }); const stdout = createOutputCapture(); const stderr = createOutputCapture(); daemonProcess.stdout?.on("data", (data) => { appendOutputCapture(stdout, data); }); daemonProcess.stderr?.on("data", (data) => { appendOutputCapture(stderr, data); }); const cleanup = async () => { if (daemonProcess) { await terminateProcessTree(daemonProcess, 5000); } // Clean up temp directories try { if (existsSync(paseoHome)) { await rm(paseoHome, { recursive: true, force: true }); } } catch { // Ignore cleanup errors } try { if (existsSync(workDir)) { await rm(workDir, { recursive: true, force: true }); } } catch { // Ignore cleanup errors } }; // Handle process errors daemonProcess.on("error", (err) => { console.error("Daemon process error:", err); }); daemonProcess.on("exit", (code) => { if (code !== 0 && code !== null) { console.error(`Daemon process exited with code ${code}`); const stderrText = formatOutputCapture(stderr); if (stderrText) { console.error("Daemon stderr:", stderrText); } } }); const ctx: TestDaemonContext = { port, wsUrl, paseoHome, workDir, process: daemonProcess, isReady: false, stop: cleanup, }; // Wait for daemon to be ready try { await waitForDaemonReady(port, timeout); ctx.isReady = true; } catch (err) { // 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: ${formatOutputCapture(stdout)}\nStderr: ${formatOutputCapture(stderr)}`, ); } return ctx; } /** * Run a paseo CLI command against a test daemon * * This is a helper that sets the correct environment variables * to point at the test daemon. */ export async function runPaseoCli( ctx: TestDaemonContext, args: string[], options?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; }, ): Promise<{ exitCode: number; stdout: string; stderr: string }> { const timeout = options?.timeout ?? 60000; const cwd = options?.cwd ?? ctx.workDir; const cliDir = join(import.meta.dirname, "..", ".."); const cliSrcPath = join(cliDir, "src", "index.ts"); return new Promise((resolve, reject) => { const proc = spawn("npx", ["tsx", cliSrcPath, ...args], { env: { ...process.env, ...TEST_DAEMON_ENV_DEFAULTS, PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`, PASEO_HOME: ctx.paseoHome, ...options?.env, }, cwd, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", }); const stdout = createOutputCapture(); const stderr = createOutputCapture(); proc.stdout?.on("data", (data) => { appendOutputCapture(stdout, data); }); proc.stderr?.on("data", (data) => { appendOutputCapture(stderr, data); }); const timeoutId = setTimeout(() => { if (proc.pid) { signalProcessTree(proc.pid, "SIGKILL"); } reject(new Error(`CLI command timed out after ${timeout}ms: paseo ${args.join(" ")}`)); }, timeout); proc.on("exit", (code) => { clearTimeout(timeoutId); resolve({ exitCode: code ?? 1, stdout: formatOutputCapture(stdout), stderr: formatOutputCapture(stderr), }); }); proc.on("error", (err) => { clearTimeout(timeoutId); reject(err); }); }); } /** * Create a test context that includes a started daemon * and a helper to run CLI commands against it. * * This is the main entry point for E2E tests. */ export async function createE2ETestContext(options?: { timeout?: number }): Promise< TestDaemonContext & { /** Run a paseo CLI command against this daemon */ paseo: ( args: string[], opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv }, ) => Promise<{ exitCode: number; stdout: string; stderr: string; }>; } > { const ctx = await startTestDaemon({ timeout: options?.timeout }); const paseo = ( args: string[], opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv }, ) => runPaseoCli(ctx, args, opts); return { ...ctx, paseo, }; }