openclaw/test/helpers/run-node-script.test.ts
Peter Steinberger ee51203de7
fix(test): keep subprocess fixtures consistent across Node and Bun (#163805)
Couple test-worker arguments to the executable selected by the shared subprocess helper and migrate its runtime-dependent callers. Repair command parsing, runtime pinning, worker observation, storage settlement, and cleanup-clock fixtures while preserving their contracts.

Fourteen scoped suites pass on Node 24 and Bun; the Bun readSync gap remains an unchanged stop. Refreshed proof covers 638 passed/1 existing skip on Node and 636 passed/3 existing skips on Bun. P2 review and exact-head ClawSweeper found no actionable defects. Production LOC delta is zero.

The first CI head exposed an inherited UI-validator fixture omission. After incorporating its existing main fix, exact-head CI passed, including the published-driver update job. No same-head retry or admin exception was used.
2026-10-02 15:46:52 -07:00

205 lines
7.8 KiB
TypeScript

import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { afterEach, expect, it, vi } from "vitest";
import { createVitestResourceOwner } from "../../scripts/lib/vitest-resource-ownership.mts";
import { createFixtureLifetime } from "./fixture-lifetime.js";
import { isProcessAlive, waitForDead, waitForPidFile } from "./process-wait.js";
import { withinTest } from "./promise.js";
import { runNodeScript } from "./run-node-script.js";
import { useAutoCleanupTempDirTracker } from "./temp-dir.js";
const PID_CLEANUP_HANG_GUARD_MS = 2_000;
const PROCESS_CLEANUP_HANG_GUARD_MS = 15_000;
let cleanupFixture: (() => Promise<void>) | undefined;
const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
afterEach(async () => {
// Timeout hooks can run before the suspended body finishes its finally block.
await cleanupFixture?.();
cleanupFixture = undefined;
cleanup();
}),
);
// The escaped leaf's parent exits, leaving no ChildProcess handle to join.
async function waitForProcessExit(pid: number, signal: AbortSignal): Promise<void> {
try {
for (;;) {
signal.throwIfAborted();
if (!isProcessAlive(pid)) {
return;
}
await delay(5, undefined, { signal });
}
} catch (error) {
if (signal.aborted) {
throw new Error(`process still alive: ${pid}`, { cause: error });
}
throw error;
}
}
it("captures inherited output written after the script exits", async () => {
const script = join(tempDirs.make("openclaw-node-script-output-"), "parent.mjs");
writeFileSync(
script,
`import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-e", \`
process.on("disconnect", () => {
process.stdout.write("drained stdout\\\\n");
process.stderr.write("drained stderr\\\\n");
});
process.send("ready");
\`], { stdio: ["ignore", "inherit", "inherit", "ipc"] });
child.once("message", () => process.exit(17));
`,
);
const result = await runNodeScript(script, process.env, 5_000);
expect(result).toEqual({
error: undefined,
status: 17,
stdout: "drained stdout\n",
stderr: "drained stderr\n",
});
});
it.for(["node", "current"] as const)(
"builds source worker arguments for the selected %s runtime",
async (runtime) => {
const script = join(tempDirs.make("openclaw-node-script-runtime-"), "worker.ts");
writeFileSync(
script,
`enum Answer { value = 42 }
console.log(JSON.stringify({ answer: Answer.value, bun: Boolean(process.versions.bun), args: process.argv.slice(2) }));
`,
);
const result = await runNodeScript(
(workerArgv) => [...workerArgv(pathToFileURL(script)), "worker-argument"],
process.env,
5_000,
{ executable: runtime === "current" ? process.execPath : undefined },
);
expect(result.error, result.stderr).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
answer: 42,
bun: runtime === "current" && Boolean(process.versions.bun),
args: ["worker-argument"],
});
},
);
it.for(["at limit", "stdout overflow", "stderr overflow"])(
"preserves independent 2 MiB output failure boundaries: %s",
async (mode) => {
const directory = tempDirs.make("openclaw-node-script-buffer-");
const script = join(directory, "output.mjs");
const pidFile = join(directory, "pid");
const maxBuffer = 2 * 1024 * 1024;
writeFileSync(
script,
`import fs from 'node:fs';
fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));
const mode=${JSON.stringify(mode)}, bytes=${maxBuffer};
if(mode==='at limit') {
process.stdout.write(Buffer.alloc(bytes,97));
process.stderr.write(Buffer.alloc(bytes,98));
} else {
process[mode.split(' ')[0]].write(Buffer.alloc(bytes+1,97));
setTimeout(()=>process.exit(73),10000);
}
`,
);
const result = await runNodeScript(script, process.env, 5_000, { maxBuffer });
const pid = Number(readFileSync(pidFile, "utf8"));
expect(() => process.kill(pid, 0)).toThrow();
if (mode === "at limit") {
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
expect(result.stdout).toBe("a".repeat(maxBuffer));
expect(result.stderr).toBe("b".repeat(maxBuffer));
} else {
expect(result.error).toHaveProperty("code", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER");
expect(result.status).toBeNull();
expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(maxBuffer);
expect(Buffer.byteLength(result.stderr)).toBeLessThanOrEqual(maxBuffer);
}
},
);
it.skipIf(process.platform === "win32")(
"retains inputs when captured command output leaves cleanup uncertain",
async ({ signal }) => {
const ownerRoot = tempDirs.make("node-script-owner-");
const owner = createVitestResourceOwner(ownerRoot);
for (const key of ["TMPDIR", "TMP", "TEMP"]) {
vi.stubEnv(key, ownerRoot);
}
const fixture = createFixtureLifetime();
const directory = fixture.createTempDir("openclaw-node-script-uncertain-");
const script = join(directory, "parent.mjs");
const pidFile = join(directory, "leaf.pid");
const release = join(directory, "release");
const input = join(directory, "input");
const read = join(directory, "read");
writeFileSync(input, "still owned");
const leaf = `
const fs=require('node:fs');
const deadline=setTimeout(()=>process.exit(73),15000);
const poll=setInterval(()=>{
if(!fs.existsSync(${JSON.stringify(release)})) return;
fs.writeFileSync(${JSON.stringify(read)},fs.readFileSync(${JSON.stringify(input)}));
clearInterval(poll);clearTimeout(deadline);
},5);
fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));
process.send('ready');process.disconnect();
`;
writeFileSync(
script,
`import {spawn} from 'node:child_process';
const child=spawn(process.execPath,['--eval',${JSON.stringify(leaf)}],{detached:true,stdio:['ignore','inherit','inherit','ipc']});
child.once('message',()=>process.exit(0));
`,
);
const command = fixture.track(
runNodeScript(script, process.env, undefined, { signal, requireProcessTreeExit: true }),
);
let cleanupPromise: Promise<void> | undefined;
const cleanup = () =>
(cleanupPromise ??= (async () => {
await command;
// Rescue only this bounded escaped leaf, and certify its exit before
// manually disposing roots the lifetime owner deliberately retained.
if (existsSync(pidFile)) {
writeFileSync(release, "release");
// Cleanup hang guards after the owner released the leaf, not readiness races.
const pid = await waitForPidFile(pidFile, AbortSignal.timeout(PID_CLEANUP_HANG_GUARD_MS));
await waitForDead(pid, AbortSignal.timeout(PROCESS_CLEANUP_HANG_GUARD_MS));
}
await fixture.cleanup();
rmSync(directory, { recursive: true, force: true });
vi.unstubAllEnvs();
})());
cleanupFixture = cleanup;
try {
const result = await command;
expect(result.error).toMatchObject({
code: "EPROCESSGROUP_CLEANUP_FAILED",
processTreeState: "indeterminate",
});
await expect(fixture.cleanup()).rejects.toThrow("Fixture cleanup unverified");
expect(() => owner.assertReleased()).toThrow("Unreleased Vitest resource claim");
expect(existsSync(directory)).toBe(true);
writeFileSync(release, "release");
// The leaf writes its PID before ready; only that IPC lets the parent exit.
const pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10);
expect(Number.isInteger(pid) && pid > 0).toBe(true);
await withinTest(waitForProcessExit(pid, signal), signal);
expect(readFileSync(read, "utf8")).toBe("still owned");
} finally {
await cleanup();
}
},
);