Files
openclaw/test/vitest-unit-fast-config.test.ts
weiqinl a29d8c8803 test(unit-fast): isolate computer-tool.test-helpers consumers (#125167)
* test(unit-fast): isolate computer-tool.test-helpers consumers

The unit-fast shard classifier recognizes "stateful" test helpers
(those whose source trips a disqualifying pattern such as `vi.mock(`
or top-level dynamic `import`) via `statefulTestHelperImportPattern`,
so that tests importing them are routed to the isolated pool instead
of the shared `isolate:false` pool.

`computer-tool.test-helpers` was missing from that pattern, even though
the helper sets up `vi.mock("./gateway.js")`, `vi.mock("./nodes-utils.js")`
and uses top-level `await import(...)` — i.e. it is stateful. Its
consumers (`computer-tool.v2/schema/context.test.ts`) therefore
classified with empty `reasons`, fell into the `isolate:false` pool,
and relied on `vi.mock` against a shared module registry. When another
test file loaded the real `./gateway.js` / `./nodes-utils.js` in the
same worker first, the mock was bypassed and `computer-tool.v2.test.ts`
threw `GatewayCredentialsRequiredError: gateway node.list requires
credentials` for all 8 of its cases — while passing in isolation.

Add `computer-tool\.test-helpers` to the pattern (mirroring the existing
`message-action-runner\.test-helpers` entry) so consumers are routed to
the isolated pool where the mock is honored.

Verified on `main` (`83d279a`), Node 24.15.0:
- Before: `pnpm test:unit:fast` -> 8 failures in
  `src/agents/tools/computer-tool.v2.test.ts` (`GatewayCredentialsRequiredError`);
  the file passes alone (8/8).
- After: the three consumers classify `inIsolated=true`; `pnpm test:unit:fast`
  no longer runs them in the shared pool; the suite's only remaining
  failures are unrelated (`test/ui.presenter-next-run.test.ts` locale,
  `test/scripts/resolve-openclaw-ref.test.ts` git version) and the
  computer-tool suite passes (16/16) under `vitest --isolate`.

AI-assisted.

* test(unit-fast): cover computer-tool helper isolation

Punchcard-Session: ember-willow-valley-9r

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-08-20 01:17:25 -07:00

405 lines
18 KiB
TypeScript

// 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 { cliProcessTestFiles } from "./vitest/vitest.cli-process-paths.mjs";
import { createCommandsLightVitestConfig } from "./vitest/vitest.commands-light.config.ts";
import { createContractsPluginVitestConfig } from "./vitest/vitest.contracts-plugin.config.ts";
import { pluginContractPatterns } from "./vitest/vitest.contracts-shared.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";
const ENV_ISOLATION_SETUP_PATH = /[\\/]test[\\/]setup\.env\.ts$/u;
function requireTestConfig<T extends { test?: unknown }>(config: T): NonNullable<T["test"]> {
if (!config.test) {
throw new Error("expected unit-fast vitest test config");
}
return config.test as NonNullable<T["test"]>;
}
function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
let count = 0;
for (const item of items) {
if (predicate(item)) {
count += 1;
}
}
return count;
}
type UnitFastAnalysisEntry = ReturnType<typeof collectUnitFastTestFileAnalysis>[number];
function collectUnroutedForcedFiles(
analysis: readonly UnitFastAnalysisEntry[],
forcedFiles: ReadonlySet<string>,
): Array<{ file: string; forced: boolean; unitFast: boolean }> {
const unrouted: Array<{ file: string; forced: boolean; unitFast: boolean }> = [];
for (const entry of analysis) {
if (!forcedFiles.has(entry.file)) {
continue;
}
if (!entry.forced || !entry.unitFast) {
unrouted.push({ file: entry.file, forced: entry.forced, unitFast: entry.unitFast });
}
}
return unrouted;
}
describe("unit-fast vitest lane", () => {
let configProbeResult: ReturnType<typeof spawnNodeEvalSync>;
let unitFastConfig: ReturnType<typeof createUnitFastVitestConfig>;
let unitFastTestFiles: ReturnType<typeof getUnitFastTestFiles>;
let unitFastIsolatedTestFiles: ReturnType<typeof getUnitFastIsolatedTestFiles>;
let unitFastTimerTestFiles: ReturnType<typeof getUnitFastTimerTestFiles>;
let unitFastAnalysis: ReturnType<typeof collectUnitFastTestFileAnalysis>;
let broadCandidates: ReturnType<typeof collectBroadUnitFastTestCandidates>;
let broadAnalysis: ReturnType<typeof collectUnitFastTestFileAnalysis>;
let currentCandidates: ReturnType<typeof collectUnitFastTestCandidates>;
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(
"UNIT_FAST_IO_PROBE",
gitLsFilesCalls,
readdirSyncCalls,
scopedHookFileReads,
scopedOutsideFileReads,
);
`;
configProbeResult = spawnNodeEvalSync(script, {
env: {
...process.env,
FORCE_COLOR: "0",
NO_COLOR: "1",
OPENCLAW_VITEST_INCLUDE_FILE: undefined,
},
evalFlag: "-e",
imports: ["tsx"],
});
unitFastConfig = createUnitFastVitestConfig({});
unitFastTestFiles = getUnitFastTestFiles();
unitFastIsolatedTestFiles = getUnitFastIsolatedTestFiles();
unitFastTimerTestFiles = getUnitFastTimerTestFiles();
unitFastAnalysis = collectUnitFastTestFileAnalysis();
currentCandidates = collectUnitFastTestCandidates();
broadCandidates = collectBroadUnitFastTestCandidates();
broadAnalysis = collectUnitFastTestFileAnalysis(process.cwd(), { scope: "broad" });
});
it("loads the config without recursively walking repo roots", () => {
expect(configProbeResult.status, configProbeResult.stderr).toBe(0);
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", () => {
const testConfig = requireTestConfig(unitFastConfig);
expect(testConfig.isolate).toBe(false);
expect(testConfig.runner).toBeUndefined();
// Env isolation only: the fast shards install the isolated test home but
// must not pull in the shared setup's module mocks.
expect(testConfig.setupFiles).toStrictEqual([expect.stringMatching(ENV_ISOLATION_SETUP_PATH)]);
expect(testConfig.include).toContain(
"src/agents/agent-tools.deferred-followup-guidance.test.ts",
);
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/plugins/config-policy.test.ts");
expect(testConfig.include).toContain("src/sessions/session-lifecycle-events.test.ts");
expect(testConfig.include).toContain("src/plugin-sdk/provider-entry.test.ts");
expect(testConfig.include).not.toEqual(expect.arrayContaining(unitFastIsolatedTestFiles));
});
it("does not treat moved config paths as CLI include filters", () => {
const config = createUnitFastVitestConfig(
{},
{
argv: ["node", "vitest", "run", "--config", "test/vitest/vitest.unit-fast.config.ts"],
},
);
const testConfig = requireTestConfig(config);
expect(testConfig.include).toContain("src/plugin-sdk/provider-entry.test.ts");
expect(testConfig.include).toContain("src/commands/status-overview-values.test.ts");
});
it("keeps excluded stateful files out of directory-scoped CLI runs", () => {
// A directory argument must narrow the curated inventory, never replace it with the
// directory glob. The lane is non-isolated, so re-admitting an excluded stateful file
// pollutes whichever unrelated files share its worker.
const otherLaneFiles = new Set([
...getUnitFastTimerTestFiles(),
...getUnitFastIsolatedTestFiles(),
]);
for (const dir of ["src/plugins", "src/agents", "src/commands"]) {
const testConfig = requireTestConfig(
createUnitFastVitestConfig({}, { argv: ["node", "vitest", "run", dir] }),
);
const include = testConfig.include as string[];
const expected = unitFastTestFiles.filter(
(file) => file.startsWith(`${dir}/`) && !otherLaneFiles.has(file),
);
expect(include, dir).toEqual(expected);
expect(
include.filter((entry) => !isUnitFastTestFile(entry)),
`${dir} admitted non-unit-fast entries`,
).toEqual([]);
}
const pluginsInclude = requireTestConfig(
createUnitFastVitestConfig({}, { argv: ["node", "vitest", "run", "src/plugins"] }),
).include as string[];
expect(isUnitFastTestFile("src/plugins/install-persistence.test.ts")).toBe(false);
expect(pluginsInclude).not.toContain("src/plugins/install-persistence.test.ts");
// Glob-scoped lanes keep their own scope too: a parent-directory argument must not widen
// contracts-plugin from `contracts/` to every sibling test under `src/plugins`.
const contractsInclude = requireTestConfig(
createContractsPluginVitestConfig({}, ["node", "vitest", "run", "src/plugins"]),
).include as string[];
expect(contractsInclude).toEqual(pluginContractPatterns);
});
it("keeps obvious stateful files out of the unit-fast lane", () => {
expect(isUnitFastTestFile("src/plugin-sdk/temp-path.test.ts")).toBe(false);
expect(isUnitFastTestFile("src/agents/openai-transport-stream.base.test.ts")).toBe(false);
expect(
isUnitFastTestFile("src/agents/embedded-agent-runner/run.shared-integration.test.ts"),
).toBe(false);
expect(isUnitFastTestFile("src/auto-reply/reply/dispatch-from-config.test.ts")).toBe(false);
expect(isUnitFastTestFile("src/agents/sandbox.resolveSandboxContext.test.ts")).toBe(false);
expect(isUnitFastTestFile("src/acp/runtime/session-meta.test.ts")).toBe(false);
expect(isUnitFastTestFile("src/system-agent/assistant.test.ts")).toBe(false);
expect(isUnitFastTestFile("src/flows/channel-setup.test.ts")).toBe(false);
expect(isUnitFastTestFile("src/flows/doctor-health-contributions.test.ts")).toBe(false);
expect(isUnitFastTestFile("src/plugins/install.npm-spec.test.ts")).toBe(false);
expect(isUnitFastTestFile("src/secrets/runtime.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("keeps process-launching CLI files in their owner lane", () => {
for (const file of cliProcessTestFiles) {
expect(isUnitFastTestFile(file), file).toBe(false);
expect(unitFastTestFiles, file).not.toContain(file);
}
});
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("routes audited stateful-looking tests through the isolated fast lane", () => {
const forcedFileSet = new Set(forcedUnitFastTestFiles);
const forcedAnalysisCount = countMatching(unitFastAnalysis, (entry) =>
forcedFileSet.has(entry.file),
);
expect(forcedAnalysisCount).toBe(forcedUnitFastTestFiles.length);
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", () => {
// Fixture files must genuinely import a stateful test helper; #121923
// rewrote the outbound poll tests to be stateless, so they left this list.
const files = [
"src/acp/translator.error-kind.test.ts",
"src/agents/auth-profiles/oauth-refresh-error.test.ts",
"src/agents/embedded-agent-runner/model.provider-hooks.timeout.test.ts",
"src/agents/tools/computer-tool.context.test.ts",
"src/agents/tools/computer-tool.schema.test.ts",
"src/agents/tools/computer-tool.v2.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", () => {
const fakeTimerFiles = unitFastAnalysis
.filter((entry) => entry.unitFast && entry.reasons.includes("fake-timers"))
.map((entry) => entry.file);
expect(unitFastTimerTestFiles.length).toBeGreaterThan(0);
expect(unitFastTimerTestFiles).toEqual(fakeTimerFiles);
for (const file of unitFastTimerTestFiles) {
expect(isUnitFastTimerTestFile(file)).toBe(true);
expect(resolveUnitFastTestIncludePattern(file)).toBeNull();
expect(resolveUnitFastTimerTestIncludePattern(file)).toBe(file);
}
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);
expect(timerConfig.setupFiles).toStrictEqual([expect.stringMatching(ENV_ISOLATION_SETUP_PATH)]);
});
it("keeps broad audit candidates separate from automatically routed unit-fast tests", () => {
expect(currentCandidates.length).toBeGreaterThanOrEqual(unitFastTestFiles.length);
expect(broadCandidates.length).toBeGreaterThan(currentCandidates.length);
expect(countMatching(broadAnalysis, (entry) => entry.unitFast)).toBeGreaterThan(
unitFastTestFiles.length,
);
});
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(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({});
expect(unitFastTestFiles).toContain("src/plugin-sdk/provider-entry.test.ts");
expect(requireTestConfig(pluginSdkLight).exclude).toContain(
"plugin-sdk/provider-entry.test.ts",
);
expect(requireTestConfig(commandsLight).exclude).toContain("status-overview-values.test.ts");
});
});