diff --git a/package.json b/package.json index 28488c0b8c6..d1ef289e04e 100644 --- a/package.json +++ b/package.json @@ -1248,6 +1248,9 @@ "test:startup:bench:update": "node scripts/test-update-cli-startup-bench.mjs", "test:startup:memory": "node scripts/check-cli-startup-memory.mjs", "test:ui": "pnpm ui:i18n:check && pnpm lint:ui:no-raw-window-open && pnpm --dir ui test", + "test:unit": "pnpm test:unit:fast && node scripts/run-vitest.mjs run --config vitest.unit.config.ts", + "test:unit:fast": "node scripts/run-vitest.mjs run --config vitest.unit-fast.config.ts", + "test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs", "test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs", "test:watch": "node scripts/test-projects.mjs --watch", "tool-display:check": "node --import tsx scripts/tool-display.ts --check", diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index b8bcf3b8be6..8665670c56a 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -27,6 +27,7 @@ import { resolvePluginSdkLightIncludePattern, } from "../vitest.plugin-sdk-paths.mjs"; import { fullSuiteVitestShards } from "../vitest.test-shards.mjs"; +import { resolveUnitFastTestIncludePattern } from "../vitest.unit-fast-paths.mjs"; import { isBoundaryTestFile, isBundledPluginDependentUnitTestFile } from "../vitest.unit-paths.mjs"; import { resolveVitestCliEntry, resolveVitestNodeArgs } from "./run-vitest.mjs"; @@ -70,6 +71,7 @@ const LOGGING_VITEST_CONFIG = "vitest.logging.config.ts"; const PLUGIN_SDK_LIGHT_VITEST_CONFIG = "vitest.plugin-sdk-light.config.ts"; const PLUGIN_SDK_VITEST_CONFIG = "vitest.plugin-sdk.config.ts"; const PLUGINS_VITEST_CONFIG = "vitest.plugins.config.ts"; +const UNIT_FAST_VITEST_CONFIG = "vitest.unit-fast.config.ts"; const PROCESS_VITEST_CONFIG = "vitest.process.config.ts"; const RUNTIME_CONFIG_VITEST_CONFIG = "vitest.runtime-config.config.ts"; const SECRETS_VITEST_CONFIG = "vitest.secrets.config.ts"; @@ -82,6 +84,58 @@ const UTILS_VITEST_CONFIG = "vitest.utils.config.ts"; const WIZARD_VITEST_CONFIG = "vitest.wizard.config.ts"; const INCLUDE_FILE_ENV_KEY = "OPENCLAW_VITEST_INCLUDE_FILE"; const CHANGED_ARGS_PATTERN = /^--changed(?:=(.+))?$/u; +const VITEST_CONFIG_BY_KIND = { + acp: ACP_VITEST_CONFIG, + agent: AGENTS_VITEST_CONFIG, + autoReply: AUTO_REPLY_VITEST_CONFIG, + boundary: BOUNDARY_VITEST_CONFIG, + bundled: BUNDLED_VITEST_CONFIG, + channel: CHANNEL_VITEST_CONFIG, + cli: CLI_VITEST_CONFIG, + command: COMMANDS_VITEST_CONFIG, + commandLight: COMMANDS_LIGHT_VITEST_CONFIG, + contracts: CONTRACTS_VITEST_CONFIG, + cron: CRON_VITEST_CONFIG, + daemon: DAEMON_VITEST_CONFIG, + e2e: E2E_VITEST_CONFIG, + extension: EXTENSIONS_VITEST_CONFIG, + extensionAcpx: EXTENSION_ACPX_VITEST_CONFIG, + extensionBlueBubbles: EXTENSION_BLUEBUBBLES_VITEST_CONFIG, + extensionChannel: EXTENSION_CHANNELS_VITEST_CONFIG, + extensionDiffs: EXTENSION_DIFFS_VITEST_CONFIG, + extensionFeishu: EXTENSION_FEISHU_VITEST_CONFIG, + extensionIrc: EXTENSION_IRC_VITEST_CONFIG, + extensionMatrix: EXTENSION_MATRIX_VITEST_CONFIG, + extensionMattermost: EXTENSION_MATTERMOST_VITEST_CONFIG, + extensionMemory: EXTENSION_MEMORY_VITEST_CONFIG, + extensionMessaging: EXTENSION_MESSAGING_VITEST_CONFIG, + extensionMsTeams: EXTENSION_MSTEAMS_VITEST_CONFIG, + extensionProvider: EXTENSION_PROVIDERS_VITEST_CONFIG, + extensionTelegram: EXTENSION_TELEGRAM_VITEST_CONFIG, + extensionVoiceCall: EXTENSION_VOICE_CALL_VITEST_CONFIG, + extensionWhatsApp: EXTENSION_WHATSAPP_VITEST_CONFIG, + extensionZalo: EXTENSION_ZALO_VITEST_CONFIG, + gateway: GATEWAY_VITEST_CONFIG, + hooks: HOOKS_VITEST_CONFIG, + infra: INFRA_VITEST_CONFIG, + logging: LOGGING_VITEST_CONFIG, + media: MEDIA_VITEST_CONFIG, + mediaUnderstanding: MEDIA_UNDERSTANDING_VITEST_CONFIG, + plugin: PLUGINS_VITEST_CONFIG, + pluginSdk: PLUGIN_SDK_VITEST_CONFIG, + pluginSdkLight: PLUGIN_SDK_LIGHT_VITEST_CONFIG, + process: PROCESS_VITEST_CONFIG, + unitFast: UNIT_FAST_VITEST_CONFIG, + runtimeConfig: RUNTIME_CONFIG_VITEST_CONFIG, + secrets: SECRETS_VITEST_CONFIG, + sharedCore: SHARED_CORE_VITEST_CONFIG, + tasks: TASKS_VITEST_CONFIG, + tooling: TOOLING_VITEST_CONFIG, + tui: TUI_VITEST_CONFIG, + ui: UI_VITEST_CONFIG, + utils: UTILS_VITEST_CONFIG, + wizard: WIZARD_VITEST_CONFIG, +}; const BROAD_CHANGED_RERUN_PATTERNS = [ /^package\.json$/u, /^pnpm-lock\.yaml$/u, @@ -221,6 +275,9 @@ export function resolveChangedTargetArgs( function classifyTarget(arg, cwd) { const relative = toRepoRelativeTarget(arg, cwd); + if (resolveUnitFastTestIncludePattern(relative)) { + return "unitFast"; + } if (relative.endsWith(".e2e.test.ts")) { return "e2e"; } @@ -376,6 +433,10 @@ function classifyTarget(arg, cwd) { function resolveLightLaneIncludePatterns(kind, targetArg, cwd) { const relative = toRepoRelativeTarget(targetArg, cwd); + if (kind === "unitFast") { + const includePattern = resolveUnitFastTestIncludePattern(relative); + return includePattern ? [includePattern] : null; + } if (kind === "pluginSdkLight") { const includePattern = resolvePluginSdkLightIncludePattern(relative); return includePattern ? [includePattern] : null; @@ -459,6 +520,7 @@ export function buildVitestRunPlans( const nonTargetArgs = activeForwardedArgs.filter((arg) => !activeTargetArgs.includes(arg)); const orderedKinds = [ + "unitFast", "default", "boundary", "tooling", @@ -516,122 +578,7 @@ export function buildVitestRunPlans( if (!grouped || grouped.length === 0) { continue; } - const config = - kind === "boundary" - ? BOUNDARY_VITEST_CONFIG - : kind === "tooling" - ? TOOLING_VITEST_CONFIG - : kind === "contracts" - ? CONTRACTS_VITEST_CONFIG - : kind === "bundled" - ? BUNDLED_VITEST_CONFIG - : kind === "gateway" - ? GATEWAY_VITEST_CONFIG - : kind === "hooks" - ? HOOKS_VITEST_CONFIG - : kind === "infra" - ? INFRA_VITEST_CONFIG - : kind === "runtimeConfig" - ? RUNTIME_CONFIG_VITEST_CONFIG - : kind === "cron" - ? CRON_VITEST_CONFIG - : kind === "daemon" - ? DAEMON_VITEST_CONFIG - : kind === "media" - ? MEDIA_VITEST_CONFIG - : kind === "logging" - ? LOGGING_VITEST_CONFIG - : kind === "pluginSdkLight" - ? PLUGIN_SDK_LIGHT_VITEST_CONFIG - : kind === "pluginSdk" - ? PLUGIN_SDK_VITEST_CONFIG - : kind === "process" - ? PROCESS_VITEST_CONFIG - : kind === "secrets" - ? SECRETS_VITEST_CONFIG - : kind === "sharedCore" - ? SHARED_CORE_VITEST_CONFIG - : kind === "tasks" - ? TASKS_VITEST_CONFIG - : kind === "tui" - ? TUI_VITEST_CONFIG - : kind === "mediaUnderstanding" - ? MEDIA_UNDERSTANDING_VITEST_CONFIG - : kind === "acp" - ? ACP_VITEST_CONFIG - : kind === "cli" - ? CLI_VITEST_CONFIG - : kind === "commandLight" - ? COMMANDS_LIGHT_VITEST_CONFIG - : kind === "command" - ? COMMANDS_VITEST_CONFIG - : kind === "autoReply" - ? AUTO_REPLY_VITEST_CONFIG - : kind === "agent" - ? AGENTS_VITEST_CONFIG - : kind === "plugin" - ? PLUGINS_VITEST_CONFIG - : kind === "ui" - ? UI_VITEST_CONFIG - : kind === "utils" - ? UTILS_VITEST_CONFIG - : kind === "wizard" - ? WIZARD_VITEST_CONFIG - : kind === "e2e" - ? E2E_VITEST_CONFIG - : kind === "extensionAcpx" - ? EXTENSION_ACPX_VITEST_CONFIG - : kind === "extensionDiffs" - ? EXTENSION_DIFFS_VITEST_CONFIG - : kind === - "extensionBlueBubbles" - ? EXTENSION_BLUEBUBBLES_VITEST_CONFIG - : kind === - "extensionFeishu" - ? EXTENSION_FEISHU_VITEST_CONFIG - : kind === - "extensionIrc" - ? EXTENSION_IRC_VITEST_CONFIG - : kind === - "extensionMattermost" - ? EXTENSION_MATTERMOST_VITEST_CONFIG - : kind === - "extensionChannel" - ? EXTENSION_CHANNELS_VITEST_CONFIG - : kind === - "extensionTelegram" - ? EXTENSION_TELEGRAM_VITEST_CONFIG - : kind === - "extensionVoiceCall" - ? EXTENSION_VOICE_CALL_VITEST_CONFIG - : kind === - "extensionWhatsApp" - ? EXTENSION_WHATSAPP_VITEST_CONFIG - : kind === - "extensionZalo" - ? EXTENSION_ZALO_VITEST_CONFIG - : kind === - "extensionMatrix" - ? EXTENSION_MATRIX_VITEST_CONFIG - : kind === - "extensionMemory" - ? EXTENSION_MEMORY_VITEST_CONFIG - : kind === - "extensionMsTeams" - ? EXTENSION_MSTEAMS_VITEST_CONFIG - : kind === - "extensionMessaging" - ? EXTENSION_MESSAGING_VITEST_CONFIG - : kind === - "extensionProvider" - ? EXTENSION_PROVIDERS_VITEST_CONFIG - : kind === - "channel" - ? CHANNEL_VITEST_CONFIG - : kind === - "extension" - ? EXTENSIONS_VITEST_CONFIG - : DEFAULT_VITEST_CONFIG; + const config = VITEST_CONFIG_BY_KIND[kind] ?? DEFAULT_VITEST_CONFIG; const useCliTargetArgs = kind === "e2e" || (kind === "default" && diff --git a/scripts/test-unit-fast-audit.mjs b/scripts/test-unit-fast-audit.mjs new file mode 100644 index 00000000000..a7390115d4a --- /dev/null +++ b/scripts/test-unit-fast-audit.mjs @@ -0,0 +1,60 @@ +import { + collectBroadUnitFastTestCandidates, + collectUnitFastTestFileAnalysis, + collectUnitFastTestCandidates, + unitFastTestFiles, +} from "../vitest.unit-fast-paths.mjs"; + +const args = new Set(process.argv.slice(2)); +const json = args.has("--json"); +const scope = args.has("--broad") ? "broad" : "current"; + +const analysis = collectUnitFastTestFileAnalysis(process.cwd(), { scope }); +const rejected = analysis.filter((entry) => !entry.unitFast); +const reasonCounts = new Map(); +const candidateCount = + scope === "broad" + ? collectBroadUnitFastTestCandidates(process.cwd()).length + : collectUnitFastTestCandidates(process.cwd()).length; +const unitFastCount = analysis.filter((entry) => entry.unitFast).length; + +for (const entry of rejected) { + for (const reason of entry.reasons) { + reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1); + } +} + +if (json) { + console.log( + JSON.stringify( + { + candidates: candidateCount, + unitFast: unitFastCount, + routed: unitFastTestFiles.length, + rejected: rejected.length, + reasonCounts: Object.fromEntries( + [...reasonCounts.entries()].toSorted(([a], [b]) => a.localeCompare(b)), + ), + scope, + files: analysis, + }, + null, + 2, + ), + ); + process.exit(0); +} + +console.log( + [ + `[test-unit-fast-audit] scope=${scope} candidates=${analysis.length} unitFast=${unitFastCount} routed=${unitFastTestFiles.length} rejected=${rejected.length}`, + scope === "broad" + ? `[test-unit-fast-audit] broad unit-fast candidates are not routed automatically` + : "", + "", + "Rejected reasons:", + ...[...reasonCounts.entries()] + .toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([reason, count]) => ` ${String(count).padStart(4, " ")} ${reason}`), + ].join("\n"), +); diff --git a/src/infra/vitest-config.test.ts b/src/infra/vitest-config.test.ts index fa73313e2a3..d9d61040130 100644 --- a/src/infra/vitest-config.test.ts +++ b/src/infra/vitest-config.test.ts @@ -226,6 +226,13 @@ describe("test scripts", () => { expect(pkg.scripts?.["test:fast"]).toBe( "node scripts/run-vitest.mjs run --config vitest.unit.config.ts", ); + expect(pkg.scripts?.["test:unit"]).toBe( + "pnpm test:unit:fast && node scripts/run-vitest.mjs run --config vitest.unit.config.ts", + ); + expect(pkg.scripts?.["test:unit:fast"]).toBe( + "node scripts/run-vitest.mjs run --config vitest.unit-fast.config.ts", + ); + expect(pkg.scripts?.["test:unit:fast:audit"]).toBe("node scripts/test-unit-fast-audit.mjs"); expect(pkg.scripts?.["test"]).toBe("node scripts/test-projects.mjs"); expect(pkg.scripts?.["test:gateway"]).toBe( "node scripts/run-vitest.mjs run --config vitest.gateway.config.ts", diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index 3a6c3e0f1b8..463e9838c9b 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.test.ts @@ -163,33 +163,44 @@ describe("test-projects args", () => { }); it("routes daemon targets to the daemon config", () => { - expect(buildVitestRunPlans(["src/daemon/constants.test.ts"])).toEqual([ + expect(buildVitestRunPlans(["src/daemon/inspect.test.ts"])).toEqual([ { config: "vitest.daemon.config.ts", forwardedArgs: [], - includePatterns: ["src/daemon/constants.test.ts"], + includePatterns: ["src/daemon/inspect.test.ts"], watchMode: false, }, ]); }); it("routes media targets to the media config", () => { - expect(buildVitestRunPlans(["src/media/mime.test.ts"])).toEqual([ + expect(buildVitestRunPlans(["src/media/fetch.test.ts"])).toEqual([ { config: "vitest.media.config.ts", forwardedArgs: [], - includePatterns: ["src/media/mime.test.ts"], + includePatterns: ["src/media/fetch.test.ts"], watchMode: false, }, ]); }); it("routes plugin-sdk targets to the plugin-sdk config", () => { - expect(buildVitestRunPlans(["src/plugin-sdk/provider-stream.test.ts"])).toEqual([ + expect(buildVitestRunPlans(["src/plugin-sdk/anthropic-vertex-auth-presence.test.ts"])).toEqual([ { config: "vitest.plugin-sdk.config.ts", forwardedArgs: [], - includePatterns: ["src/plugin-sdk/provider-stream.test.ts"], + includePatterns: ["src/plugin-sdk/anthropic-vertex-auth-presence.test.ts"], + watchMode: false, + }, + ]); + }); + + it("routes unit-fast light targets to the cache-friendly unit-fast config", () => { + expect(buildVitestRunPlans(["src/plugin-sdk/provider-entry.test.ts"])).toEqual([ + { + config: "vitest.unit-fast.config.ts", + forwardedArgs: [], + includePatterns: ["src/plugin-sdk/provider-entry.test.ts"], watchMode: false, }, ]); @@ -217,10 +228,10 @@ describe("test-projects args", () => { ]); }); - it("routes shared-core targets to the shared-core config", () => { + it("routes unit-fast shared-core targets to the unit-fast config", () => { expect(buildVitestRunPlans(["src/shared/text-chunking.test.ts"])).toEqual([ { - config: "vitest.shared-core.config.ts", + config: "vitest.unit-fast.config.ts", forwardedArgs: [], includePatterns: ["src/shared/text-chunking.test.ts"], watchMode: false, @@ -240,11 +251,11 @@ describe("test-projects args", () => { }); it("routes logging targets to the logging config", () => { - expect(buildVitestRunPlans(["src/logging/load-levels.test.ts"])).toEqual([ + expect(buildVitestRunPlans(["src/logging/console-settings.test.ts"])).toEqual([ { config: "vitest.logging.config.ts", forwardedArgs: [], - includePatterns: ["src/logging/load-levels.test.ts"], + includePatterns: ["src/logging/console-settings.test.ts"], watchMode: false, }, ]); @@ -339,11 +350,11 @@ describe("test-projects args", () => { }); it("routes channel targets to the channels config", () => { - expect(buildVitestRunPlans(["src/channels/ids.test.ts"])).toEqual([ + expect(buildVitestRunPlans(["src/channels/session.test.ts"])).toEqual([ { config: "vitest.channels.config.ts", forwardedArgs: [], - includePatterns: ["src/channels/ids.test.ts"], + includePatterns: ["src/channels/session.test.ts"], watchMode: false, }, ]); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index f473bcca69f..d10d1b4e82f 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -55,9 +55,9 @@ describe("scripts/test-projects changed-target routing", () => { expect(plans).toEqual([ { - config: "vitest.shared-core.config.ts", + config: "vitest.unit-fast.config.ts", forwardedArgs: [], - includePatterns: ["src/shared/**/*.test.ts"], + includePatterns: ["src/shared/string-normalization.test.ts"], watchMode: false, }, { @@ -83,13 +83,29 @@ describe("scripts/test-projects changed-target routing", () => { }); it("routes explicit commands light tests to the lighter commands lane", () => { - const plans = buildVitestRunPlans(["src/commands/cleanup-utils.test.ts"], process.cwd()); + const plans = buildVitestRunPlans(["src/commands/status-json-runtime.test.ts"], process.cwd()); expect(plans).toEqual([ { config: "vitest.commands-light.config.ts", forwardedArgs: [], - includePatterns: ["src/commands/cleanup-utils.test.ts"], + includePatterns: ["src/commands/status-json-runtime.test.ts"], + watchMode: false, + }, + ]); + }); + + it("routes unit-fast light tests to the cache-friendly unit-fast lane", () => { + const plans = buildVitestRunPlans( + ["src/commands/status-overview-values.test.ts"], + process.cwd(), + ); + + expect(plans).toEqual([ + { + config: "vitest.unit-fast.config.ts", + forwardedArgs: [], + includePatterns: ["src/commands/status-overview-values.test.ts"], watchMode: false, }, ]); @@ -97,14 +113,14 @@ describe("scripts/test-projects changed-target routing", () => { it("routes changed plugin-sdk source allowlist files to sibling light tests", () => { const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [ - "src/plugin-sdk/lazy-value.ts", + "src/plugin-sdk/provider-entry.ts", ]); expect(plans).toEqual([ { - config: "vitest.plugin-sdk-light.config.ts", + config: "vitest.unit-fast.config.ts", forwardedArgs: [], - includePatterns: ["src/plugin-sdk/lazy-value.test.ts"], + includePatterns: ["src/plugin-sdk/provider-entry.test.ts"], watchMode: false, }, ]); @@ -118,7 +134,7 @@ describe("scripts/test-projects changed-target routing", () => { expect(plans).toEqual([ { - config: "vitest.commands-light.config.ts", + config: "vitest.unit-fast.config.ts", forwardedArgs: [], includePatterns: [ "src/commands/status-overview-values.test.ts", @@ -163,6 +179,12 @@ describe("scripts/test-projects changed-target routing", () => { describe("scripts/test-projects full-suite sharding", () => { it("splits untargeted runs into fixed shard configs", () => { expect(buildFullSuiteVitestRunPlans([], process.cwd())).toEqual([ + { + config: "vitest.full-core-unit-fast.config.ts", + forwardedArgs: [], + includePatterns: null, + watchMode: false, + }, { config: "vitest.full-core-unit-src.config.ts", forwardedArgs: [], diff --git a/test/vitest-projects-config.test.ts b/test/vitest-projects-config.test.ts index fe85007fbe5..677718605db 100644 --- a/test/vitest-projects-config.test.ts +++ b/test/vitest-projects-config.test.ts @@ -8,6 +8,7 @@ import { createContractsVitestConfig } from "../vitest.contracts.config.ts"; import { createGatewayVitestConfig } from "../vitest.gateway.config.ts"; import { createPluginSdkLightVitestConfig } from "../vitest.plugin-sdk-light.config.ts"; import { createUiVitestConfig } from "../vitest.ui.config.ts"; +import { createUnitFastVitestConfig } from "../vitest.unit-fast.config.ts"; import { createUnitVitestConfig } from "../vitest.unit.config.ts"; describe("projects vitest config", () => { @@ -21,6 +22,7 @@ describe("projects vitest config", () => { expect(createCommandsLightVitestConfig().test.pool).toBe("threads"); expect(createCommandsVitestConfig().test.pool).toBe("threads"); expect(createPluginSdkLightVitestConfig().test.pool).toBe("threads"); + expect(createUnitFastVitestConfig().test.pool).toBe("threads"); expect(createContractsVitestConfig().test.pool).toBe("threads"); }); @@ -46,6 +48,12 @@ describe("projects vitest config", () => { expect(config.test.runner).toBe("./test/non-isolated-runner.ts"); }); + it("keeps the unit-fast lane on shared workers without the reset-heavy runner", () => { + const config = createUnitFastVitestConfig(); + expect(config.test.isolate).toBe(false); + expect(config.test.runner).toBeUndefined(); + }); + it("keeps the bundled lane on thread workers with the non-isolated runner", () => { expect(bundledConfig.test?.pool).toBe("threads"); expect(bundledConfig.test?.isolate).toBe(false); diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts new file mode 100644 index 00000000000..e984626fc35 --- /dev/null +++ b/test/vitest-unit-fast-config.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { createCommandsLightVitestConfig } from "../vitest.commands-light.config.ts"; +import { createPluginSdkLightVitestConfig } from "../vitest.plugin-sdk-light.config.ts"; +import { + classifyUnitFastTestFileContent, + collectBroadUnitFastTestCandidates, + collectUnitFastTestCandidates, + collectUnitFastTestFileAnalysis, + isUnitFastTestFile, + unitFastTestFiles, + resolveUnitFastTestIncludePattern, +} from "../vitest.unit-fast-paths.mjs"; +import { createUnitFastVitestConfig } from "../vitest.unit-fast.config.ts"; + +describe("unit-fast vitest lane", () => { + it("runs cache-friendly tests without the reset-heavy runner or runtime setup", () => { + const config = createUnitFastVitestConfig({}); + + expect(config.test?.isolate).toBe(false); + expect(config.test?.runner).toBeUndefined(); + expect(config.test?.setupFiles).toEqual([]); + expect(config.test?.include).toContain("src/plugin-sdk/provider-entry.test.ts"); + expect(config.test?.include).toContain("src/commands/status-overview-values.test.ts"); + }); + + it("keeps obvious stateful files out of the unit-fast lane", () => { + expect(isUnitFastTestFile("src/plugin-sdk/temp-path.test.ts")).toBe(false); + expect(resolveUnitFastTestIncludePattern("src/plugin-sdk/temp-path.ts")).toBeNull(); + expect(classifyUnitFastTestFileContent("vi.resetModules(); await import('./x.js')")).toEqual([ + "module-mocking", + "vitest-mock-api", + "dynamic-import", + ]); + }); + + it("routes unit-fast source files to their unit-fast sibling tests", () => { + expect(resolveUnitFastTestIncludePattern("src/plugin-sdk/provider-entry.ts")).toBe( + "src/plugin-sdk/provider-entry.test.ts", + ); + expect(resolveUnitFastTestIncludePattern("src/commands/status-overview-values.ts")).toBe( + "src/commands/status-overview-values.test.ts", + ); + }); + + it("keeps broad audit candidates separate from automatically routed unit-fast tests", () => { + const currentCandidates = collectUnitFastTestCandidates(); + const broadCandidates = collectBroadUnitFastTestCandidates(); + const broadAnalysis = collectUnitFastTestFileAnalysis(process.cwd(), { scope: "broad" }); + + expect(currentCandidates.length).toBeGreaterThanOrEqual(unitFastTestFiles.length); + expect(broadCandidates.length).toBeGreaterThan(currentCandidates.length); + expect(broadAnalysis.filter((entry) => entry.unitFast).length).toBeGreaterThan( + unitFastTestFiles.length, + ); + }); + + it("excludes unit-fast files from the older light lanes so full runs do not duplicate them", () => { + const pluginSdkLight = createPluginSdkLightVitestConfig({}); + const commandsLight = createCommandsLightVitestConfig({}); + + expect(unitFastTestFiles).toContain("src/plugin-sdk/provider-entry.test.ts"); + expect(pluginSdkLight.test?.exclude).toContain("plugin-sdk/provider-entry.test.ts"); + expect(commandsLight.test?.exclude).toContain("status-overview-values.test.ts"); + }); +}); diff --git a/vitest.commands-light.config.ts b/vitest.commands-light.config.ts index 9a02de62bf4..82cef3b2223 100644 --- a/vitest.commands-light.config.ts +++ b/vitest.commands-light.config.ts @@ -1,10 +1,12 @@ import { commandsLightTestFiles } from "./vitest.commands-light-paths.mjs"; import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; +import { unitFastTestFiles } from "./vitest.unit-fast-paths.mjs"; export function createCommandsLightVitestConfig(env?: Record) { return createScopedVitestConfig(commandsLightTestFiles, { dir: "src/commands", env, + exclude: unitFastTestFiles, includeOpenClawRuntimeSetup: false, name: "commands-light", passWithNoTests: true, diff --git a/vitest.config.ts b/vitest.config.ts index c4e9715cd0b..5e4a302dad4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,6 +26,7 @@ export const rootVitestProjects = [ "vitest.agents.config.ts", "vitest.daemon.config.ts", "vitest.media.config.ts", + "vitest.unit-fast.config.ts", "vitest.plugin-sdk-light.config.ts", "vitest.plugin-sdk.config.ts", "vitest.plugins.config.ts", diff --git a/vitest.full-core-unit-fast.config.ts b/vitest.full-core-unit-fast.config.ts new file mode 100644 index 00000000000..1ed50bfc366 --- /dev/null +++ b/vitest.full-core-unit-fast.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; +import { sharedVitestConfig } from "./vitest.shared.config.ts"; + +export default defineConfig({ + ...sharedVitestConfig, + test: { + ...sharedVitestConfig.test, + runner: undefined, + projects: ["vitest.unit-fast.config.ts"], + }, +}); diff --git a/vitest.plugin-sdk-light.config.ts b/vitest.plugin-sdk-light.config.ts index e6d497328c1..90fe7f830d0 100644 --- a/vitest.plugin-sdk-light.config.ts +++ b/vitest.plugin-sdk-light.config.ts @@ -1,10 +1,12 @@ import { pluginSdkLightTestFiles } from "./vitest.plugin-sdk-paths.mjs"; import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; +import { unitFastTestFiles } from "./vitest.unit-fast-paths.mjs"; export function createPluginSdkLightVitestConfig(env?: Record) { return createScopedVitestConfig(pluginSdkLightTestFiles, { dir: "src", env, + exclude: unitFastTestFiles, includeOpenClawRuntimeSetup: false, name: "plugin-sdk-light", passWithNoTests: true, diff --git a/vitest.scoped-config.ts b/vitest.scoped-config.ts index 463a8cec358..1a0c6f06253 100644 --- a/vitest.scoped-config.ts +++ b/vitest.scoped-config.ts @@ -1,6 +1,7 @@ import { defineConfig } from "vitest/config"; import { loadPatternListFromEnv, narrowIncludePatternsForCli } from "./vitest.pattern-file.ts"; import { sharedVitestConfig } from "./vitest.shared.config.ts"; +import { unitFastTestFiles } from "./vitest.unit-fast-paths.mjs"; function normalizePathPattern(value: string): string { return value.replaceAll("\\", "/"); @@ -59,7 +60,7 @@ export function createScopedVitestConfig( const includeFromEnv = loadPatternListFromEnv("OPENCLAW_VITEST_INCLUDE_FILE", env); const cliInclude = narrowIncludePatternsForCli(include, options?.argv); const exclude = relativizeScopedPatterns( - [...(baseTest.exclude ?? []), ...(options?.exclude ?? [])], + [...(baseTest.exclude ?? []), ...unitFastTestFiles, ...(options?.exclude ?? [])], scopedDir, ); const isolate = options?.isolate ?? resolveVitestIsolation(options?.env); diff --git a/vitest.shared-core.config.ts b/vitest.shared-core.config.ts index 84afa7a4501..b60a787e54a 100644 --- a/vitest.shared-core.config.ts +++ b/vitest.shared-core.config.ts @@ -1,9 +1,11 @@ import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; +import { unitFastTestFiles } from "./vitest.unit-fast-paths.mjs"; export function createSharedCoreVitestConfig(env?: Record) { return createScopedVitestConfig(["src/shared/**/*.test.ts"], { dir: "src", env, + exclude: unitFastTestFiles, includeOpenClawRuntimeSetup: false, name: "shared-core", passWithNoTests: true, diff --git a/vitest.shared.config.ts b/vitest.shared.config.ts index ce82f4d56b0..2c359667af3 100644 --- a/vitest.shared.config.ts +++ b/vitest.shared.config.ts @@ -256,6 +256,8 @@ export const sharedVitestConfig = { "vitest.media.config.ts", "vitest.media-understanding.config.ts", "vitest.performance-config.ts", + "vitest.unit-fast.config.ts", + "vitest.unit-fast-paths.mjs", "vitest.scoped-config.ts", "vitest.shared-core.config.ts", "vitest.shared.config.ts", diff --git a/vitest.test-shards.mjs b/vitest.test-shards.mjs index 0b7a7298490..7af04748efe 100644 --- a/vitest.test-shards.mjs +++ b/vitest.test-shards.mjs @@ -7,6 +7,11 @@ export const autoReplyTopLevelReplyTestInclude = ["src/auto-reply/reply*.test.ts export const autoReplyReplySubtreeTestInclude = ["src/auto-reply/reply/**/*.test.ts"]; export const fullSuiteVitestShards = [ + { + config: "vitest.full-core-unit-fast.config.ts", + name: "core-unit-fast", + projects: ["vitest.unit-fast.config.ts"], + }, { config: "vitest.full-core-unit-src.config.ts", name: "core-unit-src", diff --git a/vitest.unit-fast-paths.mjs b/vitest.unit-fast-paths.mjs new file mode 100644 index 00000000000..9a0e3872c58 --- /dev/null +++ b/vitest.unit-fast-paths.mjs @@ -0,0 +1,231 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + commandsLightSourceFiles, + commandsLightTestFiles, +} from "./vitest.commands-light-paths.mjs"; +import { pluginSdkLightSourceFiles, pluginSdkLightTestFiles } from "./vitest.plugin-sdk-paths.mjs"; + +const normalizeRepoPath = (value) => value.replaceAll("\\", "/"); + +const unitFastCandidateGlobs = [ + "packages/memory-host-sdk/**/*.test.ts", + "packages/plugin-package-contract/**/*.test.ts", + "src/acp/**/*.test.ts", + "src/bootstrap/**/*.test.ts", + "src/channels/**/*.test.ts", + "src/cli/**/*.test.ts", + "src/config/**/*.test.ts", + "src/daemon/**/*.test.ts", + "src/i18n/**/*.test.ts", + "src/hooks/**/*.test.ts", + "src/image-generation/**/*.test.ts", + "src/infra/**/*.test.ts", + "src/interactive/**/*.test.ts", + "src/link-understanding/**/*.test.ts", + "src/logging/**/*.test.ts", + "src/markdown/**/*.test.ts", + "src/media/**/*.test.ts", + "src/media-generation/**/*.test.ts", + "src/media-understanding/**/*.test.ts", + "src/memory-host-sdk/**/*.test.ts", + "src/music-generation/**/*.test.ts", + "src/node-host/**/*.test.ts", + "src/plugin-sdk/**/*.test.ts", + "src/poll-params.test.ts", + "src/polls.test.ts", + "src/process/**/*.test.ts", + "src/routing/**/*.test.ts", + "src/sessions/**/*.test.ts", + "src/shared/**/*.test.ts", + "src/terminal/**/*.test.ts", + "src/test-utils/**/*.test.ts", + "src/tasks/**/*.test.ts", + "src/tts/**/*.test.ts", + "src/utils/**/*.test.ts", + "src/video-generation/**/*.test.ts", + "src/wizard/**/*.test.ts", + "test/**/*.test.ts", +]; +const unitFastCandidateExactFiles = [...pluginSdkLightTestFiles, ...commandsLightTestFiles]; +const broadUnitFastCandidateGlobs = [ + "src/**/*.test.ts", + "packages/**/*.test.ts", + "test/**/*.test.ts", +]; +const broadUnitFastCandidateSkipGlobs = [ + "**/*.e2e.test.ts", + "**/*.live.test.ts", + "test/fixtures/**/*.test.ts", + "test/setup-home-isolation.test.ts", + "src/config/schema.base.generated.test.ts", + "src/gateway/**/*.test.ts", + "src/security/**/*.test.ts", + "src/secrets/**/*.test.ts", + "src/tasks/**/*.test.ts", +]; + +const disqualifyingPatterns = [ + { + code: "jsdom-environment", + pattern: /@vitest-environment\s+jsdom/u, + }, + { + code: "module-mocking", + pattern: /\bvi\.(?:mock|doMock|unmock|doUnmock|importActual|resetModules)\s*\(/u, + }, + { + code: "vitest-mock-api", + pattern: /\bvi\b/u, + }, + { + code: "dynamic-import", + pattern: /\b(?:await\s+)?import\s*\(/u, + }, + { + code: "fake-timers", + pattern: + /\bvi\.(?:useFakeTimers|setSystemTime|advanceTimers|runAllTimers|runOnlyPendingTimers)\s*\(/u, + }, + { + code: "env-or-global-stub", + pattern: /\bvi\.(?:stubEnv|stubGlobal|unstubAllEnvs|unstubAllGlobals)\s*\(/u, + }, + { + code: "process-env-mutation", + pattern: /(?:process\.env(?:\.[A-Za-z_$][\w$]*|\[[^\]]+\])?\s*=|delete\s+process\.env)/u, + }, + { + code: "global-mutation", + pattern: /(?:globalThis|global)\s*\[[^\]]+\]\s*=/u, + }, + { + code: "filesystem-state", + pattern: + /\b(?:mkdtemp|rmSync|writeFileSync|appendFileSync|mkdirSync|createTemp|makeTempDir|tempDir|tmpdir|node:fs|node:os)\b/u, + }, + { + code: "runtime-singleton-state", + pattern: /\b(?:setActivePluginRegistry|resetPluginRuntimeStateForTest|reset.*ForTest)\s*\(/u, + }, +]; + +function matchesAnyGlob(file, patterns) { + return patterns.some((pattern) => path.matchesGlob(file, pattern)); +} + +function walkFiles(directory, files = []) { + let entries; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + return files; + } + + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "vendor") { + continue; + } + walkFiles(entryPath, files); + continue; + } + if (entry.isFile() && entry.name.endsWith(".test.ts")) { + files.push(normalizeRepoPath(entryPath)); + } + } + return files; +} + +export function classifyUnitFastTestFileContent(source) { + const reasons = []; + for (const { code, pattern } of disqualifyingPatterns) { + if (pattern.test(source)) { + reasons.push(code); + } + } + return reasons; +} + +export function collectUnitFastTestCandidates(cwd = process.cwd()) { + const discovered = ["src", "packages", "test"] + .flatMap((directory) => walkFiles(path.join(cwd, directory))) + .map((file) => normalizeRepoPath(path.relative(cwd, file))) + .filter( + (file) => + matchesAnyGlob(file, unitFastCandidateGlobs) && + !matchesAnyGlob(file, broadUnitFastCandidateSkipGlobs), + ); + return [...new Set([...discovered, ...unitFastCandidateExactFiles])].toSorted((a, b) => + a.localeCompare(b), + ); +} + +export function collectBroadUnitFastTestCandidates(cwd = process.cwd()) { + const discovered = ["src", "packages", "test"] + .flatMap((directory) => walkFiles(path.join(cwd, directory))) + .map((file) => normalizeRepoPath(path.relative(cwd, file))) + .filter( + (file) => + matchesAnyGlob(file, broadUnitFastCandidateGlobs) && + !matchesAnyGlob(file, broadUnitFastCandidateSkipGlobs), + ); + return [...new Set([...discovered, ...unitFastCandidateExactFiles])].toSorted((a, b) => + a.localeCompare(b), + ); +} + +export function collectUnitFastTestFileAnalysis(cwd = process.cwd(), options = {}) { + const candidates = + options.scope === "broad" + ? collectBroadUnitFastTestCandidates(cwd) + : collectUnitFastTestCandidates(cwd); + return candidates.map((file) => { + const absolutePath = path.join(cwd, file); + let source = ""; + try { + source = fs.readFileSync(absolutePath, "utf8"); + } catch { + return { + file, + unitFast: false, + reasons: ["missing-file"], + }; + } + const reasons = classifyUnitFastTestFileContent(source); + return { + file, + unitFast: reasons.length === 0, + reasons, + }; + }); +} + +export const unitFastTestFiles = collectUnitFastTestFileAnalysis() + .filter((entry) => entry.unitFast) + .map((entry) => entry.file); + +const unitFastTestFileSet = new Set(unitFastTestFiles); +const sourceToUnitFastTestFile = new Map( + [...pluginSdkLightSourceFiles, ...commandsLightSourceFiles].flatMap((sourceFile) => { + const testFile = sourceFile.replace(/\.ts$/u, ".test.ts"); + return unitFastTestFileSet.has(testFile) ? [[sourceFile, testFile]] : []; + }), +); + +export function isUnitFastTestFile(file) { + return unitFastTestFileSet.has(normalizeRepoPath(file)); +} + +export function resolveUnitFastTestIncludePattern(file) { + const normalized = normalizeRepoPath(file); + if (unitFastTestFileSet.has(normalized)) { + return normalized; + } + const siblingTestFile = normalized.replace(/\.ts$/u, ".test.ts"); + if (unitFastTestFileSet.has(siblingTestFile)) { + return siblingTestFile; + } + return sourceToUnitFastTestFile.get(normalized) ?? null; +} diff --git a/vitest.unit-fast.config.ts b/vitest.unit-fast.config.ts new file mode 100644 index 00000000000..b26f9c44b27 --- /dev/null +++ b/vitest.unit-fast.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "vitest/config"; +import { loadPatternListFromEnv, narrowIncludePatternsForCli } from "./vitest.pattern-file.ts"; +import { sharedVitestConfig } from "./vitest.shared.config.ts"; +import { unitFastTestFiles } from "./vitest.unit-fast-paths.mjs"; + +export function createUnitFastVitestConfig( + env: Record = process.env, + options: { argv?: string[] } = {}, +) { + const sharedTest = sharedVitestConfig.test ?? {}; + const includeFromEnv = loadPatternListFromEnv("OPENCLAW_VITEST_INCLUDE_FILE", env); + const cliInclude = narrowIncludePatternsForCli(unitFastTestFiles, options.argv); + + return defineConfig({ + ...sharedVitestConfig, + test: { + ...sharedTest, + name: "unit-fast", + isolate: false, + runner: undefined, + setupFiles: [], + include: includeFromEnv ?? cliInclude ?? unitFastTestFiles, + exclude: sharedTest.exclude ?? [], + passWithNoTests: true, + }, + }); +} + +export default createUnitFastVitestConfig(); diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts index 52c26cb89e5..3d8dff36ce0 100644 --- a/vitest.unit.config.ts +++ b/vitest.unit.config.ts @@ -2,6 +2,7 @@ import { defineProject } from "vitest/config"; import { loadPatternListFromEnv, narrowIncludePatternsForCli } from "./vitest.pattern-file.ts"; import { resolveVitestIsolation } from "./vitest.scoped-config.ts"; import { sharedVitestConfig } from "./vitest.shared.config.ts"; +import { unitFastTestFiles } from "./vitest.unit-fast-paths.mjs"; import { isBundledPluginDependentUnitTestFile, unitTestAdditionalExcludePatterns, @@ -59,6 +60,7 @@ export function createUnitVitestConfigWithOptions( ...new Set([ ...exclude, ...baseExcludePatterns, + ...unitFastTestFiles, ...(options.extraExcludePatterns ?? []), ...loadExtraExcludePatternsFromEnv(env), ]), diff --git a/vitest.utils.config.ts b/vitest.utils.config.ts index fe6dd1bef5d..a84c52101a6 100644 --- a/vitest.utils.config.ts +++ b/vitest.utils.config.ts @@ -1,9 +1,11 @@ import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; +import { unitFastTestFiles } from "./vitest.unit-fast-paths.mjs"; export function createUtilsVitestConfig(env?: Record) { return createScopedVitestConfig(["src/utils/**/*.test.ts"], { dir: "src", env, + exclude: unitFastTestFiles, includeOpenClawRuntimeSetup: false, name: "utils", passWithNoTests: true,