import { EventEmitter } from "node:events"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { createDaemonCommandHandlers } from "./daemon-manager"; const mocks = vi.hoisted(() => ({ paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home", settings: { releaseChannel: "stable", daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true, }, }, runExternalCliJsonCommand: vi.fn(), runExternalCliTextCommand: vi.fn(), spawnProcess: vi.fn(), logInfo: vi.fn(), logError: vi.fn(), })); vi.mock("electron", () => ({ app: { getPath: vi.fn(() => "/tmp/paseo-user-data"), getVersion: vi.fn(() => "1.2.3"), isPackaged: true, }, ipcMain: { handle: vi.fn() }, powerMonitor: { getSystemIdleTime: vi.fn(() => 0) }, })); vi.mock("electron-log/main", () => ({ default: { info: mocks.logInfo, error: mocks.logError }, })); vi.mock("@getpaseo/server", () => ({ resolvePaseoHome: vi.fn(() => mocks.paseoHome), spawnProcess: mocks.spawnProcess, })); vi.mock("../settings/desktop-settings-electron.js", () => ({ getDesktopSettingsStore: () => ({ get: async () => mocks.settings, patch: vi.fn(), migrateLegacyRendererSettings: vi.fn(), }), })); vi.mock("./runtime-paths.js", () => ({ createNodeEntrypointInvocation: vi.fn(() => ({ command: "node", args: [], env: {}, })), resolveDaemonRunnerEntrypoint: vi.fn(() => ({ entryPath: "/tmp/daemon.js", execArgv: [], })), })); vi.mock("./cli/external.js", () => ({ runExternalCliJsonCommand: mocks.runExternalCliJsonCommand, runExternalCliTextCommand: mocks.runExternalCliTextCommand, })); function desktopSettingsWithManagement(enabled: boolean) { return { ...DEFAULT_DESKTOP_SETTINGS, daemon: { ...DEFAULT_DESKTOP_SETTINGS.daemon, manageBuiltInDaemon: enabled, }, }; } type MockChildProcess = EventEmitter & { pid: number; spawnfile: string; spawnargs: string[]; unref: ReturnType; }; function createMockChildProcess(): MockChildProcess { const child = new EventEmitter() as MockChildProcess; child.pid = 1234; child.spawnfile = "node"; child.spawnargs = ["node", "daemon.js"]; child.unref = vi.fn(); return child; } function scheduleFailedStartup(child: MockChildProcess): void { setImmediate(() => { child.emit("exit", 1, null); }); } describe("daemon-manager commands", () => { beforeEach(() => { mocks.settings = DEFAULT_DESKTOP_SETTINGS; mocks.runExternalCliJsonCommand.mockReset(); mocks.runExternalCliTextCommand.mockReset(); mocks.spawnProcess.mockReset(); mocks.logInfo.mockReset(); mocks.logError.mockReset(); rmSync(mocks.paseoHome, { recursive: true, force: true }); }); afterEach(() => { rmSync(mocks.paseoHome, { recursive: true, force: true }); }); it("refuses start and restart while built-in daemon management is disabled", async () => { mocks.settings = desktopSettingsWithManagement(false); const handlers = createDaemonCommandHandlers(); await expect(handlers.start_desktop_daemon()).rejects.toThrow( "Built-in daemon management is disabled.", ); await expect(handlers.restart_desktop_daemon()).rejects.toThrow( "Built-in daemon management is disabled.", ); expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled(); expect(mocks.spawnProcess).not.toHaveBeenCalled(); }); it("keeps stop callable while built-in daemon management is disabled", async () => { mocks.settings = desktopSettingsWithManagement(false); mocks.runExternalCliJsonCommand.mockResolvedValue({ localDaemon: "stopped", serverId: "", }); const handlers = createDaemonCommandHandlers(); await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ serverId: "", status: "stopped", listen: null, hostname: null, pid: null, home: mocks.paseoHome, version: null, desktopManaged: false, error: null, }); expect(mocks.runExternalCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]); }); it("routes running desktop daemon stops through external CLI daemon stop", async () => { mocks.runExternalCliJsonCommand .mockResolvedValueOnce({ localDaemon: "running", serverId: "server-1", pid: 4242, listen: "127.0.0.1:6767", desktopManaged: true, }) .mockResolvedValueOnce({ action: "stopped" }) .mockResolvedValueOnce({ localDaemon: "stopped", serverId: "", }); const handlers = createDaemonCommandHandlers(); await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ serverId: "", status: "stopped", listen: null, hostname: null, pid: null, home: mocks.paseoHome, version: null, desktopManaged: false, error: null, }); expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(1, [ "daemon", "status", "--json", ]); expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(2, [ "daemon", "stop", "--json", "--timeout", "5", "--force", "--kill-timeout", "5", ]); expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(3, [ "daemon", "status", "--json", ]); expect(mocks.logInfo).toHaveBeenCalledWith( "[desktop daemon]", "desktop daemon stop requested", expect.objectContaining({ reason: "manual_ipc", statusBefore: expect.objectContaining({ status: "running", pid: 4242, serverId: "server-1", desktopManaged: true, }), }), ); expect(mocks.logInfo).toHaveBeenCalledWith( "[desktop daemon]", "desktop daemon stop completed", expect.objectContaining({ reason: "manual_ipc", cliResult: { action: "stopped" }, statusAfter: expect.objectContaining({ status: "stopped", serverId: null, }), }), ); }); it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => { mocks.runExternalCliJsonCommand .mockResolvedValueOnce({ localDaemon: "stale_pid", connectedDaemon: "reachable", serverId: "server-1", pid: 7675, listen: "127.0.0.1:6767", daemonVersion: "1.2.2", desktopManaged: true, }) .mockResolvedValueOnce({ action: "stopped" }) .mockResolvedValueOnce({ localDaemon: "stopped", connectedDaemon: "unreachable", serverId: "", }); const handlers = createDaemonCommandHandlers(); await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ serverId: "", status: "stopped", listen: null, hostname: null, pid: null, home: mocks.paseoHome, version: null, desktopManaged: false, error: null, }); expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(2, [ "daemon", "stop", "--json", "--timeout", "5", "--force", "--kill-timeout", "5", ]); }); it("records the renderer stop reason when stopping the desktop daemon", async () => { mocks.runExternalCliJsonCommand .mockResolvedValueOnce({ localDaemon: "running", serverId: "server-1", pid: 4242, listen: "127.0.0.1:6767", desktopManaged: true, }) .mockResolvedValueOnce({ action: "stopped", reason: "lifecycle_shutdown_rpc" }) .mockResolvedValueOnce({ localDaemon: "stopped", serverId: "", }); const handlers = createDaemonCommandHandlers(); await handlers.stop_desktop_daemon({ reason: "host_remove" }); expect(mocks.logInfo).toHaveBeenCalledWith( "[desktop daemon]", "desktop daemon stop requested", expect.objectContaining({ reason: "host_remove" }), ); expect(mocks.logInfo).toHaveBeenCalledWith( "[desktop daemon]", "desktop daemon stop completed", expect.objectContaining({ reason: "host_remove", cliResult: { action: "stopped", reason: "lifecycle_shutdown_rpc" }, }), ); }); it("uses a stale reachable desktop daemon when the version matches", async () => { mocks.runExternalCliJsonCommand.mockResolvedValue({ localDaemon: "stale_pid", connectedDaemon: "reachable", serverId: "server-1", pid: 7675, listen: "127.0.0.1:6767", hostname: "dev-host", daemonVersion: "1.2.3", desktopManaged: true, }); const handlers = createDaemonCommandHandlers(); await expect(handlers.start_desktop_daemon()).resolves.toEqual({ serverId: "server-1", status: "running", listen: "127.0.0.1:6767", hostname: "dev-host", pid: null, home: mocks.paseoHome, version: "1.2.3", desktopManaged: true, error: null, }); expect(mocks.spawnProcess).not.toHaveBeenCalled(); }); it("restarts a stale reachable desktop daemon when the version differs", async () => { mocks.runExternalCliJsonCommand .mockResolvedValueOnce({ localDaemon: "stale_pid", connectedDaemon: "reachable", serverId: "server-1", pid: 7675, listen: "127.0.0.1:6767", hostname: "dev-host", daemonVersion: "1.2.2", desktopManaged: true, }) .mockResolvedValueOnce({ localDaemon: "stale_pid", connectedDaemon: "reachable", serverId: "server-1", pid: 7675, listen: "127.0.0.1:6767", daemonVersion: "1.2.2", desktopManaged: true, }) .mockResolvedValueOnce({ action: "stopped" }) .mockResolvedValueOnce({ localDaemon: "stopped", connectedDaemon: "unreachable", serverId: "", }) .mockResolvedValueOnce({ localDaemon: "running", connectedDaemon: "reachable", serverId: "server-2", pid: 8888, listen: "127.0.0.1:6767", hostname: "dev-host", daemonVersion: "1.2.3", desktopManaged: true, }); mocks.spawnProcess.mockReturnValue(createMockChildProcess()); const handlers = createDaemonCommandHandlers(); await expect(handlers.start_desktop_daemon()).resolves.toEqual({ serverId: "server-2", status: "running", listen: "127.0.0.1:6767", hostname: "dev-host", pid: 8888, home: mocks.paseoHome, version: "1.2.3", desktopManaged: true, error: null, }); expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(3, [ "daemon", "stop", "--json", "--timeout", "5", "--force", "--kill-timeout", "5", ]); expect(mocks.spawnProcess).toHaveBeenCalled(); }); it("starts the managed daemon detached from desktop stdio and reports daemon log failures", async () => { mkdirSync(mocks.paseoHome, { recursive: true }); writeFileSync( `${mocks.paseoHome}/daemon.log`, ["old log line", "recent daemon failure"].join("\n"), ); mocks.runExternalCliJsonCommand.mockResolvedValue({ localDaemon: "stopped", connectedDaemon: "unreachable", serverId: "", }); mocks.spawnProcess.mockImplementation(() => { const child = createMockChildProcess(); scheduleFailedStartup(child); return child; }); const handlers = createDaemonCommandHandlers(); let thrown: Error | null = null; try { await handlers.start_desktop_daemon(); } catch (error) { thrown = error instanceof Error ? error : new Error(String(error)); } expect(thrown).toBeInstanceOf(Error); const message = thrown?.message ?? ""; const recentLogsLabel = message.match(/Recent logs \(([^)]*)\):/)?.[1]; expect(message).toContain("Daemon failed to start: exit code 1"); expect(recentLogsLabel?.split(/[\\/]/).at(-1)).toBe("daemon.log"); expect(message).toContain("recent daemon failure"); expect(mocks.spawnProcess).toHaveBeenCalledWith( "node", [], expect.objectContaining({ detached: true, stdio: ["ignore", "ignore", "ignore"], envOverlay: expect.objectContaining({ PASEO_WEB_UI_ENABLED: "false" }), }), ); }); });