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.
This commit is contained in:
Mohamed Boudra
2026-09-09 14:09:53 +02:00
parent fdf3b4b47f
commit 264276880e
166 changed files with 3824 additions and 4467 deletions
+21 -48
View File
@@ -140,7 +140,7 @@ try {
const result = await daemonCommand(["status"]);
assert.strictEqual(result.exitCode, 0, "status should succeed when daemon is stopped");
const output = result.stdout.toLowerCase();
assert(output.includes("local daemon"), "status table should include Local Daemon row");
assert(output.includes("localdaemon"), "status table should include Local Daemon row");
assert(output.includes("stopped"), "status should report stopped");
console.log("✓ daemon status reports stopped when not running\n");
}
@@ -164,7 +164,7 @@ try {
const result = await daemonCommand(["status", "--json"]);
assert.strictEqual(result.exitCode, 0, "--json status should succeed");
const status = JSON.parse(result.stdout);
assert.strictEqual(typeof status.serverId, "string", "json status should include serverId");
assert.strictEqual(status.serverId, undefined, "stopped observation must not invent identity");
assert.strictEqual(status.localDaemon, "stopped", "json status should report stopped");
assert.strictEqual(status.home, paseoHome, "json status should reflect the isolated home");
assert.strictEqual(
@@ -175,20 +175,12 @@ try {
console.log("✓ daemon status --json outputs valid JSON\n");
}
// Global hosts do not change local daemon lifecycle commands.
{
console.log("Test 5b: daemon status ignores a global --host");
const result = await runLocalPaseo(["--host", "localhost:1", "daemon", "status", "--json"], {
const result = await runLocalPaseo(["--host", "127.0.0.1:1", "daemon", "status", "--json"], {
PASEO_HOME: paseoHome,
});
assert.strictEqual(result.exitCode, 0, `daemon status should stay local: ${result.stderr}`);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(
parsed.localDaemon,
"stopped",
"daemon should report the local stopped state",
);
console.log("✓ daemon status ignores a global --host\n");
assert.strictEqual(result.exitCode, 1, "an explicit endpoint must not report local status");
assert.match(result.stderr, /127.0.0.1:1/);
}
// Test 6: daemon stop handles daemon not running gracefully
@@ -205,16 +197,12 @@ try {
console.log("✓ daemon stop succeeds gracefully when daemon not running\n");
}
// Test 7: daemon restart starts daemon and can be stopped
{
console.log("Test 7: daemon restart starts daemon and can be stopped");
const result = await daemonCommand(["restart", "--port", String(port)]);
assert.strictEqual(result.exitCode, 0, "restart should succeed even when previously stopped");
assert(result.stdout.toLowerCase().includes("restarted"), "output should report restart");
const cleanup = await daemonCommand(["stop", "--force"]);
assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after restart");
console.log("✓ daemon restart starts and stop cleanup succeeds\n");
const result = await daemonCommand(["restart", "--json"]);
assert.strictEqual(result.exitCode, 1, "restart must not start a stopped daemon");
assert.match(result.stderr, /DAEMON_NOT_RUNNING/);
const configured = await daemonCommand(["config", "set", "daemon.listen", `127.0.0.1:${port}`]);
assert.strictEqual(configured.exitCode, 0, configured.stderr);
}
// Test 8: status uses the running daemon as relay authority over local IPC
@@ -256,10 +244,10 @@ try {
const relayProbe = await retryWhileWorkerRuns(
worker,
async () => {
const statusResult = await daemonCommand(["status", "--json"]);
const statusResult = await daemonCommand(["status", "--host", listen, "--json"]);
const statusPayload =
statusResult.exitCode === 0 ? JSON.parse(statusResult.stdout) : null;
const pairingResult = await daemonCommand(["pair", "--json"]);
const pairingResult = await daemonCommand(["pair", "--host", listen, "--json"]);
return { statusResult, statusPayload, pairingResult };
},
(result) =>
@@ -280,8 +268,8 @@ try {
assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable");
assert.strictEqual(
payload.localDaemon,
"stopped",
"missing PID should report stopped locally",
undefined,
"endpoint status must not invent local PID state",
);
assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state");
assert.strictEqual(
@@ -335,31 +323,16 @@ try {
try {
await writeFile(
join(foreignHome, "config.json"),
`${JSON.stringify({ daemon: { listen } }, null, 2)}\n`,
`${JSON.stringify({ daemon: { listen, relay: { enabled: false } } }, 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",
const foreignPairing = await runLocalPaseo(
["daemon", "pair", "--home", foreignHome, "--json"],
{ PASEO_HOME: foreignHome },
);
assert.strictEqual(foreignPairing.exitCode, 1);
assert.match(foreignPairing.stderr, /RELAY_DISABLED/);
assert(!foreignPairing.stdout.includes("#offer="));
} finally {
await rm(foreignHome, { recursive: true, force: true });
}
@@ -1,53 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Phase 18: Local daemon utility tests.
*
* Tests pure helpers that do not require a running daemon.
*/
import assert from "node:assert";
import { resolveTcpHostFromListen } from "../src/commands/daemon/local-daemon.js";
console.log("=== Local Daemon Utility Helpers ===\n");
{
console.log("Test 1: resolves numeric listen values to localhost host:port");
assert.strictEqual(resolveTcpHostFromListen("6767"), "127.0.0.1:6767");
assert.strictEqual(resolveTcpHostFromListen(" 7777 "), "127.0.0.1:7777");
console.log("✓ resolves numeric listen values\n");
}
{
console.log("Test 2: preserves explicit host:port listen values");
assert.strictEqual(resolveTcpHostFromListen("localhost:6767"), "localhost:6767");
assert.strictEqual(resolveTcpHostFromListen("0.0.0.0:8080"), "0.0.0.0:8080");
console.log("✓ preserves explicit host:port values\n");
}
{
console.log("Test 3: rejects unix socket listen values");
assert.strictEqual(resolveTcpHostFromListen("/tmp/paseo.sock"), null);
assert.strictEqual(resolveTcpHostFromListen("unix:///tmp/paseo.sock"), null);
assert.strictEqual(resolveTcpHostFromListen("pipe://\\\\.\\pipe\\paseo-managed-test"), null);
assert.strictEqual(resolveTcpHostFromListen("\\\\.\\pipe\\paseo-managed-test"), null);
console.log("✓ rejects unix socket listen values\n");
}
{
console.log("Test 4: rejects empty and non-host listen values");
assert.strictEqual(resolveTcpHostFromListen(""), null);
assert.strictEqual(resolveTcpHostFromListen(" "), null);
assert.strictEqual(resolveTcpHostFromListen("localhost"), null);
console.log("✓ rejects empty and non-host listen values\n");
}
{
console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)");
assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null);
assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null);
console.log("✓ rejects Windows absolute paths\n");
}
console.log("=== All local daemon utility tests passed ===");
@@ -1,38 +0,0 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { homedir } from "node:os";
import { join } from "node:path";
import { resolvePaseoHomePath, resolvePaseoWorktreesDir } from "../src/commands/worktree/ls.js";
console.log("=== Worktree LS Path Helper Tests ===\n");
const originalPaseoHome = process.env.PASEO_HOME;
try {
{
console.log("Test 1: resolves explicit PASEO_HOME when set");
process.env.PASEO_HOME = "/tmp/paseo-explicit-home";
assert.strictEqual(resolvePaseoHomePath(), "/tmp/paseo-explicit-home");
assert.strictEqual(resolvePaseoWorktreesDir(), "/tmp/paseo-explicit-home/worktrees");
console.log("\u2713 explicit PASEO_HOME is respected\n");
}
{
console.log("Test 2: falls back to homedir/.paseo when PASEO_HOME is unset");
delete process.env.PASEO_HOME;
assert.strictEqual(resolvePaseoHomePath(), join(homedir(), ".paseo"));
assert.strictEqual(resolvePaseoWorktreesDir(), join(homedir(), ".paseo", "worktrees"));
console.log("\u2713 fallback home path is derived from os.homedir()\n");
}
} finally {
if (originalPaseoHome === undefined) {
delete process.env.PASEO_HOME;
} else {
process.env.PASEO_HOME = originalPaseoHome;
}
}
console.log("=== All worktree ls path helper tests passed ===");
@@ -213,8 +213,9 @@ try {
);
const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs);
assert(
capturedSupervisorLogs.includes('"msg":"Worker requested shutdown"') &&
capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"'),
process.platform === "win32"
? capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"')
: capturedSupervisorLogs.includes("supervisor_received_SIGTERM"),
`stop should log lifecycle shutdown reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
);
assert(
@@ -6,7 +6,7 @@
*/
import assert from "node:assert";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -40,57 +40,33 @@ function isProcessRunning(pid: number): boolean {
}
}
function readWorkerPid(supervisorPid: number): number | null {
if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) {
return null;
}
const result = spawnSync("ps", ["ax", "-o", "pid=,ppid="], { encoding: "utf8" });
if (result.status !== 0 || result.error) {
return null;
}
for (const line of result.stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const [pidToken, ppidToken] = trimmed.split(/\s+/);
const pid = Number.parseInt(pidToken ?? "", 10);
const ppid = Number.parseInt(ppidToken ?? "", 10);
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) {
continue;
}
if (ppid === supervisorPid && pid > 0) {
return pid;
}
}
return null;
}
interface DaemonStatus {
localDaemon: string | null;
pid: number | null;
workerPid: number | null;
}
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
if (result.exitCode !== 0) {
return { localDaemon: null, pid: null };
return { localDaemon: null, pid: null, workerPid: null };
}
try {
const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown };
const parsed = JSON.parse(result.stdout) as {
localDaemon?: unknown;
pid?: unknown;
workerPid?: number;
};
const localDaemon = typeof parsed.localDaemon === "string" ? parsed.localDaemon : null;
const pid =
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null;
return { localDaemon, pid };
return { localDaemon, pid, workerPid: parsed.workerPid ?? null };
} catch {
return { localDaemon: null, pid: null };
return { localDaemon: null, pid: null, workerPid: null };
}
}
@@ -173,7 +149,7 @@ try {
);
assert(supervisorPid !== null, "supervisor pid should exist once daemon starts");
assert(isProcessRunning(supervisorPid), "supervisor process should be running");
const workerPidBeforeRestart = readWorkerPid(supervisorPid);
const workerPidBeforeRestart = statusBeforeRestart.workerPid;
assert(workerPidBeforeRestart !== null, "supervisor should have a worker process before restart");
assert(
isProcessRunning(workerPidBeforeRestart),
@@ -184,7 +160,7 @@ try {
);
console.log("Test 2: app-style restart request should restart worker and keep daemon healthy");
const client = await tryConnectToDaemon({ host, timeout: 5000 });
const client = await tryConnectToDaemon({ target: { kind: "endpoint", host }, timeout: 5000 });
assert(client, "daemon client should connect");
try {
const restartAck = await client.restartServer("settings_update");
@@ -198,8 +174,8 @@ try {
}
await waitFor(
() => {
const workerPid = readWorkerPid(supervisorPid);
async () => {
const workerPid = (await readDaemonStatus(paseoHome)).workerPid;
return (
workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid)
);
@@ -208,7 +184,7 @@ try {
"worker pid did not change after restart request",
);
const workerPidAfterRestart = readWorkerPid(supervisorPid);
const workerPidAfterRestart = (await readDaemonStatus(paseoHome)).workerPid;
assert(workerPidAfterRestart !== null, "worker process should exist after restart");
assert.notStrictEqual(
workerPidAfterRestart,
@@ -1,16 +1,13 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
getDaemonHost,
normalizeDaemonHost,
resolveDaemonPassword,
resolveDaemonTarget,
resolveDefaultDaemonHosts,
} from "../src/utils/client.js";
import { selectDaemonTarget } from "../src/utils/daemon-target.js";
import { resolveCliVersion } from "../src/version.js";
console.log("=== CLI IPC Target Helpers ===\n");
@@ -81,48 +78,13 @@ console.log("=== CLI IPC Target Helpers ===\n");
}
{
console.log("Test 6: default host resolution tries local IPC first, then localhost fallback");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
try {
mkdirSync(paseoHome, { recursive: true });
writeFileSync(
path.join(paseoHome, "paseo.pid"),
JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-from-pid.sock" }),
);
assert.deepStrictEqual(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome }), [
"unix:///tmp/paseo-from-pid.sock",
"localhost:6767",
]);
const previousHome = process.env.PASEO_HOME;
const previousHost = process.env.PASEO_HOST;
process.env.PASEO_HOME = paseoHome;
delete process.env.PASEO_HOST;
assert.strictEqual(getDaemonHost(), "unix:///tmp/paseo-from-pid.sock");
if (previousHome === undefined) delete process.env.PASEO_HOME;
else process.env.PASEO_HOME = previousHome;
if (previousHost === undefined) delete process.env.PASEO_HOST;
else process.env.PASEO_HOST = previousHost;
} finally {
rmSync(paseoHome, { recursive: true, force: true });
}
console.log("✓ default host resolution tries local IPC first, then localhost fallback\n");
}
{
console.log("Test 7: configured TCP host is preserved before the localhost fallback");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-tcp-"));
try {
assert.deepStrictEqual(
resolveDefaultDaemonHosts({
PASEO_HOME: paseoHome,
PASEO_LISTEN: "127.0.0.1:7777",
}),
["127.0.0.1:7777", "localhost:6767"],
);
} finally {
rmSync(paseoHome, { recursive: true, force: true });
}
console.log("✓ configured TCP host is preserved before the localhost fallback\n");
const target = selectDaemonTarget(
{ home: "/tmp/selected-home" },
{ PASEO_HOST: "ignored:12345", PASEO_LISTEN: "ignored:23456" },
);
assert.deepStrictEqual(target, { kind: "instance", home: "/tmp/selected-home" });
assert.strictEqual(getDaemonHost({ target }), "home /tmp/selected-home");
assert.throws(() => selectDaemonTarget({}, { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" }));
}
{
@@ -131,28 +93,6 @@ console.log("=== CLI IPC Target Helpers ===\n");
console.log("✓ CLI app version resolves for daemon hello compatibility\n");
}
{
console.log("Test 9: local IPC still takes priority over configured TCP hosts");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-order-"));
try {
mkdirSync(paseoHome, { recursive: true });
writeFileSync(
path.join(paseoHome, "paseo.pid"),
JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-priority.sock" }),
);
assert.deepStrictEqual(
resolveDefaultDaemonHosts({
PASEO_HOME: paseoHome,
PASEO_LISTEN: "127.0.0.1:7777",
}),
["unix:///tmp/paseo-priority.sock", "127.0.0.1:7777", "localhost:6767"],
);
} finally {
rmSync(paseoHome, { recursive: true, force: true });
}
console.log("✓ local IPC still takes priority over configured TCP hosts\n");
}
{
console.log("Test 10: daemon password resolution prefers TCP URI query, falls back to env");
const previousEnv = process.env.PASEO_PASSWORD;
@@ -51,7 +51,10 @@ try {
const config = JSON.parse(await readFile(join(paseoHome, "config.json"), "utf-8"));
assert.strictEqual(result.configPath, join(paseoHome, "config.json"));
assert.strictEqual(result.restartCommand, "paseo daemon restart");
assert.strictEqual(
result.restartCommand,
`paseo daemon restart --home ${JSON.stringify(paseoHome)}`,
);
assert.strictEqual(config.daemon.listen, "127.0.0.1:9999");
assert.strictEqual(config.daemon.relay.enabled, false);
assert.notStrictEqual(config.daemon.auth.password, "shared-secret");
@@ -68,6 +71,7 @@ try {
const result = await runSetPasswordCommand(
{
home: paseoHome,
daemonTarget: { kind: "instance", home: paseoHome },
promptPassword: promptSequence(["new-secret", "new-secret"]),
},
{} as Command,
@@ -88,6 +92,7 @@ try {
runSetPasswordCommand(
{
home: paseoHome,
daemonTarget: { kind: "instance", home: paseoHome },
promptPassword: promptSequence(["first-secret", "second-secret"]),
},
{} as Command,
@@ -118,6 +118,8 @@ try {
join(paseoHome, "paseo.pid"),
JSON.stringify({
pid: ownerProcess.pid,
hostname: "test",
uid: process.getuid?.() ?? -1,
listen: "127.0.0.1:1",
startedAt: new Date().toISOString(),
}),
@@ -153,15 +155,11 @@ try {
action: parsed.action,
forced: parsed.forced,
usedLifecycleRpc: parsed.usedLifecycleRpc,
reason: parsed.reason,
message: parsed.message,
},
{
action: "stopped",
forced: true,
usedLifecycleRpc: false,
reason: "owner_pid_sigkill",
message: "Daemon owner process was force-stopped",
},
`stop should report forced tree cleanup: ${stopResult.stdout}`,
);
@@ -26,7 +26,7 @@ try {
assert.strictEqual(status.connectedDaemon, "auth_required");
assert(!("runningAgents" in status), "status should not fetch agent counts");
assert(!("idleAgents" in status), "status should not fetch agent counts");
assert.match(status.note, /requires a password/i);
assert.match(status.note, /Password required/i);
assert.doesNotMatch(status.note, /not reachable/i);
console.log("✓ missing password reports auth_required\n");
}
@@ -44,7 +44,7 @@ try {
assert.strictEqual(status.localDaemon, "running");
assert.strictEqual(status.connectedDaemon, "auth_failed");
assert.match(status.note, /password was rejected/i);
assert.match(status.note, /Incorrect password/i);
assert.doesNotMatch(status.note, /not reachable/i);
console.log("✓ wrong password reports auth_failed\n");
}
@@ -1,8 +1,8 @@
#!/usr/bin/env npx tsx
/**
* Regression: `paseo daemon stop` must stop a reachable daemon even when the
* local pid file points at a dead supervisor owner.
* Regression: `paseo daemon stop` must leave a reachable daemon alone when the
* selected home points at a dead supervisor owner.
*/
import assert from "node:assert";
@@ -12,6 +12,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "zx";
import { connectToDaemon } from "../src/utils/client.js";
import { getAvailablePort } from "./helpers/network.ts";
$.verbose = false;
@@ -146,21 +147,24 @@ try {
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
return status.localDaemon === "stale_pid" && status.connectedDaemon === "reachable";
try {
const client = await connectToDaemon({ target: { kind: "endpoint", host }, timeout: 500 });
await client.close();
return true;
} catch {
return false;
}
},
120000,
"daemon did not enter stale_pid + reachable state in time",
"unowned worker did not become reachable in time",
);
const statusBeforeStop = await readDaemonStatus(paseoHome);
assert.strictEqual(statusBeforeStop.pid, stalePid, "status should report the stale owner pid");
assert.strictEqual(statusBeforeStop.pid, null, "status should not claim a dead owner is running");
assert(workerProcess.pid && isProcessRunning(workerProcess.pid), "worker should be running");
console.log(`✓ fixture has stale pid ${stalePid} and live worker ${workerProcess.pid}\n`);
console.log(
"Test 2: `paseo daemon stop` should stop reachable worker instead of saying not_running",
);
console.log("Test 2: home-selected stop leaves the unowned reachable worker running");
const stopResult =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow();
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`);
@@ -169,22 +173,11 @@ try {
pid?: unknown;
message?: unknown;
};
assert.strictEqual(stopJson.action, "stopped", "stop should report stopped action");
assert.strictEqual(
stopJson.pid,
String(stalePid),
"stop should report the stale pid it recovered from",
);
assert.strictEqual(
stopJson.message,
"Daemon stopped gracefully",
"stop should route through lifecycle shutdown",
);
await waitFor(
() => !isProcessRunning(workerProcess?.pid ?? -1),
15000,
"worker remained running after stop",
assert.strictEqual(stopJson.action, "not_running");
assert.strictEqual(stopJson.pid, stalePid);
assert(
workerProcess.pid && isProcessRunning(workerProcess.pid),
"stop must not contact the stale endpoint",
);
assert.strictEqual(existsSync(pidPath), false, "stale pid file should be removed after stop");
console.log("✓ stop recovered stale supervisor pid state\n");
@@ -55,7 +55,16 @@ const env = {
};
try {
const start = await runLocalPaseo(["daemon", "restart", "--port", String(port)], env);
for (const [field, value] of [
["daemon.listen", `127.0.0.1:${port}`],
["daemon.relay.enabled", "false"],
["features.dictation.enabled", "false"],
["features.voiceMode.enabled", "false"],
]) {
const saved = await runLocalPaseo(["daemon", "config", "set", field!, value!], env);
assert.equal(saved.exitCode, 0, saved.stderr);
}
const start = await runLocalPaseo(["daemon", "start"], env);
assert.strictEqual(
start.exitCode,
0,
@@ -44,7 +44,9 @@ async function main(): Promise<void> {
assert.equal(install.exitCode, 0, install.stderr);
assert.equal(JSON.parse(install.stdout).id, "cli-e2e");
const client = await connectToDaemon({ host: `127.0.0.1:${context.port}` });
const client = await connectToDaemon({
target: { kind: "endpoint", host: `127.0.0.1:${context.port}` },
});
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.close();
+13 -1
View File
@@ -1,5 +1,10 @@
import { $, ProcessPromise } from "zx";
import { join } from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
const cliHome = mkdtempSync(join(tmpdir(), "paseo-test-cli-os-home-"));
process.once("exit", () => rmSync(cliHome, { recursive: true, force: true }));
const CLI_ENTRY = join(import.meta.dirname, "..", "..", "dist", "index.js");
@@ -11,6 +16,13 @@ export function runLocalPaseo(
$.verbose = false;
return $({
cwd,
env: { ...process.env, ...env },
env: {
...Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
),
...env,
HOME: cliHome,
USERPROFILE: cliHome,
},
})`${process.execPath} ${CLI_ENTRY} ${args}`.nothrow();
}
+31 -23
View File
@@ -169,19 +169,23 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st
* 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, env?: NodeJS.ProcessEnv): Promise<boolean> {
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: "",
paseoHome,
workDir: "",
process: null,
isReady: false,
stop: async () => {},
},
["agent", "ls"],
["agent", "ls", "--host", `${TEST_DAEMON_HOST}:${port}`],
{ env },
);
return exitCode === 0;
@@ -192,13 +196,14 @@ async function probeDaemonReady(port: number, env?: NodeJS.ProcessEnv): Promise<
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, env)) return;
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`);
}
@@ -240,23 +245,23 @@ export async function startTestDaemon(options?: {
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 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();
@@ -319,7 +324,7 @@ export async function startTestDaemon(options?: {
// Wait for daemon to be ready
try {
await waitForDaemonReady(port, timeout, options?.env);
await waitForDaemonReady(port, paseoHome, timeout, options?.env);
ctx.isReady = true;
} catch (err) {
// Daemon failed to start - clean up and rethrow
@@ -358,11 +363,14 @@ export async function runPaseoCli(
return new Promise((resolve, reject) => {
const proc = spawn(process.execPath, [TSX_ENTRY, cliSrcPath, ...args], {
env: {
...process.env,
...Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
),
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
PASEO_HOME: ctx.paseoHome,
...options?.env,
HOME: ctx.paseoHome,
USERPROFILE: ctx.paseoHome,
},
cwd,
stdio: ["ignore", "pipe", "pipe"],
+7 -1
View File
@@ -196,12 +196,17 @@ async function runSingleTest(testFile: string): Promise<TestOutcome> {
const testName = testFile.replace(/\.test\.ts$/, "");
const startedAt = Date.now();
const npmCache = await mkdtemp(join(tmpdir(), "paseo-cli-test-npm-cache-"));
const osHome = await mkdtemp(join(tmpdir(), "paseo-cli-test-os-home-"));
try {
return await new Promise<TestOutcome>((resolve) => {
const proc = spawn("npx", ["tsx", testPath], {
env: {
...process.env,
...Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
),
HOME: osHome,
USERPROFILE: osHome,
PATH: [rootNodeModulesBin, process.env.PATH].filter(Boolean).join(delimiter),
npm_config_cache: npmCache,
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
@@ -249,6 +254,7 @@ async function runSingleTest(testFile: string): Promise<TestOutcome> {
});
} finally {
await rm(npmCache, { recursive: true, force: true });
await rm(osHome, { recursive: true, force: true });
}
}
+30 -8
View File
@@ -20,6 +20,16 @@ const TEST_ENV_DEFAULTS = {
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;
@@ -83,19 +93,25 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st
* 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> {
async function probeDaemon(port: number, paseoHome: string): Promise<boolean> {
try {
const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow();
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, timeout = 30000): Promise<void> {
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)) return;
if (await probeDaemon(port, paseoHome)) return;
if (Date.now() >= deadline) {
throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`);
}
@@ -110,8 +126,14 @@ export async function waitForDaemon(port: number, timeout = 30000): Promise<void
*/
export async function startDaemon(port: number, paseoHome: string): Promise<ProcessPromise> {
$.verbose = false;
const daemon =
$`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 paseo daemon start --foreground`.nothrow();
const daemon = $({
env: {
...testEnvironment(paseoHome),
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
})`paseo daemon run`.nothrow();
return daemon;
}
@@ -125,7 +147,7 @@ export async function createTestContext(): Promise<TestContext> {
// Helper to run CLI commands against test daemon
const paseo = (args: string[]): ProcessPromise => {
$.verbose = false;
return $`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} paseo ${args}`.nothrow();
return $({ env: testEnvironment(paseoHome) })`paseo --home ${paseoHome} ${args}`.nothrow();
};
// Cleanup function
@@ -162,7 +184,7 @@ export async function createTestContext(): Promise<TestContext> {
export async function createTestContextWithDaemon(): Promise<TestContext> {
const ctx = await createTestContext();
ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome);
await waitForDaemon(ctx.port);
await waitForDaemon(ctx.port, ctx.paseoHome);
return ctx;
}