Skip to content

Commit b08797a

Browse files
committed
test(daemon): add launchd integration restart coverage
1 parent 7d72da2 commit b08797a

File tree

1 file changed

+112
-0
lines changed

1 file changed

+112
-0
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { spawnSync } from "node:child_process";
2+
import { randomUUID } from "node:crypto";
3+
import fs from "node:fs/promises";
4+
import os from "node:os";
5+
import path from "node:path";
6+
import { PassThrough } from "node:stream";
7+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
8+
import {
9+
installLaunchAgent,
10+
readLaunchAgentRuntime,
11+
restartLaunchAgent,
12+
resolveLaunchAgentPlistPath,
13+
uninstallLaunchAgent,
14+
} from "./launchd.js";
15+
import type { GatewayServiceEnv } from "./service-types.js";
16+
17+
const WAIT_INTERVAL_MS = 200;
18+
const WAIT_TIMEOUT_MS = 15_000;
19+
20+
function canRunLaunchdIntegration(): boolean {
21+
if (process.platform !== "darwin") {
22+
return false;
23+
}
24+
if (typeof process.getuid !== "function") {
25+
return false;
26+
}
27+
const domain = `gui/${process.getuid()}`;
28+
const probe = spawnSync("launchctl", ["print", domain], { encoding: "utf8" });
29+
if (probe.error) {
30+
return false;
31+
}
32+
return probe.status === 0;
33+
}
34+
35+
const describeLaunchdIntegration = canRunLaunchdIntegration() ? describe : describe.skip;
36+
37+
async function waitForRunningRuntime(params: {
38+
env: GatewayServiceEnv;
39+
pidNot?: number;
40+
timeoutMs?: number;
41+
}): Promise<{ pid: number }> {
42+
const timeoutMs = params.timeoutMs ?? WAIT_TIMEOUT_MS;
43+
const deadline = Date.now() + timeoutMs;
44+
let lastStatus = "unknown";
45+
let lastPid: number | undefined;
46+
while (Date.now() < deadline) {
47+
const runtime = await readLaunchAgentRuntime(params.env);
48+
lastStatus = runtime.status;
49+
lastPid = runtime.pid;
50+
if (
51+
runtime.status === "running" &&
52+
typeof runtime.pid === "number" &&
53+
runtime.pid > 1 &&
54+
(params.pidNot === undefined || runtime.pid !== params.pidNot)
55+
) {
56+
return { pid: runtime.pid };
57+
}
58+
await new Promise((resolve) => {
59+
setTimeout(resolve, WAIT_INTERVAL_MS);
60+
});
61+
}
62+
throw new Error(
63+
`Timed out waiting for launchd runtime (status=${lastStatus}, pid=${lastPid ?? "none"})`,
64+
);
65+
}
66+
67+
describeLaunchdIntegration("launchd integration", () => {
68+
let env: GatewayServiceEnv | undefined;
69+
let homeDir = "";
70+
const stdout = new PassThrough();
71+
72+
beforeAll(async () => {
73+
const testId = randomUUID().slice(0, 8);
74+
homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-launchd-int-${testId}-`));
75+
env = {
76+
HOME: homeDir,
77+
OPENCLAW_LAUNCHD_LABEL: `ai.openclaw.launchd-int-${testId}`,
78+
OPENCLAW_LOG_PREFIX: `gateway-launchd-int-${testId}`,
79+
};
80+
await installLaunchAgent({
81+
env,
82+
stdout,
83+
programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"],
84+
});
85+
await waitForRunningRuntime({ env });
86+
}, 30_000);
87+
88+
afterAll(async () => {
89+
if (env) {
90+
try {
91+
await uninstallLaunchAgent({ env, stdout });
92+
} catch {
93+
// Best-effort cleanup in case launchctl state already changed.
94+
}
95+
}
96+
if (homeDir) {
97+
await fs.rm(homeDir, { recursive: true, force: true });
98+
}
99+
}, 30_000);
100+
101+
it("restarts launchd service and keeps it running with a new pid", async () => {
102+
if (!env) {
103+
throw new Error("launchd integration env was not initialized");
104+
}
105+
const before = await waitForRunningRuntime({ env });
106+
await restartLaunchAgent({ env, stdout });
107+
const after = await waitForRunningRuntime({ env, pidNot: before.pid });
108+
expect(after.pid).toBeGreaterThan(1);
109+
expect(after.pid).not.toBe(before.pid);
110+
await fs.access(resolveLaunchAgentPlistPath(env));
111+
}, 30_000);
112+
});

0 commit comments

Comments
 (0)