diff --git a/scripts/lib/vitest-local-scheduling.d.mts b/scripts/lib/vitest-local-scheduling.d.mts new file mode 100644 index 00000000000..bca6e722319 --- /dev/null +++ b/scripts/lib/vitest-local-scheduling.d.mts @@ -0,0 +1,40 @@ +export type VitestHostInfo = { + cpuCount?: number; + loadAverage1m?: number; + totalMemoryBytes?: number; +}; + +export type LocalVitestScheduling = { + maxWorkers: number; + fileParallelism: boolean; + throttledBySystem: boolean; +}; + +export const DEFAULT_LOCAL_FULL_SUITE_PARALLELISM: number; +export const LARGE_LOCAL_FULL_SUITE_PARALLELISM: number; +export const DEFAULT_LOCAL_FULL_SUITE_VITEST_WORKERS: number; +export const LARGE_LOCAL_FULL_SUITE_VITEST_WORKERS: number; + +export function isCiLikeEnv(env?: Record): boolean; +export function detectVitestHostInfo(): Required; +export function resolveLocalVitestMaxWorkers( + env?: Record, + system?: VitestHostInfo, + pool?: "forks" | "threads", +): number; +export function resolveLocalVitestScheduling( + env?: Record, + system?: VitestHostInfo, + pool?: "forks" | "threads", +): LocalVitestScheduling; +export function shouldUseLargeLocalFullSuiteProfile( + env?: Record, + system?: VitestHostInfo, +): boolean; +export function resolveLocalFullSuiteProfile( + env?: Record, + system?: VitestHostInfo, +): { + shardParallelism: number; + vitestMaxWorkers: number; +}; diff --git a/scripts/lib/vitest-local-scheduling.mjs b/scripts/lib/vitest-local-scheduling.mjs new file mode 100644 index 00000000000..12434c3b650 --- /dev/null +++ b/scripts/lib/vitest-local-scheduling.mjs @@ -0,0 +1,162 @@ +/** @typedef {{ cpuCount?: number, loadAverage1m?: number, totalMemoryBytes?: number }} VitestHostInfo */ +/** @typedef {{ maxWorkers: number, fileParallelism: boolean, throttledBySystem: boolean }} LocalVitestScheduling */ + +import os from "node:os"; + +export const DEFAULT_LOCAL_FULL_SUITE_PARALLELISM = 4; +export const LARGE_LOCAL_FULL_SUITE_PARALLELISM = 10; +export const DEFAULT_LOCAL_FULL_SUITE_VITEST_WORKERS = 1; +export const LARGE_LOCAL_FULL_SUITE_VITEST_WORKERS = 2; + +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + +function parsePositiveInt(value) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function isSystemThrottleDisabled(env) { + const normalized = env.OPENCLAW_VITEST_DISABLE_SYSTEM_THROTTLE?.trim().toLowerCase(); + return normalized === "1" || normalized === "true"; +} + +export function isCiLikeEnv(env = process.env) { + return env.CI === "true" || env.GITHUB_ACTIONS === "true"; +} + +export function detectVitestHostInfo() { + return { + cpuCount: + typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length, + loadAverage1m: os.loadavg()[0] ?? 0, + totalMemoryBytes: os.totalmem(), + }; +} + +export function resolveLocalVitestMaxWorkers( + env = process.env, + system = detectVitestHostInfo(), + pool = "threads", +) { + return resolveLocalVitestScheduling(env, system, pool).maxWorkers; +} + +/** + * @param {Record} env + * @param {VitestHostInfo} system + * @param {"forks" | "threads"} pool + * @returns {LocalVitestScheduling} + */ +export function resolveLocalVitestScheduling( + env = process.env, + system = detectVitestHostInfo(), + pool = "threads", +) { + const override = parsePositiveInt(env.OPENCLAW_VITEST_MAX_WORKERS ?? env.OPENCLAW_TEST_WORKERS); + if (override !== null) { + const maxWorkers = clamp(override, 1, 16); + return { + maxWorkers, + fileParallelism: maxWorkers > 1, + throttledBySystem: false, + }; + } + + const cpuCount = Math.max(1, system.cpuCount ?? 1); + const loadAverage1m = Math.max(0, system.loadAverage1m ?? 0); + const totalMemoryGb = (system.totalMemoryBytes ?? 0) / 1024 ** 3; + + let inferred = + cpuCount <= 2 + ? 1 + : cpuCount <= 4 + ? 2 + : cpuCount <= 8 + ? 4 + : Math.max(1, Math.floor(cpuCount * 0.75)); + + if (totalMemoryGb <= 16) { + inferred = Math.min(inferred, 2); + } else if (totalMemoryGb <= 32) { + inferred = Math.min(inferred, 4); + } else if (totalMemoryGb <= 64) { + inferred = Math.min(inferred, 6); + } else if (totalMemoryGb <= 128) { + inferred = Math.min(inferred, 8); + } else if (totalMemoryGb <= 256) { + inferred = Math.min(inferred, 12); + } else { + inferred = Math.min(inferred, 16); + } + + const loadRatio = loadAverage1m > 0 ? loadAverage1m / cpuCount : 0; + if (loadRatio >= 1) { + inferred = Math.max(1, Math.floor(inferred / 2)); + } else if (loadRatio >= 0.75) { + inferred = Math.max(1, inferred - 2); + } else if (loadRatio >= 0.5) { + inferred = Math.max(1, inferred - 1); + } + + if (pool === "forks") { + inferred = Math.min(inferred, 8); + } + + inferred = clamp(inferred, 1, 16); + + if (isSystemThrottleDisabled(env)) { + return { + maxWorkers: inferred, + fileParallelism: true, + throttledBySystem: false, + }; + } + + if (loadRatio >= 1) { + const maxWorkers = Math.max(1, Math.floor(inferred / 2)); + return { + maxWorkers, + fileParallelism: maxWorkers > 1, + throttledBySystem: maxWorkers < inferred, + }; + } + + if (loadRatio >= 0.75) { + const maxWorkers = Math.max(2, Math.ceil(inferred * 0.75)); + return { + maxWorkers, + fileParallelism: true, + throttledBySystem: maxWorkers < inferred, + }; + } + + return { + maxWorkers: inferred, + fileParallelism: true, + throttledBySystem: false, + }; +} + +export function shouldUseLargeLocalFullSuiteProfile( + env = process.env, + system = detectVitestHostInfo(), +) { + if (isCiLikeEnv(env)) { + return false; + } + const scheduling = resolveLocalVitestScheduling(env, system, "threads"); + return scheduling.maxWorkers >= 5 && !scheduling.throttledBySystem; +} + +export function resolveLocalFullSuiteProfile(env = process.env, system = detectVitestHostInfo()) { + if (shouldUseLargeLocalFullSuiteProfile(env, system)) { + return { + shardParallelism: LARGE_LOCAL_FULL_SUITE_PARALLELISM, + vitestMaxWorkers: LARGE_LOCAL_FULL_SUITE_VITEST_WORKERS, + }; + } + return { + shardParallelism: DEFAULT_LOCAL_FULL_SUITE_PARALLELISM, + vitestMaxWorkers: DEFAULT_LOCAL_FULL_SUITE_VITEST_WORKERS, + }; +} diff --git a/scripts/test-projects.mjs b/scripts/test-projects.mjs index fc0a83b1f4a..916ec91343d 100644 --- a/scripts/test-projects.mjs +++ b/scripts/test-projects.mjs @@ -1,5 +1,6 @@ import fs from "node:fs"; import { acquireLocalHeavyCheckLockSync } from "./lib/local-heavy-check-runtime.mjs"; +import { isCiLikeEnv, resolveLocalFullSuiteProfile } from "./lib/vitest-local-scheduling.mjs"; import { spawnPnpmRunner } from "./pnpm-runner.mjs"; import { forwardVitestOutput, @@ -157,14 +158,15 @@ function runVitestSpec(spec) { } function applyDefaultParallelVitestWorkerBudget(specs, env) { - if (env.OPENCLAW_VITEST_MAX_WORKERS || env.OPENCLAW_TEST_WORKERS) { + if (env.OPENCLAW_VITEST_MAX_WORKERS || env.OPENCLAW_TEST_WORKERS || isCiLikeEnv(env)) { return specs; } + const { vitestMaxWorkers } = resolveLocalFullSuiteProfile(env); return specs.map((spec) => ({ ...spec, env: { ...spec.env, - OPENCLAW_VITEST_MAX_WORKERS: "1", + OPENCLAW_VITEST_MAX_WORKERS: String(vitestMaxWorkers), }, })); } @@ -255,6 +257,7 @@ async function main() { if (isFullSuiteRun) { const concurrency = resolveParallelFullSuiteConcurrency(runSpecs.length, process.env); if (concurrency > 1) { + const localFullSuiteProfile = resolveLocalFullSuiteProfile(process.env); const parallelSpecs = applyDefaultParallelVitestWorkerBudget( applyParallelVitestCachePaths(orderFullSuiteSpecsForParallelRun(runSpecs), { cwd: process.cwd(), @@ -262,6 +265,16 @@ async function main() { }), process.env, ); + if ( + !isCiLikeEnv(process.env) && + !process.env.OPENCLAW_TEST_PROJECTS_PARALLEL && + !process.env.OPENCLAW_VITEST_MAX_WORKERS && + !process.env.OPENCLAW_TEST_WORKERS && + localFullSuiteProfile.shardParallelism === 10 && + localFullSuiteProfile.vitestMaxWorkers === 2 + ) { + console.error("[test] using host-aware local full-suite profile: shards=10 workers=2"); + } console.error( `[test] running ${parallelSpecs.length} Vitest shards with parallelism ${concurrency}`, ); diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index b5f6f3431e1..4da20a6fc4d 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -32,6 +32,7 @@ import { isBoundaryTestFile, isBundledPluginDependentUnitTestFile, } from "../test/vitest/vitest.unit-paths.mjs"; +import { isCiLikeEnv, resolveLocalFullSuiteProfile } from "./lib/vitest-local-scheduling.mjs"; import { resolveVitestCliEntry, resolveVitestNodeArgs } from "./run-vitest.mjs"; const DEFAULT_VITEST_CONFIG = "test/vitest/vitest.unit.config.ts"; @@ -88,7 +89,6 @@ const UTILS_VITEST_CONFIG = "test/vitest/vitest.utils.config.ts"; const WIZARD_VITEST_CONFIG = "test/vitest/vitest.wizard.config.ts"; const INCLUDE_FILE_ENV_KEY = "OPENCLAW_VITEST_INCLUDE_FILE"; const FS_MODULE_CACHE_PATH_ENV_KEY = "OPENCLAW_VITEST_FS_MODULE_CACHE_PATH"; -const DEFAULT_LOCAL_FULL_SUITE_PARALLELISM = 4; const CHANGED_ARGS_PATTERN = /^--changed(?:=(.+))?$/u; const VITEST_CONFIG_BY_KIND = { acp: ACP_VITEST_CONFIG, @@ -670,7 +670,7 @@ function hasConservativeVitestWorkerBudget(env) { return workerBudget !== null && workerBudget <= 1; } -export function resolveParallelFullSuiteConcurrency(specCount, env = process.env) { +export function resolveParallelFullSuiteConcurrency(specCount, env = process.env, hostInfo) { const override = parsePositiveInt(env.OPENCLAW_TEST_PROJECTS_PARALLEL); if (override !== null) { return Math.min(override, specCount); @@ -678,7 +678,7 @@ export function resolveParallelFullSuiteConcurrency(specCount, env = process.env if (env.OPENCLAW_TEST_PROJECTS_SERIAL === "1") { return 1; } - if (env.CI === "true" || env.GITHUB_ACTIONS === "true") { + if (isCiLikeEnv(env)) { return 1; } if (hasConservativeVitestWorkerBudget(env)) { @@ -690,7 +690,7 @@ export function resolveParallelFullSuiteConcurrency(specCount, env = process.env ) { return 1; } - return Math.min(DEFAULT_LOCAL_FULL_SUITE_PARALLELISM, specCount); + return Math.min(resolveLocalFullSuiteProfile(env, hostInfo).shardParallelism, specCount); } function sanitizeVitestCachePathSegment(value) { diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index a650ac1ec1f..ff1794ec285 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -6,6 +6,7 @@ import { buildVitestRunPlans, shouldAcquireLocalHeavyCheckLock, resolveChangedTargetArgs, + resolveParallelFullSuiteConcurrency, } from "../../scripts/test-projects.test-support.mjs"; describe("scripts/test-projects changed-target routing", () => { @@ -247,6 +248,52 @@ describe("scripts/test-projects local heavy-check lock", () => { }); describe("scripts/test-projects full-suite sharding", () => { + it("uses the large host-aware local profile on roomy local hosts", () => { + expect( + resolveParallelFullSuiteConcurrency( + 61, + {}, + { + cpuCount: 14, + loadAverage1m: 0, + totalMemoryBytes: 48 * 1024 ** 3, + }, + ), + ).toBe(10); + }); + + it("keeps CI full-suite runs serial even on roomy hosts", () => { + expect( + resolveParallelFullSuiteConcurrency( + 61, + { + CI: "true", + }, + { + cpuCount: 14, + loadAverage1m: 0, + totalMemoryBytes: 48 * 1024 ** 3, + }, + ), + ).toBe(1); + }); + + it("keeps explicit parallel overrides ahead of the host-aware profile", () => { + expect( + resolveParallelFullSuiteConcurrency( + 61, + { + OPENCLAW_TEST_PROJECTS_PARALLEL: "3", + }, + { + cpuCount: 14, + loadAverage1m: 0, + totalMemoryBytes: 48 * 1024 ** 3, + }, + ), + ).toBe(3); + }); + it("splits untargeted runs into fixed core shards and per-extension configs", () => { const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL; const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL; diff --git a/test/scripts/vitest-local-scheduling.test.ts b/test/scripts/vitest-local-scheduling.test.ts new file mode 100644 index 00000000000..70de3289a9f --- /dev/null +++ b/test/scripts/vitest-local-scheduling.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + resolveLocalFullSuiteProfile, + resolveLocalVitestScheduling, + shouldUseLargeLocalFullSuiteProfile, +} from "../../scripts/lib/vitest-local-scheduling.mjs"; + +describe("vitest local full-suite profile", () => { + it("selects the large local profile on roomy hosts that are not throttled", () => { + const env = {}; + const hostInfo = { + cpuCount: 14, + loadAverage1m: 0, + totalMemoryBytes: 48 * 1024 ** 3, + }; + + expect(resolveLocalVitestScheduling(env, hostInfo, "threads")).toEqual({ + maxWorkers: 6, + fileParallelism: true, + throttledBySystem: false, + }); + expect(shouldUseLargeLocalFullSuiteProfile(env, hostInfo)).toBe(true); + expect(resolveLocalFullSuiteProfile(env, hostInfo)).toEqual({ + shardParallelism: 10, + vitestMaxWorkers: 2, + }); + }); + + it("keeps the smaller local profile when the host is already throttled", () => { + const hostInfo = { + cpuCount: 14, + loadAverage1m: 14, + totalMemoryBytes: 48 * 1024 ** 3, + }; + + expect(shouldUseLargeLocalFullSuiteProfile({}, hostInfo)).toBe(false); + expect(resolveLocalFullSuiteProfile({}, hostInfo)).toEqual({ + shardParallelism: 4, + vitestMaxWorkers: 1, + }); + }); + + it("never selects the large local profile in CI", () => { + const hostInfo = { + cpuCount: 14, + loadAverage1m: 0, + totalMemoryBytes: 48 * 1024 ** 3, + }; + + expect(shouldUseLargeLocalFullSuiteProfile({ CI: "true" }, hostInfo)).toBe(false); + expect(resolveLocalFullSuiteProfile({ CI: "true" }, hostInfo)).toEqual({ + shardParallelism: 4, + vitestMaxWorkers: 1, + }); + }); +}); diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 7c75bd7f46b..398e022d5b5 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -1,7 +1,12 @@ -import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs"; +import { + detectVitestHostInfo as detectVitestHostInfoImpl, + isCiLikeEnv, + resolveLocalVitestMaxWorkers as resolveLocalVitestMaxWorkersImpl, + resolveLocalVitestScheduling as resolveLocalVitestSchedulingImpl, +} from "../../scripts/lib/vitest-local-scheduling.mjs"; import { BUNDLED_PLUGIN_ROOT_DIR, BUNDLED_PLUGIN_TEST_GLOB, @@ -9,18 +14,6 @@ import { import { loadVitestExperimentalConfig } from "./vitest.performance-config.ts"; import { shouldPrintVitestThrottle } from "./vitest.system-load.ts"; -const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); - -function parsePositiveInt(value: string | undefined): number | null { - const parsed = Number.parseInt(value ?? "", 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : null; -} - -function isSystemThrottleDisabled(env: Record): boolean { - const normalized = env.OPENCLAW_VITEST_DISABLE_SYSTEM_THROTTLE?.trim().toLowerCase(); - return normalized === "1" || normalized === "true"; -} - type VitestHostInfo = { cpuCount?: number; loadAverage1m?: number; @@ -45,12 +38,7 @@ export const jsdomOptimizedDeps = { }; function detectVitestHostInfo(): Required { - return { - cpuCount: - typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length, - loadAverage1m: os.loadavg()[0] ?? 0, - totalMemoryBytes: os.totalmem(), - }; + return detectVitestHostInfoImpl() as Required; } export function resolveLocalVitestMaxWorkers( @@ -58,7 +46,7 @@ export function resolveLocalVitestMaxWorkers( system: VitestHostInfo = detectVitestHostInfo(), pool: OpenClawVitestPool = resolveDefaultVitestPool(env), ): number { - return resolveLocalVitestScheduling(env, system, pool).maxWorkers; + return resolveLocalVitestMaxWorkersImpl(env, system, pool); } export function resolveLocalVitestScheduling( @@ -66,91 +54,7 @@ export function resolveLocalVitestScheduling( system: VitestHostInfo = detectVitestHostInfo(), pool: OpenClawVitestPool = resolveDefaultVitestPool(env), ): LocalVitestScheduling { - const override = parsePositiveInt(env.OPENCLAW_VITEST_MAX_WORKERS ?? env.OPENCLAW_TEST_WORKERS); - if (override !== null) { - const maxWorkers = clamp(override, 1, 16); - return { - maxWorkers, - fileParallelism: maxWorkers > 1, - throttledBySystem: false, - }; - } - - const cpuCount = Math.max(1, system.cpuCount ?? 1); - const loadAverage1m = Math.max(0, system.loadAverage1m ?? 0); - const totalMemoryGb = (system.totalMemoryBytes ?? 0) / 1024 ** 3; - - // Keep smaller hosts conservative, but let large local boxes actually use - // their cores. Thread workers scale much better than the old fork-first cap. - let inferred = - cpuCount <= 2 - ? 1 - : cpuCount <= 4 - ? 2 - : cpuCount <= 8 - ? 4 - : Math.max(1, Math.floor(cpuCount * 0.75)); - - if (totalMemoryGb <= 16) { - inferred = Math.min(inferred, 2); - } else if (totalMemoryGb <= 32) { - inferred = Math.min(inferred, 4); - } else if (totalMemoryGb <= 64) { - inferred = Math.min(inferred, 6); - } else if (totalMemoryGb <= 128) { - inferred = Math.min(inferred, 8); - } else if (totalMemoryGb <= 256) { - inferred = Math.min(inferred, 12); - } else { - inferred = Math.min(inferred, 16); - } - - const loadRatio = loadAverage1m > 0 ? loadAverage1m / cpuCount : 0; - if (loadRatio >= 1) { - inferred = Math.max(1, Math.floor(inferred / 2)); - } else if (loadRatio >= 0.75) { - inferred = Math.max(1, inferred - 2); - } else if (loadRatio >= 0.5) { - inferred = Math.max(1, inferred - 1); - } - - if (pool === "forks") { - inferred = Math.min(inferred, 8); - } - - inferred = clamp(inferred, 1, 16); - - if (isSystemThrottleDisabled(env)) { - return { - maxWorkers: inferred, - fileParallelism: true, - throttledBySystem: false, - }; - } - - if (loadRatio >= 1) { - const maxWorkers = Math.max(1, Math.floor(inferred / 2)); - return { - maxWorkers, - fileParallelism: maxWorkers > 1, - throttledBySystem: maxWorkers < inferred, - }; - } - - if (loadRatio >= 0.75) { - const maxWorkers = Math.max(2, Math.ceil(inferred * 0.75)); - return { - maxWorkers, - fileParallelism: true, - throttledBySystem: maxWorkers < inferred, - }; - } - - return { - maxWorkers: inferred, - fileParallelism: true, - throttledBySystem: false, - }; + return resolveLocalVitestSchedulingImpl(env, system, pool) as LocalVitestScheduling; } export function resolveDefaultVitestPool( @@ -164,7 +68,7 @@ export const nonIsolatedRunnerPath = path.join(repoRoot, "test", "non-isolated-r export function resolveRepoRootPath(value: string): string { return path.isAbsolute(value) ? value : path.join(repoRoot, value); } -const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; +const isCI = isCiLikeEnv(process.env); const isWindows = process.platform === "win32"; const defaultPool = resolveDefaultVitestPool(); const localScheduling = resolveLocalVitestScheduling(