Files
Mohamed Boudra 0110302b6f fix(daemon): keep lifecycle operations on the selected instance (#4575)
* 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.
2026-09-11 08:33:22 +02:00

206 lines
5.5 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",
};
function testEnvironment(paseoHome: string): NodeJS.ProcessEnv {
return {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))),
...TEST_ENV_DEFAULTS,
PASEO_HOME: paseoHome,
HOME: paseoHome,
USERPROFILE: paseoHome,
};
}
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, paseoHome: string): Promise<boolean> {
try {
const result = await $({
env: testEnvironment(paseoHome),
})`paseo agent ls --host localhost:${port}`.nothrow();
return result.exitCode === 0;
} catch {
return false;
}
}
export async function waitForDaemon(
port: number,
paseoHome: string,
timeout = 30000,
): Promise<void> {
const deadline = Date.now() + timeout;
async function poll(): Promise<void> {
if (await probeDaemon(port, paseoHome)) 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: {
...testEnvironment(paseoHome),
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
})`paseo daemon run`.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: testEnvironment(paseoHome) })`paseo --home ${paseoHome} ${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, ctx.paseoHome);
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);
}