From bf7f8f686b30e7c3d021d1cef6464ca5d98a619e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 10 May 2026 16:38:21 +0800 Subject: [PATCH] refactor(cli): inject local daemon launch runtime (#874) * refactor(cli): inject local daemon launch runtime * test(app/e2e): target mobile sidebar toggle state --- packages/app/e2e/helpers/sidebar.ts | 4 +- .../daemon/local-daemon.supervision.test.ts | 147 +++++++++++------- .../cli/src/commands/daemon/local-daemon.ts | 47 +++++- 3 files changed, 133 insertions(+), 65 deletions(-) diff --git a/packages/app/e2e/helpers/sidebar.ts b/packages/app/e2e/helpers/sidebar.ts index eaea7c09b..50380752f 100644 --- a/packages/app/e2e/helpers/sidebar.ts +++ b/packages/app/e2e/helpers/sidebar.ts @@ -21,12 +21,12 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise { - await page.getByTestId("menu-button").click(); + await page.getByRole("button", { name: "Open menu" }).click(); } // force=true: the overlay covers the button when the mobile sidebar is open. export async function closeMobileAgentSidebar(page: Page): Promise { - await page.getByTestId("menu-button").click({ force: true }); + await page.getByRole("button", { name: "Close menu" }).click({ force: true }); } // The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position. diff --git a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts index 2f883a664..931ea80cf 100644 --- a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts +++ b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts @@ -1,32 +1,67 @@ import { EventEmitter } from "node:events"; import { beforeEach, describe, expect, test, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ - spawnSync: vi.fn(), - spawnProcess: vi.fn(), -})); +import { + type DaemonLaunchRuntime, + type DetachedDaemonProcess, + startLocalDaemonDetached, + startLocalDaemonForeground, +} from "./local-daemon.js"; -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { - ...actual, - spawnSync: mocks.spawnSync, - }; -}); +type RecordedDaemonLaunch = + | { + mode: "detached"; + command: string; + args: string[]; + options: Parameters[2]; + } + | { + mode: "foreground"; + command: string; + args: string[]; + options: Parameters[2]; + }; -vi.mock("@getpaseo/server", async () => { - const actual = await vi.importActual("@getpaseo/server"); - return { - ...actual, - loadConfig: () => ({ listen: "127.0.0.1:6767" }), - resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo", - spawnProcess: mocks.spawnProcess, - }; -}); - -class FakeChildProcess extends EventEmitter { +class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess { pid = 4242; - unref = vi.fn(); + wasUnreferenced = false; + + unref(): void { + this.wasUnreferenced = true; + } +} + +class FakeDaemonRuntime implements DaemonLaunchRuntime { + readonly recordedLaunches: RecordedDaemonLaunch[] = []; + readonly daemonProcess = new FakeDaemonProcess(); + foregroundStatus = 0; + runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts"; + + resolveRunnerEntry(): string { + return this.runnerEntry; + } + + resolveHome(env: NodeJS.ProcessEnv): string { + return env.PASEO_HOME ?? "/tmp/paseo"; + } + + spawnDetached( + command: string, + args: string[], + options: Parameters[2], + ): DetachedDaemonProcess { + this.recordedLaunches.push({ mode: "detached", command, args, options }); + return this.daemonProcess; + } + + spawnForeground( + command: string, + args: string[], + options: Parameters[2], + ) { + this.recordedLaunches.push({ mode: "foreground", command, args, options }); + return { status: this.foregroundStatus, error: undefined }; + } } function expectSupervisorLaunch(argv: string[]): void { @@ -41,59 +76,59 @@ function expectSupervisorLaunch(argv: string[]): void { describe("local daemon launch supervision", () => { beforeEach(() => { vi.useRealTimers(); - mocks.spawnSync.mockReset(); - mocks.spawnProcess.mockReset(); }); test("foreground start spawns supervisor-entrypoint instead of server/index", async () => { - mocks.spawnSync.mockReturnValue({ status: 0, error: undefined }); + const runtime = new FakeDaemonRuntime(); - const { startLocalDaemonForeground } = await import("./local-daemon.js"); - const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }); + const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime); expect(status).toBe(0); - expect(mocks.spawnSync).toHaveBeenCalledOnce(); - const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]]; - expect(command).toBe(process.execPath); - expectSupervisorLaunch(argv); - expect(argv).toContain("--no-relay"); + expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); + const launch = runtime.recordedLaunches[0]; + expect(launch?.mode).toBe("foreground"); + expect(launch?.command).toBe(process.execPath); + expectSupervisorLaunch(launch?.args ?? []); + expect(launch?.args).toContain("--no-relay"); }); test("detached start spawns supervisor-entrypoint instead of server/index", async () => { vi.useFakeTimers(); - const child = new FakeChildProcess(); - mocks.spawnProcess.mockReturnValue(child); + const runtime = new FakeDaemonRuntime(); - const { startLocalDaemonDetached } = await import("./local-daemon.js"); - const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false }); + const resultPromise = startLocalDaemonDetached( + { home: "/tmp/paseo-test", mcp: false }, + runtime, + ); await vi.advanceTimersByTimeAsync(1200); const result = await resultPromise; expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" }); - expect(child.unref).toHaveBeenCalledOnce(); - expect(mocks.spawnProcess).toHaveBeenCalledOnce(); - const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]]; - expect(command).toBe(process.execPath); - expectSupervisorLaunch(argv); - expect(argv).toContain("--no-mcp"); + expect(runtime.daemonProcess.wasUnreferenced).toBe(true); + expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]); + const launch = runtime.recordedLaunches[0]; + expect(launch?.mode).toBe("detached"); + expect(launch?.command).toBe(process.execPath); + expectSupervisorLaunch(launch?.args ?? []); + expect(launch?.args).toContain("--no-mcp"); }); test("relay TLS flag is passed to the supervised daemon", async () => { - mocks.spawnSync.mockReturnValue({ status: 0, error: undefined }); + const runtime = new FakeDaemonRuntime(); - const { startLocalDaemonForeground } = await import("./local-daemon.js"); - const status = startLocalDaemonForeground({ - home: "/tmp/paseo-test", - relayUseTls: true, - }); + const status = startLocalDaemonForeground( + { + home: "/tmp/paseo-test", + relayUseTls: true, + }, + runtime, + ); expect(status).toBe(0); - const [, argv, options] = mocks.spawnSync.mock.calls[0] as [ - string, - string[], - { env?: NodeJS.ProcessEnv }, - ]; - expect(argv).toContain("--relay-use-tls"); - expect(options.env?.PASEO_RELAY_USE_TLS).toBe("true"); + expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); + const launch = runtime.recordedLaunches[0]; + expect(launch?.mode).toBe("foreground"); + expect(launch?.args).toContain("--relay-use-tls"); + expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true"); }); }); diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 4e2eab7cb..4fbb97770 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawnSync, type ChildProcess } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; @@ -68,6 +68,28 @@ interface ProcessExitDetails { type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails); +export interface DetachedDaemonProcess extends Pick {} + +export interface ForegroundDaemonProcessResult { + status: number | null; + error?: Error; +} + +export interface DaemonLaunchRuntime { + resolveRunnerEntry(): string; + resolveHome(env: NodeJS.ProcessEnv): string; + spawnDetached( + command: string, + args: string[], + options: Parameters[2], + ): DetachedDaemonProcess; + spawnForeground( + command: string, + args: string[], + options: Parameters[2], + ): ForegroundDaemonProcessResult; +} + const DETACHED_STARTUP_GRACE_MS = 1200; const PID_POLL_INTERVAL_MS = 100; const DAEMON_LOG_FILENAME = "daemon.log"; @@ -78,6 +100,13 @@ export const DEFAULT_KILL_TIMEOUT_MS = 3_000; const require = createRequire(import.meta.url); +const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = { + resolveRunnerEntry: resolveDaemonRunnerEntry, + resolveHome: resolvePaseoHome, + spawnDetached: spawnProcess, + spawnForeground: spawnSync, +}; + const startupReady = (): DetachedStartupResult => ({ exitedEarly: false }); const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({ @@ -395,17 +424,18 @@ export function tailDaemonLog(home?: string, lines = 30): string | null { export async function startLocalDaemonDetached( options: DaemonStartOptions, + runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime, ): Promise { if (options.listen && options.port) { throw new Error("Cannot use --listen and --port together"); } - const daemonRunnerEntry = resolveDaemonRunnerEntry(); + const daemonRunnerEntry = runtime.resolveRunnerEntry(); const childEnv = buildChildEnv(options); - const paseoHome = resolvePaseoHome(childEnv); + const paseoHome = runtime.resolveHome(childEnv); const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME); - const child = spawnProcess( + const child = runtime.spawnDetached( process.execPath, [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], { @@ -461,14 +491,17 @@ export async function startLocalDaemonDetached( }; } -export function startLocalDaemonForeground(options: DaemonStartOptions): number { +export function startLocalDaemonForeground( + options: DaemonStartOptions, + runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime, +): number { if (options.listen && options.port) { throw new Error("Cannot use --listen and --port together"); } - const daemonRunnerEntry = resolveDaemonRunnerEntry(); + const daemonRunnerEntry = runtime.resolveRunnerEntry(); const childEnv = buildChildEnv(options); - const result = spawnSync( + const result = runtime.spawnForeground( process.execPath, [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], {