From bd9b1b428c365d890a0c155ee5fe62bada03c9ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 11:53:30 -0400 Subject: [PATCH] perf(test): scope unit-fast discovery --- test/vitest-unit-fast-config.test.ts | 104 ++++++++++++- test/vitest/vitest.commands-light.config.ts | 2 - test/vitest/vitest.plugin-sdk-light.config.ts | 2 - test/vitest/vitest.scoped-config.ts | 10 +- test/vitest/vitest.shared-core.config.ts | 2 - test/vitest/vitest.unit-fast-paths.mjs | 140 ++++++++++++++++-- test/vitest/vitest.utils.config.ts | 2 - 7 files changed, 232 insertions(+), 30 deletions(-) diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts index c09236722d6b..e71f76989244 100644 --- a/test/vitest-unit-fast-config.test.ts +++ b/test/vitest-unit-fast-config.test.ts @@ -1,4 +1,5 @@ // Vitest unit-fast config tests validate fast unit test project setup. +import path from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { spawnNodeEvalSync } from "../src/test-utils/node-process.js"; import { createCommandsLightVitestConfig } from "./vitest/vitest.commands-light.config.ts"; @@ -11,6 +12,7 @@ import { collectUnitFastTestFileAnalysis, forcedUnitFastTestFiles, getUnitFastTestFiles, + getUnitFastTestFilesForIncludePatterns, getUnitFastTimerTestFiles, isUnitFastTestFile, isUnitFastTimerTestFile, @@ -68,15 +70,60 @@ describe("unit-fast vitest lane", () => { beforeAll(() => { const script = ` + import childProcess from "node:child_process"; import fs from "node:fs"; + import { syncBuiltinESMExports } from "node:module"; + let gitLsFilesCalls = 0; + const originalSpawnSync = childProcess.spawnSync; + childProcess.spawnSync = function patchedSpawnSync(...args) { + const [command, commandArgs] = args; + if (command === "git" && commandArgs?.[0] === "ls-files") { + gitLsFilesCalls += 1; + const stdout = [ + "src/agents/agent-tools.deferred-followup-guidance.test.ts", + "src/hooks/frontmatter.test.ts", + ].join("\\n") + "\\n"; + return { + pid: 0, + output: [null, stdout, ""], + stdout, + stderr: "", + status: 0, + signal: null, + }; + } + return originalSpawnSync.apply(this, args); + }; + syncBuiltinESMExports(); let readdirSyncCalls = 0; + let hookFileReads = 0; + let outsideFileReads = 0; const originalReaddirSync = fs.readdirSync; + const originalReadFileSync = fs.readFileSync; fs.readdirSync = function patchedReaddirSync(...args) { readdirSyncCalls += 1; return originalReaddirSync.apply(this, args); }; + fs.readFileSync = function patchedReadFileSync(...args) { + const file = String(args[0]).replaceAll("\\\\", "/"); + if (file.endsWith("/src/hooks/frontmatter.test.ts")) { + hookFileReads += 1; + } else if (file.endsWith("/src/agents/agent-tools.deferred-followup-guidance.test.ts")) { + outsideFileReads += 1; + } + return originalReadFileSync.apply(this, args); + }; + await import("./test/vitest/vitest.hooks.config.ts?scope-probe=" + Date.now()); + const scopedHookFileReads = hookFileReads; + const scopedOutsideFileReads = outsideFileReads; await import("./test/vitest/vitest.unit-fast.config.ts?io-probe=" + Date.now()); - console.log(readdirSyncCalls); + console.log( + "UNIT_FAST_IO_PROBE", + gitLsFilesCalls, + readdirSyncCalls, + scopedHookFileReads, + scopedOutsideFileReads, + ); `; configProbeResult = spawnNodeEvalSync(script, { env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" }, @@ -94,13 +141,14 @@ describe("unit-fast vitest lane", () => { it("loads the config without recursively walking repo roots", () => { expect(configProbeResult.status, configProbeResult.stderr).toBe(0); - const numericOutputLines = configProbeResult.stdout - .trim() - .split(/\r?\n/u) - .map((line) => Number(line.trim())) - .filter(Number.isFinite); - expect(numericOutputLines.length, configProbeResult.stdout).toBeGreaterThan(0); - expect(numericOutputLines.at(-1)).toBeLessThan(20); + const probeMatch = configProbeResult.stdout.match( + /UNIT_FAST_IO_PROBE (\d+) (\d+) (\d+) (\d+)/u, + ); + expect(probeMatch, configProbeResult.stdout).not.toBeNull(); + expect(Number(probeMatch?.[1])).toBe(1); + expect(Number(probeMatch?.[2])).toBeLessThan(20); + expect(Number(probeMatch?.[3])).toBe(1); + expect(Number(probeMatch?.[4])).toBe(0); }); it("runs cache-friendly tests without the reset-heavy runner or runtime setup", () => { @@ -225,6 +273,46 @@ describe("unit-fast vitest lane", () => { ); }); + it("keeps scoped unit-fast exclusions equivalent to the full inventory", () => { + const cases = [ + { dir: "src/hooks", patterns: ["src/hooks/**/*.test.ts"] }, + { dir: "src", patterns: ["src/agents/*/**/*.test.ts"] }, + { dir: "src/acp", patterns: ["src/acp/client.test.ts"] }, + { dir: "extensions", patterns: ["extensions/**/*.test.ts"] }, + { dir: undefined, patterns: ["test/**/*.test.ts"] }, + { dir: undefined, patterns: ["src/{hooks,infra}/**/*.test.ts"] }, + { dir: "src", patterns: [] }, + ]; + + for (const { dir, patterns } of cases) { + const prefix = dir ? `${dir}/` : ""; + const expected = unitFastTestFiles.filter((file) => { + if (prefix && !file.startsWith(prefix)) { + return false; + } + return patterns.some((pattern) => path.matchesGlob(file, pattern)); + }); + expect(getUnitFastTestFilesForIncludePatterns(patterns, { dir })).toEqual(expected); + } + + const extensionUnitFastFiles = getUnitFastTestFilesForIncludePatterns( + ["extensions/**/*.test.ts"], + { dir: "extensions" }, + ); + expect(getUnitFastTestFilesForIncludePatterns(["**/*.test.ts"], { dir: "extensions" })).toEqual( + extensionUnitFastFiles, + ); + expect(extensionUnitFastFiles).toEqual( + expect.arrayContaining([ + "extensions/canvas/src/host/server.test.ts", + "extensions/canvas/src/host/server.state-dir.test.ts", + ]), + ); + expect(getUnitFastTestFilesForIncludePatterns(["!src/**/*.test.ts"])).toEqual( + unitFastTestFiles, + ); + }); + it("excludes unit-fast files from the older light lanes so full runs do not duplicate them", () => { const pluginSdkLight = createPluginSdkLightVitestConfig({}); const commandsLight = createCommandsLightVitestConfig({}); diff --git a/test/vitest/vitest.commands-light.config.ts b/test/vitest/vitest.commands-light.config.ts index 19c6323f5c24..6787d2ee7525 100644 --- a/test/vitest/vitest.commands-light.config.ts +++ b/test/vitest/vitest.commands-light.config.ts @@ -1,13 +1,11 @@ // Vitest commands light config wires the commands light test shard. import { commandsLightTestFiles } from "./vitest.commands-light-paths.mjs"; import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -import { getUnitFastTestFiles } from "./vitest.unit-fast-paths.mjs"; export function createCommandsLightVitestConfig(env?: Record) { return createScopedVitestConfig(commandsLightTestFiles, { dir: "src/commands", env, - exclude: getUnitFastTestFiles(), includeOpenClawRuntimeSetup: false, name: "commands-light", passWithNoTests: true, diff --git a/test/vitest/vitest.plugin-sdk-light.config.ts b/test/vitest/vitest.plugin-sdk-light.config.ts index d24cd6523b8f..d359204c2697 100644 --- a/test/vitest/vitest.plugin-sdk-light.config.ts +++ b/test/vitest/vitest.plugin-sdk-light.config.ts @@ -1,13 +1,11 @@ // Vitest plugin sdk light config wires the plugin sdk light test shard. import { pluginSdkLightTestFiles } from "./vitest.plugin-sdk-paths.mjs"; import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -import { getUnitFastTestFiles } from "./vitest.unit-fast-paths.mjs"; export function createPluginSdkLightVitestConfig(env?: Record) { return createScopedVitestConfig(pluginSdkLightTestFiles, { dir: "src", env, - exclude: getUnitFastTestFiles(), includeOpenClawRuntimeSetup: false, name: "plugin-sdk-light", passWithNoTests: true, diff --git a/test/vitest/vitest.scoped-config.ts b/test/vitest/vitest.scoped-config.ts index 35f8f42f1be6..8070017ac5f9 100644 --- a/test/vitest/vitest.scoped-config.ts +++ b/test/vitest/vitest.scoped-config.ts @@ -8,7 +8,7 @@ import { resolveRepoRootPath, sharedVitestConfig, } from "./vitest.shared.config.ts"; -import { getUnitFastTestFiles } from "./vitest.unit-fast-paths.mjs"; +import { getUnitFastTestFilesForIncludePatterns } from "./vitest.unit-fast-paths.mjs"; function normalizePathPattern(value: string): string { return value.replaceAll("\\", "/"); @@ -219,8 +219,12 @@ export function createScopedVitestConfig( const cliInclude = narrowIncludePatternsForCli(include, options?.argv, { scopedDir, }); + const effectiveInclude = includeFromEnv ?? cliInclude ?? include; + const scopedInclude = relativizeScopedPatterns(effectiveInclude, scopedDir); const unitFastExcludePatterns = - options?.excludeUnitFastTests === false ? [] : getUnitFastTestFiles(); + options?.excludeUnitFastTests === false + ? [] + : getUnitFastTestFilesForIncludePatterns(effectiveInclude, { dir: scopedDir }); const exclude = relativizeScopedPatterns( [...(baseTest.exclude ?? []), ...unitFastExcludePatterns, ...(options?.exclude ?? [])], scopedDir, @@ -249,7 +253,7 @@ export function createScopedVitestConfig( ...(runner ? { runner } : { runner: undefined }), setupFiles, ...(resolvedScopedDir ? { dir: resolvedScopedDir } : {}), - include: relativizeScopedPatterns(includeFromEnv ?? cliInclude ?? include, scopedDir), + include: scopedInclude, exclude, ...(options?.pool ? { pool: options.pool } : {}), ...(options?.fileParallelism === undefined diff --git a/test/vitest/vitest.shared-core.config.ts b/test/vitest/vitest.shared-core.config.ts index 7eacf2ca4ebf..636f4b8692e2 100644 --- a/test/vitest/vitest.shared-core.config.ts +++ b/test/vitest/vitest.shared-core.config.ts @@ -1,12 +1,10 @@ // Vitest shared core config wires the shared core test shard. import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -import { getUnitFastTestFiles } from "./vitest.unit-fast-paths.mjs"; export function createSharedCoreVitestConfig(env?: Record) { return createScopedVitestConfig(["src/shared/**/*.test.ts"], { dir: "src", env, - exclude: getUnitFastTestFiles(), includeOpenClawRuntimeSetup: false, name: "shared-core", passWithNoTests: true, diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index cdb2c5ead673..2d5bb1178648 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -300,13 +300,21 @@ function matchesAnyGlob(file, patterns) { return patterns.some((pattern) => path.matchesGlob(file, pattern)); } +const unitFastCandidateFileByPath = new Map(); + function isUnitFastCandidateFile(file) { - return ( + const cached = unitFastCandidateFileByPath.get(file); + if (cached !== undefined) { + return cached; + } + const candidate = forcedUnitFastTestFileSet.has(file) || unitFastCandidateExactFileSet.has(file) || (matchesAnyGlob(file, unitFastCandidateGlobs) && - !matchesAnyGlob(file, broadUnitFastCandidateSkipGlobs)) - ); + !matchesAnyGlob(file, broadUnitFastCandidateSkipGlobs)); + // Candidate rules are static for the process lifetime; scoped configs overlap heavily. + unitFastCandidateFileByPath.set(file, candidate); + return candidate; } function walkFiles(directory, files = []) { @@ -365,6 +373,68 @@ function collectRepoTestFiles(cwd) { return files; } +const unitFastCandidateInventoryByCwd = new Map(); + +function collectUnitFastCandidateInventory(cwd) { + const cacheKey = normalizeRepoPath(cwd); + const cached = unitFastCandidateInventoryByCwd.get(cacheKey); + if (cached) { + return cached; + } + const inventory = [ + ...new Set([ + ...collectRepoTestFiles(cwd), + ...unitFastCandidateExactFiles, + ...forcedUnitFastTestFiles, + ]), + ]; + // Git inventory and routing constants are stable for the lifetime of a config process. + unitFastCandidateInventoryByCwd.set(cacheKey, inventory); + return inventory; +} + +function normalizeScopedDir(dir) { + const normalized = normalizeRepoPath(dir ?? "").replace(/^\.\/+|\/+$/gu, ""); + return normalized === "." ? "" : normalized; +} + +function hasRepoRootPrefix(value) { + return /^(?:apps|extensions|packages|src|test|ui)(?:\/|$)/u.test(value); +} + +function cannotSafelyNarrowIncludePattern(value, dir) { + return ( + value.startsWith("!") || + value.startsWith("./") || + /^(?:[A-Za-z]:\/|\/)/u.test(value) || + /(?:^|\/)\.\.(?:\/|$)/u.test(value) || + (Boolean(dir) && /^[{[(]/u.test(value)) + ); +} + +function anchorScopedIncludePattern(value, dir) { + const normalized = normalizeRepoPath(value); + if (!dir || hasRepoRootPrefix(normalized)) { + return normalized; + } + return `${dir}/${normalized}`; +} + +function isFileWithinScope(file, dir) { + return !dir || file.startsWith(`${dir}/`); +} + +function literalGlobPrefix(pattern) { + const dynamicIndex = pattern.search(/[!?*[{(@+]/u); + return dynamicIndex < 0 ? pattern : pattern.slice(0, dynamicIndex); +} + +function matchesCompiledInclude(file, compiledPatterns) { + return compiledPatterns.some( + ({ pattern, prefix }) => file.startsWith(prefix) && path.matchesGlob(file, pattern), + ); +} + export function classifyUnitFastTestFileContent(source) { const reasons = []; for (const { code, pattern } of disqualifyingPatterns) { @@ -384,14 +454,19 @@ function collectUnitFastCandidates(cwd, scope) { return cached; } const broad = scope === "broad"; - const discovered = collectRepoTestFiles(cwd).filter( - (file) => - matchesAnyGlob(file, broad ? broadUnitFastCandidateGlobs : unitFastCandidateGlobs) && - !matchesAnyGlob(file, broadUnitFastCandidateSkipGlobs), - ); - const candidates = [ - ...new Set([...discovered, ...unitFastCandidateExactFiles, ...forcedUnitFastTestFiles]), - ].toSorted((a, b) => a.localeCompare(b)); + const candidates = collectUnitFastCandidateInventory(cwd) + .filter((file) => { + if (!broad) { + return isUnitFastCandidateFile(file); + } + return ( + forcedUnitFastTestFileSet.has(file) || + unitFastCandidateExactFileSet.has(file) || + (matchesAnyGlob(file, broadUnitFastCandidateGlobs) && + !matchesAnyGlob(file, broadUnitFastCandidateSkipGlobs)) + ); + }) + .toSorted((a, b) => a.localeCompare(b)); // Candidate discovery is immutable for the lifetime of a Vitest/audit process. unitFastCandidatesByKey.set(cacheKey, candidates); return candidates; @@ -458,6 +533,49 @@ let cachedUnitFastTestFiles = null; let cachedUnitFastTestFileSet = null; let cachedUnitFastTimerTestFiles = null; let cachedUnitFastTimerTestFileSet = null; +const scopedUnitFastTestFilesByKey = new Map(); + +export function getUnitFastTestFilesForIncludePatterns(includePatterns, options = {}) { + const cwd = process.cwd(); + const normalizedCwd = normalizeRepoPath(cwd); + const dir = normalizeScopedDir(options.dir); + const normalizedPatterns = includePatterns.map(normalizeRepoPath); + if (normalizedPatterns.some((pattern) => cannotSafelyNarrowIncludePattern(pattern, dir))) { + // Keep the former full exclusion list when Vitest syntax cannot be safely mapped to repo paths. + return getUnitFastTestFiles(); + } + const patterns = [ + ...new Set(normalizedPatterns.map((pattern) => anchorScopedIncludePattern(pattern, dir))), + ].toSorted(); + const cacheKey = JSON.stringify([normalizedCwd, dir, patterns]); + const cached = scopedUnitFastTestFilesByKey.get(cacheKey); + if (cached) { + return cached; + } + if (patterns.length === 0) { + scopedUnitFastTestFilesByKey.set(cacheKey, []); + return []; + } + const compiledPatterns = patterns.map((pattern) => ({ + pattern, + prefix: literalGlobPrefix(pattern), + })); + + const files = collectUnitFastCandidateInventory(cwd) + .filter((file) => { + return ( + isFileWithinScope(file, dir) && + matchesCompiledInclude(file, compiledPatterns) && + isUnitFastCandidateFile(file) + ); + }) + .toSorted((a, b) => a.localeCompare(b)) + .filter((file) => analyzeUnitFastTestFile(cwd, file).unitFast); + + // Scoped discovery is a process-start snapshot, matching the full unit-fast inventory cache. + scopedUnitFastTestFilesByKey.set(cacheKey, files); + return files; +} export function getUnitFastTestFiles() { if (cachedUnitFastTestFiles !== null) { diff --git a/test/vitest/vitest.utils.config.ts b/test/vitest/vitest.utils.config.ts index 04a47a03e8d0..5c2ba265c59b 100644 --- a/test/vitest/vitest.utils.config.ts +++ b/test/vitest/vitest.utils.config.ts @@ -1,12 +1,10 @@ // Vitest utils config wires the utils test shard. import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -import { getUnitFastTestFiles } from "./vitest.unit-fast-paths.mjs"; export function createUtilsVitestConfig(env?: Record) { return createScopedVitestConfig(["src/utils/**/*.test.ts"], { dir: "src", env, - exclude: getUnitFastTestFiles(), includeOpenClawRuntimeSetup: false, name: "utils", passWithNoTests: true,