import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; import { installLaunchAgent, isLaunchAgentListed, parseLaunchctlPrint, repairLaunchAgentBootstrap, resolveLaunchAgentPlistPath, } from "./launchd.js"; async function withLaunchctlStub( options: { listOutput?: string }, run: (context: { env: Record; logPath: string }) => Promise, ) { const originalPath = process.env.PATH; const originalLogPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG; const originalListOutput = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT; const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-launchctl-test-")); try { const binDir = path.join(tmpDir, "bin"); const homeDir = path.join(tmpDir, "home"); const logPath = path.join(tmpDir, "launchctl.log"); await fs.mkdir(binDir, { recursive: true }); await fs.mkdir(homeDir, { recursive: true }); const stubJsPath = path.join(binDir, "launchctl.js"); await fs.writeFile( stubJsPath, [ 'import fs from "node:fs";', "const args = process.argv.slice(2);", "const logPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;", "if (logPath) {", ' fs.appendFileSync(logPath, JSON.stringify(args) + "\\n", "utf8");', "}", 'if (args[0] === "list") {', ' const output = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT || "";', " process.stdout.write(output);", "}", "process.exit(0);", "", ].join("\n"), "utf8", ); if (process.platform === "win32") { await fs.writeFile( path.join(binDir, "launchctl.cmd"), `@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`, "utf8", ); } else { const shPath = path.join(binDir, "launchctl"); await fs.writeFile(shPath, `#!/bin/sh\nnode "$(dirname "$0")/launchctl.js" "$@"\n`, "utf8"); await fs.chmod(shPath, 0o755); } process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = logPath; process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT = options.listOutput ?? ""; process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; await run({ env: { HOME: homeDir, CLAWDBOT_PROFILE: "default", }, logPath, }); } finally { process.env.PATH = originalPath; if (originalLogPath === undefined) { delete process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG; } else { process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = originalLogPath; } if (originalListOutput === undefined) { delete process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT; } else { process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT = originalListOutput; } await fs.rm(tmpDir, { recursive: true, force: true }); } } describe("launchd runtime parsing", () => { it("parses state, pid, and exit status", () => { const output = [ "state = running", "pid = 4242", "last exit status = 1", "last exit reason = exited", ].join("\n"); expect(parseLaunchctlPrint(output)).toEqual({ state: "running", pid: 4242, lastExitStatus: 1, lastExitReason: "exited", }); }); }); describe("launchctl list detection", () => { it("detects the resolved label in launchctl list", async () => { await withLaunchctlStub({ listOutput: "123 0 com.clawdbot.gateway\n" }, async ({ env }) => { const listed = await isLaunchAgentListed({ env }); expect(listed).toBe(true); }); }); it("returns false when the label is missing", async () => { await withLaunchctlStub({ listOutput: "123 0 com.other.service\n" }, async ({ env }) => { const listed = await isLaunchAgentListed({ env }); expect(listed).toBe(false); }); }); }); describe("launchd bootstrap repair", () => { it("bootstraps and kickstarts the resolved label", async () => { await withLaunchctlStub({}, async ({ env, logPath }) => { const repair = await repairLaunchAgentBootstrap({ env }); expect(repair.ok).toBe(true); const calls = (await fs.readFile(logPath, "utf8")) .split("\n") .filter(Boolean) .map((line) => JSON.parse(line) as string[]); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const label = "com.clawdbot.gateway"; const plistPath = resolveLaunchAgentPlistPath(env); expect(calls).toContainEqual(["bootstrap", domain, plistPath]); expect(calls).toContainEqual(["kickstart", "-k", `${domain}/${label}`]); }); }); }); describe("launchd install", () => { it("enables service before bootstrap (clears persisted disabled state)", async () => { const originalPath = process.env.PATH; const originalLogPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG; const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-launchctl-test-")); try { const binDir = path.join(tmpDir, "bin"); const homeDir = path.join(tmpDir, "home"); const logPath = path.join(tmpDir, "launchctl.log"); await fs.mkdir(binDir, { recursive: true }); await fs.mkdir(homeDir, { recursive: true }); const stubJsPath = path.join(binDir, "launchctl.js"); await fs.writeFile( stubJsPath, [ 'import fs from "node:fs";', "const logPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;", "if (logPath) {", ' fs.appendFileSync(logPath, JSON.stringify(process.argv.slice(2)) + "\\n", "utf8");', "}", "process.exit(0);", "", ].join("\n"), "utf8", ); if (process.platform === "win32") { await fs.writeFile( path.join(binDir, "launchctl.cmd"), `@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`, "utf8", ); } else { const shPath = path.join(binDir, "launchctl"); await fs.writeFile(shPath, `#!/bin/sh\nnode "$(dirname "$0")/launchctl.js" "$@"\n`, "utf8"); await fs.chmod(shPath, 0o755); } process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = logPath; process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; const env: Record = { HOME: homeDir, CLAWDBOT_PROFILE: "default", }; await installLaunchAgent({ env, stdout: new PassThrough(), programArguments: ["node", "-e", "process.exit(0)"], }); const calls = (await fs.readFile(logPath, "utf8")) .split("\n") .filter(Boolean) .map((line) => JSON.parse(line) as string[]); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const label = "com.clawdbot.gateway"; const plistPath = resolveLaunchAgentPlistPath(env); const serviceId = `${domain}/${label}`; const enableCalls = calls.filter((c) => c[0] === "enable" && c[1] === serviceId); expect(enableCalls).toHaveLength(1); const enableIndex = calls.findIndex((c) => c[0] === "enable" && c[1] === serviceId); const bootstrapIndex = calls.findIndex( (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, ); expect(enableIndex).toBeGreaterThanOrEqual(0); expect(bootstrapIndex).toBeGreaterThanOrEqual(0); expect(enableIndex).toBeLessThan(bootstrapIndex); } finally { process.env.PATH = originalPath; if (originalLogPath === undefined) { delete process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG; } else { process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = originalLogPath; } await fs.rm(tmpDir, { recursive: true, force: true }); } }); }); describe("resolveLaunchAgentPlistPath", () => { it("uses default label when CLAWDBOT_PROFILE is default", () => { const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "default" }; expect(resolveLaunchAgentPlistPath(env)).toBe( "/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist", ); }); it("uses default label when CLAWDBOT_PROFILE is unset", () => { const env = { HOME: "/Users/test" }; expect(resolveLaunchAgentPlistPath(env)).toBe( "/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist", ); }); it("uses profile-specific label when CLAWDBOT_PROFILE is set to a custom value", () => { const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "jbphoenix" }; expect(resolveLaunchAgentPlistPath(env)).toBe( "/Users/test/Library/LaunchAgents/com.clawdbot.jbphoenix.plist", ); }); it("prefers CLAWDBOT_LAUNCHD_LABEL over CLAWDBOT_PROFILE", () => { const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "jbphoenix", CLAWDBOT_LAUNCHD_LABEL: "com.custom.label", }; expect(resolveLaunchAgentPlistPath(env)).toBe( "/Users/test/Library/LaunchAgents/com.custom.label.plist", ); }); it("trims whitespace from CLAWDBOT_LAUNCHD_LABEL", () => { const env = { HOME: "/Users/test", CLAWDBOT_LAUNCHD_LABEL: " com.custom.label ", }; expect(resolveLaunchAgentPlistPath(env)).toBe( "/Users/test/Library/LaunchAgents/com.custom.label.plist", ); }); it("ignores empty CLAWDBOT_LAUNCHD_LABEL and falls back to profile", () => { const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "myprofile", CLAWDBOT_LAUNCHD_LABEL: " ", }; expect(resolveLaunchAgentPlistPath(env)).toBe( "/Users/test/Library/LaunchAgents/com.clawdbot.myprofile.plist", ); }); it("handles case-insensitive 'Default' profile", () => { const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "Default" }; expect(resolveLaunchAgentPlistPath(env)).toBe( "/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist", ); }); it("handles case-insensitive 'DEFAULT' profile", () => { const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "DEFAULT" }; expect(resolveLaunchAgentPlistPath(env)).toBe( "/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist", ); }); it("trims whitespace from CLAWDBOT_PROFILE", () => { const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: " myprofile " }; expect(resolveLaunchAgentPlistPath(env)).toBe( "/Users/test/Library/LaunchAgents/com.clawdbot.myprofile.plist", ); }); });