test: speed up isolated test lanes
This commit is contained in:
parent
93fbe26adb
commit
85a5d64d8f
@ -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 + what’s covered: `docs/help/testing.md`.
|
- Full kit + what’s covered: `docs/help/testing.md`.
|
||||||
|
|||||||
@ -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 don’t 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 don’t 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`.
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user