255 lines
7.1 KiB
TypeScript
255 lines
7.1 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
|
|
import { createDaemonCommandHandlers } from "./daemon-manager";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
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: false,
|
|
},
|
|
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(() => "/tmp/paseo-home"),
|
|
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 & {
|
|
stdout: EventEmitter;
|
|
stderr: EventEmitter;
|
|
pid: number;
|
|
spawnfile: string;
|
|
spawnargs: string[];
|
|
unref: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
function createMockChildProcess(): MockChildProcess {
|
|
const child = new EventEmitter() as MockChildProcess;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.pid = 1234;
|
|
child.spawnfile = "node";
|
|
child.spawnargs = ["node", "daemon.js"];
|
|
child.unref = vi.fn();
|
|
return child;
|
|
}
|
|
|
|
function scheduleFailedStartupOutput(child: MockChildProcess): void {
|
|
setImmediate(() => {
|
|
child.stdout.emit("data", Buffer.from(`${"x".repeat(80_000)}stdout-tail`));
|
|
child.stderr.emit("data", Buffer.from(`${"y".repeat(80_000)}stderr-tail`));
|
|
child.emit("exit", 1, null);
|
|
});
|
|
}
|
|
|
|
describe("daemon-manager commands", () => {
|
|
beforeEach(() => {
|
|
mocks.settings = DEFAULT_DESKTOP_SETTINGS;
|
|
mocks.runExternalCliJsonCommand.mockReset();
|
|
mocks.runExternalCliTextCommand.mockReset();
|
|
mocks.spawnProcess.mockReset();
|
|
});
|
|
|
|
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: "/tmp/paseo-home",
|
|
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: "/tmp/paseo-home",
|
|
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("uses a reachable daemon when the PID file is stale", 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.2",
|
|
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: "/tmp/paseo-home",
|
|
version: "1.2.2",
|
|
desktopManaged: false,
|
|
error: null,
|
|
});
|
|
|
|
expect(mocks.spawnProcess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("bounds captured daemon startup output", async () => {
|
|
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
|
localDaemon: "stopped",
|
|
connectedDaemon: "unreachable",
|
|
serverId: "",
|
|
});
|
|
mocks.spawnProcess.mockImplementation(() => {
|
|
const child = createMockChildProcess();
|
|
scheduleFailedStartupOutput(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 ?? "";
|
|
expect(message).toContain("Daemon failed to start: exit code 1");
|
|
expect(message).toContain("output truncated to the last 65536 chars");
|
|
expect(message).toContain("stdout-tail");
|
|
expect(message).toContain("stderr-tail");
|
|
expect(message.length).toBeLessThan(150_000);
|
|
});
|
|
});
|