mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
* feat(cli): connect daemons to Hub through browser approval Let interactive setup complete through a short-lived browser authorization while preserving --token for automation and existing integrations. * ci: build server before CLI tests * fix(cli): keep Hub approval polling within expiry Bound both response headers and body consumption to the original authorization lifetime so stalled responses remain retryable without extending approval. Run the CLI test helper through Node and the resolved tsx entry for cross-platform behavior. * fix(cli): bound Hub registration startup Fail a stalled authorization start within a fixed timeout without retrying the non-idempotent request. Declare the response validator as a direct CLI runtime dependency. * fix(cli): restrict Hub activation URLs * fix(cli): harden Hub approval transport
435 lines
12 KiB
TypeScript
435 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): Promise<boolean> {
|
|
try {
|
|
const { exitCode } = await runPaseoCli(
|
|
{
|
|
port,
|
|
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
|
|
paseoHome: "",
|
|
workDir: "",
|
|
process: null,
|
|
isReady: false,
|
|
stop: async () => {},
|
|
},
|
|
["agent", "ls"],
|
|
);
|
|
return exitCode === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function waitForDaemonReady(port: number, timeout = 30000): Promise<void> {
|
|
const deadline = Date.now() + timeout;
|
|
|
|
async function poll(): Promise<void> {
|
|
if (await probeDaemonReady(port)) 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", "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",
|
|
...options?.env,
|
|
},
|
|
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)}`,
|
|
{ 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: {
|
|
...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;
|
|
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,
|
|
};
|
|
}
|