2026-01-23 07:34:50 +00:00
import { spawn } from "node:child_process" ;
2026-02-07 08:27:50 +00:00
import fs from "node:fs" ;
2026-01-23 11:36:28 +00:00
import os from "node:os" ;
2026-03-13 18:36:38 -05:00
import path from "node:path" ;
2026-01-23 07:34:50 +00:00
2026-02-15 03:35:02 +00:00
// On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell
// (especially under GitHub Actions + Git Bash). Use `shell: true` and let the shell resolve pnpm.
const pnpm = "pnpm" ;
2026-01-23 07:34:50 +00:00
2026-02-14 12:42:11 +00:00
const unitIsolatedFilesRaw = [
2026-02-13 04:30:39 +00:00
"src/plugins/loader.test.ts" ,
"src/plugins/tools.optional.test.ts" ,
"src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts" ,
"src/security/fix.test.ts" ,
2026-02-22 21:59:13 +00:00
// Runtime source guard scans are sensitive to filesystem contention.
"src/security/temp-path-guard.test.ts" ,
2026-02-13 04:45:04 +00:00
"src/security/audit.test.ts" ,
2026-02-13 04:30:39 +00:00
"src/utils.test.ts" ,
"src/auto-reply/tool-meta.test.ts" ,
2026-02-13 04:45:04 +00:00
"src/auto-reply/envelope.test.ts" ,
2026-02-13 04:30:39 +00:00
"src/commands/auth-choice.test.ts" ,
2026-02-24 04:39:29 +00:00
// Process supervision + docker setup suites are stable but setup-heavy.
"src/process/supervisor/supervisor.test.ts" ,
"src/docker-setup.test.ts" ,
// Filesystem-heavy skills sync suite.
"src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts" ,
// Real git hook integration test; keep signal, move off unit-fast critical path.
"test/git-hooks-pre-commit.test.ts" ,
// Setup-heavy doctor command suites; keep them off the unit-fast critical path.
"src/commands/doctor.warns-state-directory-is-missing.test.ts" ,
"src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts" ,
"src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts" ,
// Setup-heavy CLI update flow suite; move off unit-fast critical path.
"src/cli/update-cli.test.ts" ,
2026-03-09 01:59:16 +08:00
// Uses temp repos + module cache resets; keep it off vmForks to avoid ref-resolution flakes.
"src/infra/git-commit.test.ts" ,
2026-02-24 04:39:29 +00:00
// Expensive schema build/bootstrap checks; keep coverage but run in isolated lane.
"src/config/schema.test.ts" ,
"src/config/schema.tags.test.ts" ,
// CLI smoke/agent flows are stable but setup-heavy.
"src/cli/program.smoke.test.ts" ,
"src/commands/agent.test.ts" ,
2026-02-14 03:29:55 +00:00
"src/media/store.test.ts" ,
2026-02-13 04:30:39 +00:00
"src/media/store.header-ext.test.ts" ,
2026-02-14 03:29:55 +00:00
"src/web/media.test.ts" ,
"src/web/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts" ,
2026-02-13 04:30:39 +00:00
"src/browser/server.covers-additional-endpoint-branches.test.ts" ,
"src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts" ,
"src/browser/server.agent-contract-snapshot-endpoints.test.ts" ,
"src/browser/server.agent-contract-form-layout-act-commands.test.ts" ,
"src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts" ,
"src/browser/server.auth-token-gates-http.test.ts" ,
2026-02-16 04:20:21 +00:00
// Keep this high-variance heavy file off the unit-fast critical path.
"src/auto-reply/reply.block-streaming.test.ts" ,
2026-02-16 04:31:26 +00:00
// Archive extraction/fixture-heavy suite; keep off unit-fast critical path.
"src/hooks/install.test.ts" ,
2026-02-22 21:59:13 +00:00
// Download/extraction safety cases can spike under unit-fast contention.
"src/agents/skills-install.download.test.ts" ,
2026-03-02 22:29:13 +00:00
// Skills discovery/snapshot suites are filesystem-heavy and high-variance in vmForks lanes.
"src/agents/skills.test.ts" ,
"src/agents/skills.buildworkspaceskillsnapshot.test.ts" ,
"src/browser/extension-relay.test.ts" ,
"extensions/acpx/src/runtime.test.ts" ,
// Shell-heavy script harness can contend under vmForks startup bursts.
"test/scripts/ios-team-id.test.ts" ,
2026-02-22 21:59:13 +00:00
// Heavy runner/exec/archive suites are stable but contend on shared resources under vmForks.
"src/agents/pi-embedded-runner.test.ts" ,
"src/agents/bash-tools.test.ts" ,
"src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts" ,
"src/agents/bash-tools.exec.background-abort.test.ts" ,
"src/agents/subagent-announce.format.test.ts" ,
"src/infra/archive.test.ts" ,
"src/cli/daemon-cli.coverage.test.ts" ,
// Model normalization test imports config/model discovery stack; keep off unit-fast critical path.
"src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts" ,
// Auth profile rotation suite is retry-heavy and high-variance under vmForks contention.
"src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts" ,
// Heavy trigger command scenarios; keep off unit-fast critical path to reduce contention noise.
"src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts" ,
2026-02-23 13:30:47 +00:00
"src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts" ,
2026-02-22 21:59:13 +00:00
"src/auto-reply/reply.triggers.group-intro-prompts.test.ts" ,
"src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts" ,
"src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts" ,
2026-02-16 04:34:48 +00:00
// Setup-heavy bot bootstrap suite.
"src/telegram/bot.create-telegram-bot.test.ts" ,
2026-02-16 04:50:19 +00:00
// Medium-heavy bot behavior suite; move off unit-fast critical path.
"src/telegram/bot.test.ts" ,
2026-02-16 04:34:48 +00:00
// Slack slash registration tests are setup-heavy and can bottleneck unit-fast.
"src/slack/monitor/slash.test.ts" ,
2026-02-15 04:49:48 +00:00
// Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage.
2026-02-15 19:18:49 +00:00
"src/imessage/monitor.shutdown.unhandled-rejection.test.ts" ,
2026-03-08 18:47:34 +01:00
// Mutates process.cwd() and mocks core module loaders; isolate from the shared fast lane.
"src/infra/git-commit.test.ts" ,
2026-02-13 04:30:39 +00:00
] ;
2026-02-14 12:42:11 +00:00
const unitIsolatedFiles = unitIsolatedFilesRaw . filter ( ( file ) => fs . existsSync ( file ) ) ;
2026-02-13 04:30:39 +00:00
const children = new Set ( ) ;
const isCI = process . env . CI === "true" || process . env . GITHUB _ACTIONS === "true" ;
const isMacOS = process . platform === "darwin" || process . env . RUNNER _OS === "macOS" ;
const isWindows = process . platform === "win32" || process . env . RUNNER _OS === "Windows" ;
const isWindowsCi = isCI && isWindows ;
2026-02-25 12:16:17 +02:00
const hostCpuCount = os . cpus ( ) . length ;
const hostMemoryGiB = Math . floor ( os . totalmem ( ) / 1024 * * 3 ) ;
// Keep aggressive local defaults for high-memory workstations (Mac Studio class).
const highMemLocalHost = ! isCI && hostMemoryGiB >= 96 ;
const lowMemLocalHost = ! isCI && hostMemoryGiB < 64 ;
2026-02-13 08:15:25 -05:00
const nodeMajor = Number . parseInt ( process . versions . node . split ( "." ) [ 0 ] ? ? "" , 10 ) ;
2026-03-13 20:38:09 +00:00
// vmForks is a big win for transform/import heavy suites. Node 24 is stable again
// for the default unit-fast lane after moving the known flaky files to fork-only
// isolation, but Node 25+ still falls back to process forks until re-validated.
// Keep it opt-out via OPENCLAW_TEST_VM_FORKS=0, and let users force-enable with =1.
const supportsVmForks = Number . isFinite ( nodeMajor ) ? nodeMajor <= 24 : true ;
2026-02-13 04:30:39 +00:00
const useVmForks =
process . env . OPENCLAW _TEST _VM _FORKS === "1" ||
2026-02-25 12:16:17 +02:00
( process . env . OPENCLAW _TEST _VM _FORKS !== "0" && ! isWindows && supportsVmForks && ! lowMemLocalHost ) ;
2026-02-13 13:28:23 +00:00
const disableIsolation = process . env . OPENCLAW _TEST _NO _ISOLATE === "1" ;
2026-03-02 05:31:39 +00:00
const includeGatewaySuite = process . env . OPENCLAW _TEST _INCLUDE _GATEWAY === "1" ;
const includeExtensionsSuite = process . env . OPENCLAW _TEST _INCLUDE _EXTENSIONS === "1" ;
2026-03-06 17:45:35 -05:00
const rawTestProfile = process . env . OPENCLAW _TEST _PROFILE ? . trim ( ) . toLowerCase ( ) ;
const testProfile =
rawTestProfile === "low" ||
rawTestProfile === "max" ||
rawTestProfile === "normal" ||
rawTestProfile === "serial"
? rawTestProfile
: "normal" ;
2026-03-08 18:39:37 +00:00
// Even on low-memory hosts, keep the isolated lane split so files like
// git-commit.test.ts still get the worker/process isolation they require.
const shouldSplitUnitRuns = testProfile !== "serial" ;
2026-01-23 07:34:50 +00:00
const runs = [
2026-03-06 17:45:35 -05:00
... ( shouldSplitUnitRuns
2026-02-13 04:30:39 +00:00
? [
{
name : "unit-fast" ,
args : [
"vitest" ,
"run" ,
"--config" ,
"vitest.unit.config.ts" ,
2026-03-06 17:45:35 -05:00
` --pool= ${ useVmForks ? "vmForks" : "forks" } ` ,
2026-02-13 13:28:23 +00:00
... ( disableIsolation ? [ "--isolate=false" ] : [ ] ) ,
2026-02-13 04:30:39 +00:00
... unitIsolatedFiles . flatMap ( ( file ) => [ "--exclude" , file ] ) ,
] ,
} ,
{
name : "unit-isolated" ,
args : [
"vitest" ,
"run" ,
"--config" ,
"vitest.unit.config.ts" ,
"--pool=forks" ,
... unitIsolatedFiles ,
] ,
} ,
]
: [
{
name : "unit" ,
2026-03-06 17:45:35 -05:00
args : [
"vitest" ,
"run" ,
"--config" ,
"vitest.unit.config.ts" ,
` --pool= ${ useVmForks ? "vmForks" : "forks" } ` ,
... ( disableIsolation ? [ "--isolate=false" ] : [ ] ) ,
] ,
2026-02-13 04:30:39 +00:00
} ,
] ) ,
2026-03-02 05:31:39 +00:00
... ( includeExtensionsSuite
? [
{
name : "extensions" ,
args : [
"vitest" ,
"run" ,
"--config" ,
"vitest.extensions.config.ts" ,
... ( useVmForks ? [ "--pool=vmForks" ] : [ ] ) ,
] ,
} ,
]
: [ ] ) ,
... ( includeGatewaySuite
? [
{
name : "gateway" ,
args : [
"vitest" ,
"run" ,
"--config" ,
"vitest.gateway.config.ts" ,
// Gateway tests are sensitive to vmForks behavior (global state + env stubs).
// Keep them on process forks for determinism even when other suites use vmForks.
"--pool=forks" ,
] ,
} ,
]
: [ ] ) ,
2026-01-23 07:34:50 +00:00
] ;
2026-01-30 03:15:10 +01:00
const shardOverride = Number . parseInt ( process . env . OPENCLAW _TEST _SHARDS ? ? "" , 10 ) ;
2026-02-26 00:33:36 -06:00
const configuredShardCount =
Number . isFinite ( shardOverride ) && shardOverride > 1 ? shardOverride : null ;
const shardCount = configuredShardCount ? ? ( isWindowsCi ? 2 : 1 ) ;
const shardIndexOverride = ( ( ) => {
const parsed = Number . parseInt ( process . env . OPENCLAW _TEST _SHARD _INDEX ? ? "" , 10 ) ;
return Number . isFinite ( parsed ) && parsed > 0 ? parsed : null ;
} ) ( ) ;
2026-03-13 18:36:38 -05:00
const OPTION _TAKES _VALUE = new Set ( [
"-t" ,
"-c" ,
"-r" ,
"--testNamePattern" ,
"--config" ,
"--root" ,
"--dir" ,
"--reporter" ,
"--outputFile" ,
"--pool" ,
"--execArgv" ,
"--vmMemoryLimit" ,
"--maxWorkers" ,
"--environment" ,
"--shard" ,
"--changed" ,
"--sequence" ,
"--inspect" ,
"--inspectBrk" ,
"--testTimeout" ,
"--hookTimeout" ,
"--bail" ,
"--retry" ,
"--diff" ,
"--exclude" ,
"--project" ,
"--slowTestThreshold" ,
"--teardownTimeout" ,
"--attachmentsDir" ,
"--mode" ,
"--api" ,
"--browser" ,
"--maxConcurrency" ,
"--mergeReports" ,
"--configLoader" ,
"--experimental" ,
] ) ;
const SINGLE _RUN _ONLY _FLAGS = new Set ( [ "--coverage" , "--outputFile" , "--mergeReports" ] ) ;
2026-02-26 00:33:36 -06:00
if ( shardIndexOverride !== null && shardCount <= 1 ) {
console . error (
` [test-parallel] OPENCLAW_TEST_SHARD_INDEX= ${ String (
shardIndexOverride ,
) } requires OPENCLAW_TEST_SHARDS>1. ` ,
) ;
process . exit ( 2 ) ;
}
if ( shardIndexOverride !== null && shardIndexOverride > shardCount ) {
console . error (
` [test-parallel] OPENCLAW_TEST_SHARD_INDEX= ${ String (
shardIndexOverride ,
) } exceeds OPENCLAW_TEST_SHARDS= ${ String ( shardCount ) } . ` ,
) ;
process . exit ( 2 ) ;
}
2026-02-06 23:18:19 -03:00
const windowsCiArgs = isWindowsCi ? [ "--dangerouslyIgnoreUnhandledErrors" ] : [ ] ;
2026-02-12 17:59:44 +00:00
const silentArgs =
process . env . OPENCLAW _TEST _SHOW _PASSED _LOGS === "1" ? [ ] : [ "--silent=passed-only" ] ;
2026-02-07 20:02:32 -08:00
const rawPassthroughArgs = process . argv . slice ( 2 ) ;
const passthroughArgs =
rawPassthroughArgs [ 0 ] === "--" ? rawPassthroughArgs . slice ( 1 ) : rawPassthroughArgs ;
2026-03-13 18:36:38 -05:00
const parsePassthroughArgs = ( args ) => {
const fileFilters = [ ] ;
const optionArgs = [ ] ;
let consumeNextAsOptionValue = false ;
for ( const arg of args ) {
if ( consumeNextAsOptionValue ) {
optionArgs . push ( arg ) ;
consumeNextAsOptionValue = false ;
continue ;
}
if ( arg === "--" ) {
optionArgs . push ( arg ) ;
continue ;
}
if ( arg . startsWith ( "-" ) ) {
optionArgs . push ( arg ) ;
consumeNextAsOptionValue = ! arg . includes ( "=" ) && OPTION _TAKES _VALUE . has ( arg ) ;
continue ;
}
fileFilters . push ( arg ) ;
}
return { fileFilters , optionArgs } ;
} ;
const { fileFilters : passthroughFileFilters , optionArgs : passthroughOptionArgs } =
parsePassthroughArgs ( passthroughArgs ) ;
const passthroughRequiresSingleRun = passthroughOptionArgs . some ( ( arg ) => {
if ( ! arg . startsWith ( "-" ) ) {
return false ;
}
const [ flag ] = arg . split ( "=" , 1 ) ;
return SINGLE _RUN _ONLY _FLAGS . has ( flag ) ;
} ) ;
const channelPrefixes = [ "src/telegram/" , "src/discord/" , "src/web/" , "src/browser/" , "src/line/" ] ;
const baseConfigPrefixes = [ "src/agents/" , "src/auto-reply/" , "src/commands/" , "test/" , "ui/" ] ;
const normalizeRepoPath = ( value ) => value . split ( path . sep ) . join ( "/" ) ;
const walkTestFiles = ( rootDir ) => {
if ( ! fs . existsSync ( rootDir ) ) {
return [ ] ;
}
const entries = fs . readdirSync ( rootDir , { withFileTypes : true } ) ;
const files = [ ] ;
for ( const entry of entries ) {
const fullPath = path . join ( rootDir , entry . name ) ;
if ( entry . isDirectory ( ) ) {
files . push ( ... walkTestFiles ( fullPath ) ) ;
continue ;
}
if ( ! entry . isFile ( ) ) {
continue ;
}
if (
fullPath . endsWith ( ".test.ts" ) ||
fullPath . endsWith ( ".live.test.ts" ) ||
fullPath . endsWith ( ".e2e.test.ts" )
) {
files . push ( normalizeRepoPath ( fullPath ) ) ;
}
}
return files ;
} ;
const allKnownTestFiles = [
... new Set ( [
... walkTestFiles ( "src" ) ,
... walkTestFiles ( "extensions" ) ,
... walkTestFiles ( "test" ) ,
... walkTestFiles ( path . join ( "ui" , "src" , "ui" ) ) ,
] ) ,
] ;
const inferTarget = ( fileFilter ) => {
const isolated = unitIsolatedFiles . includes ( fileFilter ) ;
if ( fileFilter . endsWith ( ".live.test.ts" ) ) {
return { owner : "live" , isolated } ;
}
if ( fileFilter . endsWith ( ".e2e.test.ts" ) ) {
return { owner : "e2e" , isolated } ;
}
if ( fileFilter . startsWith ( "extensions/" ) ) {
return { owner : "extensions" , isolated } ;
}
if ( fileFilter . startsWith ( "src/gateway/" ) ) {
return { owner : "gateway" , isolated } ;
}
if ( channelPrefixes . some ( ( prefix ) => fileFilter . startsWith ( prefix ) ) ) {
return { owner : "channels" , isolated } ;
}
if ( baseConfigPrefixes . some ( ( prefix ) => fileFilter . startsWith ( prefix ) ) ) {
return { owner : "base" , isolated } ;
}
if ( fileFilter . startsWith ( "src/" ) ) {
return { owner : "unit" , isolated } ;
}
return { owner : "base" , isolated } ;
} ;
const resolveFilterMatches = ( fileFilter ) => {
const normalizedFilter = normalizeRepoPath ( fileFilter ) ;
if ( fs . existsSync ( fileFilter ) ) {
const stats = fs . statSync ( fileFilter ) ;
if ( stats . isFile ( ) ) {
return [ normalizedFilter ] ;
}
if ( stats . isDirectory ( ) ) {
const prefix = normalizedFilter . endsWith ( "/" ) ? normalizedFilter : ` ${ normalizedFilter } / ` ;
return allKnownTestFiles . filter ( ( file ) => file . startsWith ( prefix ) ) ;
}
}
if ( /[*?[\]{}]/ . test ( normalizedFilter ) ) {
return allKnownTestFiles . filter ( ( file ) => path . matchesGlob ( file , normalizedFilter ) ) ;
}
return allKnownTestFiles . filter ( ( file ) => file . includes ( normalizedFilter ) ) ;
} ;
const createTargetedEntry = ( owner , isolated , filters ) => {
const name = isolated ? ` ${ owner } -isolated ` : owner ;
const forceForks = isolated ;
if ( owner === "unit" ) {
return {
name ,
args : [
"vitest" ,
"run" ,
"--config" ,
"vitest.unit.config.ts" ,
` --pool= ${ forceForks ? "forks" : useVmForks ? "vmForks" : "forks" } ` ,
... ( disableIsolation ? [ "--isolate=false" ] : [ ] ) ,
... filters ,
] ,
} ;
}
if ( owner === "extensions" ) {
return {
name ,
args : [
"vitest" ,
"run" ,
"--config" ,
"vitest.extensions.config.ts" ,
... ( forceForks ? [ "--pool=forks" ] : useVmForks ? [ "--pool=vmForks" ] : [ ] ) ,
... filters ,
] ,
} ;
}
if ( owner === "gateway" ) {
return {
name ,
args : [ "vitest" , "run" , "--config" , "vitest.gateway.config.ts" , "--pool=forks" , ... filters ] ,
} ;
}
if ( owner === "channels" ) {
return {
name ,
args : [
"vitest" ,
"run" ,
"--config" ,
"vitest.channels.config.ts" ,
... ( forceForks ? [ "--pool=forks" ] : [ ] ) ,
... filters ,
] ,
} ;
}
if ( owner === "live" ) {
return {
name ,
args : [ "vitest" , "run" , "--config" , "vitest.live.config.ts" , ... filters ] ,
} ;
}
if ( owner === "e2e" ) {
return {
name ,
args : [ "vitest" , "run" , "--config" , "vitest.e2e.config.ts" , ... filters ] ,
} ;
}
return {
name ,
args : [
"vitest" ,
"run" ,
"--config" ,
"vitest.config.ts" ,
... ( forceForks ? [ "--pool=forks" ] : [ ] ) ,
... filters ,
] ,
} ;
} ;
const targetedEntries = ( ( ) => {
if ( passthroughFileFilters . length === 0 ) {
return [ ] ;
}
const groups = passthroughFileFilters . reduce ( ( acc , fileFilter ) => {
const matchedFiles = resolveFilterMatches ( fileFilter ) ;
if ( matchedFiles . length === 0 ) {
const target = inferTarget ( normalizeRepoPath ( fileFilter ) ) ;
const key = ` ${ target . owner } : ${ target . isolated ? "isolated" : "default" } ` ;
const files = acc . get ( key ) ? ? [ ] ;
files . push ( normalizeRepoPath ( fileFilter ) ) ;
acc . set ( key , files ) ;
return acc ;
}
for ( const matchedFile of matchedFiles ) {
const target = inferTarget ( matchedFile ) ;
const key = ` ${ target . owner } : ${ target . isolated ? "isolated" : "default" } ` ;
const files = acc . get ( key ) ? ? [ ] ;
files . push ( matchedFile ) ;
acc . set ( key , files ) ;
}
return acc ;
} , new Map ( ) ) ;
return Array . from ( groups , ( [ key , filters ] ) => {
const [ owner , mode ] = key . split ( ":" ) ;
return createTargetedEntry ( owner , mode === "isolated" , [ ... new Set ( filters ) ] ) ;
} ) ;
} ) ( ) ;
2026-03-06 17:45:35 -05:00
const topLevelParallelEnabled = testProfile !== "low" && testProfile !== "serial" ;
2026-01-30 03:15:10 +01:00
const overrideWorkers = Number . parseInt ( process . env . OPENCLAW _TEST _WORKERS ? ? "" , 10 ) ;
2026-01-31 21:21:09 +09:00
const resolvedOverride =
Number . isFinite ( overrideWorkers ) && overrideWorkers > 0 ? overrideWorkers : null ;
2026-02-23 20:48:05 +02:00
const parallelGatewayEnabled =
process . env . OPENCLAW _TEST _PARALLEL _GATEWAY === "1" || ( ! isCI && highMemLocalHost ) ;
// Keep gateway serial by default except when explicitly requested or on high-memory local hosts.
2026-02-12 17:59:44 +00:00
const keepGatewaySerial =
isWindowsCi ||
process . env . OPENCLAW _TEST _SERIAL _GATEWAY === "1" ||
2026-02-15 07:40:13 -06:00
testProfile === "serial" ||
2026-02-23 20:48:05 +02:00
! parallelGatewayEnabled ;
2026-02-12 17:59:44 +00:00
const parallelRuns = keepGatewaySerial ? runs . filter ( ( entry ) => entry . name !== "gateway" ) : runs ;
const serialRuns = keepGatewaySerial ? runs . filter ( ( entry ) => entry . name === "gateway" ) : [ ] ;
2026-02-22 21:59:13 +00:00
const baseLocalWorkers = Math . max ( 4 , Math . min ( 16 , hostCpuCount ) ) ;
const loadAwareDisabledRaw = process . env . OPENCLAW _TEST _LOAD _AWARE ? . trim ( ) . toLowerCase ( ) ;
const loadAwareDisabled = loadAwareDisabledRaw === "0" || loadAwareDisabledRaw === "false" ;
const loadRatio =
! isCI && ! loadAwareDisabled && process . platform !== "win32" && hostCpuCount > 0
? os . loadavg ( ) [ 0 ] / hostCpuCount
: 0 ;
// Keep the fast-path unchanged on normal load; only throttle under extreme host pressure.
const extremeLoadScale = loadRatio >= 1.1 ? 0.75 : loadRatio >= 1 ? 0.85 : 1 ;
const localWorkers = Math . max ( 4 , Math . min ( 16 , Math . floor ( baseLocalWorkers * extremeLoadScale ) ) ) ;
2026-02-15 07:40:13 -06:00
const defaultWorkerBudget =
testProfile === "low"
? {
unit : 2 ,
unitIsolated : 1 ,
2026-02-25 12:16:17 +02:00
extensions : 4 ,
2026-02-15 07:40:13 -06:00
gateway : 1 ,
}
: testProfile === "serial"
? {
unit : 1 ,
unitIsolated : 1 ,
extensions : 1 ,
gateway : 1 ,
}
: testProfile === "max"
? {
unit : localWorkers ,
unitIsolated : Math . min ( 4 , localWorkers ) ,
extensions : Math . max ( 1 , Math . min ( 6 , Math . floor ( localWorkers / 2 ) ) ) ,
gateway : Math . max ( 1 , Math . min ( 2 , Math . floor ( localWorkers / 4 ) ) ) ,
}
2026-02-23 20:48:05 +02:00
: highMemLocalHost
? {
// High-memory local hosts can prioritize wall-clock speed.
unit : Math . max ( 4 , Math . min ( 14 , Math . floor ( ( localWorkers * 7 ) / 8 ) ) ) ,
unitIsolated : Math . max ( 1 , Math . min ( 2 , Math . floor ( localWorkers / 6 ) || 1 ) ) ,
extensions : Math . max ( 1 , Math . min ( 4 , Math . floor ( localWorkers / 4 ) ) ) ,
2026-02-24 04:39:29 +00:00
gateway : Math . max ( 2 , Math . min ( 6 , Math . floor ( localWorkers / 2 ) ) ) ,
2026-02-23 20:48:05 +02:00
}
: lowMemLocalHost
? {
// Sub-64 GiB local hosts are prone to OOM with large vmFork runs.
unit : 2 ,
unitIsolated : 1 ,
2026-02-25 12:16:17 +02:00
extensions : 4 ,
2026-02-23 20:48:05 +02:00
gateway : 1 ,
}
: {
// 64-95 GiB local hosts: conservative split with some parallel headroom.
unit : Math . max ( 2 , Math . min ( 8 , Math . floor ( localWorkers / 2 ) ) ) ,
unitIsolated : 1 ,
extensions : Math . max ( 1 , Math . min ( 4 , Math . floor ( localWorkers / 4 ) ) ) ,
gateway : 1 ,
} ;
2026-02-07 07:57:50 +00:00
2026-01-25 07:22:36 -05:00
// Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM.
2026-01-25 10:18:51 +05:30
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
2026-02-07 07:57:50 +00:00
const maxWorkersForRun = ( name ) => {
if ( resolvedOverride ) {
return resolvedOverride ;
}
if ( isCI && ! isMacOS ) {
return null ;
}
if ( isCI && isMacOS ) {
return 1 ;
}
2026-03-13 18:36:38 -05:00
if ( name === "unit-isolated" || name . endsWith ( "-isolated" ) ) {
2026-02-15 07:40:13 -06:00
return defaultWorkerBudget . unitIsolated ;
2026-02-13 04:30:39 +00:00
}
2026-02-07 07:57:50 +00:00
if ( name === "extensions" ) {
2026-02-15 07:40:13 -06:00
return defaultWorkerBudget . extensions ;
2026-02-07 07:57:50 +00:00
}
if ( name === "gateway" ) {
2026-02-15 07:40:13 -06:00
return defaultWorkerBudget . gateway ;
2026-02-07 07:57:50 +00:00
}
2026-02-15 07:40:13 -06:00
return defaultWorkerBudget . unit ;
2026-02-07 07:57:50 +00:00
} ;
2026-01-23 07:34:50 +00:00
2026-01-24 11:16:41 +00:00
const WARNING _SUPPRESSION _FLAGS = [
"--disable-warning=ExperimentalWarning" ,
"--disable-warning=DEP0040" ,
"--disable-warning=DEP0060" ,
2026-02-13 13:28:23 +00:00
"--disable-warning=MaxListenersExceededWarning" ,
2026-01-24 11:16:41 +00:00
] ;
2026-02-15 05:06:58 +00:00
const DEFAULT _CI _MAX _OLD _SPACE _SIZE _MB = 4096 ;
const maxOldSpaceSizeMb = ( ( ) => {
// CI can hit Node heap limits (especially on large suites). Allow override, default to 4GB.
const raw = process . env . OPENCLAW _TEST _MAX _OLD _SPACE _SIZE _MB ? ? "" ;
const parsed = Number . parseInt ( raw , 10 ) ;
if ( Number . isFinite ( parsed ) && parsed > 0 ) {
return parsed ;
}
if ( isCI && ! isWindows ) {
return DEFAULT _CI _MAX _OLD _SPACE _SIZE _MB ;
}
return null ;
} ) ( ) ;
2026-01-27 16:39:28 +00:00
const runOnce = ( entry , extraArgs = [ ] ) =>
2026-01-23 07:34:50 +00:00
new Promise ( ( resolve ) => {
2026-02-07 07:57:50 +00:00
const maxWorkers = maxWorkersForRun ( entry . name ) ;
2026-02-25 12:16:17 +02:00
// vmForks with a single worker has shown cross-file leakage in extension suites.
// Fall back to process forks when we intentionally clamp that lane to one worker.
const entryArgs =
entry . name === "extensions" && maxWorkers === 1 && entry . args . includes ( "--pool=vmForks" )
? entry . args . map ( ( arg ) => ( arg === "--pool=vmForks" ? "--pool=forks" : arg ) )
: entry . args ;
2026-01-27 16:39:28 +00:00
const args = maxWorkers
2026-02-07 08:27:50 +00:00
? [
2026-02-25 12:16:17 +02:00
... entryArgs ,
2026-02-07 08:27:50 +00:00
"--maxWorkers" ,
String ( maxWorkers ) ,
2026-02-12 17:59:44 +00:00
... silentArgs ,
2026-02-07 08:27:50 +00:00
... windowsCiArgs ,
... extraArgs ,
]
2026-03-01 13:03:06 -08:00
: [ ... entryArgs , ... silentArgs , ... windowsCiArgs , ... extraArgs ] ;
2026-01-24 11:16:41 +00:00
const nodeOptions = process . env . NODE _OPTIONS ? ? "" ;
const nextNodeOptions = WARNING _SUPPRESSION _FLAGS . reduce (
( acc , flag ) => ( acc . includes ( flag ) ? acc : ` ${ acc } ${ flag } ` . trim ( ) ) ,
nodeOptions ,
) ;
2026-02-15 05:06:58 +00:00
const heapFlag =
maxOldSpaceSizeMb && ! nextNodeOptions . includes ( "--max-old-space-size=" )
? ` --max-old-space-size= ${ maxOldSpaceSizeMb } `
: null ;
const resolvedNodeOptions = heapFlag
? ` ${ nextNodeOptions } ${ heapFlag } ` . trim ( )
: nextNodeOptions ;
2026-02-15 03:35:02 +00:00
let child ;
try {
child = spawn ( pnpm , args , {
stdio : "inherit" ,
2026-02-15 05:06:58 +00:00
env : { ... process . env , VITEST _GROUP : entry . name , NODE _OPTIONS : resolvedNodeOptions } ,
2026-02-15 03:35:02 +00:00
shell : isWindows ,
} ) ;
} catch ( err ) {
console . error ( ` [test-parallel] spawn failed: ${ String ( err ) } ` ) ;
resolve ( 1 ) ;
return ;
}
2026-01-23 07:34:50 +00:00
children . add ( child ) ;
2026-02-15 03:35:02 +00:00
child . on ( "error" , ( err ) => {
console . error ( ` [test-parallel] child error: ${ String ( err ) } ` ) ;
} ) ;
2026-01-23 07:34:50 +00:00
child . on ( "exit" , ( code , signal ) => {
children . delete ( child ) ;
resolve ( code ? ? ( signal ? 1 : 0 ) ) ;
} ) ;
} ) ;
2026-03-13 18:36:38 -05:00
const run = async ( entry , extraArgs = [ ] ) => {
2026-01-31 21:29:14 +09:00
if ( shardCount <= 1 ) {
2026-03-13 18:36:38 -05:00
return runOnce ( entry , extraArgs ) ;
2026-01-31 21:29:14 +09:00
}
2026-02-26 00:33:36 -06:00
if ( shardIndexOverride !== null ) {
2026-03-13 18:36:38 -05:00
return runOnce ( entry , [ "--shard" , ` ${ shardIndexOverride } / ${ shardCount } ` , ... extraArgs ] ) ;
2026-02-26 00:33:36 -06:00
}
2026-01-27 16:39:28 +00:00
for ( let shardIndex = 1 ; shardIndex <= shardCount ; shardIndex += 1 ) {
// eslint-disable-next-line no-await-in-loop
2026-03-13 18:36:38 -05:00
const code = await runOnce ( entry , [ "--shard" , ` ${ shardIndex } / ${ shardCount } ` , ... extraArgs ] ) ;
2026-01-31 21:29:14 +09:00
if ( code !== 0 ) {
return code ;
}
2026-01-27 16:39:28 +00:00
}
return 0 ;
} ;
2026-03-13 18:36:38 -05:00
const runEntries = async ( entries , extraArgs = [ ] ) => {
2026-03-06 17:45:35 -05:00
if ( topLevelParallelEnabled ) {
2026-03-13 18:36:38 -05:00
const codes = await Promise . all ( entries . map ( ( entry ) => run ( entry , extraArgs ) ) ) ;
2026-03-06 17:45:35 -05:00
return codes . find ( ( code ) => code !== 0 ) ;
}
for ( const entry of entries ) {
// eslint-disable-next-line no-await-in-loop
2026-03-13 18:36:38 -05:00
const code = await run ( entry , extraArgs ) ;
2026-03-06 17:45:35 -05:00
if ( code !== 0 ) {
return code ;
}
}
return undefined ;
} ;
2026-01-23 07:34:50 +00:00
const shutdown = ( signal ) => {
for ( const child of children ) {
child . kill ( signal ) ;
}
} ;
process . on ( "SIGINT" , ( ) => shutdown ( "SIGINT" ) ) ;
process . on ( "SIGTERM" , ( ) => shutdown ( "SIGTERM" ) ) ;
2026-03-13 18:36:38 -05:00
if ( targetedEntries . length > 0 ) {
if ( passthroughRequiresSingleRun && targetedEntries . length > 1 ) {
console . error (
"[test-parallel] The provided Vitest args require a single run, but the selected test filters span multiple wrapper configs. Run one target/config at a time." ,
) ;
process . exit ( 2 ) ;
}
const targetedParallelRuns = keepGatewaySerial
? targetedEntries . filter ( ( entry ) => entry . name !== "gateway" )
: targetedEntries ;
const targetedSerialRuns = keepGatewaySerial
? targetedEntries . filter ( ( entry ) => entry . name === "gateway" )
: [ ] ;
const failedTargetedParallel = await runEntries ( targetedParallelRuns , passthroughOptionArgs ) ;
if ( failedTargetedParallel !== undefined ) {
process . exit ( failedTargetedParallel ) ;
}
for ( const entry of targetedSerialRuns ) {
// eslint-disable-next-line no-await-in-loop
const code = await run ( entry , passthroughOptionArgs ) ;
if ( code !== 0 ) {
process . exit ( code ) ;
2026-02-15 03:35:02 +00:00
}
2026-03-13 18:36:38 -05:00
}
process . exit ( 0 ) ;
}
if ( passthroughRequiresSingleRun && passthroughOptionArgs . length > 0 ) {
console . error (
"[test-parallel] The provided Vitest args require a single run. Use the dedicated npm script for that workflow (for example `pnpm test:coverage`) or target a single test file/filter." ,
) ;
process . exit ( 2 ) ;
2026-02-06 18:03:03 -08:00
}
2026-03-13 18:36:38 -05:00
const failedParallel = await runEntries ( parallelRuns , passthroughOptionArgs ) ;
2026-01-23 11:36:28 +00:00
if ( failedParallel !== undefined ) {
process . exit ( failedParallel ) ;
}
for ( const entry of serialRuns ) {
// eslint-disable-next-line no-await-in-loop
2026-03-13 18:36:38 -05:00
const code = await run ( entry , passthroughOptionArgs ) ;
2026-01-23 11:36:28 +00:00
if ( code !== 0 ) {
process . exit ( code ) ;
}
}
process . exit ( 0 ) ;