Files
paseo/packages/desktop/src/daemon/daemon-manager.test.ts
T

376 lines
10 KiB
TypeScript

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(),
}));
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: vi.fn(), error: vi.fn() },
}));
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<typeof vi.fn>;
};
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();
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",
]);
});
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("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"],
}),
);
});
});