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

View File

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