From a9012617543cfc9e6f92f850d65fd3c593d2eea8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 9 Aug 2026 02:53:38 +0800 Subject: [PATCH] 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 --- test/scripts/vitest-e2e-global-setup.test.ts | 123 +++++++++++++++++++ test/vitest/vitest.e2e.global-setup.ts | 73 +++++++---- 2 files changed, 173 insertions(+), 23 deletions(-) create mode 100644 test/scripts/vitest-e2e-global-setup.test.ts diff --git a/test/scripts/vitest-e2e-global-setup.test.ts b/test/scripts/vitest-e2e-global-setup.test.ts new file mode 100644 index 000000000000..dfbc1f6c4075 --- /dev/null +++ b/test/scripts/vitest-e2e-global-setup.test.ts @@ -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[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((resolve) => { + resolveFirstCommand = resolve; + }); + const runCommand = vi + .fn() + .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() + .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 }); + } + }); +}); diff --git a/test/vitest/vitest.e2e.global-setup.ts b/test/vitest/vitest.e2e.global-setup.ts index 8659ade2c045..4f2c81830f04 100644 --- a/test/vitest/vitest.e2e.global-setup.ts +++ b/test/vitest/vitest.e2e.global-setup.ts @@ -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; + +export function runE2eSetupCommand(args: string[], env: NodeJS.ProcessEnv): Promise { + 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 { + 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(); }