Files
openclaw/src/process/exec.test.ts
2026-04-03 22:14:59 +01:00

237 lines
7.5 KiB
TypeScript

import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import process from "node:process";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
const spawnMock = vi.hoisted(() => vi.fn());
let attachChildProcessBridge: typeof import("./child-process-bridge.js").attachChildProcessBridge;
let resolveCommandEnv: typeof import("./exec.js").resolveCommandEnv;
let resolveProcessExitCode: typeof import("./exec.js").resolveProcessExitCode;
let runCommandWithTimeout: typeof import("./exec.js").runCommandWithTimeout;
let shouldSpawnWithShell: typeof import("./exec.js").shouldSpawnWithShell;
async function loadExecModules(options?: { mockSpawn?: boolean }) {
vi.resetModules();
if (options?.mockSpawn) {
vi.doMock("node:child_process", async () => {
const actual =
await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: spawnMock,
};
});
} else {
vi.doUnmock("node:child_process");
}
({ attachChildProcessBridge } = await import("./child-process-bridge.js"));
({ resolveCommandEnv, resolveProcessExitCode, runCommandWithTimeout, shouldSpawnWithShell } =
await import("./exec.js"));
}
describe("runCommandWithTimeout", () => {
function createSilentIdleArgv(): string[] {
return [process.execPath, "-e", "setInterval(() => {}, 1_000)"];
}
function createKilledChild(signal: NodeJS.Signals = "SIGKILL"): ChildProcess {
const child = new EventEmitter() as EventEmitter & ChildProcess;
child.stdout = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stdout"]>;
child.stderr = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stderr"]>;
child.stdin = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stdin"]>;
child.stdin.write = vi.fn(() => true) as NonNullable<ChildProcess["stdin"]>["write"];
child.stdin.end = vi.fn() as NonNullable<ChildProcess["stdin"]>["end"];
let killed = false;
let exitCode: number | null = null;
let signalCode: NodeJS.Signals | null = null;
Object.defineProperties(child, {
pid: {
configurable: true,
enumerable: true,
get: () => 1234,
},
killed: {
configurable: true,
enumerable: true,
get: () => killed,
},
exitCode: {
configurable: true,
enumerable: true,
get: () => exitCode,
},
signalCode: {
configurable: true,
enumerable: true,
get: () => signalCode,
},
});
child.kill = vi.fn((receivedSignal?: NodeJS.Signals) => {
const resolvedSignal = receivedSignal ?? signal;
killed = true;
exitCode = null;
signalCode = resolvedSignal;
child.emit("exit", null, resolvedSignal);
child.emit("close", null, resolvedSignal);
return true;
}) as ChildProcess["kill"];
return child;
}
beforeEach(async () => {
vi.useRealTimers();
spawnMock.mockReset();
await loadExecModules();
});
afterEach(() => {
vi.useRealTimers();
vi.doUnmock("node:child_process");
});
it("never enables shell execution (Windows cmd.exe injection hardening)", () => {
expect(
shouldSpawnWithShell({
resolvedCommand: "npm.cmd",
platform: "win32",
}),
).toBe(false);
});
it("merges custom env with base env and drops undefined values", async () => {
const resolved = resolveCommandEnv({
argv: ["node", "script.js"],
baseEnv: {
OPENCLAW_BASE_ENV: "base",
OPENCLAW_TO_REMOVE: undefined,
},
env: {
OPENCLAW_TEST_ENV: "ok",
},
});
expect(resolved.OPENCLAW_BASE_ENV).toBe("base");
expect(resolved.OPENCLAW_TEST_ENV).toBe("ok");
expect(resolved.OPENCLAW_TO_REMOVE).toBeUndefined();
expect(resolved.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE);
});
it("suppresses npm fund prompts for npm argv", async () => {
const resolved = resolveCommandEnv({
argv: ["npm", "--version"],
baseEnv: {},
});
expect(resolved.NPM_CONFIG_FUND).toBe("false");
expect(resolved.npm_config_fund).toBe("false");
});
it("infers success for shimmed Windows commands when exit codes are missing", () => {
expect(
resolveProcessExitCode({
explicitCode: null,
childExitCode: null,
resolvedSignal: null,
usesWindowsExitCodeShim: true,
timedOut: false,
noOutputTimedOut: false,
killIssuedByTimeout: false,
}),
).toBe(0);
});
it("does not infer success when this process already issued a timeout kill", () => {
expect(
resolveProcessExitCode({
explicitCode: null,
childExitCode: null,
resolvedSignal: null,
usesWindowsExitCodeShim: true,
timedOut: true,
noOutputTimedOut: false,
killIssuedByTimeout: true,
}),
).toBeNull();
});
it.runIf(process.platform !== "win32")(
"kills command when no output timeout elapses",
{ timeout: 5_000 },
async () => {
vi.useFakeTimers();
const child = createKilledChild();
spawnMock.mockReturnValue(child);
await loadExecModules({ mockSpawn: true });
const resultPromise = runCommandWithTimeout(createSilentIdleArgv(), {
timeoutMs: 2_000,
noOutputTimeoutMs: 200,
});
await vi.advanceTimersByTimeAsync(250);
const result = await resultPromise;
expect(result.termination).toBe("no-output-timeout");
expect(result.noOutputTimedOut).toBe(true);
expect(result.code).not.toBe(0);
},
);
it.runIf(process.platform !== "win32")(
"reports global timeout termination when overall timeout elapses",
{ timeout: 5_000 },
async () => {
vi.useFakeTimers();
const child = createKilledChild();
spawnMock.mockReturnValue(child);
await loadExecModules({ mockSpawn: true });
const resultPromise = runCommandWithTimeout(createSilentIdleArgv(), {
timeoutMs: 200,
});
await vi.advanceTimersByTimeAsync(250);
const result = await resultPromise;
expect(result.termination).toBe("timeout");
expect(result.noOutputTimedOut).toBe(false);
expect(result.code).not.toBe(0);
},
);
});
describe("attachChildProcessBridge", () => {
function createFakeChild() {
const emitter = new EventEmitter() as EventEmitter & ChildProcess;
const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => true);
emitter.kill = kill as ChildProcess["kill"];
return { child: emitter, kill };
}
it("forwards SIGTERM to the wrapped child and detaches on exit", () => {
const beforeSigterm = new Set(process.listeners("SIGTERM"));
const { child, kill } = createFakeChild();
const observedSignals: NodeJS.Signals[] = [];
const { detach } = attachChildProcessBridge(child, {
signals: ["SIGTERM"],
onSignal: (signal) => observedSignals.push(signal),
});
const afterSigterm = process.listeners("SIGTERM");
const addedSigterm = afterSigterm.find((listener) => !beforeSigterm.has(listener));
if (!addedSigterm) {
throw new Error("expected SIGTERM listener");
}
addedSigterm("SIGTERM");
expect(observedSignals).toEqual(["SIGTERM"]);
expect(kill).toHaveBeenCalledWith("SIGTERM");
child.emit("exit");
expect(process.listeners("SIGTERM")).toHaveLength(beforeSigterm.size);
// Detached already via exit; should remain a safe no-op.
detach();
});
});