mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Catalog auth derivation used an environment probe, so missing local keys could make provider listing throw. Catalog hints are now static and CLI tests scrub provider-key env vars.
223 lines
6.4 KiB
TypeScript
223 lines
6.4 KiB
TypeScript
/**
|
|
* 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";
|
|
|
|
const TEST_ENV_DEFAULTS = {
|
|
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",
|
|
};
|
|
// Keep in sync with catalog.ts api_key auth envVar hints. These are scrubbed so
|
|
// local developer credentials cannot hide CI-missing-auth failures.
|
|
const PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS = [
|
|
"OPENROUTER_API_KEY",
|
|
"KIMI_API_KEY",
|
|
"OPENCODE_API_KEY",
|
|
] as const;
|
|
|
|
function scrubPaseoAgentProviderAuthEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
const scrubbed = { ...env };
|
|
for (const key of PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS) {
|
|
delete scrubbed[key];
|
|
}
|
|
return scrubbed;
|
|
}
|
|
|
|
function testCliEnv(port: number): NodeJS.ProcessEnv {
|
|
return scrubPaseoAgentProviderAuthEnv({
|
|
...process.env,
|
|
PASEO_HOST: `localhost:${port}`,
|
|
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
|
|
PASEO_DICTATION_ENABLED: TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED,
|
|
PASEO_VOICE_MODE_ENABLED: TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED,
|
|
});
|
|
}
|
|
|
|
function testDaemonEnv(port: number, paseoHome: string): NodeJS.ProcessEnv {
|
|
return scrubPaseoAgentProviderAuthEnv({
|
|
...process.env,
|
|
PASEO_HOME: paseoHome,
|
|
PASEO_LISTEN: `127.0.0.1:${port}`,
|
|
PASEO_RELAY_ENABLED: "false",
|
|
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
|
|
PASEO_DICTATION_ENABLED: TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED,
|
|
PASEO_VOICE_MODE_ENABLED: TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED,
|
|
CI: "true",
|
|
});
|
|
}
|
|
|
|
function killPidTree(pid: number, signal: NodeJS.Signals): void {
|
|
if (!Number.isInteger(pid) || pid <= 0) {
|
|
return;
|
|
}
|
|
|
|
if (process.platform !== "win32") {
|
|
try {
|
|
process.kill(-pid, signal);
|
|
return;
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code === "ESRCH") {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
process.kill(pid, signal);
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code !== "ESRCH") {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 ls` which connects via WebSocket
|
|
*/
|
|
async function probeDaemon(port: number): Promise<boolean> {
|
|
try {
|
|
const result = await $({ env: testCliEnv(port) })`paseo agent ls`.nothrow();
|
|
return result.exitCode === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function waitForDaemon(port: number, timeout = 30000): Promise<void> {
|
|
const deadline = Date.now() + timeout;
|
|
async function poll(): Promise<void> {
|
|
if (await probeDaemon(port)) return;
|
|
if (Date.now() >= deadline) {
|
|
throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`);
|
|
}
|
|
await sleep(100);
|
|
return poll();
|
|
}
|
|
return poll();
|
|
}
|
|
|
|
/**
|
|
* Start an isolated test daemon
|
|
*/
|
|
export async function startDaemon(port: number, paseoHome: string): Promise<ProcessPromise> {
|
|
$.verbose = false;
|
|
const daemon = $({
|
|
env: testDaemonEnv(port, paseoHome),
|
|
})`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 $({ env: testCliEnv(port) })`paseo ${args}`.nothrow();
|
|
};
|
|
|
|
// Cleanup function
|
|
const cleanup = async (): Promise<void> => {
|
|
if (ctx.daemon) {
|
|
if (typeof ctx.daemon.pid === "number") {
|
|
killPidTree(ctx.daemon.pid, "SIGTERM");
|
|
await sleep(250);
|
|
killPidTree(ctx.daemon.pid, "SIGKILL");
|
|
} else {
|
|
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);
|
|
}
|