/** * Unit tests for macOS launchd service management. * * Mocks filesystem and child_process to test logic without touching launchd. * Covers: plist generation, path resolution, status parsing, install/uninstall * flows, restart/stop commands, readInstalledPlist parsing, and error paths. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // ── Mocks ────────────────────────────────────────────────────────────────── const mockExistsSync = vi.fn<(path: string) => boolean>(); const mockMkdirSync = vi.fn(); const mockWriteFileSync = vi.fn(); const mockReadFileSync = vi.fn<(path: string, encoding: string) => string>(); const mockRealpathSync = vi.fn<(path: string) => string>(); const mockUnlinkSync = vi.fn(); const mockExecSync = vi.fn<(cmd: string, opts?: object) => string>(); const mockHomedir = vi.fn(() => "/Users/testuser"); vi.mock("node:fs", () => ({ existsSync: (...args: unknown[]) => mockExistsSync(args[0] as string), mkdirSync: (...args: unknown[]) => mockMkdirSync(...args), writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args), readFileSync: (...args: unknown[]) => mockReadFileSync(args[0] as string, args[1] as string), realpathSync: (...args: unknown[]) => mockRealpathSync(args[0] as string), unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args), })); vi.mock("node:child_process", () => ({ execSync: (...args: unknown[]) => mockExecSync(args[0] as string, args[1] as object | undefined), })); vi.mock("node:os", () => ({ homedir: () => mockHomedir(), })); // Stub process.getuid to return a fake uid on all platforms const originalGetuid = process.getuid; beforeEach(() => { process.getuid = () => 501; mockUnlinkSync.mockImplementation(() => undefined); mockRealpathSync.mockImplementation((path: string) => path); mockReadFileSync.mockImplementation((path: string) => { if (path.endsWith("package.json")) { return JSON.stringify({ engines: { node: ">=23.6.0" } }); } return ""; }); mockExecSync.mockImplementation((cmd: string) => { if (cmd.includes("--version")) { return "v25.0.0"; } return ""; }); }); afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); process.getuid = originalGetuid; }); // Import after mocks are in place import { getServiceStatus, installService, readInstalledPlist, restartService, stopService, uninstallService, } from "../src/launchd.js"; // ── Plist path ───────────────────────────────────────────────────────────── describe("plist path resolution", () => { it("derives plist path from homedir", () => { mockExistsSync.mockReturnValue(false); const status = getServiceStatus(); expect(status.plistPath).toBe( "/Users/testuser/Library/LaunchAgents/dev.chaosdonkey.oppi.plist", ); expect(status.label).toBe("dev.chaosdonkey.oppi"); }); }); // ── Plist XML generation (via installService) ────────────────────────────── describe("plist XML generation", () => { function captureWrittenPlist(dataDir?: string): string { mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/bin/node") return true; if (p === "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js") return true; if (p.endsWith(".plist")) return false; return false; }); installService(dataDir); const writeCall = mockWriteFileSync.mock.calls[0]; return writeCall[1] as string; } it("generates valid plist XML with correct structure", () => { const xml = captureWrittenPlist("/tmp/test-oppi"); expect(xml).toContain(''); expect(xml).toContain("'); expect(xml).toContain("Label"); expect(xml).toContain("dev.chaosdonkey.oppi"); }); it("sets ProgramArguments with runtime, CLI, serve, and data-dir", () => { const xml = captureWrittenPlist("/tmp/test-oppi"); expect(xml).toContain("ProgramArguments"); expect(xml).toContain("/opt/homebrew/bin/node"); expect(xml).toContain( "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js", ); expect(xml).toContain("serve"); expect(xml).toContain("--data-dir"); expect(xml).toContain("/tmp/test-oppi"); }); it("includes KeepAlive with SuccessfulExit=false", () => { const xml = captureWrittenPlist("/tmp/test-oppi"); expect(xml).toContain("KeepAlive"); expect(xml).toContain("SuccessfulExit"); expect(xml).toContain(""); }); it("includes RunAtLoad", () => { const xml = captureWrittenPlist("/tmp/test-oppi"); expect(xml).toContain("RunAtLoad"); expect(xml).toContain(""); }); it("sets PATH and OPPI_DATA_DIR without a second runtime owner", () => { const xml = captureWrittenPlist("/tmp/test-oppi"); expect(xml).toContain("EnvironmentVariables"); expect(xml).toContain("PATH"); expect(xml).toContain("/opt/homebrew/bin"); expect(xml).toContain("OPPI_DATA_DIR"); expect(xml).toContain("/tmp/test-oppi"); expect(xml).not.toContain("OPPI_RUNTIME_BIN"); }); it("sets log paths to dataDir/server.log", () => { const xml = captureWrittenPlist("/tmp/test-oppi"); expect(xml).toContain("StandardOutPath"); expect(xml).toContain("StandardErrorPath"); expect(xml).toContain("/tmp/test-oppi/server.log"); }); it("includes ThrottleInterval and ProcessType", () => { const xml = captureWrittenPlist("/tmp/test-oppi"); expect(xml).toContain("ThrottleInterval"); expect(xml).toContain("5"); expect(xml).toContain("ProcessType"); expect(xml).toContain("Standard"); }); it("sets WorkingDirectory to homedir", () => { const xml = captureWrittenPlist("/tmp/test-oppi"); expect(xml).toContain("WorkingDirectory"); expect(xml).toContain("/Users/testuser"); }); it("defaults dataDir to ~/.config/oppi when not provided", () => { const xml = captureWrittenPlist(undefined); expect(xml).toContain("OPPI_DATA_DIR"); expect(xml).toContain("/Users/testuser/.config/oppi"); }); }); // ── Runtime resolution ───────────────────────────────────────────────────── describe("runtime resolution", () => { it("prefers Homebrew node", () => { mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/bin/node") return true; if (p === "/usr/local/bin/node") return true; if (p === "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js") return true; if (p.endsWith(".plist")) return false; return false; }); const result = installService("/tmp/data"); expect(result.runtimePath).toBe("/opt/homebrew/bin/node"); }); it("falls back to /usr/local/bin/node when Homebrew node is unavailable", () => { mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/bin/node") return false; if (p === "/usr/local/bin/node") return true; if (p === "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js") return true; if (p.endsWith(".plist")) return false; return false; }); const result = installService("/tmp/data"); expect(result.runtimePath).toBe("/usr/local/bin/node"); }); it("rejects nodes older than the server minimum", () => { mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/bin/node") return true; if (p === "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js") return true; if (p.endsWith(".plist")) return false; return false; }); mockExecSync.mockImplementation((cmd: string) => { if (cmd.includes("--version")) { return "v20.11.1"; } return ""; }); const result = installService("/tmp/data"); expect(result.ok).toBe(false); expect(result.message).toContain("Node.js 20.11.1 found"); expect(result.message).toContain("23.6.0 or newer"); }); it("returns error when no runtime found", () => { mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js") return true; return false; }); const result = installService("/tmp/data"); expect(result.ok).toBe(false); expect(result.message).toContain("Node.js 23.6.0 or newer not found"); }); }); // ── CLI resolution ───────────────────────────────────────────────────────── describe("CLI resolution", () => { it("returns error when CLI not found", () => { // Runtime exists, but no CLI mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/bin/node") return true; return false; }); const result = installService("/tmp/data"); expect(result.ok).toBe(false); expect(result.message).toContain("Oppi CLI not found"); }); it("resolves the globally installed npm CLI", () => { mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/bin/node") return true; if (p === "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js") return true; if (p.endsWith(".plist")) return false; return false; }); const result = installService("/tmp/data"); expect(result.ok).toBe(true); expect(result.cliPath).toBe("/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js"); }); it("resolves the current npm CLI symlink before fallback locations", () => { const originalArgv1 = process.argv[1]; process.argv[1] = "/tmp/npm-prefix/bin/oppi"; mockRealpathSync.mockImplementation((p: string) => { if (p === "/tmp/npm-prefix/bin/oppi") { return "/tmp/npm-prefix/lib/node_modules/oppi-server/dist/src/cli.js"; } return p; }); mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/bin/node") return true; if (p === "/tmp/npm-prefix/bin/oppi") return true; if (p === "/tmp/npm-prefix/lib/node_modules/oppi-server/dist/src/cli.js") return true; if (p === "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js") return true; if (p.endsWith(".plist")) return false; return false; }); try { const result = installService("/tmp/data"); expect(result.ok).toBe(true); expect(result.cliPath).toBe("/tmp/npm-prefix/bin/oppi"); } finally { process.argv[1] = originalArgv1; } }); }); // ── installService flow ──────────────────────────────────────────────────── describe("installService", () => { function setupValidInstall() { mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/bin/node") return true; if (p === "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js") return true; if (p.endsWith(".plist")) return false; return false; }); } it("creates LaunchAgents directory", () => { setupValidInstall(); installService("/tmp/data"); expect(mockMkdirSync).toHaveBeenCalledWith("/Users/testuser/Library/LaunchAgents", { recursive: true, }); }); it("writes plist with mode 0o644", () => { setupValidInstall(); installService("/tmp/data"); expect(mockWriteFileSync).toHaveBeenCalledWith( "/Users/testuser/Library/LaunchAgents/dev.chaosdonkey.oppi.plist", expect.any(String), { mode: 0o644 }, ); }); it("calls launchctl bootstrap after writing plist", () => { setupValidInstall(); installService("/tmp/data"); const bootstrapCall = mockExecSync.mock.calls.find(([cmd]) => (cmd as string).includes("bootstrap"), ); expect(bootstrapCall).toBeDefined(); expect(bootstrapCall![0]).toBe( "launchctl bootstrap gui/501 /Users/testuser/Library/LaunchAgents/dev.chaosdonkey.oppi.plist", ); }); it("bootouts existing plist before installing", () => { mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/bin/node") return true; if (p === "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js") return true; if (p === "/Users/testuser/Library/LaunchAgents/dev.chaosdonkey.oppi.plist") return true; return false; }); installService("/tmp/data"); expect(mockExecSync).toHaveBeenCalledWith( "launchctl bootout gui/501 /Users/testuser/Library/LaunchAgents/dev.chaosdonkey.oppi.plist 2>/dev/null", { stdio: "pipe" }, ); }); it("disables legacy dev.chenda LaunchAgent before installing", () => { mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/bin/node") return true; if (p === "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js") return true; if (p === "/Users/testuser/Library/LaunchAgents/dev.chenda.oppi.plist") return true; if (p === "/Users/testuser/Library/LaunchAgents/dev.chaosdonkey.oppi.plist") return false; return false; }); const result = installService("/tmp/data"); expect(result.ok).toBe(true); expect(result.legacyRemoved).toEqual([ "/Users/testuser/Library/LaunchAgents/dev.chenda.oppi.plist", ]); expect(mockExecSync).toHaveBeenCalledWith( "launchctl bootout gui/501/dev.chenda.oppi 2>/dev/null", { stdio: "pipe" }, ); expect(mockExecSync).toHaveBeenCalledWith( "launchctl bootout gui/501 /Users/testuser/Library/LaunchAgents/dev.chenda.oppi.plist 2>/dev/null", { stdio: "pipe" }, ); expect(mockUnlinkSync).toHaveBeenCalledWith( "/Users/testuser/Library/LaunchAgents/dev.chenda.oppi.plist", ); }); it("fails install when legacy LaunchAgent plist cannot be removed", () => { mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/bin/node") return true; if (p === "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js") return true; if (p === "/Users/testuser/Library/LaunchAgents/dev.chenda.oppi.plist") return true; return false; }); mockUnlinkSync.mockImplementation(() => { throw new Error("EACCES: permission denied"); }); const result = installService("/tmp/data"); expect(result.ok).toBe(false); expect(result.message).toContain("Failed to disable legacy LaunchAgent"); expect(result.message).toContain("EACCES"); }); it("handles error 37 (already loaded) by kickstarting", () => { setupValidInstall(); mockExecSync.mockImplementation((cmd: string) => { if (cmd.includes("--version")) { return "v25.0.0"; } if (cmd.includes("bootstrap")) { throw new Error("37: Service is already loaded"); } return ""; }); const result = installService("/tmp/data"); expect(result.ok).toBe(true); const kickstartCall = mockExecSync.mock.calls.find(([cmd]) => (cmd as string).includes("kickstart"), ); expect(kickstartCall).toBeDefined(); expect(kickstartCall![0]).toBe("launchctl kickstart -k gui/501/dev.chaosdonkey.oppi"); }); it("returns error on non-37 bootstrap failure", () => { setupValidInstall(); mockExecSync.mockImplementation((cmd: string) => { if (cmd.includes("--version")) { return "v25.0.0"; } if (cmd.includes("bootstrap")) { throw new Error("5: Some other error"); } return ""; }); const result = installService("/tmp/data"); expect(result.ok).toBe(false); expect(result.message).toContain("Failed to load LaunchAgent"); expect(result.message).toContain("5: Some other error"); }); it("returns ok with paths on success", () => { setupValidInstall(); const result = installService("/tmp/data"); expect(result.ok).toBe(true); expect(result.message).toContain("installed and started"); expect(result.runtimePath).toBe("/opt/homebrew/bin/node"); expect(result.cliPath).toBe("/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js"); }); }); // ── uninstallService ─────────────────────────────────────────────────────── describe("uninstallService", () => { it("returns ok when plist not installed", () => { mockExistsSync.mockReturnValue(false); const result = uninstallService(); expect(result.ok).toBe(true); expect(result.message).toContain("not installed"); }); it("bootouts and removes plist when installed", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockReturnValue(""); const result = uninstallService(); expect(result.ok).toBe(true); expect(result.message).toContain("uninstalled"); const bootoutCall = mockExecSync.mock.calls.find(([cmd]) => (cmd as string).includes("bootout"), ); expect(bootoutCall).toBeDefined(); expect(mockUnlinkSync).toHaveBeenCalledWith( "/Users/testuser/Library/LaunchAgents/dev.chaosdonkey.oppi.plist", ); }); it("still removes plist if bootout fails", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockImplementation(() => { throw new Error("already unloaded"); }); const result = uninstallService(); expect(result.ok).toBe(true); expect(mockUnlinkSync).toHaveBeenCalled(); }); it("returns error if plist removal fails", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockReturnValue(""); mockUnlinkSync.mockImplementation(() => { throw new Error("EACCES: permission denied"); }); const result = uninstallService(); expect(result.ok).toBe(false); expect(result.message).toContain("Failed to remove plist"); expect(result.message).toContain("EACCES"); }); }); // ── restartService ───────────────────────────────────────────────────────── describe("restartService", () => { it("returns error when plist not installed", () => { mockExistsSync.mockReturnValue(false); const result = restartService(); expect(result.ok).toBe(false); expect(result.message).toContain("not installed"); }); it("calls kickstart -k with correct label", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockReturnValue(""); const result = restartService(); expect(result.ok).toBe(true); expect(result.message).toContain("restarted"); expect(mockExecSync).toHaveBeenCalledWith( "launchctl kickstart -k gui/501/dev.chaosdonkey.oppi", { stdio: "pipe" }, ); }); it("returns error on kickstart failure", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockImplementation(() => { throw new Error("kickstart: no such process"); }); const result = restartService(); expect(result.ok).toBe(false); expect(result.message).toContain("Restart failed"); }); }); // ── stopService ──────────────────────────────────────────────────────────── describe("stopService", () => { it("returns error when plist not installed", () => { mockExistsSync.mockReturnValue(false); const result = stopService(); expect(result.ok).toBe(false); expect(result.message).toContain("not installed"); }); it("bootouts by label on stop", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockReturnValue(""); const result = stopService(); expect(result.ok).toBe(true); expect(result.message).toContain("stopped"); expect(mockExecSync).toHaveBeenCalledWith("launchctl bootout gui/501/dev.chaosdonkey.oppi", { stdio: "pipe", }); }); it("treats 'No such process' as success (already stopped)", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockImplementation(() => { throw new Error("No such process"); }); const result = stopService(); expect(result.ok).toBe(true); expect(result.message).toContain("not running"); }); it("treats 'Could not find' as success (already stopped)", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockImplementation(() => { throw new Error("Could not find service"); }); const result = stopService(); expect(result.ok).toBe(true); expect(result.message).toContain("not running"); }); it("returns error on unexpected stop failure", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockImplementation(() => { throw new Error("unexpected launchctl error"); }); const result = stopService(); expect(result.ok).toBe(false); expect(result.message).toContain("Stop failed"); }); }); // ── getServiceStatus ─────────────────────────────────────────────────────── describe("getServiceStatus", () => { it("returns not installed when plist does not exist", () => { mockExistsSync.mockReturnValue(false); const status = getServiceStatus(); expect(status.installed).toBe(false); expect(status.running).toBe(false); expect(status.pid).toBeNull(); }); it("parses PID from launchctl print output", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockReturnValue( [ "dev.chaosdonkey.oppi = {", " active count = 1", " pid = 12345", " state = running", "}", ].join("\n"), ); const status = getServiceStatus(); expect(status.installed).toBe(true); expect(status.running).toBe(true); expect(status.pid).toBe(12345); }); it("detects running state even without PID line", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockReturnValue( ["dev.chaosdonkey.oppi = {", " active count = 1", " state = running", "}"].join("\n"), ); const status = getServiceStatus(); expect(status.installed).toBe(true); expect(status.running).toBe(true); expect(status.pid).toBeNull(); }); it("treats pid = 0 as not running", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockReturnValue( ["dev.chaosdonkey.oppi = {", " pid = 0", " state = waiting", "}"].join("\n"), ); const status = getServiceStatus(); expect(status.installed).toBe(true); expect(status.running).toBe(false); expect(status.pid).toBe(0); }); it("handles launchctl print failure (service not loaded)", () => { mockExistsSync.mockReturnValue(true); mockExecSync.mockImplementation(() => { throw new Error("Could not find service"); }); const status = getServiceStatus(); expect(status.installed).toBe(true); expect(status.running).toBe(false); expect(status.pid).toBeNull(); }); it("always includes label and plist path", () => { mockExistsSync.mockReturnValue(false); const status = getServiceStatus(); expect(status.label).toBe("dev.chaosdonkey.oppi"); expect(status.plistPath).toBe( "/Users/testuser/Library/LaunchAgents/dev.chaosdonkey.oppi.plist", ); }); }); // ── readInstalledPlist ───────────────────────────────────────────────────── describe("readInstalledPlist", () => { it("returns null when plist does not exist", () => { mockExistsSync.mockReturnValue(false); expect(readInstalledPlist()).toBeNull(); }); it("parses runtime, CLI, and data-dir from plist XML", () => { const plistXml = ` ProgramArguments /opt/homebrew/bin/node /opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js serve --data-dir /Users/testuser/.config/oppi `; mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(plistXml); const parsed = readInstalledPlist(); expect(parsed).not.toBeNull(); expect(parsed!.runtimePath).toBe("/opt/homebrew/bin/node"); expect(parsed!.cliPath).toBe("/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js"); expect(parsed!.dataDir).toBe("/Users/testuser/.config/oppi"); }); it("returns null when ProgramArguments has fewer than 5 entries", () => { const plistXml = ` ProgramArguments /opt/homebrew/bin/node serve `; mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(plistXml); expect(readInstalledPlist()).toBeNull(); }); it("returns null when readFileSync throws", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockImplementation(() => { throw new Error("EACCES: permission denied"); }); expect(readInstalledPlist()).toBeNull(); }); it("returns null when plist has no ProgramArguments key", () => { const plistXml = ` Label dev.chaosdonkey.oppi `; mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(plistXml); expect(readInstalledPlist()).toBeNull(); }); }); // ── uid() edge case ──────────────────────────────────────────────────────── describe("uid unavailable", () => { it("installService returns error when getuid is not available", () => { // Remove getuid to simulate non-macOS process.getuid = undefined as unknown as () => number; mockExistsSync.mockImplementation((p: string) => { if (p === "/opt/homebrew/bin/node") return true; if (p === "/opt/homebrew/lib/node_modules/oppi-server/dist/src/cli.js") return true; // No existing plist, so bootout path is skipped — uid() is hit at bootstrap if (p.endsWith(".plist")) return false; return false; }); const result = installService("/tmp/data"); expect(result.ok).toBe(false); expect(result.message).toContain("uid() not available"); }); });