diff --git a/scripts/lib/ci-node-test-plan.mjs b/scripts/lib/ci-node-test-plan.mjs index f4a2ecc3e642..587389979577 100644 --- a/scripts/lib/ci-node-test-plan.mjs +++ b/scripts/lib/ci-node-test-plan.mjs @@ -902,6 +902,7 @@ const SPLIT_NODE_SHARDS = new Map([ shardName: "core-unit-fast", configs: [ "test/vitest/vitest.unit-fast.config.ts", + "test/vitest/vitest.unit-fast-isolated.config.ts", "test/vitest/vitest.unit-fast-fake-timers.config.ts", ], requiresDist: false, diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index ef1d620279e2..819e61b3a20a 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -51,8 +51,10 @@ import { toolingIsolatedTestFiles, } from "../test/vitest/vitest.tooling-isolated-paths.mjs"; import { + getUnitFastIsolatedTestFiles, getUnitFastTestFiles, getUnitFastTimerTestFiles, + resolveUnitFastIsolatedTestIncludePattern, resolveUnitFastTestIncludePattern, resolveUnitFastTimerTestIncludePattern, } from "../test/vitest/vitest.unit-fast-paths.mjs"; @@ -164,6 +166,7 @@ const PLUGIN_SDK_LIGHT_VITEST_CONFIG = "test/vitest/vitest.plugin-sdk-light.conf const PLUGIN_SDK_VITEST_CONFIG = "test/vitest/vitest.plugin-sdk.config.ts"; const PLUGINS_VITEST_CONFIG = "test/vitest/vitest.plugins.config.ts"; const UNIT_FAST_VITEST_CONFIG = "test/vitest/vitest.unit-fast.config.ts"; +const UNIT_FAST_ISOLATED_VITEST_CONFIG = "test/vitest/vitest.unit-fast-isolated.config.ts"; const UNIT_FAST_FAKE_TIMERS_VITEST_CONFIG = "test/vitest/vitest.unit-fast-fake-timers.config.ts"; const UNIT_SECURITY_VITEST_CONFIG = "test/vitest/vitest.unit-security.config.ts"; const UNIT_SRC_VITEST_CONFIG = "test/vitest/vitest.unit-src.config.ts"; @@ -201,6 +204,7 @@ const FULL_SUITE_CONFIG_WEIGHT = new Map([ ["test/vitest/vitest.tasks.config.ts", 165], [CHANNEL_VITEST_CONFIG, 164], [UNIT_FAST_VITEST_CONFIG, 160], + [UNIT_FAST_ISOLATED_VITEST_CONFIG, 159], [AUTO_REPLY_REPLY_VITEST_CONFIG, 155], [INFRA_VITEST_CONFIG, 145], ["test/vitest/vitest.secrets.config.ts", 140], @@ -385,6 +389,7 @@ const VITEST_CONFIG_BY_KIND = { pluginSdkLight: PLUGIN_SDK_LIGHT_VITEST_CONFIG, process: PROCESS_VITEST_CONFIG, unitFast: UNIT_FAST_VITEST_CONFIG, + unitFastIsolated: UNIT_FAST_ISOLATED_VITEST_CONFIG, unitFastFakeTimers: UNIT_FAST_FAKE_TIMERS_VITEST_CONFIG, unitSecurity: UNIT_SECURITY_VITEST_CONFIG, unitSrc: UNIT_SRC_VITEST_CONFIG, @@ -2565,7 +2570,10 @@ function listToolingFullSuiteTestTargets(cwd) { function listUnitFastFullSuiteTestTargets() { const timerTargets = new Set(getUnitFastTimerTestFiles()); - return getUnitFastTestFiles().filter((file) => !timerTargets.has(file)); + const isolatedTargets = new Set(getUnitFastIsolatedTestFiles()); + return getUnitFastTestFiles().filter( + (file) => !timerTargets.has(file) && !isolatedTargets.has(file), + ); } function listAgentsCoreFullSuiteTestTargets(cwd) { @@ -3798,6 +3806,9 @@ function classifyTarget(arg, cwd) { if (resolveUnitFastTimerTestIncludePattern(relative)) { return "unitFastFakeTimers"; } + if (resolveUnitFastIsolatedTestIncludePattern(relative)) { + return "unitFastIsolated"; + } if (resolveUnitFastTestIncludePattern(relative)) { return "unitFast"; } @@ -3991,6 +4002,10 @@ function resolveLightLaneIncludePatterns(kind, targetArg, cwd) { const includePattern = resolveUnitFastTimerTestIncludePattern(relative); return includePattern ? [includePattern] : null; } + if (kind === "unitFastIsolated") { + const includePattern = resolveUnitFastIsolatedTestIncludePattern(relative); + return includePattern ? [includePattern] : null; + } if (kind === "pluginSdkLight") { const includePattern = resolvePluginSdkLightIncludePattern(relative); return includePattern ? [includePattern] : null; @@ -4170,6 +4185,7 @@ export function buildVitestRunPlans( const orderedKinds = [ "unitFast", + "unitFastIsolated", "unitFastFakeTimers", "default", "boundary", diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index 6e61af38cba7..c073ed90de99 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -312,6 +312,7 @@ describe("scripts/lib/ci-node-test-plan.mjs", () => { { configs: [ "test/vitest/vitest.unit-fast.config.ts", + "test/vitest/vitest.unit-fast-isolated.config.ts", "test/vitest/vitest.unit-fast-fake-timers.config.ts", ], requiresDist: false, diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 7b7e43e97c47..64d20df201e3 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -2707,12 +2707,18 @@ describe("scripts/test-projects changed-target routing", () => { it("chunks the broad shell helper tooling shard after isolated targets", () => { const plans = buildVitestRunPlans(["test/scripts"], process.cwd()); - expect(plans.slice(0, 3)).toEqual([ + expect(plans.slice(0, 4)).toEqual([ expect.objectContaining({ config: "test/vitest/vitest.unit-fast.config.ts", includePatterns: expect.arrayContaining(["test/scripts/arg-utils.test.ts"]), watchMode: false, }), + { + config: "test/vitest/vitest.unit-fast-isolated.config.ts", + forwardedArgs: [], + includePatterns: ["test/scripts/android-version.test.ts"], + watchMode: false, + }, { config: "test/vitest/vitest.tooling-docker.config.ts", forwardedArgs: [], @@ -2732,7 +2738,7 @@ describe("scripts/test-projects changed-target routing", () => { ]); const e2ePlans = plans.filter((plan) => plan.config === "test/vitest/vitest.e2e.config.ts"); const toolingPlans = plans - .slice(3) + .slice(4) .filter((plan) => plan.config === "test/vitest/vitest.tooling.config.ts"); const toolingTargets = toolingPlans.flatMap((plan) => plan.includePatterns ?? []); @@ -2818,6 +2824,10 @@ describe("scripts/test-projects changed-target routing", () => { config: "test/vitest/vitest.unit-fast.config.ts", includePatterns: expect.arrayContaining(["src/plugin-sdk/access-groups.test.ts"]), }), + expect.objectContaining({ + config: "test/vitest/vitest.unit-fast-isolated.config.ts", + includePatterns: ["src/plugin-sdk/memory-host-events.test.ts"], + }), expect.objectContaining({ config: "test/vitest/vitest.plugin-sdk-light.config.ts", includePatterns: expect.arrayContaining(["src/plugin-sdk/acp-runtime.test.ts"]), @@ -2855,12 +2865,18 @@ describe("scripts/test-projects changed-target routing", () => { it("chunks broad shell helper globs after isolated targets", () => { const plans = buildVitestRunPlans(["test/scripts/*.test.ts"], process.cwd()); - expect(plans.slice(0, 3)).toEqual([ + expect(plans.slice(0, 4)).toEqual([ expect.objectContaining({ config: "test/vitest/vitest.unit-fast.config.ts", includePatterns: expect.arrayContaining(["test/scripts/arg-utils.test.ts"]), watchMode: false, }), + { + config: "test/vitest/vitest.unit-fast-isolated.config.ts", + forwardedArgs: [], + includePatterns: ["test/scripts/android-version.test.ts"], + watchMode: false, + }, { config: "test/vitest/vitest.tooling-docker.config.ts", forwardedArgs: [], @@ -2880,7 +2896,7 @@ describe("scripts/test-projects changed-target routing", () => { ]); const e2ePlans = plans.filter((plan) => plan.config === "test/vitest/vitest.e2e.config.ts"); const toolingPlans = plans - .slice(3) + .slice(4) .filter((plan) => plan.config === "test/vitest/vitest.tooling.config.ts"); const toolingTargets = toolingPlans.flatMap((plan) => plan.includePatterns ?? []); @@ -3747,6 +3763,22 @@ describe("scripts/test-projects changed-target routing", () => { ]); }); + it("routes forced stateful unit-fast tests to the isolated lane", () => { + const plans = buildVitestRunPlans( + ["src/system-agent/assistant.configured.test.ts"], + process.cwd(), + ); + + expect(plans).toEqual([ + { + config: "test/vitest/vitest.unit-fast-isolated.config.ts", + forwardedArgs: [], + includePatterns: ["src/system-agent/assistant.configured.test.ts"], + watchMode: false, + }, + ]); + }); + it("routes fake-timer unit-fast tests to the serial fake-timer lane", () => { const plans = buildVitestRunPlans(["src/acp/control-plane/manager.test.ts"], process.cwd()); @@ -4457,6 +4489,7 @@ describe("scripts/test-projects full-suite sharding", () => { } expect(leafShardPlans.map((plan) => plan.config)).toEqual([ ...unitFastPlans.map(() => unitFastConfig), + "test/vitest/vitest.unit-fast-isolated.config.ts", "test/vitest/vitest.unit-fast-fake-timers.config.ts", "test/vitest/vitest.unit-src.config.ts", "test/vitest/vitest.unit-security.config.ts", @@ -4573,7 +4606,7 @@ describe("scripts/test-projects full-suite sharding", () => { expect(unitFastPlans.every((plan) => plan.forwardedArgs.length <= 70)).toBe(true); expect(unitFastTargets.length).toBeGreaterThan(1_000); expect(new Set(unitFastTargets).size).toBe(unitFastTargets.length); - expect(unitFastTargets).toContain("extensions/canvas/src/host/server.state-dir.test.ts"); + expect(unitFastTargets).not.toContain("extensions/canvas/src/host/server.state-dir.test.ts"); expect(unitFastTargets).not.toContain("src/utils.test.ts"); const toolingTargets = toolingPlans.flatMap((plan) => plan.forwardedArgs); expect(toolingPlans.length).toBeGreaterThan(1); diff --git a/test/vitest-projects-config.test.ts b/test/vitest-projects-config.test.ts index bce275a2af34..e6de3c96d3d9 100644 --- a/test/vitest-projects-config.test.ts +++ b/test/vitest-projects-config.test.ts @@ -29,6 +29,7 @@ import { import { fullSuiteVitestShards } from "./vitest/vitest.test-shards.mjs"; import { createUiVitestConfig } from "./vitest/vitest.ui.config.ts"; import { createUnitFastFakeTimersVitestConfig } from "./vitest/vitest.unit-fast-fake-timers.config.ts"; +import { createUnitFastIsolatedVitestConfig } from "./vitest/vitest.unit-fast-isolated.config.ts"; import { createUnitFastVitestConfig } from "./vitest/vitest.unit-fast.config.ts"; import { createUnitVitestConfig } from "./vitest/vitest.unit.config.ts"; @@ -237,6 +238,13 @@ describe("projects vitest config", () => { expect(testConfig.runner).toBeUndefined(); }); + it("isolates forced unit-fast files from shared module caches", () => { + const config = createUnitFastIsolatedVitestConfig(); + const testConfig = requireTestConfig(config); + expect(testConfig.isolate).toBe(true); + expect(testConfig.runner).toBeUndefined(); + }); + it("keeps fake-timer unit-fast files serial with the non-isolated runner", () => { const config = createUnitFastFakeTimersVitestConfig(); const testConfig = requireTestConfig(config); diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts index 3aa0a4af937f..aac46d013dd7 100644 --- a/test/vitest-unit-fast-config.test.ts +++ b/test/vitest-unit-fast-config.test.ts @@ -5,18 +5,22 @@ import { spawnNodeEvalSync } from "../src/test-utils/node-process.js"; import { createCommandsLightVitestConfig } from "./vitest/vitest.commands-light.config.ts"; import { createPluginSdkLightVitestConfig } from "./vitest/vitest.plugin-sdk-light.config.ts"; import { createUnitFastFakeTimersVitestConfig } from "./vitest/vitest.unit-fast-fake-timers.config.ts"; +import { createUnitFastIsolatedVitestConfig } from "./vitest/vitest.unit-fast-isolated.config.ts"; import { classifyUnitFastTestFileContent, collectBroadUnitFastTestCandidates, collectUnitFastTestCandidates, collectUnitFastTestFileAnalysis, forcedUnitFastTestFiles, + getUnitFastIsolatedTestFiles, getUnitFastTestFiles, getUnitFastTestFilesForIncludePatterns, getUnitFastTimerTestFiles, isUnitFastTestFile, + isUnitFastIsolatedTestFile, isUnitFastTimerTestFile, resolveUnitFastTestIncludePattern, + resolveUnitFastIsolatedTestIncludePattern, resolveUnitFastTimerTestIncludePattern, } from "./vitest/vitest.unit-fast-paths.mjs"; import { createUnitFastVitestConfig } from "./vitest/vitest.unit-fast.config.ts"; @@ -62,6 +66,7 @@ describe("unit-fast vitest lane", () => { let configProbeResult: ReturnType; let unitFastConfig: ReturnType; let unitFastTestFiles: ReturnType; + let unitFastIsolatedTestFiles: ReturnType; let unitFastTimerTestFiles: ReturnType; let unitFastAnalysis: ReturnType; let broadCandidates: ReturnType; @@ -137,6 +142,7 @@ describe("unit-fast vitest lane", () => { }); unitFastConfig = createUnitFastVitestConfig({}); unitFastTestFiles = getUnitFastTestFiles(); + unitFastIsolatedTestFiles = getUnitFastIsolatedTestFiles(); unitFastTimerTestFiles = getUnitFastTimerTestFiles(); unitFastAnalysis = collectUnitFastTestFileAnalysis(); currentCandidates = collectUnitFastTestCandidates(); @@ -170,30 +176,10 @@ describe("unit-fast vitest lane", () => { expect(testConfig.include).toContain("src/acp/control-plane/runtime-cache.test.ts"); expect(testConfig.include).toContain("src/acp/runtime/registry.test.ts"); expect(testConfig.include).toContain("src/commands/status-overview-values.test.ts"); - expect(testConfig.include).toContain("src/entry.version-fast-path.test.ts"); - expect(testConfig.include).toContain("src/flows/doctor-startup-channel-maintenance.test.ts"); - expect(testConfig.include).toContain("src/system-agent/rescue-policy.test.ts"); - expect(testConfig.include).toContain("src/system-agent/assistant.configured.test.ts"); - expect(testConfig.include).toContain("src/flows/search-setup.test.ts"); expect(testConfig.include).toContain("src/plugins/config-policy.test.ts"); - expect(testConfig.include).toContain("src/proxy-capture/proxy-server.test.ts"); - expect(testConfig.include).toContain("src/talk/agent-consult-tool.test.ts"); expect(testConfig.include).toContain("src/sessions/session-lifecycle-events.test.ts"); - expect(testConfig.include).toContain("src/sessions/transcript-events.test.ts"); - expect(testConfig.include).toContain("src/security/audit-channel-source-config-slack.test.ts"); - expect(testConfig.include).toContain("src/security/audit-config-symlink.test.ts"); - expect(testConfig.include).toContain("src/security/audit-exec-sandbox-host.test.ts"); - expect(testConfig.include).toContain("src/security/audit-gateway.test.ts"); - expect(testConfig.include).toContain("src/security/audit-gateway-auth-selection.test.ts"); - expect(testConfig.include).toContain("src/security/audit-gateway-http-auth.test.ts"); - expect(testConfig.include).toContain("src/security/audit-gateway-tools-http.test.ts"); - expect(testConfig.include).toContain("src/security/audit-plugin-readonly-scope.test.ts"); - expect(testConfig.include).toContain("src/security/audit-loopback-logging.test.ts"); - expect(testConfig.include).toContain("src/video-generation/provider-registry.test.ts"); expect(testConfig.include).toContain("src/plugin-sdk/provider-entry.test.ts"); - expect(testConfig.include).toContain("src/security/dangerous-config-flags.test.ts"); - expect(testConfig.include).toContain("src/security/context-visibility.test.ts"); - expect(testConfig.include).toContain("src/security/safe-regex.test.ts"); + expect(testConfig.include).not.toEqual(expect.arrayContaining(unitFastIsolatedTestFiles)); }); it("does not treat moved config paths as CLI include filters", () => { @@ -237,7 +223,7 @@ describe("unit-fast vitest lane", () => { ); }); - it("routes audited stateful-looking tests through the fast lane", () => { + it("routes audited stateful-looking tests through the isolated fast lane", () => { const forcedFileSet = new Set(forcedUnitFastTestFiles); const forcedAnalysisCount = countMatching(unitFastAnalysis, (entry) => forcedFileSet.has(entry.file), @@ -247,9 +233,39 @@ describe("unit-fast vitest lane", () => { for (const file of forcedUnitFastTestFiles) { expect(unitFastTestFiles).toContain(file); expect(isUnitFastTestFile(file)).toBe(true); + if (unitFastTimerTestFiles.includes(file)) { + expect(unitFastIsolatedTestFiles).not.toContain(file); + } else { + expect(unitFastIsolatedTestFiles).toContain(file); + expect(isUnitFastIsolatedTestFile(file)).toBe(true); + expect(resolveUnitFastTestIncludePattern(file)).toBeNull(); + expect(resolveUnitFastIsolatedTestIncludePattern(file)).toBe(file); + } } const unroutedForcedFiles = collectUnroutedForcedFiles(unitFastAnalysis, forcedFileSet); expect(unroutedForcedFiles).toStrictEqual([]); + + const isolatedConfig = requireTestConfig(createUnitFastIsolatedVitestConfig({})); + expect(isolatedConfig.isolate).toBe(true); + expect(isolatedConfig.runner).toBeUndefined(); + expect(isolatedConfig.include).toEqual(unitFastIsolatedTestFiles); + expect(isolatedConfig.setupFiles).toStrictEqual([ + expect.stringMatching(ENV_ISOLATION_SETUP_PATH), + ]); + }); + + it("isolates tests that import stateful test helpers", () => { + const files = [ + "src/agents/auth-profiles/oauth-refresh-error.test.ts", + "src/auto-reply/reply/agent-runner-execution-runtime.test.ts", + ]; + for (const file of files) { + const analysis = unitFastAnalysis.find((entry) => entry.file === file); + expect(analysis?.reasons).toContain("stateful-test-helper"); + expect(unitFastIsolatedTestFiles).toContain(file); + expect(resolveUnitFastTestIncludePattern(file)).toBeNull(); + expect(resolveUnitFastIsolatedTestIncludePattern(file)).toBe(file); + } }); it("routes fake-timer unit-fast tests through the serial fake-timer lane", () => { @@ -265,8 +281,10 @@ describe("unit-fast vitest lane", () => { } const fastConfig = requireTestConfig(unitFastConfig); + const isolatedConfig = requireTestConfig(createUnitFastIsolatedVitestConfig({})); const timerConfig = requireTestConfig(createUnitFastFakeTimersVitestConfig({})); expect(fastConfig.include).not.toEqual(expect.arrayContaining(unitFastTimerTestFiles)); + expect(isolatedConfig.include).not.toEqual(expect.arrayContaining(unitFastTimerTestFiles)); expect(timerConfig.include).toEqual(unitFastTimerTestFiles); expect(timerConfig.fileParallelism).toBe(false); expect(timerConfig.maxWorkers).toBe(1); diff --git a/test/vitest/vitest.config.ts b/test/vitest/vitest.config.ts index c3201f03928a..a8a90b813390 100644 --- a/test/vitest/vitest.config.ts +++ b/test/vitest/vitest.config.ts @@ -40,6 +40,7 @@ export const rootVitestProjects = [ "test/vitest/vitest.daemon.config.ts", "test/vitest/vitest.media.config.ts", "test/vitest/vitest.unit-fast.config.ts", + "test/vitest/vitest.unit-fast-isolated.config.ts", "test/vitest/vitest.unit-fast-fake-timers.config.ts", "test/vitest/vitest.plugin-sdk-light.config.ts", "test/vitest/vitest.plugin-sdk.config.ts", diff --git a/test/vitest/vitest.full-core-unit-fast.config.ts b/test/vitest/vitest.full-core-unit-fast.config.ts index 2ada04232dd3..897128213e58 100644 --- a/test/vitest/vitest.full-core-unit-fast.config.ts +++ b/test/vitest/vitest.full-core-unit-fast.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ runner: undefined, projects: [ "test/vitest/vitest.unit-fast.config.ts", + "test/vitest/vitest.unit-fast-isolated.config.ts", "test/vitest/vitest.unit-fast-fake-timers.config.ts", ], }, diff --git a/test/vitest/vitest.test-shards.mjs b/test/vitest/vitest.test-shards.mjs index 646e50bea1c2..52dbda6b6233 100644 --- a/test/vitest/vitest.test-shards.mjs +++ b/test/vitest/vitest.test-shards.mjs @@ -16,6 +16,7 @@ export const fullSuiteVitestShards = [ name: "core-unit-fast", projects: [ "test/vitest/vitest.unit-fast.config.ts", + "test/vitest/vitest.unit-fast-isolated.config.ts", "test/vitest/vitest.unit-fast-fake-timers.config.ts", ], }, diff --git a/test/vitest/vitest.unit-fast-isolated.config.ts b/test/vitest/vitest.unit-fast-isolated.config.ts new file mode 100644 index 000000000000..d3b6ab66d358 --- /dev/null +++ b/test/vitest/vitest.unit-fast-isolated.config.ts @@ -0,0 +1,32 @@ +// Vitest unit fast isolated config wires audited stateful tests out of shared module caches. +import { defineConfig } from "vitest/config"; +import { loadPatternListFromEnv, narrowIncludePatternsForCli } from "./vitest.pattern-file.ts"; +import { resolveRepoRootPath, sharedVitestConfig } from "./vitest.shared.config.ts"; +import { getUnitFastIsolatedTestFiles } from "./vitest.unit-fast-paths.mjs"; + +export function createUnitFastIsolatedVitestConfig( + env: Record = process.env, + options: { argv?: string[] } = {}, +) { + const sharedTest = sharedVitestConfig.test ?? {}; + const includeFromEnv = loadPatternListFromEnv("OPENCLAW_VITEST_INCLUDE_FILE", env); + const isolatedTestFiles = getUnitFastIsolatedTestFiles(); + const cliInclude = narrowIncludePatternsForCli(isolatedTestFiles, options.argv); + + return defineConfig({ + ...sharedVitestConfig, + test: { + ...sharedTest, + name: "unit-fast-isolated", + // Forced stateful tests can mock modules imported by later files, so each gets a fresh graph. + isolate: true, + runner: undefined, + setupFiles: [resolveRepoRootPath("test/setup.env.ts")], + include: includeFromEnv ?? cliInclude ?? isolatedTestFiles, + exclude: sharedTest.exclude ?? [], + passWithNoTests: true, + }, + }); +} + +export default createUnitFastIsolatedVitestConfig(); diff --git a/test/vitest/vitest.unit-fast-paths.d.mts b/test/vitest/vitest.unit-fast-paths.d.mts index 4f991d61f09e..a556ca9aed96 100644 --- a/test/vitest/vitest.unit-fast-paths.d.mts +++ b/test/vitest/vitest.unit-fast-paths.d.mts @@ -22,8 +22,11 @@ export function getUnitFastTestFilesForIncludePatterns( options?: { dir?: string }, ): string[]; export function getUnitFastTestFiles(): string[]; +export function getUnitFastIsolatedTestFiles(): string[]; export function getUnitFastTimerTestFiles(): string[]; export function isUnitFastTestFile(file: string): boolean; +export function isUnitFastIsolatedTestFile(file: string): boolean; export function isUnitFastTimerTestFile(file: string): boolean; export function resolveUnitFastTestIncludePattern(file: string): string | null; +export function resolveUnitFastIsolatedTestIncludePattern(file: string): string | null; export function resolveUnitFastTimerTestIncludePattern(file: string): string | null; diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index e0dcdf0c8460..3ad98109b0e3 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -306,6 +306,42 @@ const disqualifyingPatterns = [ }, ]; +const statefulTestHelperImportPattern = + /\bfrom\s+["']([^"']*(?:test-support|\.harness)(?:\.js|\.ts)?)["']/gu; +const statefulTestHelperByKey = new Map(); + +function importsStatefulTestHelper(cwd, file, source) { + for (const match of source.matchAll(statefulTestHelperImportPattern)) { + const specifier = match[1]; + if (!specifier.startsWith(".")) { + continue; + } + const helperPath = path.join( + path.dirname(file), + specifier.endsWith(".js") + ? `${specifier.slice(0, -3)}.ts` + : specifier.endsWith(".ts") + ? specifier + : `${specifier}.ts`, + ); + const cacheKey = `${normalizeRepoPath(cwd)}\0${normalizeRepoPath(helperPath)}`; + let stateful = statefulTestHelperByKey.get(cacheKey); + if (stateful === undefined) { + try { + const helperSource = fs.readFileSync(path.join(cwd, helperPath), "utf8"); + stateful = classifyUnitFastTestFileContent(helperSource).length > 0; + } catch { + stateful = false; + } + statefulTestHelperByKey.set(cacheKey, stateful); + } + if (stateful) { + return true; + } + } + return false; +} + function matchesAnyGlob(file, patterns) { return patterns.some((pattern) => path.matchesGlob(file, pattern)); } @@ -504,10 +540,18 @@ function analyzeUnitFastTestFile(cwd, file) { try { const source = fs.readFileSync(path.join(cwd, file), "utf8"); const reasons = classifyUnitFastTestFileContent(source); + if (importsStatefulTestHelper(cwd, file, source)) { + // The helper executes in the importing file's module scope, so its mocks and + // singleton mutations need the same isolation as stateful code in the test itself. + reasons.push("stateful-test-helper"); + } const forced = forcedUnitFastTestFileSet.has(file); analysis = { file, - unitFast: forced || reasons.length === 0, + unitFast: + forced || + reasons.length === 0 || + reasons.every((reason) => reason === "stateful-test-helper"), forced, reasons, }; @@ -541,6 +585,8 @@ export function collectUnitFastTestFileAnalysis(cwd = process.cwd(), options = { let cachedUnitFastTestFiles = null; let cachedUnitFastTestFileSet = null; +let cachedUnitFastIsolatedTestFiles = null; +let cachedUnitFastIsolatedTestFileSet = null; let cachedUnitFastTimerTestFiles = null; let cachedUnitFastTimerTestFileSet = null; const scopedUnitFastTestFilesByKey = new Map(); @@ -607,6 +653,22 @@ export function getUnitFastTimerTestFiles() { return cachedUnitFastTimerTestFiles; } +export function getUnitFastIsolatedTestFiles() { + if (cachedUnitFastIsolatedTestFiles !== null) { + return cachedUnitFastIsolatedTestFiles; + } + const timerTestFiles = new Set(getUnitFastTimerTestFiles()); + cachedUnitFastIsolatedTestFiles = collectUnitFastTestFileAnalysis() + .filter( + (entry) => + entry.unitFast && + !timerTestFiles.has(entry.file) && + (entry.forced || entry.reasons.includes("stateful-test-helper")), + ) + .map((entry) => entry.file); + return cachedUnitFastIsolatedTestFiles; +} + function getUnitFastTestFileSet() { if (cachedUnitFastTestFileSet !== null) { return cachedUnitFastTestFileSet; @@ -623,6 +685,14 @@ function getUnitFastTimerTestFileSet() { return cachedUnitFastTimerTestFileSet; } +function getUnitFastIsolatedTestFileSet() { + if (cachedUnitFastIsolatedTestFileSet !== null) { + return cachedUnitFastIsolatedTestFileSet; + } + cachedUnitFastIsolatedTestFileSet = new Set(getUnitFastIsolatedTestFiles()); + return cachedUnitFastIsolatedTestFileSet; +} + function isUnitFastTestFileOnDemand(file, cwd = process.cwd()) { const normalized = normalizeRepoPath(file); if (!isUnitFastCandidateFile(normalized)) { @@ -639,11 +709,18 @@ export function isUnitFastTimerTestFile(file) { return getUnitFastTimerTestFileSet().has(normalizeRepoPath(file)); } +export function isUnitFastIsolatedTestFile(file) { + return getUnitFastIsolatedTestFileSet().has(normalizeRepoPath(file)); +} + export function resolveUnitFastTestIncludePattern(file) { const normalized = normalizeRepoPath(file); if (isUnitFastTimerTestFile(normalized)) { return null; } + if (isUnitFastIsolatedTestFile(normalized)) { + return null; + } if (isUnitFastTestFileOnDemand(normalized)) { return normalized; } @@ -651,6 +728,9 @@ export function resolveUnitFastTestIncludePattern(file) { if (isUnitFastTimerTestFile(siblingTestFile)) { return null; } + if (isUnitFastIsolatedTestFile(siblingTestFile)) { + return null; + } if (isUnitFastTestFileOnDemand(siblingTestFile)) { return siblingTestFile; } @@ -669,3 +749,12 @@ export function resolveUnitFastTimerTestIncludePattern(file) { const siblingTestFile = normalized.replace(/\.ts$/u, ".test.ts"); return isUnitFastTimerTestFile(siblingTestFile) ? siblingTestFile : null; } + +export function resolveUnitFastIsolatedTestIncludePattern(file) { + const normalized = normalizeRepoPath(file); + if (isUnitFastIsolatedTestFile(normalized)) { + return normalized; + } + const siblingTestFile = normalized.replace(/\.ts$/u, ".test.ts"); + return isUnitFastIsolatedTestFile(siblingTestFile) ? siblingTestFile : null; +} diff --git a/test/vitest/vitest.unit-fast.config.ts b/test/vitest/vitest.unit-fast.config.ts index 5ae8978b47da..b048ddb19e2a 100644 --- a/test/vitest/vitest.unit-fast.config.ts +++ b/test/vitest/vitest.unit-fast.config.ts @@ -2,7 +2,11 @@ import { defineConfig } from "vitest/config"; import { loadPatternListFromEnv, narrowIncludePatternsForCli } from "./vitest.pattern-file.ts"; import { resolveRepoRootPath, sharedVitestConfig } from "./vitest.shared.config.ts"; -import { getUnitFastTestFiles, getUnitFastTimerTestFiles } from "./vitest.unit-fast-paths.mjs"; +import { + getUnitFastIsolatedTestFiles, + getUnitFastTestFiles, + getUnitFastTimerTestFiles, +} from "./vitest.unit-fast-paths.mjs"; export function createUnitFastVitestConfig( env: Record = process.env, @@ -11,7 +15,10 @@ export function createUnitFastVitestConfig( const sharedTest = sharedVitestConfig.test ?? {}; const includeFromEnv = loadPatternListFromEnv("OPENCLAW_VITEST_INCLUDE_FILE", env); const timerTestFiles = new Set(getUnitFastTimerTestFiles()); - const unitFastTestFiles = getUnitFastTestFiles().filter((file) => !timerTestFiles.has(file)); + const isolatedTestFiles = new Set(getUnitFastIsolatedTestFiles()); + const unitFastTestFiles = getUnitFastTestFiles().filter( + (file) => !timerTestFiles.has(file) && !isolatedTestFiles.has(file), + ); const cliInclude = narrowIncludePatternsForCli(unitFastTestFiles, options.argv); return defineConfig({