diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 94d2a173a0e..8c63e61aeb4 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { channelTestPrefixes } from "../vitest.channel-paths.mjs"; +import { isUnitConfigTestFile } from "../vitest.unit-paths.mjs"; import { loadTestRunnerBehavior, loadUnitTimingManifest, @@ -16,10 +17,11 @@ const pnpm = "pnpm"; const behaviorManifest = loadTestRunnerBehavior(); const existingFiles = (entries) => entries.map((entry) => entry.file).filter((file) => fs.existsSync(file)); -const unitBehaviorIsolatedFiles = existingFiles(behaviorManifest.unit.isolated); -const unitSingletonIsolatedFiles = existingFiles(behaviorManifest.unit.singletonIsolated); -const unitThreadSingletonFiles = existingFiles(behaviorManifest.unit.threadSingleton); -const unitVmForkSingletonFiles = existingFiles(behaviorManifest.unit.vmForkSingleton); +const existingUnitConfigFiles = (entries) => existingFiles(entries).filter(isUnitConfigTestFile); +const unitBehaviorIsolatedFiles = existingUnitConfigFiles(behaviorManifest.unit.isolated); +const unitSingletonIsolatedFiles = existingUnitConfigFiles(behaviorManifest.unit.singletonIsolated); +const unitThreadSingletonFiles = existingUnitConfigFiles(behaviorManifest.unit.threadSingleton); +const unitVmForkSingletonFiles = existingUnitConfigFiles(behaviorManifest.unit.vmForkSingleton); const unitBehaviorOverrideSet = new Set([ ...unitBehaviorIsolatedFiles, ...unitSingletonIsolatedFiles, @@ -237,10 +239,7 @@ const parseEnvNumber = (name, fallback) => { return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; }; const allKnownUnitFiles = allKnownTestFiles.filter((file) => { - if (file.endsWith(".live.test.ts") || file.endsWith(".e2e.test.ts")) { - return false; - } - return inferTarget(file).owner !== "gateway"; + return isUnitConfigTestFile(file); }); const defaultHeavyUnitFileLimit = testProfile === "serial" ? 0 : testProfile === "low" ? 20 : highMemLocalHost ? 80 : 60; @@ -730,10 +729,12 @@ const runOnce = (entry, extraArgs = []) => const run = async (entry, extraArgs = []) => { const explicitFilterCount = countExplicitEntryFilters(entry.args); - // Wrapper-generated singleton/small-file lanes should not ask Vitest to shard - // into more buckets than there are explicit test filters. + // Vitest requires the shard count to stay strictly below the number of + // resolved test files, so explicit-filter lanes need a `< fileCount` cap. const effectiveShardCount = - explicitFilterCount === null ? shardCount : Math.min(shardCount, explicitFilterCount); + explicitFilterCount === null + ? shardCount + : Math.min(shardCount, Math.max(1, explicitFilterCount - 1)); if (effectiveShardCount <= 1) { if (shardIndexOverride !== null && shardIndexOverride > effectiveShardCount) { diff --git a/test/vitest-unit-paths.test.ts b/test/vitest-unit-paths.test.ts new file mode 100644 index 00000000000..e8cbe961990 --- /dev/null +++ b/test/vitest-unit-paths.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { isUnitConfigTestFile } from "../vitest.unit-paths.mjs"; + +describe("isUnitConfigTestFile", () => { + it("accepts unit-config src, test, and whitelisted ui tests", () => { + expect(isUnitConfigTestFile("src/infra/git-commit.test.ts")).toBe(true); + expect(isUnitConfigTestFile("test/format-error.test.ts")).toBe(true); + expect(isUnitConfigTestFile("ui/src/ui/views/chat.test.ts")).toBe(true); + }); + + it("rejects files excluded from the unit config", () => { + expect( + isUnitConfigTestFile("extensions/imessage/src/monitor.shutdown.unhandled-rejection.test.ts"), + ).toBe(false); + expect(isUnitConfigTestFile("src/agents/pi-embedded-runner.test.ts")).toBe(false); + expect(isUnitConfigTestFile("src/commands/onboard.test.ts")).toBe(false); + expect(isUnitConfigTestFile("ui/src/ui/views/other.test.ts")).toBe(false); + expect(isUnitConfigTestFile("src/infra/git-commit.live.test.ts")).toBe(false); + expect(isUnitConfigTestFile("src/infra/git-commit.e2e.test.ts")).toBe(false); + }); +}); diff --git a/vitest.unit-paths.mjs b/vitest.unit-paths.mjs new file mode 100644 index 00000000000..c0becc4d048 --- /dev/null +++ b/vitest.unit-paths.mjs @@ -0,0 +1,46 @@ +import path from "node:path"; + +export const unitTestIncludePatterns = [ + "src/**/*.test.ts", + "test/**/*.test.ts", + "ui/src/ui/app-chat.test.ts", + "ui/src/ui/views/agents-utils.test.ts", + "ui/src/ui/views/chat.test.ts", + "ui/src/ui/views/usage-render-details.test.ts", + "ui/src/ui/controllers/agents.test.ts", + "ui/src/ui/controllers/chat.test.ts", +]; + +export const unitTestAdditionalExcludePatterns = [ + "src/gateway/**", + "extensions/**", + "src/browser/**", + "src/line/**", + "src/agents/**", + "src/auto-reply/**", + "src/commands/**", +]; + +const sharedBaseExcludePatterns = [ + "dist/**", + "apps/macos/**", + "apps/macos/.build/**", + "**/node_modules/**", + "**/vendor/**", + "dist/OpenClaw.app/**", + "**/*.live.test.ts", + "**/*.e2e.test.ts", +]; + +const normalizeRepoPath = (value) => value.split(path.sep).join("/"); + +const matchesAny = (file, patterns) => patterns.some((pattern) => path.matchesGlob(file, pattern)); + +export function isUnitConfigTestFile(file) { + const normalizedFile = normalizeRepoPath(file); + return ( + matchesAny(normalizedFile, unitTestIncludePatterns) && + !matchesAny(normalizedFile, sharedBaseExcludePatterns) && + !matchesAny(normalizedFile, unitTestAdditionalExcludePatterns) + ); +} diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts index 4d4fd934fe1..ab6757c3351 100644 --- a/vitest.unit.config.ts +++ b/vitest.unit.config.ts @@ -1,27 +1,19 @@ import { defineConfig } from "vitest/config"; import baseConfig from "./vitest.config.ts"; +import { + unitTestAdditionalExcludePatterns, + unitTestIncludePatterns, +} from "./vitest.unit-paths.mjs"; const base = baseConfig as unknown as Record; const baseTest = (baseConfig as { test?: { include?: string[]; exclude?: string[] } }).test ?? {}; -const include = ( - baseTest.include ?? ["src/**/*.test.ts", "extensions/**/*.test.ts", "test/format-error.test.ts"] -).filter((pattern) => !pattern.includes("extensions/")); const exclude = baseTest.exclude ?? []; export default defineConfig({ ...base, test: { ...baseTest, - include, - exclude: [ - ...exclude, - "src/gateway/**", - "extensions/**", - "src/browser/**", - "src/line/**", - "src/agents/**", - "src/auto-reply/**", - "src/commands/**", - ], + include: unitTestIncludePatterns, + exclude: [...exclude, ...unitTestAdditionalExcludePatterns], }, });