fix(test): keep fork E2E setup builds observable (#120653)

* fix(test): stream E2E setup build progress

* fix(test): keep E2E setup builds in runner group

* fix(test): own E2E setup streams and signals

* test(ci): route E2E setup regression correctly
This commit is contained in:
Vincent Koc
2026-08-09 02:53:38 +08:00
committed by GitHub
parent 82f6280fa8
commit a901261754
2 changed files with 173 additions and 23 deletions
@@ -0,0 +1,123 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
forceKillVitestProcessGroup,
forwardSignalToVitestProcessGroup,
} from "../../scripts/vitest-process-group.mjs";
import {
isProcessAlive,
waitForChildClose,
waitForDead,
waitForPidFile,
} from "../helpers/process-wait.js";
import { runE2eGlobalSetup } from "../vitest/vitest.e2e.global-setup.js";
type SetupCommandRunner = NonNullable<Parameters<typeof runE2eGlobalSetup>[0]>;
const posixIt = process.platform === "win32" ? it.skip : it;
const PROCESS_TIMEOUT_MS = process.env.CI ? 15_000 : 5_000;
describe("vitest E2E global setup", () => {
it("runs both build commands sequentially with their exact environments", async () => {
let resolveFirstCommand!: (status: number) => void;
const firstCommand = new Promise<number>((resolve) => {
resolveFirstCommand = resolve;
});
const runCommand = vi
.fn<SetupCommandRunner>()
.mockImplementationOnce(() => firstCommand)
.mockResolvedValueOnce(0);
const setupPromise = runE2eGlobalSetup(runCommand);
await vi.waitFor(() => expect(runCommand).toHaveBeenCalledTimes(1));
resolveFirstCommand(0);
await setupPromise;
expect(runCommand.mock.calls).toEqual([
[
["scripts/run-node.mjs", "--version"],
{
...process.env,
OPENCLAW_BUILD_PRIVATE_QA: "1",
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "0",
},
],
[["scripts/tsdown-build.mjs", "--config", "tsdown.ai.config.ts"], process.env],
]);
});
it("propagates a nonzero command status", async () => {
const runCommand = vi
.fn<SetupCommandRunner>()
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(23);
await expect(runE2eGlobalSetup(runCommand)).rejects.toThrow(
"E2E setup command failed with exit code 23: scripts/tsdown-build.mjs --config tsdown.ai.config.ts",
);
});
posixIt("forwards output and SIGTERM through the runner process group", async () => {
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-e2e-setup-group-"));
const fixturePath = path.join(fixtureDir, "build-fixture.mjs");
const pidPaths = ["child.pid", "descendant.pid"].map((name) => path.join(fixtureDir, name));
fs.writeFileSync(
fixturePath,
`import { spawn } from "node:child_process";
import fs from "node:fs";
process.stdin.once("data", () => {
const descendant = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
fs.writeFileSync(${JSON.stringify(pidPaths[0])}, String(process.pid));
fs.writeFileSync(${JSON.stringify(pidPaths[1])}, String(descendant.pid));
process.stdout.write("setup-stdout\\n");
process.stderr.write("setup-stderr\\n");
setInterval(() => {}, 1000);
});
process.stdin.resume();
`,
);
const setupUrl = new URL("../vitest/vitest.e2e.global-setup.ts", import.meta.url).href;
const runnerScript = `import { runE2eSetupCommand } from ${JSON.stringify(setupUrl)};
await runE2eSetupCommand([${JSON.stringify(fixturePath)}], process.env);`;
const runner = spawn(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", runnerScript],
{ detached: true, stdio: ["pipe", "pipe", "pipe"] },
);
const pids: number[] = [];
let stdout = "";
let stderr = "";
runner.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
runner.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
try {
runner.stdin.write("start\n");
pids.push(
...(await Promise.all(pidPaths.map((file) => waitForPidFile(file, PROCESS_TIMEOUT_MS)))),
);
await vi.waitFor(() => {
expect(stdout).toContain("setup-stdout");
expect(stderr).toContain("setup-stderr");
});
const closed = waitForChildClose(runner, PROCESS_TIMEOUT_MS);
expect(
forwardSignalToVitestProcessGroup({
child: runner,
kill: process.kill.bind(process),
signal: "SIGTERM",
}),
).toBe(true);
await expect(closed).resolves.toEqual({ code: null, signal: "SIGTERM" });
await Promise.all(pids.map((pid) => waitForDead(pid, PROCESS_TIMEOUT_MS)));
} finally {
forceKillVitestProcessGroup(runner);
for (const pid of pids) {
if (isProcessAlive(pid)) {
process.kill(pid, "SIGKILL");
}
}
fs.rmSync(fixtureDir, { force: true, recursive: true });
}
});
});
+50 -23
View File
@@ -1,29 +1,56 @@
// Builds the shared CLI/package artifacts once before parallel E2E workers
// start long-lived Gateway processes that import those artifacts lazily.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { spawn } from "node:child_process";
const execFileAsync = promisify(execFile);
type SetupCommandRunner = (args: string[], env: NodeJS.ProcessEnv) => Promise<number>;
export function runE2eSetupCommand(args: string[], env: NodeJS.ProcessEnv): Promise<number> {
const child = spawn(process.execPath, args, {
cwd: process.cwd(),
detached: false,
env,
stdio: ["inherit", "pipe", "pipe"],
});
child.stdout.pipe(process.stdout, { end: false });
child.stderr.pipe(process.stderr, { end: false });
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", (status, signal) => {
if (signal) {
reject(new Error(`E2E setup command terminated by ${signal}: ${args.join(" ")}`));
return;
}
resolve(status ?? 1);
});
});
}
export async function runE2eGlobalSetup(
runCommand: SetupCommandRunner = runE2eSetupCommand,
): Promise<void> {
const commands = [
{
args: ["scripts/run-node.mjs", "--version"],
env: {
...process.env,
OPENCLAW_BUILD_PRIVATE_QA: "1",
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "0",
},
},
{
args: ["scripts/tsdown-build.mjs", "--config", "tsdown.ai.config.ts"],
env: process.env,
},
];
for (const { args, env } of commands) {
const status = await runCommand(args, env);
if (status !== 0) {
throw new Error(`E2E setup command failed with exit code ${status}: ${args.join(" ")}`);
}
}
}
export default async function setup() {
await execFileAsync(process.execPath, ["scripts/run-node.mjs", "--version"], {
cwd: process.cwd(),
env: {
...process.env,
OPENCLAW_BUILD_PRIVATE_QA: "1",
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "0",
},
maxBuffer: 8 * 1024 * 1024,
timeout: 300_000,
});
await execFileAsync(
process.execPath,
["scripts/tsdown-build.mjs", "--config", "tsdown.ai.config.ts"],
{
cwd: process.cwd(),
env: process.env,
maxBuffer: 8 * 1024 * 1024,
timeout: 300_000,
},
);
await runE2eGlobalSetup();
}