Files
openclaw/src/daemon/launchd.integration.e2e.test.ts
2026-03-02 05:33:07 +00:00

146 lines
4.3 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
installLaunchAgent,
readLaunchAgentRuntime,
restartLaunchAgent,
resolveLaunchAgentPlistPath,
uninstallLaunchAgent,
} from "./launchd.js";
import type { GatewayServiceEnv } from "./service-types.js";
const WAIT_INTERVAL_MS = 200;
const WAIT_TIMEOUT_MS = 30_000;
const STARTUP_TIMEOUT_MS = 45_000;
function canRunLaunchdIntegration(): boolean {
if (process.platform !== "darwin") {
return false;
}
if (typeof process.getuid !== "function") {
return false;
}
const domain = `gui/${process.getuid()}`;
const probe = spawnSync("launchctl", ["print", domain], { encoding: "utf8" });
if (probe.error) {
return false;
}
return probe.status === 0;
}
const describeLaunchdIntegration = canRunLaunchdIntegration() ? describe : describe.skip;
async function withTimeout<T>(params: {
run: () => Promise<T>;
timeoutMs: number;
message: string;
}): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
params.run(),
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(params.message)), params.timeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
async function waitForRunningRuntime(params: {
env: GatewayServiceEnv;
pidNot?: number;
timeoutMs?: number;
}): Promise<{ pid: number }> {
const timeoutMs = params.timeoutMs ?? WAIT_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
let lastStatus = "unknown";
let lastPid: number | undefined;
while (Date.now() < deadline) {
const runtime = await readLaunchAgentRuntime(params.env);
lastStatus = runtime.status ?? "unknown";
lastPid = runtime.pid;
if (
runtime.status === "running" &&
typeof runtime.pid === "number" &&
runtime.pid > 1 &&
(params.pidNot === undefined || runtime.pid !== params.pidNot)
) {
return { pid: runtime.pid };
}
await new Promise((resolve) => {
setTimeout(resolve, WAIT_INTERVAL_MS);
});
}
throw new Error(
`Timed out waiting for launchd runtime (status=${lastStatus}, pid=${lastPid ?? "none"})`,
);
}
describeLaunchdIntegration("launchd integration", () => {
let env: GatewayServiceEnv | undefined;
let homeDir = "";
const stdout = new PassThrough();
beforeAll(async () => {
const testId = randomUUID().slice(0, 8);
homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-launchd-int-${testId}-`));
env = {
HOME: homeDir,
OPENCLAW_LAUNCHD_LABEL: `ai.openclaw.launchd-int-${testId}`,
OPENCLAW_LOG_PREFIX: `gateway-launchd-int-${testId}`,
};
});
afterAll(async () => {
if (env) {
try {
await uninstallLaunchAgent({ env, stdout });
} catch {
// Best-effort cleanup in case launchctl state already changed.
}
}
if (homeDir) {
await fs.rm(homeDir, { recursive: true, force: true });
}
}, 60_000);
it("restarts launchd service and keeps it running with a new pid", async () => {
if (!env) {
throw new Error("launchd integration env was not initialized");
}
const launchEnv = env;
try {
await withTimeout({
run: async () => {
await installLaunchAgent({
env: launchEnv,
stdout,
programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"],
});
await waitForRunningRuntime({ env: launchEnv });
},
timeoutMs: STARTUP_TIMEOUT_MS,
message: "Timed out initializing launchd integration runtime",
});
} catch {
// Best-effort integration check only; skip when launchctl is unstable in CI.
return;
}
const before = await waitForRunningRuntime({ env: launchEnv });
await restartLaunchAgent({ env: launchEnv, stdout });
const after = await waitForRunningRuntime({ env: launchEnv, pidNot: before.pid });
expect(after.pid).toBeGreaterThan(1);
expect(after.pid).not.toBe(before.pid);
await fs.access(resolveLaunchAgentPlistPath(launchEnv));
}, 60_000);
});