2026-03-02 11:16:24 +00:00
|
|
|
import type { ChildProcess } from "node:child_process";
|
|
|
|
|
import { EventEmitter } from "node:events";
|
2026-02-23 04:44:42 +00:00
|
|
|
import process from "node:process";
|
2026-03-02 11:16:24 +00:00
|
|
|
import { describe, expect, it, vi } from "vitest";
|
2026-02-21 13:17:06 +00:00
|
|
|
import { withEnvAsync } from "../test-utils/env.js";
|
2026-02-23 04:44:42 +00:00
|
|
|
import { attachChildProcessBridge } from "./child-process-bridge.js";
|
2026-02-15 03:24:21 +01:00
|
|
|
import { runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js";
|
2026-01-01 22:55:21 +01:00
|
|
|
|
|
|
|
|
describe("runCommandWithTimeout", () => {
|
2026-02-15 03:24:21 +01:00
|
|
|
it("never enables shell execution (Windows cmd.exe injection hardening)", () => {
|
|
|
|
|
expect(
|
|
|
|
|
shouldSpawnWithShell({
|
|
|
|
|
resolvedCommand: "npm.cmd",
|
|
|
|
|
platform: "win32",
|
|
|
|
|
}),
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-11 10:48:46 +00:00
|
|
|
it("merges custom env with process.env", async () => {
|
2026-02-21 13:17:06 +00:00
|
|
|
await withEnvAsync({ OPENCLAW_BASE_ENV: "base" }, async () => {
|
2026-01-11 10:48:46 +00:00
|
|
|
const result = await runCommandWithTimeout(
|
|
|
|
|
[
|
|
|
|
|
process.execPath,
|
|
|
|
|
"-e",
|
2026-01-30 03:15:10 +01:00
|
|
|
'process.stdout.write((process.env.OPENCLAW_BASE_ENV ?? "") + "|" + (process.env.OPENCLAW_TEST_ENV ?? ""))',
|
2026-01-11 10:48:46 +00:00
|
|
|
],
|
|
|
|
|
{
|
2026-03-02 13:52:54 +00:00
|
|
|
timeoutMs: 80,
|
2026-01-30 03:15:10 +01:00
|
|
|
env: { OPENCLAW_TEST_ENV: "ok" },
|
2026-01-11 10:48:46 +00:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result.code).toBe(0);
|
|
|
|
|
expect(result.stdout).toBe("base|ok");
|
2026-02-16 09:32:05 +08:00
|
|
|
expect(result.termination).toBe("exit");
|
2026-02-21 13:17:06 +00:00
|
|
|
});
|
2026-01-11 10:48:46 +00:00
|
|
|
});
|
2026-02-16 09:32:05 +08:00
|
|
|
|
|
|
|
|
it("kills command when no output timeout elapses", async () => {
|
|
|
|
|
const result = await runCommandWithTimeout(
|
2026-03-02 13:52:54 +00:00
|
|
|
[process.execPath, "-e", "setTimeout(() => {}, 10)"],
|
2026-02-16 09:32:05 +08:00
|
|
|
{
|
2026-03-02 13:52:54 +00:00
|
|
|
timeoutMs: 30,
|
|
|
|
|
noOutputTimeoutMs: 4,
|
2026-02-16 09:32:05 +08:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result.termination).toBe("no-output-timeout");
|
|
|
|
|
expect(result.noOutputTimedOut).toBe(true);
|
|
|
|
|
expect(result.code).not.toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("resets no output timer when command keeps emitting output", async () => {
|
|
|
|
|
const result = await runCommandWithTimeout(
|
|
|
|
|
[
|
|
|
|
|
process.execPath,
|
|
|
|
|
"-e",
|
2026-02-22 23:32:02 +01:00
|
|
|
[
|
|
|
|
|
'process.stdout.write(".");',
|
|
|
|
|
"let count = 0;",
|
|
|
|
|
'const ticker = setInterval(() => { process.stdout.write(".");',
|
|
|
|
|
"count += 1;",
|
2026-03-02 12:18:18 +00:00
|
|
|
"if (count === 3) {",
|
2026-02-22 23:32:02 +01:00
|
|
|
"clearInterval(ticker);",
|
|
|
|
|
"process.exit(0);",
|
|
|
|
|
"}",
|
2026-03-02 12:18:18 +00:00
|
|
|
"}, 6);",
|
2026-02-22 23:32:02 +01:00
|
|
|
].join(" "),
|
2026-02-16 09:32:05 +08:00
|
|
|
],
|
|
|
|
|
{
|
2026-03-02 12:57:53 +00:00
|
|
|
timeoutMs: 180,
|
2026-03-02 09:45:57 +00:00
|
|
|
// Keep a healthy margin above the emit interval while avoiding long idle waits.
|
2026-03-02 12:18:18 +00:00
|
|
|
noOutputTimeoutMs: 120,
|
2026-02-16 09:32:05 +08:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-16 22:37:34 -05:00
|
|
|
expect(result.code ?? 0).toBe(0);
|
2026-02-16 09:32:05 +08:00
|
|
|
expect(result.termination).toBe("exit");
|
|
|
|
|
expect(result.noOutputTimedOut).toBe(false);
|
2026-03-02 11:56:57 +00:00
|
|
|
expect(result.stdout.length).toBeGreaterThanOrEqual(3);
|
2026-02-16 09:32:05 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reports global timeout termination when overall timeout elapses", async () => {
|
|
|
|
|
const result = await runCommandWithTimeout(
|
2026-03-02 13:52:54 +00:00
|
|
|
[process.execPath, "-e", "setTimeout(() => {}, 10)"],
|
2026-02-16 09:32:05 +08:00
|
|
|
{
|
2026-03-02 13:52:54 +00:00
|
|
|
timeoutMs: 4,
|
2026-02-16 09:32:05 +08:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result.termination).toBe("timeout");
|
|
|
|
|
expect(result.noOutputTimedOut).toBe(false);
|
|
|
|
|
expect(result.code).not.toBe(0);
|
|
|
|
|
});
|
2026-03-02 02:23:42 +00:00
|
|
|
|
|
|
|
|
it.runIf(process.platform === "win32")(
|
|
|
|
|
"on Windows spawns node + npm-cli.js for npm argv to avoid spawn EINVAL",
|
|
|
|
|
async () => {
|
|
|
|
|
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 10_000 });
|
|
|
|
|
expect(result.code).toBe(0);
|
|
|
|
|
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-01-01 22:55:21 +01:00
|
|
|
});
|
2026-02-23 04:44:42 +00:00
|
|
|
|
|
|
|
|
describe("attachChildProcessBridge", () => {
|
2026-03-02 11:16:24 +00:00
|
|
|
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", () => {
|
2026-02-23 04:44:42 +00:00
|
|
|
const beforeSigterm = new Set(process.listeners("SIGTERM"));
|
2026-03-02 11:16:24 +00:00
|
|
|
const { child, kill } = createFakeChild();
|
|
|
|
|
const observedSignals: NodeJS.Signals[] = [];
|
|
|
|
|
|
|
|
|
|
const { detach } = attachChildProcessBridge(child, {
|
|
|
|
|
signals: ["SIGTERM"],
|
|
|
|
|
onSignal: (signal) => observedSignals.push(signal),
|
2026-02-23 04:44:42 +00:00
|
|
|
});
|
2026-03-02 11:16:24 +00:00
|
|
|
|
2026-02-23 04:44:42 +00:00
|
|
|
const afterSigterm = process.listeners("SIGTERM");
|
|
|
|
|
const addedSigterm = afterSigterm.find((listener) => !beforeSigterm.has(listener));
|
|
|
|
|
|
|
|
|
|
if (!addedSigterm) {
|
|
|
|
|
throw new Error("expected SIGTERM listener");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 11:22:26 +00:00
|
|
|
addedSigterm("SIGTERM");
|
2026-03-02 11:16:24 +00:00
|
|
|
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();
|
2026-02-23 04:44:42 +00:00
|
|
|
});
|
|
|
|
|
});
|