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.mts"; 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", }, ], [ ["--import", "tsx", "scripts/tsdown-build.mts", "--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: --import tsx scripts/tsdown-build.mts --config tsdown.ai.config.ts", ); }); it.each(["OPENCLAW_E2E_SKIP_BUILD", "OPENCLAW_E2E_USE_PREBUILT_DIST"] as const)( "skips rebuilding when %s is set", async (envName) => { const runCommand = vi.fn(); await runE2eGlobalSetup(runCommand, { [envName]: "1" }); expect(runCommand).not.toHaveBeenCalled(); }, ); 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 }); } }); });