test: share systemd service test helpers

This commit is contained in:
Peter Steinberger 2026-03-13 23:50:15 +00:00
parent 26578a18c8
commit 4fe59edd84
1 changed files with 93 additions and 121 deletions

View File

@ -25,6 +25,10 @@ type ExecFileError = Error & {
code?: string | number;
};
const TEST_SERVICE_HOME = "/home/test";
const TEST_MANAGED_HOME = "/tmp/openclaw-test-home";
const GATEWAY_SERVICE = "openclaw-gateway.service";
const createExecFileError = (
message: string,
options: { stderr?: string; code?: string | number } = {},
@ -58,6 +62,48 @@ function pathLikeToString(pathname: unknown): string {
return "";
}
function assertUserSystemctlArgs(args: string[], ...command: string[]) {
expect(args).toEqual(["--user", ...command]);
}
function assertMachineUserSystemctlArgs(args: string[], user: string, ...command: string[]) {
expect(args).toEqual(["--machine", `${user}@`, "--user", ...command]);
}
async function readManagedServiceEnabled(env: NodeJS.ProcessEnv = { HOME: TEST_MANAGED_HOME }) {
const { isSystemdServiceEnabled } = await import("./systemd.js");
vi.spyOn(fs, "access").mockResolvedValue(undefined);
return isSystemdServiceEnabled({ env });
}
function mockReadGatewayServiceFile(
unitLines: string[],
extraFiles: Record<string, string | Error> = {},
) {
return vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith(`/${GATEWAY_SERVICE}`)) {
return unitLines.join("\n");
}
const extraFile = extraFiles[pathValue];
if (typeof extraFile === "string") {
return extraFile;
}
if (extraFile instanceof Error) {
throw extraFile;
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
}
async function expectExecStartWithoutEnvironment(envFileLine: string) {
mockReadGatewayServiceFile(["[Service]", "ExecStart=/usr/bin/openclaw gateway run", envFileLine]);
const command = await readSystemdServiceExecStart({ HOME: TEST_SERVICE_HOME });
expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]);
expect(command?.environment).toBeUndefined();
}
const assertRestartSuccess = async (env: NodeJS.ProcessEnv) => {
const { write, stdout } = createWritableStreamMock();
await restartSystemdService({ stdout, env });
@ -118,24 +164,18 @@ describe("systemd availability", () => {
});
describe("isSystemdServiceEnabled", () => {
const mockManagedUnitPresent = () => {
vi.spyOn(fs, "access").mockResolvedValue(undefined);
};
beforeEach(() => {
vi.restoreAllMocks();
execFileMock.mockReset();
});
it("returns false when systemctl is not present", async () => {
const { isSystemdServiceEnabled } = await import("./systemd.js");
mockManagedUnitPresent();
execFileMock.mockImplementation((_cmd, _args, _opts, cb) => {
const err = new Error("spawn systemctl EACCES") as Error & { code?: string };
err.code = "EACCES";
cb(err, "", "");
});
const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } });
const result = await readManagedServiceEnabled();
expect(result).toBe(false);
});
@ -152,55 +192,45 @@ describe("isSystemdServiceEnabled", () => {
});
it("calls systemctl is-enabled when systemctl is present", async () => {
const { isSystemdServiceEnabled } = await import("./systemd.js");
mockManagedUnitPresent();
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]);
assertUserSystemctlArgs(args, "is-enabled", GATEWAY_SERVICE);
cb(null, "enabled", "");
});
const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } });
const result = await readManagedServiceEnabled();
expect(result).toBe(true);
});
it("returns false when systemctl reports disabled", async () => {
const { isSystemdServiceEnabled } = await import("./systemd.js");
mockManagedUnitPresent();
execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => {
const err = new Error("disabled") as Error & { code?: number };
err.code = 1;
cb(err, "disabled", "");
});
const result = await isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } });
const result = await readManagedServiceEnabled();
expect(result).toBe(false);
});
it("returns false for the WSL2 Ubuntu 24.04 wrapper-only is-enabled failure", async () => {
const { isSystemdServiceEnabled } = await import("./systemd.js");
mockManagedUnitPresent();
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]);
assertUserSystemctlArgs(args, "is-enabled", GATEWAY_SERVICE);
const err = new Error(
"Command failed: systemctl --user is-enabled openclaw-gateway.service",
`Command failed: systemctl --user is-enabled ${GATEWAY_SERVICE}`,
) as Error & { code?: number };
err.code = 1;
cb(err, "", "");
});
await expect(
isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }),
).rejects.toThrow(
"systemctl is-enabled unavailable: Command failed: systemctl --user is-enabled openclaw-gateway.service",
await expect(readManagedServiceEnabled()).rejects.toThrow(
`systemctl is-enabled unavailable: Command failed: systemctl --user is-enabled ${GATEWAY_SERVICE}`,
);
});
it("returns false when is-enabled cannot connect to the user bus without machine fallback", async () => {
const { isSystemdServiceEnabled } = await import("./systemd.js");
mockManagedUnitPresent();
vi.spyOn(os, "userInfo").mockImplementationOnce(() => {
throw new Error("no user info");
});
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]);
assertUserSystemctlArgs(args, "is-enabled", GATEWAY_SERVICE);
cb(
createExecFileError("Failed to connect to bus", { stderr: "Failed to connect to bus" }),
"",
@ -209,18 +239,14 @@ describe("isSystemdServiceEnabled", () => {
});
await expect(
isSystemdServiceEnabled({
env: { HOME: "/tmp/openclaw-test-home", USER: "", LOGNAME: "" },
}),
readManagedServiceEnabled({ HOME: TEST_MANAGED_HOME, USER: "", LOGNAME: "" }),
).rejects.toThrow("systemctl is-enabled unavailable: Failed to connect to bus");
});
it("returns false when both direct and machine-scope is-enabled checks report bus unavailability", async () => {
const { isSystemdServiceEnabled } = await import("./systemd.js");
mockManagedUnitPresent();
execFileMock
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]);
assertUserSystemctlArgs(args, "is-enabled", GATEWAY_SERVICE);
cb(
createExecFileError("Failed to connect to bus", { stderr: "Failed to connect to bus" }),
"",
@ -228,13 +254,7 @@ describe("isSystemdServiceEnabled", () => {
);
})
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual([
"--machine",
"debian@",
"--user",
"is-enabled",
"openclaw-gateway.service",
]);
assertMachineUserSystemctlArgs(args, "debian", "is-enabled", GATEWAY_SERVICE);
cb(
createExecFileError("Failed to connect to user scope bus via local transport", {
stderr:
@ -246,32 +266,28 @@ describe("isSystemdServiceEnabled", () => {
});
await expect(
isSystemdServiceEnabled({
env: { HOME: "/tmp/openclaw-test-home", USER: "debian" },
}),
readManagedServiceEnabled({ HOME: TEST_MANAGED_HOME, USER: "debian" }),
).rejects.toThrow("systemctl is-enabled unavailable: Failed to connect to user scope bus");
});
it("throws when generic wrapper errors report infrastructure failures", async () => {
const { isSystemdServiceEnabled } = await import("./systemd.js");
mockManagedUnitPresent();
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]);
assertUserSystemctlArgs(args, "is-enabled", GATEWAY_SERVICE);
const err = new Error(
"Command failed: systemctl --user is-enabled openclaw-gateway.service",
`Command failed: systemctl --user is-enabled ${GATEWAY_SERVICE}`,
) as Error & { code?: number };
err.code = 1;
cb(err, "", "read-only file system");
});
await expect(
isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }),
).rejects.toThrow("systemctl is-enabled unavailable: read-only file system");
await expect(readManagedServiceEnabled()).rejects.toThrow(
"systemctl is-enabled unavailable: read-only file system",
);
});
it("throws when systemctl is-enabled fails for non-state errors", async () => {
const { isSystemdServiceEnabled } = await import("./systemd.js");
mockManagedUnitPresent();
vi.spyOn(fs, "access").mockResolvedValue(undefined);
execFileMock
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]);
@ -294,7 +310,7 @@ describe("isSystemdServiceEnabled", () => {
it("returns false when systemctl is-enabled exits with code 4 (not-found)", async () => {
const { isSystemdServiceEnabled } = await import("./systemd.js");
mockManagedUnitPresent();
vi.spyOn(fs, "access").mockResolvedValue(undefined);
execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => {
// On Ubuntu 24.04, `systemctl --user is-enabled <unit>` exits with
// code 4 and prints "not-found" to stdout when the unit doesn't exist.
@ -463,82 +479,38 @@ describe("readSystemdServiceExecStart", () => {
});
it("loads OPENCLAW_GATEWAY_TOKEN from EnvironmentFile", async () => {
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=%h/.openclaw/.env",
].join("\n");
}
if (pathValue === "/home/test/.openclaw/.env") {
return "OPENCLAW_GATEWAY_TOKEN=env-file-token\n";
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
const readFileSpy = mockReadGatewayServiceFile(
["[Service]", "ExecStart=/usr/bin/openclaw gateway run", "EnvironmentFile=%h/.openclaw/.env"],
{ [`${TEST_SERVICE_HOME}/.openclaw/.env`]: "OPENCLAW_GATEWAY_TOKEN=env-file-token\n" },
);
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
const command = await readSystemdServiceExecStart({ HOME: TEST_SERVICE_HOME });
expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("env-file-token");
expect(readFileSpy).toHaveBeenCalledTimes(2);
});
it("lets EnvironmentFile override inline Environment values", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=%h/.openclaw/.env",
'Environment="OPENCLAW_GATEWAY_TOKEN=inline-token"',
].join("\n");
}
if (pathValue === "/home/test/.openclaw/.env") {
return "OPENCLAW_GATEWAY_TOKEN=env-file-token\n";
}
throw new Error(`unexpected readFile path: ${pathValue}`);
});
mockReadGatewayServiceFile(
[
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=%h/.openclaw/.env",
'Environment="OPENCLAW_GATEWAY_TOKEN=inline-token"',
],
{ [`${TEST_SERVICE_HOME}/.openclaw/.env`]: "OPENCLAW_GATEWAY_TOKEN=env-file-token\n" },
);
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
const command = await readSystemdServiceExecStart({ HOME: TEST_SERVICE_HOME });
expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("env-file-token");
expect(command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN).toBe("file");
});
it("ignores missing optional EnvironmentFile entries", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=-%h/.openclaw/missing.env",
].join("\n");
}
throw new Error(`missing: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]);
expect(command?.environment).toBeUndefined();
await expectExecStartWithoutEnvironment("EnvironmentFile=-%h/.openclaw/missing.env");
});
it("keeps parsing when non-optional EnvironmentFile entries are missing", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) {
return [
"[Service]",
"ExecStart=/usr/bin/openclaw gateway run",
"EnvironmentFile=%h/.openclaw/missing.env",
].join("\n");
}
throw new Error(`missing: ${pathValue}`);
});
const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.programArguments).toEqual(["/usr/bin/openclaw", "gateway", "run"]);
expect(command?.environment).toBeUndefined();
await expectExecStartWithoutEnvironment("EnvironmentFile=%h/.openclaw/missing.env");
});
it("supports multiple EnvironmentFile entries and quoted paths", async () => {
@ -631,7 +603,7 @@ describe("readSystemdServiceExecStart", () => {
describe("systemd service control", () => {
const assertMachineRestartArgs = (args: string[]) => {
expect(args).toEqual(["--machine", "debian@", "--user", "restart", "openclaw-gateway.service"]);
assertMachineUserSystemctlArgs(args, "debian", "restart", GATEWAY_SERVICE);
};
beforeEach(() => {
@ -642,7 +614,7 @@ describe("systemd service control", () => {
execFileMock
.mockImplementationOnce((_cmd, _args, _opts, cb) => cb(null, "", ""))
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "stop", "openclaw-gateway.service"]);
assertUserSystemctlArgs(args, "stop", GATEWAY_SERVICE);
cb(null, "", "");
});
const write = vi.fn();
@ -664,7 +636,7 @@ describe("systemd service control", () => {
),
)
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "stop", "openclaw-gateway.service"]);
assertUserSystemctlArgs(args, "stop", GATEWAY_SERVICE);
cb(null, "", "");
});
@ -678,7 +650,7 @@ describe("systemd service control", () => {
execFileMock
.mockImplementationOnce((_cmd, _args, _opts, cb) => cb(null, "", ""))
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "restart", "openclaw-gateway-work.service"]);
assertUserSystemctlArgs(args, "restart", "openclaw-gateway-work.service");
cb(null, "", "");
});
await assertRestartSuccess({ OPENCLAW_PROFILE: "work" });
@ -724,7 +696,7 @@ describe("systemd service control", () => {
it("targets the sudo caller's user scope when SUDO_USER is set", async () => {
execFileMock
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--machine", "debian@", "--user", "status"]);
assertMachineUserSystemctlArgs(args, "debian", "status");
cb(null, "", "");
})
.mockImplementationOnce((_cmd, args, _opts, cb) => {
@ -737,11 +709,11 @@ describe("systemd service control", () => {
it("keeps direct --user scope when SUDO_USER is root", async () => {
execFileMock
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "status"]);
assertUserSystemctlArgs(args, "status");
cb(null, "", "");
})
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "restart", "openclaw-gateway.service"]);
assertUserSystemctlArgs(args, "restart", GATEWAY_SERVICE);
cb(null, "", "");
});
await assertRestartSuccess({ SUDO_USER: "root", USER: "root" });
@ -750,7 +722,7 @@ describe("systemd service control", () => {
it("falls back to machine user scope for restart when user bus env is missing", async () => {
execFileMock
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "status"]);
assertUserSystemctlArgs(args, "status");
const err = createExecFileError("Failed to connect to user scope bus", {
stderr:
"Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined",
@ -758,11 +730,11 @@ describe("systemd service control", () => {
cb(err, "", "");
})
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--machine", "debian@", "--user", "status"]);
assertMachineUserSystemctlArgs(args, "debian", "status");
cb(null, "", "");
})
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "restart", "openclaw-gateway.service"]);
assertUserSystemctlArgs(args, "restart", GATEWAY_SERVICE);
const err = createExecFileError("Failed to connect to user scope bus", {
stderr: "Failed to connect to user scope bus",
});