diff --git a/packages/cli/tests/03-daemon.test.ts b/packages/cli/tests/03-daemon.test.ts index b5401fb5b..dcafbb8e8 100644 --- a/packages/cli/tests/03-daemon.test.ts +++ b/packages/cli/tests/03-daemon.test.ts @@ -16,9 +16,13 @@ */ import assert from "node:assert"; -import { mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises"; +import { spawn, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { mkdtemp, readFile, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; import { runLocalPaseo } from "./helpers/local-cli.ts"; console.log("=== Daemon Commands ===\n"); @@ -26,11 +30,82 @@ console.log("=== Daemon Commands ===\n"); // Keep restart off default 6767 to avoid collisions with any existing daemon. const port = 10000 + Math.floor(Math.random() * 50000); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); +const require = createRequire(import.meta.url); function daemonCommand(args: string[]) { return runLocalPaseo(["daemon", ...args], { PASEO_HOME: paseoHome }); } +async function stopChildProcess(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + + const exited = once(child, "exit"); + child.kill("SIGTERM"); + const forceKill = setTimeout(() => child.kill("SIGKILL"), 5_000); + try { + await exited; + } finally { + clearTimeout(forceKill); + } +} + +function resolveDaemonWorkerEntry(): string { + let currentDir = dirname(require.resolve("@getpaseo/server")); + + while (true) { + const packageJsonPath = join(currentDir, "package.json"); + if (existsSync(packageJsonPath)) { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + if (packageJson.name === "@getpaseo/server") { + const candidates = [ + join(currentDir, "dist", "server", "server", "daemon-worker.js"), + join(currentDir, "src", "server", "daemon-worker.ts"), + ]; + const workerEntry = candidates.find(existsSync); + if (workerEntry) return workerEntry; + throw new Error(`Daemon worker not found under ${currentDir}`); + } + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + + throw new Error("Unable to resolve @getpaseo/server package root"); +} + +async function tailDaemonLog(): Promise { + try { + const log = await readFile(join(paseoHome, "daemon.log"), "utf-8"); + return log.split("\n").slice(-30).join("\n"); + } catch { + return ""; + } +} + +async function retryWhileWorkerRuns( + worker: ChildProcess, + probe: () => Promise, + accepts: (result: T) => boolean, + failureDetails: (result: T) => string | Promise, +): Promise { + const deadline = Date.now() + 120_000; + let result = await probe(); + + while (!accepts(result)) { + assert( + worker.exitCode === null && worker.signalCode === null, + "IPC daemon exited before the probe completed", + ); + assert(Date.now() < deadline, await failureDetails(result)); + await new Promise((resolve) => setTimeout(resolve, 100)); + result = await probe(); + } + + return result; +} + try { // Test 1: daemon --help shows subcommands { @@ -132,56 +207,110 @@ try { process.platform === "win32" ? `\\\\.\\pipe\\paseo-status-${process.pid}-${Date.now()}` : join(paseoHome, "status.sock"); - const start = await daemonCommand(["start", "--listen", listen, "--relay"]); - assert.strictEqual(start.exitCode, 0, `IPC daemon should start: ${start.stderr}`); - const configPath = join(paseoHome, "config.json"); const config = JSON.parse(await readFile(configPath, "utf-8")); - config.daemon = { ...config.daemon, listen }; + config.daemon = { + ...config.daemon, + listen, + relay: { ...config.daemon?.relay, enabled: false }, + }; await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); - const pidPath = join(paseoHome, "paseo.pid"); - const pidContents = await readFile(pidPath, "utf-8"); - await unlink(pidPath); - const status = await daemonCommand(["status", "--json"]); - const pairing = await daemonCommand(["pair", "--json"]); - await writeFile(pidPath, pidContents, "utf-8"); - assert.strictEqual(status.exitCode, 0, `IPC daemon status should succeed: ${status.stderr}`); - const payload = JSON.parse(status.stdout); - assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable"); - assert.strictEqual(payload.localDaemon, "stopped", "missing PID should report stopped locally"); - assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state"); - assert.strictEqual(pairing.exitCode, 0, `IPC daemon pairing should succeed: ${pairing.stderr}`); - const pairingPayload = JSON.parse(pairing.stdout); - assert.strictEqual(pairingPayload.relayEnabled, true, "pairing should use live relay state"); - assert.match(pairingPayload.url, /#offer=/, "pairing should use the live daemon offer"); + // A supervised daemon owns and heartbeats paseo.pid. Launch the worker + // directly so this fixture naturally has a reachable daemon without a PID file. + const workerEntry = resolveDaemonWorkerEntry(); + const workerArgs = workerEntry.endsWith(".ts") + ? ["--import", "tsx", workerEntry, "--relay"] + : [workerEntry, "--relay"]; + const worker = spawn(process.execPath, workerArgs, { + cwd: join(import.meta.dirname, ".."), + env: { + ...process.env, + PASEO_HOME: paseoHome, + PASEO_LISTEN: listen, + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", + CI: "true", + }, + stdio: "ignore", + }); - const foreignHome = await mkdtemp(join(tmpdir(), "paseo-test-foreign-home-")); try { - await writeFile( - join(foreignHome, "config.json"), - `${JSON.stringify({ daemon: { listen } }, null, 2)}\n`, - "utf-8", + const relayProbe = await retryWhileWorkerRuns( + worker, + async () => { + const statusResult = await daemonCommand(["status", "--json"]); + const statusPayload = + statusResult.exitCode === 0 ? JSON.parse(statusResult.stdout) : null; + const pairingResult = await daemonCommand(["pair", "--json"]); + return { statusResult, statusPayload, pairingResult }; + }, + (result) => + result.statusPayload?.connectedDaemon === "reachable" && + result.statusPayload.relay !== "disabled" && + result.pairingResult.exitCode === 0, + async (result) => + [ + "IPC daemon did not expose live relay state", + `status: ${result.statusResult.stdout || result.statusResult.stderr}`, + `pairing: ${result.pairingResult.stdout || result.pairingResult.stderr}`, + `daemon log:\n${await tailDaemonLog()}`, + ].join("\n"), ); - const foreignPairing = await runLocalPaseo( - ["daemon", "pair", "--home", foreignHome, "--json"], - { PASEO_HOME: foreignHome }, - ); - assert.notStrictEqual( - foreignPairing.exitCode, - 0, - "pairing should reject a daemon owned by another home", - ); - assert( - foreignPairing.stderr.includes("different Paseo home"), - "pairing should explain the daemon identity mismatch", - ); - assert(!foreignPairing.stdout.includes("#offer="), "pairing should not expose another offer"); - } finally { - await rm(foreignHome, { recursive: true, force: true }); - } + const { statusResult: status, statusPayload: payload, pairingResult: pairing } = relayProbe; - const cleanup = await daemonCommand(["stop", "--force"]); - assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after IPC status"); + assert.strictEqual(status.exitCode, 0, `IPC daemon status should succeed: ${status.stderr}`); + assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable"); + assert.strictEqual( + payload.localDaemon, + "stopped", + "missing PID should report stopped locally", + ); + assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state"); + assert.strictEqual( + pairing.exitCode, + 0, + `IPC daemon pairing should succeed: ${pairing.stderr}`, + ); + const pairingPayload = JSON.parse(pairing.stdout); + assert.strictEqual(pairingPayload.relayEnabled, true, "pairing should use live relay state"); + assert.match(pairingPayload.url, /#offer=/, "pairing should use the live daemon offer"); + + const foreignHome = await mkdtemp(join(tmpdir(), "paseo-test-foreign-home-")); + try { + await writeFile( + join(foreignHome, "config.json"), + `${JSON.stringify({ daemon: { listen } }, null, 2)}\n`, + "utf-8", + ); + const foreignPairing = await retryWhileWorkerRuns( + worker, + () => + runLocalPaseo(["daemon", "pair", "--home", foreignHome, "--json"], { + PASEO_HOME: foreignHome, + }), + (result) => result.stderr.includes("different Paseo home"), + (result) => `Pairing did not report the daemon identity mismatch: ${result.stderr}`, + ); + assert.notStrictEqual( + foreignPairing.exitCode, + 0, + "pairing should reject a daemon owned by another home", + ); + assert( + foreignPairing.stderr.includes("different Paseo home"), + "pairing should explain the daemon identity mismatch", + ); + assert( + !foreignPairing.stdout.includes("#offer="), + "pairing should not expose another offer", + ); + } finally { + await rm(foreignHome, { recursive: true, force: true }); + } + } finally { + await stopChildProcess(worker); + } console.log("✓ daemon status probes live relay state over local IPC\n"); }