diff --git a/extensions/qa-lab/src/suite-process-lifecycle.test-support.ts b/extensions/qa-lab/src/suite-process-lifecycle.test-support.ts index c6eaf7e32821..425c0608f224 100644 --- a/extensions/qa-lab/src/suite-process-lifecycle.test-support.ts +++ b/extensions/qa-lab/src/suite-process-lifecycle.test-support.ts @@ -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) { diff --git a/extensions/qa-lab/src/suite-process-lifecycle.test.ts b/extensions/qa-lab/src/suite-process-lifecycle.test.ts index 8e4783511d8e..6cbbb81a295b 100644 --- a/extensions/qa-lab/src/suite-process-lifecycle.test.ts +++ b/extensions/qa-lab/src/suite-process-lifecycle.test.ts @@ -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; diff --git a/scripts/lib/ci-changed-node-test-plan.mts b/scripts/lib/ci-changed-node-test-plan.mts index 10aef4d94b40..4bcb39d75180 100644 --- a/scripts/lib/ci-changed-node-test-plan.mts +++ b/scripts/lib/ci-changed-node-test-plan.mts @@ -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; diff --git a/scripts/lib/ci-node-test-plan.mts b/scripts/lib/ci-node-test-plan.mts index d38c0acc75ea..c3da62c9963b 100644 --- a/scripts/lib/ci-node-test-plan.mts +++ b/scripts/lib/ci-node-test-plan.mts @@ -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 & { groups: NodeTestShardGroup[]; }; -type NodeTestSplitShard = Omit & { +type NodeTestSplitShard = Omit & { 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), }, diff --git a/scripts/lib/vitest-build-prerequisites.mts b/scripts/lib/vitest-build-prerequisites.mts new file mode 100644 index 000000000000..77702c9cd2b6 --- /dev/null +++ b/scripts/lib/vitest-build-prerequisites.mts @@ -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; + +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 { + 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 { + // 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(" ")}`); + } + } +} diff --git a/scripts/test-projects.mts b/scripts/test-projects.mts index 9d96ca232fdd..0d780f339c55 100644 --- a/scripts/test-projects.mts +++ b/scripts/test-projects.mts @@ -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 && diff --git a/test/scripts/ci-changed-node-test-plan.test.ts b/test/scripts/ci-changed-node-test-plan.test.ts index 1ba05ba6aeea..8e01b36d2094 100644 --- a/test/scripts/ci-changed-node-test-plan.test.ts +++ b/test/scripts/ci-changed-node-test-plan.test.ts @@ -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(); diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index f53821c1e342..557fae3c9f18 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -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"), diff --git a/test/scripts/test-projects-build-admission.test.ts b/test/scripts/test-projects-build-admission.test.ts new file mode 100644 index 000000000000..d6d0386bdd1f --- /dev/null +++ b/test/scripts/test-projects-build-admission.test.ts @@ -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()), + runE2eGlobalSetup: commands.prepareE2e, +})); +vi.mock("../../scripts/run-vitest.mts", async (importOriginal) => ({ + ...(await importOriginal()), + spawnWatchedVitestProcess: commands.reader, +})); +vi.mock("../../scripts/lib/vitest-shard-timings.mts", async (importOriginal) => ({ + ...(await importOriginal()), + 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>; + +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(); + 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(); + 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"); + } + }, + ); +}); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 46caef269104..19fcd1e99d9f 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -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, }, diff --git a/test/scripts/vitest-e2e-global-setup.test.ts b/test/scripts/vitest-e2e-global-setup.test.ts index afc3bf880c6c..d4a1b842a6ce 100644 --- a/test/scripts/vitest-e2e-global-setup.test.ts +++ b/test/scripts/vitest-e2e-global-setup.test.ts @@ -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[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], diff --git a/test/vitest/vitest.e2e.global-setup.ts b/test/vitest/vitest.e2e.global-setup.ts index f26b4c9304da..f66a4c4062d7 100644 --- a/test/vitest/vitest.e2e.global-setup.ts +++ b/test/vitest/vitest.e2e.global-setup.ts @@ -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; - -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, - env: NodeJS.ProcessEnv = process.env, -): Promise { - // 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(); } diff --git a/test/vitest/vitest.tooling-isolated-paths.mjs b/test/vitest/vitest.tooling-isolated-paths.mjs index ecd0aa1e7a3b..613caa09f0b5 100644 --- a/test/vitest/vitest.tooling-isolated-paths.mjs +++ b/test/vitest/vitest.tooling-isolated-paths.mjs @@ -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);