mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(test): prepare shared builds before parallel workers (#131297)
This commit is contained in:
committed by
GitHub
parent
4ef9446daf
commit
af52e62e85
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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(" ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user