test: speed up isolated test lanes

This commit is contained in:
Peter Steinberger 2026-03-20 17:09:46 +00:00
parent 93fbe26adb
commit 85a5d64d8f
5 changed files with 114 additions and 14 deletions

View File

@ -111,6 +111,7 @@
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat. - Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing. - For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
- Do not set test workers above 16; tried already. - Do not set test workers above 16; tried already.
- Do not switch CI `pnpm test` lanes back to Vitest `vmForks` by default without fresh green evidence on current `main`; keep CI on `forks` unless explicitly re-validated.
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs. - If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`. - Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + whats covered: `docs/help/testing.md`. - Full kit + whats covered: `docs/help/testing.md`.

View File

@ -11,8 +11,9 @@ title: "Tests"
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests dont collide with a running instance. Use this when a prior gateway run left port 18789 occupied. - `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests dont collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic. - `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test` on Node 22, 23, and 24 uses Vitest `vmForks` by default for faster startup. Node 25+ falls back to `forks` until re-validated. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`. - `pnpm test` on Node 22, 23, and 24 uses Vitest `vmForks` by default for local runs with enough memory. CI stays on `forks` unless explicitly overridden. Node 25+ falls back to `forks` until re-validated. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`.
- `pnpm test`: runs the full wrapper. It keeps only a small behavioral override manifest in git, then uses a checked-in timing snapshot to peel the heaviest measured unit files into dedicated lanes. - `pnpm test`: runs the full wrapper. It keeps only a small behavioral override manifest in git, then uses a checked-in timing snapshot to peel the heaviest measured unit files into dedicated lanes.
- Files marked `singletonIsolated` no longer spawn one fresh Vitest process each by default. The wrapper batches them into dedicated `forks` lanes with `maxWorkers=1`, which preserves isolation from `unit-fast` while cutting process startup overhead. Tune lane count with `OPENCLAW_TEST_SINGLETON_ISOLATED_LANES=<n>`.
- `pnpm test:channels`: runs channel-heavy suites. - `pnpm test:channels`: runs channel-heavy suites.
- `pnpm test:extensions`: runs extension/plugin suites. - `pnpm test:extensions`: runs extension/plugin suites.
- `pnpm test:perf:update-timings`: refreshes the checked-in slow-file timing snapshot used by `scripts/test-parallel.mjs`. - `pnpm test:perf:update-timings`: refreshes the checked-in slow-file timing snapshot used by `scripts/test-parallel.mjs`.

View File

@ -15,6 +15,7 @@ import {
resolveTestRunExitCode, resolveTestRunExitCode,
} from "./test-parallel-utils.mjs"; } from "./test-parallel-utils.mjs";
import { import {
dedupeFilesPreserveOrder,
loadUnitMemoryHotspotManifest, loadUnitMemoryHotspotManifest,
loadTestRunnerBehavior, loadTestRunnerBehavior,
loadUnitTimingManifest, loadUnitTimingManifest,
@ -81,18 +82,17 @@ const testProfile =
? rawTestProfile ? rawTestProfile
: "normal"; : "normal";
const isMacMiniProfile = testProfile === "macmini"; const isMacMiniProfile = testProfile === "macmini";
// vmForks is a big win for transform/import heavy suites. Node 24 is stable again // vmForks is still useful for local transform-heavy runs, but do not flip CI
// for the default unit-fast lane after moving the known flaky files to fork-only // back to vmForks by default unless you have fresh green evidence on current
// isolation, but Node 25+ still falls back to process forks until re-validated. // main. The retained Vite/Vitest module graph is the OOM pattern we keep
// Keep it opt-out via OPENCLAW_TEST_VM_FORKS=0, and let users force-enable with =1. // tripping over under CI-style pressure. Keep it opt-out via
// OPENCLAW_TEST_VM_FORKS=0, and let users force-enable with =1 for comparison.
const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor <= 24 : true; const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor <= 24 : true;
const preferVmForksByDefault =
!isCI && !isWindows && supportsVmForks && !lowMemLocalHost && testProfile !== "low";
const useVmForks = const useVmForks =
process.env.OPENCLAW_TEST_VM_FORKS === "1" || process.env.OPENCLAW_TEST_VM_FORKS === "1" ||
(process.env.OPENCLAW_TEST_VM_FORKS !== "0" && (process.env.OPENCLAW_TEST_VM_FORKS !== "0" && preferVmForksByDefault);
!isWindows &&
supportsVmForks &&
!lowMemLocalHost &&
(isCI || testProfile !== "low"));
const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1"; const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1";
const includeGatewaySuite = process.env.OPENCLAW_TEST_INCLUDE_GATEWAY === "1"; const includeGatewaySuite = process.env.OPENCLAW_TEST_INCLUDE_GATEWAY === "1";
const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS === "1"; const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS === "1";
@ -345,15 +345,46 @@ const { memoryHeavyFiles: memoryHeavyUnitFiles, timedHeavyFiles: timedHeavyUnitF
memoryHeavyFiles: [], memoryHeavyFiles: [],
timedHeavyFiles: [], timedHeavyFiles: [],
}; };
const unitSingletonBatchFiles = dedupeFilesPreserveOrder(
unitSingletonIsolatedFiles,
new Set(unitBehaviorIsolatedFiles),
);
const unitMemorySingletonFiles = dedupeFilesPreserveOrder(
memoryHeavyUnitFiles,
new Set([...unitBehaviorOverrideSet, ...unitSingletonBatchFiles]),
);
const unitSchedulingOverrideSet = new Set([...unitBehaviorOverrideSet, ...memoryHeavyUnitFiles]); const unitSchedulingOverrideSet = new Set([...unitBehaviorOverrideSet, ...memoryHeavyUnitFiles]);
const unitFastExcludedFiles = [ const unitFastExcludedFiles = [
...new Set([...unitSchedulingOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]), ...new Set([...unitSchedulingOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]),
]; ];
const unitAutoSingletonFiles = [ const defaultSingletonBatchLaneCount =
...new Set([...unitSingletonIsolatedFiles, ...memoryHeavyUnitFiles]), testProfile === "serial"
]; ? 0
: unitSingletonBatchFiles.length === 0
? 0
: isCI
? Math.ceil(unitSingletonBatchFiles.length / 6)
: highMemLocalHost
? Math.ceil(unitSingletonBatchFiles.length / 8)
: lowMemLocalHost
? Math.ceil(unitSingletonBatchFiles.length / 12)
: Math.ceil(unitSingletonBatchFiles.length / 10);
const singletonBatchLaneCount =
unitSingletonBatchFiles.length === 0
? 0
: Math.min(
unitSingletonBatchFiles.length,
Math.max(
1,
parseEnvNumber("OPENCLAW_TEST_SINGLETON_ISOLATED_LANES", defaultSingletonBatchLaneCount),
),
);
const estimateUnitDurationMs = (file) => const estimateUnitDurationMs = (file) =>
unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs; unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs;
const unitSingletonBuckets =
singletonBatchLaneCount > 0
? packFilesByDuration(unitSingletonBatchFiles, singletonBatchLaneCount, estimateUnitDurationMs)
: [];
const unitFastExcludedFileSet = new Set(unitFastExcludedFiles); const unitFastExcludedFileSet = new Set(unitFastExcludedFiles);
const unitFastCandidateFiles = allKnownUnitFiles.filter( const unitFastCandidateFiles = allKnownUnitFiles.filter(
(file) => !unitFastExcludedFileSet.has(file), (file) => !unitFastExcludedFileSet.has(file),
@ -400,6 +431,11 @@ const unitHeavyEntries = heavyUnitBuckets.map((files, index) => ({
name: `unit-heavy-${String(index + 1)}`, name: `unit-heavy-${String(index + 1)}`,
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files], args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files],
})); }));
const unitSingletonEntries = unitSingletonBuckets.map((files, index) => ({
name:
unitSingletonBuckets.length === 1 ? "unit-singleton" : `unit-singleton-${String(index + 1)}`,
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files],
}));
const baseRuns = [ const baseRuns = [
...(shouldSplitUnitRuns ...(shouldSplitUnitRuns
? [ ? [
@ -420,7 +456,8 @@ const baseRuns = [
] ]
: []), : []),
...unitHeavyEntries, ...unitHeavyEntries,
...unitAutoSingletonFiles.map((file) => ({ ...unitSingletonEntries,
...unitMemorySingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-isolated`, name: `${path.basename(file, ".test.ts")}-isolated`,
args: [ args: [
"vitest", "vitest",
@ -756,6 +793,9 @@ const maxWorkersForRun = (name) => {
if (resolvedOverride) { if (resolvedOverride) {
return resolvedOverride; return resolvedOverride;
} }
if (name === "unit-singleton" || name.startsWith("unit-singleton-")) {
return 1;
}
if (isCI && !isMacOS) { if (isCI && !isMacOS) {
return null; return null;
} }

View File

@ -231,3 +231,18 @@ export function packFilesByDuration(files, bucketCount, estimateDurationMs) {
return buckets.map((bucket) => bucket.files).filter((bucket) => bucket.length > 0); return buckets.map((bucket) => bucket.files).filter((bucket) => bucket.length > 0);
} }
export function dedupeFilesPreserveOrder(files, exclude = new Set()) {
const result = [];
const seen = new Set();
for (const file of files) {
if (exclude.has(file) || seen.has(file)) {
continue;
}
seen.add(file);
result.push(file);
}
return result;
}

View File

@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
dedupeFilesPreserveOrder,
packFilesByDuration,
selectMemoryHeavyFiles, selectMemoryHeavyFiles,
selectTimedHeavyFiles, selectTimedHeavyFiles,
selectUnitHeavyFileGroups, selectUnitHeavyFileGroups,
@ -91,3 +93,44 @@ describe("scripts/test-runner-manifest memory selection", () => {
}); });
}); });
}); });
describe("dedupeFilesPreserveOrder", () => {
it("removes duplicates while keeping the first-seen order", () => {
expect(
dedupeFilesPreserveOrder([
"src/b.test.ts",
"src/a.test.ts",
"src/b.test.ts",
"src/c.test.ts",
"src/a.test.ts",
]),
).toEqual(["src/b.test.ts", "src/a.test.ts", "src/c.test.ts"]);
});
it("filters excluded files before deduping", () => {
expect(
dedupeFilesPreserveOrder(
["src/a.test.ts", "src/b.test.ts", "src/c.test.ts", "src/b.test.ts"],
new Set(["src/b.test.ts"]),
),
).toEqual(["src/a.test.ts", "src/c.test.ts"]);
});
});
describe("packFilesByDuration", () => {
it("packs heavier files into the lightest remaining bucket", () => {
const durationByFile = {
"src/a.test.ts": 100,
"src/b.test.ts": 90,
"src/c.test.ts": 20,
"src/d.test.ts": 10,
} satisfies Record<string, number>;
expect(
packFilesByDuration(Object.keys(durationByFile), 2, (file) => durationByFile[file] ?? 0),
).toEqual([
["src/a.test.ts", "src/d.test.ts"],
["src/b.test.ts", "src/c.test.ts"],
]);
});
});