mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
perf(test): scope unit-fast discovery
This commit is contained in:
@@ -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({});
|
||||
|
||||
@@ -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<string, string | undefined>) {
|
||||
return createScopedVitestConfig(commandsLightTestFiles, {
|
||||
dir: "src/commands",
|
||||
env,
|
||||
exclude: getUnitFastTestFiles(),
|
||||
includeOpenClawRuntimeSetup: false,
|
||||
name: "commands-light",
|
||||
passWithNoTests: true,
|
||||
|
||||
@@ -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<string, string | undefined>) {
|
||||
return createScopedVitestConfig(pluginSdkLightTestFiles, {
|
||||
dir: "src",
|
||||
env,
|
||||
exclude: getUnitFastTestFiles(),
|
||||
includeOpenClawRuntimeSetup: false,
|
||||
name: "plugin-sdk-light",
|
||||
passWithNoTests: true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string | undefined>) {
|
||||
return createScopedVitestConfig(["src/shared/**/*.test.ts"], {
|
||||
dir: "src",
|
||||
env,
|
||||
exclude: getUnitFastTestFiles(),
|
||||
includeOpenClawRuntimeSetup: false,
|
||||
name: "shared-core",
|
||||
passWithNoTests: true,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, string | undefined>) {
|
||||
return createScopedVitestConfig(["src/utils/**/*.test.ts"], {
|
||||
dir: "src",
|
||||
env,
|
||||
exclude: getUnitFastTestFiles(),
|
||||
includeOpenClawRuntimeSetup: false,
|
||||
name: "utils",
|
||||
passWithNoTests: true,
|
||||
|
||||
Reference in New Issue
Block a user