fix(test): prepare shared builds before parallel workers (#131297)

This commit is contained in:
Peter Steinberger
2026-08-27 17:30:31 -07:00
committed by GitHub
parent 4ef9446daf
commit af52e62e85
13 changed files with 430 additions and 115 deletions
@@ -1,7 +1,6 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveQaGatewayChildCommand } from "./gateway-child-command.js";
import { runQaSuite } from "./suite-launch.runtime.js";
const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
@@ -13,17 +12,12 @@ if (!outputDir || scenarioIds.length === 0) {
}
try {
const sutOpenClawCommand = {
...resolveQaGatewayChildCommand(repoRoot),
usePackagedPlugins: false,
};
const result = await runQaSuite({
repoRoot,
outputDir: path.relative(repoRoot, outputDir),
providerMode: "mock-openai",
scenarioIds,
concurrency: 4,
sutOpenClawCommand,
});
const failed = result.result.scenarios.filter((scenario) => scenario.status !== "pass");
if (failed.length > 0) {
@@ -1,5 +1,4 @@
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import net from "node:net";
import path from "node:path";
@@ -33,13 +32,8 @@ function buildSuiteProcessEnv(outputDir: string) {
OPENCLAW_HOME: home,
OPENCLAW_STATE_DIR: path.join(home, ".openclaw"),
OPENCLAW_CONFIG_PATH: path.join(home, ".openclaw", "openclaw.json"),
OPENCLAW_BUILD_PRIVATE_QA: "1",
OPENCLAW_QA_SUITE_PROGRESS: "1",
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "1",
};
if (!existsSync(path.join(repoRoot, "dist", "index.js"))) {
env.OPENCLAW_FORCE_BUILD = "1";
}
delete env.VITEST;
delete env.VITEST_POOL_ID;
delete env.VITEST_WORKER_ID;
+13 -13
View File
@@ -22,13 +22,17 @@ import {
splitExtensionTestJobTargets,
} from "./extension-test-plan.mts";
import { buildPluginSdkEntrySources, publicPluginSdkEntrypoints } from "./plugin-sdk-entries.mts";
import {
resolveVitestPretestBuildMode,
type VitestPretestBuildMode,
} from "./vitest-build-prerequisites.mts";
type ChangedNodeTestShard = {
checkName: string;
configs: string[];
includePatterns?: string[];
planConcurrency?: number;
pretestBuildMode?: "private-qa" | "runtime";
pretestBuildMode?: VitestPretestBuildMode;
requiresDist: boolean;
runner: string;
shardName: string;
@@ -45,12 +49,6 @@ const CHANGED_NODE_TEST_TARGETS_PER_JOB = 12;
// processes starve each other on 4-vCPU runners and push otherwise healthy
// integration tests past the global timeout.
const SERIAL_CHANGED_TARGET_RE = /^extensions\/memory-core\//u;
const PRETEST_RUNTIME_BUILD_TARGETS = new Set([
"test/e2e/qa-lab/runtime/gateway-support-export-runtime.test.ts",
]);
const PRETEST_PRIVATE_QA_BUILD_TARGETS = new Set([
"extensions/qa-lab/src/suite-process-lifecycle.test.ts",
]);
const BOUNDARY_NODE_TEST_CONFIG = "test/vitest/vitest.boundary.config.ts";
const DOCKER_SEED_LANE_ORDER = [
"mcp-channels",
@@ -326,10 +324,9 @@ function createChangedTargetShards(
shardName: `${names.shardName}${suffix}`,
targets: chunk,
};
if (chunk.some((target) => PRETEST_PRIVATE_QA_BUILD_TARGETS.has(target))) {
shard.pretestBuildMode = "private-qa";
} else if (chunk.some((target) => PRETEST_RUNTIME_BUILD_TARGETS.has(target))) {
shard.pretestBuildMode = "runtime";
const pretestBuildMode = resolveVitestPretestBuildMode([{ includePatterns: chunk }]);
if (pretestBuildMode) {
shard.pretestBuildMode = pretestBuildMode;
}
if (chunk.some((target) => SERIAL_CHANGED_TARGET_RE.test(target))) {
shard.planConcurrency = 1;
@@ -375,8 +372,11 @@ function createChangedExtensionConfigShards(extensionRoots: string[]) {
runner: DEFAULT_NODE_TEST_RUNNER,
shardName: `changed-extensions-config${suffix}`,
};
if (roots.includes("extensions/qa-lab")) {
shard.pretestBuildMode = "private-qa";
const pretestBuildMode = resolveVitestPretestBuildMode([
{ configs: [config], includePatterns },
]);
if (pretestBuildMode) {
shard.pretestBuildMode = pretestBuildMode;
}
if (includePatterns) {
shard.includePatterns = includePatterns;
+18 -26
View File
@@ -22,6 +22,10 @@ import {
} from "../../test/vitest/vitest.unit-fast-paths.mjs";
import { boundaryTestFiles, isUnitConfigTestFile } from "../../test/vitest/vitest.unit-paths.mjs";
import { listTrackedTestFiles } from "./list-test-files.mts";
import {
resolveVitestPretestBuildMode,
type VitestPretestBuildMode as NodeTestPretestBuildMode,
} from "./vitest-build-prerequisites.mts";
type NodeTestShardGroup = {
shard_name: string;
@@ -58,15 +62,6 @@ type NodeTestPlanOptions = {
};
type CompactNodeTestPlanMode = "pull-request" | "push";
type NodeTestPretestBuildMode = "private-qa" | "runtime";
const PRETEST_RUNTIME_BUILD_FILES = new Set([
"test/e2e/qa-lab/runtime/gateway-support-export-runtime.test.ts",
]);
function resolvePretestBuildMode(paths: readonly string[]): NodeTestPretestBuildMode | undefined {
return paths.some((file) => PRETEST_RUNTIME_BUILD_FILES.has(file)) ? "runtime" : undefined;
}
type PolicyTestWatch = {
ownerGlobs?: readonly string[];
@@ -137,7 +132,7 @@ type CompactNodeTestShard = Omit<NodeTestShard, "configs" | "groups"> & {
groups: NodeTestShardGroup[];
};
type NodeTestSplitShard = Omit<NodeTestShard, "checkName" | "runner"> & {
type NodeTestSplitShard = Omit<NodeTestShard, "checkName" | "runner" | "pretestBuildMode"> & {
includeExternalConfigs?: boolean;
runner?: string;
};
@@ -1588,19 +1583,12 @@ function createToolingSplitShards(): NodeTestSplitShard[] {
listCompactToolingTestFiles(),
COMPACT_TOOLING_NODE_TEST_GROUPS,
stripeFileWeight,
).map((includePatterns, index) => {
const pretestBuildMode = resolvePretestBuildMode(includePatterns);
const shard: NodeTestSplitShard = {
shardName: `core-tooling-${index + 1}`,
configs: [TOOLING_CONFIG],
includePatterns,
requiresDist: false,
};
if (pretestBuildMode) {
shard.pretestBuildMode = pretestBuildMode;
}
return shard;
}),
).map((includePatterns, index) => ({
shardName: `core-tooling-${index + 1}`,
configs: [TOOLING_CONFIG],
includePatterns,
requiresDist: false,
})),
{
shardName: "core-tooling-isolated",
configs: ["test/vitest/vitest.tooling-docker.config.ts", TOOLING_ISOLATED_CONFIG],
@@ -1908,6 +1896,10 @@ export function createNodeTestShards(options: NodeTestPlanOptions = {}): NodeTes
return [];
}
const pretestBuildMode = resolveVitestPretestBuildMode([
{ configs: splitConfigs, includePatterns: splitShard.includePatterns },
]);
return [
{
checkName: formatNodeTestShardCheckName(splitShard.shardName),
@@ -1915,9 +1907,7 @@ export function createNodeTestShards(options: NodeTestPlanOptions = {}): NodeTes
configs: splitConfigs,
...(splitShard.env ? { env: splitShard.env } : {}),
...(splitShard.includePatterns ? { includePatterns: splitShard.includePatterns } : {}),
...(splitShard.pretestBuildMode
? { pretestBuildMode: splitShard.pretestBuildMode }
: {}),
...(pretestBuildMode ? { pretestBuildMode } : {}),
runner: splitShard.runner ?? DEFAULT_NODE_TEST_RUNNER,
requiresDist: splitShard.requiresDist,
},
@@ -1925,11 +1915,13 @@ export function createNodeTestShards(options: NodeTestPlanOptions = {}): NodeTes
});
}
const pretestBuildMode = resolveVitestPretestBuildMode([{ configs }]);
return [
{
checkName: formatNodeTestShardCheckName(shard.name),
shardName: shard.name,
configs,
...(pretestBuildMode ? { pretestBuildMode } : {}),
runner: DEFAULT_NODE_TEST_RUNNER,
requiresDist: DIST_DEPENDENT_NODE_SHARD_NAMES.has(shard.name),
},
+105
View File
@@ -0,0 +1,105 @@
import { spawn } from "node:child_process";
import { matchesGlob } from "node:path";
import { fullSuiteVitestShards } from "../../test/vitest/vitest.test-shards.mjs";
export type VitestPretestBuildMode = "private-qa" | "runtime";
type SetupCommandRunner = (args: string[], env: NodeJS.ProcessEnv) => Promise<number>;
type TestSelection = {
configs?: readonly string[];
includePatterns?: readonly string[] | null;
};
// These process tests consume built runtime artifacts. Prepare their strongest
// prerequisite before admitting any workers: a child build invalidates dist
// while unrelated workers may still be importing its public plugin facades.
// Strongest first: a private-QA build also satisfies ordinary runtime readers.
const runtimeConsumers = [
{
file: "extensions/qa-lab/src/suite-process-lifecycle.test.ts",
config: "test/vitest/vitest.extension-qa.config.ts",
mode: "private-qa",
},
{
file: "test/e2e/qa-lab/runtime/gateway-support-export-runtime.test.ts",
config: "test/vitest/vitest.tooling.config.ts",
mode: "runtime",
},
] as const;
export function resolveVitestPretestBuildMode(
selections: readonly TestSelection[],
): VitestPretestBuildMode | undefined {
return runtimeConsumers.find(({ file, config }) =>
selections.some(({ configs, includePatterns }) =>
includePatterns
? includePatterns.some((pattern) => matchesGlob(file, pattern))
: configs?.some(
(selected) =>
selected === config ||
selected === "vitest.config.ts" ||
selected === "test/vitest/vitest.config.ts" ||
fullSuiteVitestShards.some(
(shard) => shard.config === selected && shard.projects.includes(config),
),
),
),
)?.mode;
}
export function isE2eBuildSkipped(env: NodeJS.ProcessEnv) {
return env.OPENCLAW_E2E_SKIP_BUILD === "1" || env.OPENCLAW_E2E_USE_PREBUILT_DIST === "1";
}
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,
env: NodeJS.ProcessEnv = process.env,
): Promise<void> {
// Focused suites may own their fixtures; prebuilt consumers already have the
// complete surface. Neither may start another shared artifact writer.
if (isE2eBuildSkipped(env)) {
return;
}
const commands = [
{
args: ["scripts/run-node.mjs", "--version"],
env: {
...env,
OPENCLAW_BUILD_PRIVATE_QA: "1",
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "0",
},
},
{
args: ["--import", "tsx", "scripts/tsdown-build.mts", "--config", "tsdown.ai.config.ts"],
env,
},
];
for (const { args, env: commandEnv } of commands) {
const status = await runCommand(args, commandEnv);
if (status !== 0) {
throw new Error(`E2E setup command failed with exit code ${status}: ${args.join(" ")}`);
}
}
}
+35
View File
@@ -5,6 +5,12 @@ import fs from "node:fs";
import { performance } from "node:perf_hooks";
import pMap from "p-map";
import { formatMs } from "./lib/check-timing-summary.mts";
import { runManagedCommand } from "./lib/managed-child-process.mts";
import {
isE2eBuildSkipped,
resolveVitestPretestBuildMode,
runE2eGlobalSetup,
} from "./lib/vitest-build-prerequisites.mts";
import {
isCiLikeEnv,
resolveLocalFullSuiteProfile,
@@ -304,6 +310,35 @@ async function main() {
return;
}
const pretestBuildMode = resolveVitestPretestBuildMode(
runSpecs.map((spec) => ({ configs: [spec.config], includePatterns: spec.includePatterns })),
);
const runBuildCommand = (commandArgs: string[], env: NodeJS.ProcessEnv) =>
runManagedCommand({ bin: process.execPath, args: commandArgs, cwd: process.cwd(), env });
const e2eSpecs = runSpecs.filter((spec) => spec.config === "test/vitest/vitest.e2e.config.ts");
if (e2eSpecs.length > 0) {
if (!isE2eBuildSkipped(baseEnv)) {
console.error("[test] preparing E2E runtime before Vitest workers");
await runE2eGlobalSetup(runBuildCommand, baseEnv);
// E2E preparation also covers runtime/private-QA readers. Only a completed
// owner may tell config-level setup to reuse that shared generation.
for (const spec of e2eSpecs) {
spec.env = { ...spec.env, OPENCLAW_E2E_USE_PREBUILT_DIST: "1" };
}
}
} else if (pretestBuildMode) {
console.error(`[test] preparing ${pretestBuildMode} runtime before Vitest workers`);
const code = await runBuildCommand(["scripts/run-node.mjs", "--version"], {
...baseEnv,
...(pretestBuildMode === "private-qa" ? { OPENCLAW_BUILD_PRIVATE_QA: "1" } : {}),
});
if (code !== 0) {
printTestSummary("failed", 0, performance.now() - suiteStartedAt);
process.exitCode = code;
return;
}
}
const isFullSuiteRun =
targetArgs.length === 0 &&
changedTargetArgs === null &&
@@ -543,6 +543,17 @@ describe("CI changed Node test plan", () => {
]);
});
it("routes lifecycle edits to the prepared QA config without losing boundary coverage", () => {
const target = "extensions/qa-lab/src/suite-process-lifecycle.test.ts";
expect(createChangedNodeTestShards([target])).toEqual([
expect.objectContaining({
configs: ["test/vitest/vitest.extension-qa.config.ts"],
pretestBuildMode: "private-qa",
}),
expect.objectContaining({ configs: ["test/vitest/vitest.boundary.config.ts"] }),
]);
});
it("fails safe when a targeted config needs special shard setup", () => {
expect(createChangedNodeTestShards(["scripts/docs-i18n/main.go"])).toBeNull();
expect(createChangedNodeTestShards(["src/tui/tui-pty-harness.e2e.test.ts"])).toBeNull();
+22
View File
@@ -857,6 +857,28 @@ describe("scripts/lib/ci-node-test-plan.mts", () => {
expect(requiresDistShardNames).toEqual(["core-support-boundary", "core-runtime-tui-pty"]);
});
it("preserves runtime preparation and core-only ownership in full and compact plans", () => {
const qaConfig = "test/vitest/vitest.extension-qa.config.ts";
const runtimeTarget = "test/e2e/qa-lab/runtime/gateway-support-export-runtime.test.ts";
for (const shards of [
createNodeTestShards(),
createNodeTestShardBundles({ compact: true, compactMode: "pull-request" }),
]) {
expect(
shards.flatMap((shard) =>
"configs" in shard ? shard.configs : shard.groups.flatMap((group) => group.configs),
),
).not.toContain(qaConfig);
expect(
shards.find((shard) =>
("configs" in shard ? [shard] : shard.groups).some((group) =>
group.includePatterns?.includes(runtimeTarget),
),
)?.pretestBuildMode,
).toBe("runtime");
}
});
it("splits tooling checks independently from built artifacts", () => {
const toolingShards = createNodeTestShards().filter((shard) =>
shard.shardName.startsWith("core-tooling"),
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../helpers/promise.js";
const commands = vi.hoisted(() => ({ prepare: vi.fn(), prepareE2e: vi.fn(), reader: vi.fn() }));
vi.mock("../../scripts/lib/managed-child-process.mts", () => ({
runManagedCommand: commands.prepare,
}));
vi.mock("../../scripts/lib/vitest-build-prerequisites.mts", async (importOriginal) => ({
...(await importOriginal<typeof import("../../scripts/lib/vitest-build-prerequisites.mts")>()),
runE2eGlobalSetup: commands.prepareE2e,
}));
vi.mock("../../scripts/run-vitest.mts", async (importOriginal) => ({
...(await importOriginal<typeof import("../../scripts/run-vitest.mts")>()),
spawnWatchedVitestProcess: commands.reader,
}));
vi.mock("../../scripts/lib/vitest-shard-timings.mts", async (importOriginal) => ({
...(await importOriginal<typeof import("../../scripts/lib/vitest-shard-timings.mts")>()),
readShardTimings: () => new Map(),
writeShardTimings: () => {},
}));
const modelTarget = "src/agents/embedded-agent-runner/model-resolution-consistency.test.ts";
const targets = [modelTarget, "extensions/qa-lab/src/suite-process-lifecycle.test.ts"];
const e2eTarget = "test/openclaw-launcher-version.e2e.test.ts";
const e2eConfig = "test/vitest/vitest.e2e.config.ts";
let originalArgv: string[];
let originalExitCode: typeof process.exitCode;
let terminal: ReturnType<typeof createDeferred<unknown>>;
beforeEach(() => {
vi.resetModules();
commands.prepare.mockReset();
commands.prepareE2e.mockReset();
commands.reader.mockReset().mockImplementation(() => ({
completion: Promise.resolve({ code: 0, signal: null }),
getForwardedSignal: () => undefined,
}));
originalArgv = process.argv;
originalExitCode = process.exitCode;
process.exitCode = undefined;
vi.stubEnv("OPENCLAW_TEST_PROJECTS_PARALLEL", "");
vi.stubEnv("OPENCLAW_BUILD_PRIVATE_QA", "");
vi.stubEnv("OPENCLAW_E2E_SKIP_BUILD", "");
vi.stubEnv("OPENCLAW_E2E_USE_PREBUILT_DIST", "");
terminal = createDeferred<unknown>();
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation((value: unknown) => {
if (value instanceof Error || /^\[test\] (passed|failed|skipped) /u.test(String(value))) {
terminal.resolve(value);
}
});
});
afterEach(() => {
process.argv = originalArgv;
process.exitCode = originalExitCode;
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
async function start(args: string[]) {
process.argv = [process.execPath, "scripts/test-projects.mts", ...args];
await import("../../scripts/test-projects.mts");
}
describe("test-projects build admission", () => {
it.each([false, true])(
"holds every reader until preparation completes (parallel=%s)",
async (parallel) => {
vi.stubEnv("OPENCLAW_TEST_PROJECTS_PARALLEL", parallel ? "2" : "");
const preparation = createDeferred<number>();
const readers = createDeferred<{ code: number; signal: null }>();
commands.prepare.mockReturnValue(preparation.promise);
commands.reader.mockImplementation(() => ({
completion: readers.promise,
getForwardedSignal: () => undefined,
}));
await start(targets);
try {
expect(commands.reader).not.toHaveBeenCalled();
expect(commands.prepare).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
args: ["scripts/run-node.mjs", "--version"],
env: expect.objectContaining({ OPENCLAW_BUILD_PRIVATE_QA: "1" }),
}),
);
preparation.resolve(0);
await vi.waitFor(() => expect(commands.reader).toHaveBeenCalledTimes(parallel ? 2 : 1));
} finally {
preparation.resolve(0);
readers.resolve({ code: 0, signal: null });
await terminal.promise;
}
expect(await terminal.promise).toMatch(/^\[test\] passed 2 Vitest shards/u);
expect(commands.reader).toHaveBeenCalledTimes(2);
expect(process.exitCode).toBeUndefined();
},
);
it.each(["exit", "throw"])("admits no readers when preparation fails by %s", async (failure) => {
commands.prepare.mockImplementation(async () => {
if (failure === "throw") {
throw new Error("build failed");
}
return 7;
});
await start(targets);
await terminal.promise;
expect(commands.reader).not.toHaveBeenCalled();
expect(process.exitCode).toBe(failure === "throw" ? 1 : 7);
});
it("starts unrelated tests without runtime preparation", async () => {
await start([modelTarget]);
expect(await terminal.promise).toMatch(/^\[test\] passed 1 Vitest shard/u);
expect(commands.prepare).not.toHaveBeenCalled();
expect(commands.reader).toHaveBeenCalledOnce();
});
it("coalesces mixed E2E and private QA preparation before marking only E2E prebuilt", async () => {
vi.stubEnv("OPENCLAW_TEST_PROJECTS_PARALLEL", "2");
const preparation = createDeferred();
commands.prepareE2e.mockReturnValue(preparation.promise);
await start([...targets, e2eTarget]);
try {
expect(commands.prepareE2e).toHaveBeenCalledOnce();
expect(commands.prepare).not.toHaveBeenCalled();
expect(commands.reader).not.toHaveBeenCalled();
} finally {
preparation.resolve();
await terminal.promise;
}
expect(await terminal.promise).toMatch(/^\[test\] passed 3 Vitest shards/u);
expect(commands.prepare).not.toHaveBeenCalled();
expect(commands.reader).toHaveBeenCalledTimes(3);
for (const [options] of commands.reader.mock.calls) {
expect(options.env.OPENCLAW_E2E_USE_PREBUILT_DIST).toBe(
options.pnpmArgs.includes(e2eConfig) ? "1" : "",
);
}
});
it("admits no mixed readers when E2E preparation fails", async () => {
commands.prepareE2e.mockRejectedValue(new Error("E2E build failed"));
await start([...targets, e2eTarget]);
await terminal.promise;
expect(commands.prepare).not.toHaveBeenCalled();
expect(commands.reader).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it.each(["OPENCLAW_E2E_SKIP_BUILD", "OPENCLAW_E2E_USE_PREBUILT_DIST"] as const)(
"preserves the explicit %s contract",
async (key) => {
vi.stubEnv(key, "1");
await start([...targets, e2eTarget]);
await terminal.promise;
expect(commands.prepareE2e).not.toHaveBeenCalled();
expect(commands.prepare).not.toHaveBeenCalled();
expect(commands.reader).toHaveBeenCalledTimes(3);
for (const [options] of commands.reader.mock.calls) {
expect(options.env[key]).toBe("1");
}
},
);
});
+48
View File
@@ -5,6 +5,7 @@ import os from "node:os";
import path from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { listExtensionTestFilesForRoots } from "../../scripts/lib/extension-test-plan.mts";
import { resolveVitestPretestBuildMode } from "../../scripts/lib/vitest-build-prerequisites.mts";
import {
CHANNEL_CONTRACT_CONFIG_PATTERNS,
DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_HEARTBEAT_MS,
@@ -43,6 +44,51 @@ const CODEX_TEST_PROCESS_FILE_LIMIT = 12;
const MATRIX_TEST_PROCESS_FILE_LIMIT = 40;
const TELEGRAM_TEST_PROCESS_FILE_LIMIT = 1;
describe("test runtime prerequisites", () => {
it.each([
["lifecycle file", ["extensions/qa-lab/src/suite-process-lifecycle.test.ts"], "private-qa"],
["QA directory", ["extensions/qa-lab"], "private-qa"],
["QA config", ["test/vitest/vitest.extension-qa.config.ts"], "private-qa"],
["all plugins", ["extensions"], "private-qa"],
["full local suite", [], "private-qa"],
["root config", ["vitest.config.ts"], "private-qa"],
[
"runtime reader",
["test/e2e/qa-lab/runtime/gateway-support-export-runtime.test.ts"],
"runtime",
],
["ordinary QA unit test", ["extensions/qa-lab/src/gateway-child-command.test.ts"], undefined],
[
"model reader",
["src/agents/embedded-agent-runner/model-resolution-consistency.test.ts"],
undefined,
],
] as const)("prepares only the prerequisite selected by %s", (_name, args, expected) => {
const plans = args.length ? buildVitestRunPlans([...args]) : buildFullSuiteVitestRunPlans([]);
expect(
resolveVitestPretestBuildMode(
plans.map((plan) => ({
configs: [plan.config],
includePatterns: plan.includePatterns,
})),
),
).toBe(expected);
});
it("combines private QA and runtime readers into one private build", () => {
expect(
resolveVitestPretestBuildMode([
{ includePatterns: ["test/e2e/qa-lab/runtime/**/*.test.ts"] },
{ includePatterns: ["extensions/qa-lab/**/*.test.ts"] },
]),
).toBe("private-qa");
expect(resolveVitestPretestBuildMode([])).toBeUndefined();
expect(resolveVitestPretestBuildMode([{ configs: ["test/vitest/vitest.config.ts"] }])).toBe(
"private-qa",
);
});
});
function expectedCodexTestProcessCount() {
const testFileCount = listExtensionTestFilesForRoots(["extensions/codex"]).length;
return Math.max(1, Math.ceil(testFileCount / CODEX_TEST_PROCESS_FILE_LIMIT));
@@ -1581,6 +1627,7 @@ describe("scripts/test-projects changed-target routing", () => {
"test/scripts/check-plugin-sdk-wildcard-reexports.test.ts",
"test/scripts/control-ui-i18n.test.ts",
"test/scripts/openclaw-e2e-instance.test.ts",
"test/scripts/test-projects-build-admission.test.ts",
],
watchMode: false,
},
@@ -1768,6 +1815,7 @@ describe("scripts/test-projects changed-target routing", () => {
"test/scripts/check-plugin-sdk-wildcard-reexports.test.ts",
"test/scripts/control-ui-i18n.test.ts",
"test/scripts/openclaw-e2e-instance.test.ts",
"test/scripts/test-projects-build-admission.test.ts",
],
watchMode: false,
},
+8 -5
View File
@@ -3,12 +3,12 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { runE2eGlobalSetup } from "../../scripts/lib/vitest-build-prerequisites.mts";
import {
forceKillVitestProcessGroup,
forwardSignalToVitestProcessGroup,
} from "../../scripts/vitest-process-group.mts";
import { waitForChildClose, waitForDead, waitForPidFile } from "../helpers/process-wait.js";
import { runE2eGlobalSetup } from "../vitest/vitest.e2e.global-setup.js";
type SetupCommandRunner = NonNullable<Parameters<typeof runE2eGlobalSetup>[0]>;
@@ -69,8 +69,9 @@ describe("vitest E2E global setup", () => {
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 fixturePath = path.join(fixtureDir, "scripts", "run-node.mjs");
const pidPaths = ["child.pid", "descendant.pid"].map((name) => path.join(fixtureDir, name));
fs.mkdirSync(path.dirname(fixturePath), { recursive: true });
fs.writeFileSync(
fixturePath,
`import { spawn } from "node:child_process";
@@ -86,9 +87,11 @@ process.stdin.once("data", () => {
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 setupUrl = new URL("../../scripts/lib/vitest-build-prerequisites.mts", import.meta.url)
.href;
const runnerScript = `import { runE2eGlobalSetup } from ${JSON.stringify(setupUrl)};
process.chdir(${JSON.stringify(fixtureDir)});
await runE2eGlobalSetup(undefined, process.env);`;
const runner = spawn(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", runnerScript],
+3 -59
View File
@@ -1,62 +1,6 @@
// Builds the shared CLI/package artifacts once before parallel E2E workers
// start long-lived Gateway processes that import those artifacts lazily.
import { spawn } from "node:child_process";
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,
env: NodeJS.ProcessEnv = process.env,
): Promise<void> {
// Some focused suites bring their own fixtures, while exact-run artifact consumers already
// have the complete built surface. In both cases rebuilding here would duplicate slow work.
if (env.OPENCLAW_E2E_SKIP_BUILD === "1" || env.OPENCLAW_E2E_USE_PREBUILT_DIST === "1") {
return;
}
const commands = [
{
args: ["scripts/run-node.mjs", "--version"],
env: {
...env,
OPENCLAW_BUILD_PRIVATE_QA: "1",
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "0",
},
},
{
args: ["--import", "tsx", "scripts/tsdown-build.mts", "--config", "tsdown.ai.config.ts"],
env,
},
];
for (const { args, env: commandEnv } of commands) {
const status = await runCommand(args, commandEnv);
if (status !== 0) {
throw new Error(`E2E setup command failed with exit code ${status}: ${args.join(" ")}`);
}
}
}
// Raw Vitest entrypoint; the project scheduler prepares these same artifacts
// before any selected shard and passes the existing prebuilt contract onward.
import { runE2eGlobalSetup } from "../../scripts/lib/vitest-build-prerequisites.mts";
export default async function setup() {
await runE2eGlobalSetup();
}
@@ -7,6 +7,7 @@ export const toolingIsolatedTestFiles = [
"test/scripts/check-plugin-sdk-wildcard-reexports.test.ts",
"test/scripts/control-ui-i18n.test.ts",
"test/scripts/openclaw-e2e-instance.test.ts",
"test/scripts/test-projects-build-admission.test.ts",
];
const toolingIsolatedTestFileSet = new Set(toolingIsolatedTestFiles);