diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 78b2ad44f67..ef09968b223 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -28,6 +28,25 @@ const pnpm = "pnpm"; const behaviorManifest = loadTestRunnerBehavior(); const existingFiles = (entries) => entries.map((entry) => entry.file).filter((file) => fs.existsSync(file)); +let tempArtifactDir = null; +const ensureTempArtifactDir = () => { + if (tempArtifactDir === null) { + tempArtifactDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-parallel-")); + } + return tempArtifactDir; +}; +const writeTempJsonArtifact = (name, value) => { + const filePath = path.join(ensureTempArtifactDir(), `${name}.json`); + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8"); + return filePath; +}; +const cleanupTempArtifacts = () => { + if (tempArtifactDir === null) { + return; + } + fs.rmSync(tempArtifactDir, { recursive: true, force: true }); + tempArtifactDir = null; +}; const existingUnitConfigFiles = (entries) => existingFiles(entries).filter(isUnitConfigTestFile); const unitBehaviorIsolatedFiles = existingUnitConfigFiles(behaviorManifest.unit.isolated); const unitSingletonIsolatedFiles = existingUnitConfigFiles(behaviorManifest.unit.singletonIsolated); @@ -333,6 +352,10 @@ const unitFastExcludedFiles = [ const unitAutoSingletonFiles = [ ...new Set([...unitSingletonIsolatedFiles, ...memoryHeavyUnitFiles]), ]; +const unitFastExtraExcludeFile = + unitFastExcludedFiles.length > 0 + ? writeTempJsonArtifact("vitest-unit-fast-excludes", unitFastExcludedFiles) + : null; const estimateUnitDurationMs = (file) => unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs; const heavyUnitBuckets = packFilesByDuration( @@ -349,6 +372,12 @@ const baseRuns = [ ? [ { name: "unit-fast", + env: + unitFastExtraExcludeFile === null + ? undefined + : { + OPENCLAW_VITEST_EXTRA_EXCLUDE_FILE: unitFastExtraExcludeFile, + }, args: [ "vitest", "run", @@ -356,7 +385,6 @@ const baseRuns = [ "vitest.unit.config.ts", `--pool=${useVmForks ? "vmForks" : "forks"}`, ...(disableIsolation ? ["--isolate=false"] : []), - ...unitFastExcludedFiles.flatMap((file) => ["--exclude", file]), ], }, ...(unitBehaviorIsolatedFiles.length > 0 @@ -982,7 +1010,12 @@ const runOnce = (entry, extraArgs = []) => try { child = spawn(pnpm, args, { stdio: ["inherit", "pipe", "pipe"], - env: { ...process.env, VITEST_GROUP: entry.name, NODE_OPTIONS: resolvedNodeOptions }, + env: { + ...process.env, + ...entry.env, + VITEST_GROUP: entry.name, + NODE_OPTIONS: resolvedNodeOptions, + }, shell: isWindows, }); captureTreeSample("spawn"); @@ -1134,6 +1167,7 @@ const shutdown = (signal) => { process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("exit", cleanupTempArtifacts); if (process.env.OPENCLAW_TEST_LIST_LANES === "1") { const entriesToPrint = targetedEntries.length > 0 ? targetedEntries : runs; diff --git a/test/vitest-unit-config.test.ts b/test/vitest-unit-config.test.ts new file mode 100644 index 00000000000..4b420d944cf --- /dev/null +++ b/test/vitest-unit-config.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadExtraExcludePatternsFromEnv } from "../vitest.unit.config.ts"; + +const tempDirs = new Set(); + +afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +const writeExcludeFile = (value: unknown) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-vitest-unit-config-")); + tempDirs.add(dir); + const filePath = path.join(dir, "extra-exclude.json"); + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8"); + return filePath; +}; + +describe("loadExtraExcludePatternsFromEnv", () => { + it("returns an empty list when no extra exclude file is configured", () => { + expect(loadExtraExcludePatternsFromEnv({})).toEqual([]); + }); + + it("loads extra exclude patterns from a JSON file", () => { + const filePath = writeExcludeFile([ + "src/infra/update-runner.test.ts", + 42, + "", + "ui/src/ui/views/chat.test.ts", + ]); + + expect( + loadExtraExcludePatternsFromEnv({ + OPENCLAW_VITEST_EXTRA_EXCLUDE_FILE: filePath, + }), + ).toEqual(["src/infra/update-runner.test.ts", "ui/src/ui/views/chat.test.ts"]); + }); + + it("throws when the configured file is not a JSON array", () => { + const filePath = writeExcludeFile({ exclude: ["src/infra/update-runner.test.ts"] }); + + expect(() => + loadExtraExcludePatternsFromEnv({ + OPENCLAW_VITEST_EXTRA_EXCLUDE_FILE: filePath, + }), + ).toThrow(/JSON array/u); + }); +}); diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts index ab6757c3351..8b98bae6cfe 100644 --- a/vitest.unit.config.ts +++ b/vitest.unit.config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { defineConfig } from "vitest/config"; import baseConfig from "./vitest.config.ts"; import { @@ -8,12 +9,33 @@ import { const base = baseConfig as unknown as Record; const baseTest = (baseConfig as { test?: { include?: string[]; exclude?: string[] } }).test ?? {}; const exclude = baseTest.exclude ?? []; +export function loadExtraExcludePatternsFromEnv( + env: Record = process.env, +): string[] { + const extraExcludeFile = env.OPENCLAW_VITEST_EXTRA_EXCLUDE_FILE?.trim(); + if (!extraExcludeFile) { + return []; + } + const parsed = JSON.parse(fs.readFileSync(extraExcludeFile, "utf8")) as unknown; + if (!Array.isArray(parsed)) { + throw new TypeError( + `OPENCLAW_VITEST_EXTRA_EXCLUDE_FILE must point to a JSON array: ${extraExcludeFile}`, + ); + } + return parsed.filter((value): value is string => typeof value === "string" && value.length > 0); +} export default defineConfig({ ...base, test: { ...baseTest, include: unitTestIncludePatterns, - exclude: [...exclude, ...unitTestAdditionalExcludePatterns], + exclude: [ + ...new Set([ + ...exclude, + ...unitTestAdditionalExcludePatterns, + ...loadExtraExcludePatternsFromEnv(), + ]), + ], }, });