mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 06:03:39 -06:00
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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user