refactor(core): dedupe gateway runtime and config tests

This commit is contained in:
Peter Steinberger
2026-02-22 07:37:11 +00:00
parent ad1c07e7c0
commit b109fa53ea
20 changed files with 699 additions and 561 deletions

View File

@@ -14,6 +14,62 @@ vi.mock("./install-source-utils.js", async (importOriginal) => {
});
describe("installFromNpmSpecArchive", () => {
const baseSpec = "@openclaw/test@1.0.0";
const baseArchivePath = "/tmp/openclaw-test.tgz";
const mockPackedSuccess = (overrides?: {
resolvedSpec?: string;
integrity?: string;
name?: string;
version?: string;
}) => {
vi.mocked(packNpmSpecToArchive).mockResolvedValue({
ok: true,
archivePath: baseArchivePath,
metadata: {
resolvedSpec: overrides?.resolvedSpec ?? baseSpec,
integrity: overrides?.integrity ?? "sha512-same",
...(overrides?.name ? { name: overrides.name } : {}),
...(overrides?.version ? { version: overrides.version } : {}),
},
});
};
const runInstall = async (overrides: {
expectedIntegrity?: string;
onIntegrityDrift?: (payload: {
spec: string;
expectedIntegrity: string;
actualIntegrity: string;
resolvedSpec: string;
}) => boolean | Promise<boolean>;
warn?: (message: string) => void;
installFromArchive: (params: {
archivePath: string;
}) => Promise<{ ok: boolean; [k: string]: unknown }>;
}) =>
await installFromNpmSpecArchive({
tempDirPrefix: "openclaw-test-",
spec: baseSpec,
timeoutMs: 1000,
expectedIntegrity: overrides.expectedIntegrity,
onIntegrityDrift: overrides.onIntegrityDrift,
warn: overrides.warn,
installFromArchive: overrides.installFromArchive,
});
const expectWrappedOkResult = (
result: Awaited<ReturnType<typeof runInstall>>,
installResult: Record<string, unknown>,
) => {
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error("expected ok result");
}
expect(result.installResult).toEqual(installResult);
return result;
};
beforeEach(() => {
vi.mocked(packNpmSpecToArchive).mockReset();
vi.mocked(withTempDir).mockClear();
@@ -36,52 +92,45 @@ describe("installFromNpmSpecArchive", () => {
});
it("returns resolution metadata and installer result on success", async () => {
vi.mocked(packNpmSpecToArchive).mockResolvedValue({
ok: true,
archivePath: "/tmp/openclaw-test.tgz",
metadata: {
name: "@openclaw/test",
version: "1.0.0",
resolvedSpec: "@openclaw/test@1.0.0",
integrity: "sha512-same",
},
});
mockPackedSuccess({ name: "@openclaw/test", version: "1.0.0" });
const installFromArchive = vi.fn(async () => ({ ok: true as const, target: "done" }));
const result = await installFromNpmSpecArchive({
tempDirPrefix: "openclaw-test-",
spec: "@openclaw/test@1.0.0",
timeoutMs: 1000,
const result = await runInstall({
expectedIntegrity: "sha512-same",
installFromArchive,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.installResult).toEqual({ ok: true, target: "done" });
expect(result.integrityDrift).toBeUndefined();
expect(result.npmResolution.resolvedSpec).toBe("@openclaw/test@1.0.0");
expect(result.npmResolution.resolvedAt).toBeTruthy();
const okResult = expectWrappedOkResult(result, { ok: true, target: "done" });
expect(okResult.integrityDrift).toBeUndefined();
expect(okResult.npmResolution.resolvedSpec).toBe("@openclaw/test@1.0.0");
expect(okResult.npmResolution.resolvedAt).toBeTruthy();
expect(installFromArchive).toHaveBeenCalledWith({ archivePath: "/tmp/openclaw-test.tgz" });
});
it("aborts when integrity drift callback rejects drift", async () => {
vi.mocked(packNpmSpecToArchive).mockResolvedValue({
ok: true,
archivePath: "/tmp/openclaw-test.tgz",
metadata: {
resolvedSpec: "@openclaw/test@1.0.0",
integrity: "sha512-new",
},
it("proceeds when integrity drift callback accepts drift", async () => {
mockPackedSuccess({ integrity: "sha512-new" });
const onIntegrityDrift = vi.fn(async () => true);
const installFromArchive = vi.fn(async () => ({ ok: true as const, id: "plugin-accept" }));
const result = await runInstall({
expectedIntegrity: "sha512-old",
onIntegrityDrift,
installFromArchive,
});
const okResult = expectWrappedOkResult(result, { ok: true, id: "plugin-accept" });
expect(okResult.integrityDrift).toEqual({
expectedIntegrity: "sha512-old",
actualIntegrity: "sha512-new",
});
expect(onIntegrityDrift).toHaveBeenCalledTimes(1);
});
it("aborts when integrity drift callback rejects drift", async () => {
mockPackedSuccess({ integrity: "sha512-new" });
const installFromArchive = vi.fn(async () => ({ ok: true as const }));
const result = await installFromNpmSpecArchive({
tempDirPrefix: "openclaw-test-",
spec: "@openclaw/test@1.0.0",
timeoutMs: 1000,
const result = await runInstall({
expectedIntegrity: "sha512-old",
onIntegrityDrift: async () => false,
installFromArchive,
@@ -95,32 +144,18 @@ describe("installFromNpmSpecArchive", () => {
});
it("warns and proceeds on drift when no callback is configured", async () => {
vi.mocked(packNpmSpecToArchive).mockResolvedValue({
ok: true,
archivePath: "/tmp/openclaw-test.tgz",
metadata: {
resolvedSpec: "@openclaw/test@1.0.0",
integrity: "sha512-new",
},
});
mockPackedSuccess({ integrity: "sha512-new" });
const warn = vi.fn();
const installFromArchive = vi.fn(async () => ({ ok: true as const, id: "plugin-1" }));
const result = await installFromNpmSpecArchive({
tempDirPrefix: "openclaw-test-",
spec: "@openclaw/test@1.0.0",
timeoutMs: 1000,
const result = await runInstall({
expectedIntegrity: "sha512-old",
warn,
installFromArchive,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.installResult).toEqual({ ok: true, id: "plugin-1" });
expect(result.integrityDrift).toEqual({
const okResult = expectWrappedOkResult(result, { ok: true, id: "plugin-1" });
expect(okResult.integrityDrift).toEqual({
expectedIntegrity: "sha512-old",
actualIntegrity: "sha512-new",
});
@@ -130,26 +165,15 @@ describe("installFromNpmSpecArchive", () => {
});
it("returns installer failures to callers for domain-specific handling", async () => {
vi.mocked(packNpmSpecToArchive).mockResolvedValue({
ok: true,
archivePath: "/tmp/openclaw-test.tgz",
metadata: { resolvedSpec: "@openclaw/test@1.0.0", integrity: "sha512-same" },
});
mockPackedSuccess({ integrity: "sha512-same" });
const installFromArchive = vi.fn(async () => ({ ok: false as const, error: "install failed" }));
const result = await installFromNpmSpecArchive({
tempDirPrefix: "openclaw-test-",
spec: "@openclaw/test@1.0.0",
timeoutMs: 1000,
const result = await runInstall({
expectedIntegrity: "sha512-same",
installFromArchive,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.installResult).toEqual({ ok: false, error: "install failed" });
expect(result.integrityDrift).toBeUndefined();
const okResult = expectWrappedOkResult(result, { ok: false, error: "install failed" });
expect(okResult.integrityDrift).toBeUndefined();
});
});

View File

@@ -1,32 +1,32 @@
import { describe, expect, it, vi } from "vitest";
import { retryAsync } from "./retry.js";
describe("retryAsync", () => {
async function runRetryAfterCase(options: {
maxDelayMs: number;
retryAfterMs: number;
expectedDelayMs: number;
}) {
vi.useFakeTimers();
try {
const fn = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce("ok");
const delays: number[] = [];
const promise = retryAsync(fn, {
attempts: 2,
minDelayMs: 0,
maxDelayMs: options.maxDelayMs,
jitter: 0,
retryAfterMs: () => options.retryAfterMs,
onRetry: (info) => delays.push(info.delayMs),
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe("ok");
expect(delays[0]).toBe(options.expectedDelayMs);
} finally {
vi.useRealTimers();
}
async function runRetryAfterCase(params: {
minDelayMs: number;
maxDelayMs: number;
retryAfterMs: number;
}): Promise<number[]> {
vi.useFakeTimers();
try {
const fn = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce("ok");
const delays: number[] = [];
const promise = retryAsync(fn, {
attempts: 2,
minDelayMs: params.minDelayMs,
maxDelayMs: params.maxDelayMs,
jitter: 0,
retryAfterMs: () => params.retryAfterMs,
onRetry: (info) => delays.push(info.delayMs),
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe("ok");
return delays;
} finally {
vi.useRealTimers();
}
}
describe("retryAsync", () => {
it("returns on first success", async () => {
const fn = vi.fn().mockResolvedValue("ok");
const result = await retryAsync(fn, 3, 10);
@@ -74,20 +74,18 @@ describe("retryAsync", () => {
expect(fn).toHaveBeenCalledTimes(1);
});
it.each([
{
name: "uses retryAfterMs when provided",
maxDelayMs: 1000,
retryAfterMs: 500,
expectedDelayMs: 500,
},
{
name: "clamps retryAfterMs to maxDelayMs",
maxDelayMs: 100,
retryAfterMs: 500,
expectedDelayMs: 100,
},
])("$name", async ({ maxDelayMs, retryAfterMs, expectedDelayMs }) => {
await runRetryAfterCase({ maxDelayMs, retryAfterMs, expectedDelayMs });
it("uses retryAfterMs when provided", async () => {
const delays = await runRetryAfterCase({ minDelayMs: 0, maxDelayMs: 1000, retryAfterMs: 500 });
expect(delays[0]).toBe(500);
});
it("clamps retryAfterMs to maxDelayMs", async () => {
const delays = await runRetryAfterCase({ minDelayMs: 0, maxDelayMs: 100, retryAfterMs: 500 });
expect(delays[0]).toBe(100);
});
it("clamps retryAfterMs to minDelayMs", async () => {
const delays = await runRetryAfterCase({ minDelayMs: 250, maxDelayMs: 1000, retryAfterMs: 50 });
expect(delays[0]).toBe(250);
});
});

View File

@@ -7,6 +7,16 @@ import {
} from "./system-run-command.js";
describe("system run command helpers", () => {
function expectRawCommandMismatch(params: { argv: string[]; rawCommand: string }) {
const res = validateSystemRunCommandConsistency(params);
expect(res.ok).toBe(false);
if (res.ok) {
throw new Error("unreachable");
}
expect(res.message).toContain("rawCommand does not match command");
expect(res.details?.code).toBe("RAW_COMMAND_MISMATCH");
}
test("formatExecCommand quotes args with spaces", () => {
expect(formatExecCommand(["echo", "hi there"])).toBe('echo "hi there"');
});
@@ -39,16 +49,10 @@ describe("system run command helpers", () => {
});
test("validateSystemRunCommandConsistency rejects mismatched rawCommand vs direct argv", () => {
const res = validateSystemRunCommandConsistency({
expectRawCommandMismatch({
argv: ["uname", "-a"],
rawCommand: "echo hi",
});
expect(res.ok).toBe(false);
if (res.ok) {
throw new Error("unreachable");
}
expect(res.message).toContain("rawCommand does not match command");
expect(res.details?.code).toBe("RAW_COMMAND_MISMATCH");
});
test("validateSystemRunCommandConsistency accepts rawCommand matching sh wrapper argv", () => {
@@ -60,16 +64,17 @@ describe("system run command helpers", () => {
});
test("validateSystemRunCommandConsistency rejects cmd.exe /c trailing-arg smuggling", () => {
const res = validateSystemRunCommandConsistency({
expectRawCommandMismatch({
argv: ["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"],
rawCommand: "echo",
});
expect(res.ok).toBe(false);
if (res.ok) {
throw new Error("unreachable");
}
expect(res.message).toContain("rawCommand does not match command");
expect(res.details?.code).toBe("RAW_COMMAND_MISMATCH");
});
test("validateSystemRunCommandConsistency rejects mismatched rawCommand vs sh wrapper argv", () => {
expectRawCommandMismatch({
argv: ["/bin/sh", "-lc", "echo hi"],
rawCommand: "echo bye",
});
});
test("resolveSystemRunCommand requires command when rawCommand is present", () => {

View File

@@ -12,6 +12,16 @@ const {
} = tailscale;
const tailscaleBin = expect.stringMatching(/tailscale$/i);
function createRuntimeWithExitError() {
return {
error: vi.fn(),
log: vi.fn(),
exit: ((code: number) => {
throw new Error(`exit ${code}`);
}) as (code: number) => never,
};
}
describe("tailscale helpers", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
@@ -46,31 +56,47 @@ describe("tailscale helpers", () => {
it("ensureGoInstalled installs when missing and user agrees", async () => {
const exec = vi.fn().mockRejectedValueOnce(new Error("no go")).mockResolvedValue({}); // brew install go
const prompt = vi.fn().mockResolvedValue(true);
const runtime = {
error: vi.fn(),
log: vi.fn(),
exit: ((code: number) => {
throw new Error(`exit ${code}`);
}) as (code: number) => never,
};
const runtime = createRuntimeWithExitError();
await ensureGoInstalled(exec as never, prompt, runtime);
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
});
it("ensureGoInstalled exits when missing and user declines install", async () => {
const exec = vi.fn().mockRejectedValueOnce(new Error("no go"));
const prompt = vi.fn().mockResolvedValue(false);
const runtime = createRuntimeWithExitError();
await expect(ensureGoInstalled(exec as never, prompt, runtime)).rejects.toThrow("exit 1");
expect(runtime.error).toHaveBeenCalledWith(
"Go is required to build tailscaled from source. Aborting.",
);
expect(exec).toHaveBeenCalledTimes(1);
});
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
const exec = vi.fn().mockRejectedValueOnce(new Error("missing")).mockResolvedValue({});
const prompt = vi.fn().mockResolvedValue(true);
const runtime = {
error: vi.fn(),
log: vi.fn(),
exit: ((code: number) => {
throw new Error(`exit ${code}`);
}) as (code: number) => never,
};
const runtime = createRuntimeWithExitError();
await ensureTailscaledInstalled(exec as never, prompt, runtime);
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
});
it("ensureTailscaledInstalled exits when missing and user declines install", async () => {
const exec = vi.fn().mockRejectedValueOnce(new Error("missing"));
const prompt = vi.fn().mockResolvedValue(false);
const runtime = createRuntimeWithExitError();
await expect(ensureTailscaledInstalled(exec as never, prompt, runtime)).rejects.toThrow(
"exit 1",
);
expect(runtime.error).toHaveBeenCalledWith(
"tailscaled is required for user-space funnel. Aborting.",
);
expect(exec).toHaveBeenCalledTimes(1);
});
it("enableTailscaleServe attempts normal first, then sudo", async () => {
// 1. First attempt fails
// 2. Second attempt (sudo) succeeds

View File

@@ -37,6 +37,16 @@ describe("installUnhandledRejectionHandler - fatal detection", () => {
process.exit = originalExit;
});
function emitUnhandled(reason: unknown): void {
process.emit("unhandledRejection", reason, Promise.resolve());
}
function expectExitCodeFromUnhandled(reason: unknown, expected: number[]): void {
exitCalls = [];
emitUnhandled(reason);
expect(exitCalls).toEqual(expected);
}
describe("fatal errors", () => {
it("exits on fatal runtime codes", () => {
const fatalCases = [
@@ -46,10 +56,7 @@ describe("installUnhandledRejectionHandler - fatal detection", () => {
] as const;
for (const { code, message } of fatalCases) {
exitCalls = [];
const err = Object.assign(new Error(message), { code });
process.emit("unhandledRejection", err, Promise.resolve());
expect(exitCalls).toEqual([1]);
expectExitCodeFromUnhandled(Object.assign(new Error(message), { code }), [1]);
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
@@ -67,10 +74,7 @@ describe("installUnhandledRejectionHandler - fatal detection", () => {
] as const;
for (const { code, message } of configurationCases) {
exitCalls = [];
const err = Object.assign(new Error(message), { code });
process.emit("unhandledRejection", err, Promise.resolve());
expect(exitCalls).toEqual([1]);
expectExitCodeFromUnhandled(Object.assign(new Error(message), { code }), [1]);
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
@@ -92,9 +96,7 @@ describe("installUnhandledRejectionHandler - fatal detection", () => {
];
for (const transientErr of transientCases) {
exitCalls = [];
process.emit("unhandledRejection", transientErr, Promise.resolve());
expect(exitCalls).toEqual([]);
expectExitCodeFromUnhandled(transientErr, []);
}
expect(consoleWarnSpy).toHaveBeenCalledWith(
@@ -106,13 +108,22 @@ describe("installUnhandledRejectionHandler - fatal detection", () => {
it("exits on generic errors without code", () => {
const genericErr = new Error("Something went wrong");
process.emit("unhandledRejection", genericErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
expectExitCodeFromUnhandled(genericErr, [1]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[openclaw] Unhandled promise rejection:",
expect.stringContaining("Something went wrong"),
);
});
it("does not exit on AbortError and logs suppression warning", () => {
const abortErr = new Error("This operation was aborted");
abortErr.name = "AbortError";
expectExitCodeFromUnhandled(abortErr, []);
expect(consoleWarnSpy).toHaveBeenCalledWith(
"[openclaw] Suppressed AbortError:",
expect.stringContaining("This operation was aborted"),
);
});
});
});

View File

@@ -9,13 +9,18 @@ const createFakeProcess = () =>
execPath: "/usr/local/bin/node",
}) as unknown as NodeJS.Process;
const createWatchHarness = () => {
const child = Object.assign(new EventEmitter(), {
kill: vi.fn(),
});
const spawn = vi.fn(() => child);
const fakeProcess = createFakeProcess();
return { child, spawn, fakeProcess };
};
describe("watch-node script", () => {
it("wires node watch to run-node with watched source/config paths", async () => {
const child = Object.assign(new EventEmitter(), {
kill: vi.fn(),
});
const spawn = vi.fn(() => child);
const fakeProcess = createFakeProcess();
const { child, spawn, fakeProcess } = createWatchHarness();
const runPromise = runWatchMain({
args: ["gateway", "--force"],
@@ -54,11 +59,7 @@ describe("watch-node script", () => {
});
it("terminates child on SIGINT and returns shell interrupt code", async () => {
const child = Object.assign(new EventEmitter(), {
kill: vi.fn(),
});
const spawn = vi.fn(() => child);
const fakeProcess = createFakeProcess();
const { child, spawn, fakeProcess } = createWatchHarness();
const runPromise = runWatchMain({
args: ["gateway", "--force"],
@@ -74,4 +75,22 @@ describe("watch-node script", () => {
expect(fakeProcess.listenerCount("SIGINT")).toBe(0);
expect(fakeProcess.listenerCount("SIGTERM")).toBe(0);
});
it("terminates child on SIGTERM and returns shell terminate code", async () => {
const { child, spawn, fakeProcess } = createWatchHarness();
const runPromise = runWatchMain({
args: ["gateway", "--force"],
process: fakeProcess,
spawn,
});
fakeProcess.emit("SIGTERM");
const exitCode = await runPromise;
expect(exitCode).toBe(143);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(fakeProcess.listenerCount("SIGINT")).toBe(0);
expect(fakeProcess.listenerCount("SIGTERM")).toBe(0);
});
});