mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 17:22:47 +00:00
* fix(daemon): keep lifecycle operations on the selected instance Restart reconstructed launch inputs and address fallback could redirect a home-selected shutdown to another daemon. Let supervisors publish bound endpoints, retain their worker restart contract, and share captured local lifecycle ownership between CLI and Desktop. * test(daemon): verify bounded desktop readiness and cancellable artifacts * test(cli): verify reload target hints and durable offline pairing * test(daemon): align lifecycle fixtures with ownership and targeting * fix(daemon): preserve reachability when status details time out Provider discovery can outlast a status request after authentication succeeds. Keep local connection facts separate from unavailable details, while explicit remote queries retain their error outcome. Migrate CLI and packaged Desktop fixtures to persistent managed configuration and explicit targets. Isolate generic Git observation from forge discovery and reuse disposable CLI homes on Windows. * test(daemon): observe restart through the worker status RPC The bounded CLI status observation may omit worker details while an authenticated connection remains healthy. Exercise the app-style restart through its full RPC and retain the replacement observation, with a real slow provider probe covering the distinction.
448 lines
12 KiB
TypeScript
448 lines
12 KiB
TypeScript
/**
|
|
* 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 { fileURLToPath } from "url";
|
|
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<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 TSX_ENTRY = fileURLToPath(import.meta.resolve("tsx/cli"));
|
|
|
|
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,
|
|
);
|
|
|
|
interface 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) => {
|
|
const done = () => resolve();
|
|
const onExit = () => {
|
|
clearTimeout(timeoutId);
|
|
done();
|
|
};
|
|
const timeoutId = setTimeout(() => {
|
|
signalProcessTree(pid, "SIGKILL");
|
|
processRef.removeListener("exit", onExit);
|
|
done();
|
|
}, timeoutMs);
|
|
processRef.once("exit", onExit);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 probeDaemonReady(
|
|
port: number,
|
|
paseoHome: string,
|
|
env?: NodeJS.ProcessEnv,
|
|
): Promise<boolean> {
|
|
try {
|
|
const { exitCode } = await runPaseoCli(
|
|
{
|
|
port,
|
|
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
|
|
paseoHome,
|
|
workDir: "",
|
|
process: null,
|
|
isReady: false,
|
|
stop: async () => {},
|
|
},
|
|
["agent", "ls", "--host", `${TEST_DAEMON_HOST}:${port}`],
|
|
{ env },
|
|
);
|
|
return exitCode === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function waitForDaemonReady(
|
|
port: number,
|
|
paseoHome: string,
|
|
timeout = 30000,
|
|
env?: NodeJS.ProcessEnv,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeout;
|
|
|
|
async function poll(): Promise<void> {
|
|
if (await probeDaemonReady(port, paseoHome, env)) return;
|
|
if (Date.now() >= deadline) {
|
|
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`);
|
|
}
|
|
await sleep(100);
|
|
return poll();
|
|
}
|
|
|
|
return poll();
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
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;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): Promise<TestDaemonContext> {
|
|
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(process.execPath, [TSX_ENTRY, cliSrcPath, "daemon", "run"], {
|
|
env: {
|
|
...Object.fromEntries(
|
|
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
|
|
),
|
|
...TEST_DAEMON_ENV_DEFAULTS,
|
|
PASEO_HOME: paseoHome,
|
|
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
|
|
// Force no TTY to prevent QR code output
|
|
CI: "true",
|
|
...options?.env,
|
|
HOME: paseoHome,
|
|
USERPROFILE: paseoHome,
|
|
},
|
|
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, paseoHome, timeout, options?.env);
|
|
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)}`,
|
|
{ cause: err },
|
|
);
|
|
}
|
|
|
|
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(process.execPath, [TSX_ENTRY, cliSrcPath, ...args], {
|
|
env: {
|
|
...Object.fromEntries(
|
|
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
|
|
),
|
|
...TEST_DAEMON_ENV_DEFAULTS,
|
|
PASEO_HOME: ctx.paseoHome,
|
|
...options?.env,
|
|
HOME: ctx.paseoHome,
|
|
USERPROFILE: ctx.paseoHome,
|
|
},
|
|
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;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): 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, env: options?.env });
|
|
|
|
const paseo = (
|
|
args: string[],
|
|
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
|
) => runPaseoCli(ctx, args, opts);
|
|
|
|
return {
|
|
...ctx,
|
|
paseo,
|
|
};
|
|
}
|