mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* perf(ci): run qualified unit tests with native Bun * test(ci): keep a V8-only cache warm fixture * fix(ci): include shared timeout in PR wrapper inventory * perf(test): skip Bun bytecode cache during test teardown * test: handle worker fixture retirement notifications Handle rejected observer returns without delaying retirement, matching the worker pool contract, and avoid shadowing the lane fixture input helper. * perf(ci): qualify and tune Bun test workers Complete SQLite admission before Bun Vitest threads start and retain each worker generation's canonical cleanup decision in lifecycle fixtures. Keep proxy-agent fixture values out of Bun's native fetch cache and compare large socket payloads with full byte equality. Renew the 292-file native qualification from paired 2,518-case proof. Apply the existing UI JIT and allocator settings to ordinary unit-fast Bun/Vitest selections and their cache warmer, preserving Node and native Bun settings and dual-runtime release coverage. Proof: 208 routing/warming cases; 61 lifecycle cases on each runtime; 66 ordered proxy/fetch cases plus the canonical 11-case proxy owner on each runtime; independent P0-P2 review. The changed-file gate was interrupted by the local disk reserve guard after core typing and 14 core-test type shards passed. Tuned full-cohort performance remains to be measured; the completed untuned comparison was 4.60% slower than Node. * perf(test): expand native Bun coverage and reduce artifact IO * perf(test): yield between artifact verification batches * test: qualify native Bun cases after runtime integration * test: refresh native Bun qualification after main integration
1222 lines
38 KiB
TypeScript
1222 lines
38 KiB
TypeScript
// Runs Vitest through repo project selection, local scheduling policy, output
|
|
// watchdogs, and process-group cleanup.
|
|
import fs from "node:fs";
|
|
import { constants as osConstants } from "node:os";
|
|
import path from "node:path";
|
|
import { assertTestHomeSelection } from "../test/test-home-policy.mts";
|
|
import { agentVitestProjectOwners } from "../test/vitest/vitest.agents-paths.mjs";
|
|
import { toolingIsolatedTestFiles } from "../test/vitest/vitest.tooling-isolated-paths.mjs";
|
|
import {
|
|
isPluginControlUiPath,
|
|
isUiBrowserTestFile,
|
|
isUiTestTarget,
|
|
uiTimingTestFiles,
|
|
} from "../test/vitest/vitest.ui-paths.mjs";
|
|
import { boundaryTestFiles } from "../test/vitest/vitest.unit-paths.mjs";
|
|
import { parsePermissiveBooleanToken } from "./lib/arg-utils.mts";
|
|
import { resolveExtensionTestConfig } from "./lib/extension-test-plan.mts";
|
|
import { createGatewayServerTestTargetChunks } from "./lib/gateway-server-test-plan.mts";
|
|
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
|
import {
|
|
prepareE2eVitestRuntime,
|
|
resolveVitestCliEntry,
|
|
prepareVitestRuntime,
|
|
} from "./lib/vitest-build-prerequisites.mts";
|
|
import { createVitestCacheSlots, resolveVitestCacheRoot } from "./lib/vitest-cache-slots.mts";
|
|
import {
|
|
hasNonRunVitestSubcommand,
|
|
collectVitestFileFilters,
|
|
resolveBooleanModeFlag,
|
|
resolveExplicitVitestMode,
|
|
vitestOptionConsumesNextArg,
|
|
} from "./lib/vitest-cli-mode.mts";
|
|
import { parseVitestExecutionArgs } from "./lib/vitest-cli.mts";
|
|
import { resolveVitestHomeSelection } from "./lib/vitest-home-selection.mts";
|
|
import {
|
|
resolveVitestProcessEnv,
|
|
resolveVitestNodeArgs,
|
|
resolveRunVitestSpawnEnv,
|
|
resolveVitestNoOutputTimeoutMs,
|
|
resolveVitestNoOutputHeartbeatMs,
|
|
resolveVitestConfigArg,
|
|
normalizeVitestConfigPath,
|
|
matchesVitestConfigPath,
|
|
} from "./lib/vitest-process-env.mts";
|
|
import {
|
|
spawnOwnedVitestProcess,
|
|
runVitestCli,
|
|
type exitVitestBySignal,
|
|
} from "./lib/vitest-process.mts";
|
|
import {
|
|
resolveVitestRuntimeCliSelections,
|
|
shouldPrepareVitestCoreWorkers,
|
|
} from "./lib/vitest-runtime-selection.mts";
|
|
import {
|
|
resolveNativeBunTestCommand,
|
|
resolveVitestTestCommand,
|
|
} from "./lib/vitest-test-runtime.mts";
|
|
import {
|
|
createVitestUnhandledErrorDetector,
|
|
stripVitestAnsi,
|
|
writeVitestUnhandledErrorSummary,
|
|
} from "./lib/vitest-unhandled-errors.mts";
|
|
import { createVitestWorkerRun, type VitestWorkerRun } from "./lib/vitest-worker-run.mts";
|
|
import { createPnpmRunnerSpawnSpec, type PnpmRunnerParams } from "./pnpm-runner.mts";
|
|
import {
|
|
forwardSignalToVitestProcessGroup,
|
|
installVitestProcessGroupCleanup,
|
|
shouldUseDetachedVitestProcessGroup,
|
|
terminateVitestProcessGroupForTimeout,
|
|
} from "./vitest-process-group.mts";
|
|
|
|
type VitestPathFs = Pick<typeof fs, "existsSync" | "statSync">;
|
|
type WatchdogStream = {
|
|
on(event: string, listener: (...args: unknown[]) => void): unknown;
|
|
off(event: string, listener: (...args: unknown[]) => void): unknown;
|
|
};
|
|
type NodeSignal = keyof typeof osConstants.signals;
|
|
type VitestOutputStream = {
|
|
setEncoding(encoding: "utf8"): unknown;
|
|
on(event: "data", listener: (chunk: string) => void): unknown;
|
|
on(event: "end", listener: () => void): unknown;
|
|
};
|
|
type VitestOutputTarget = {
|
|
write(chunk: string): unknown;
|
|
};
|
|
|
|
const SUPPRESSED_VITEST_STDERR_PATTERNS = ["[PLUGIN_TIMINGS]"];
|
|
const UI_VITEST_CONFIG = "test/vitest/vitest.ui.config.ts";
|
|
const UI_BROWSER_VITEST_CONFIG = "test/vitest/vitest.ui-browser.config.ts";
|
|
const TOOLING_DOCKER_VITEST_CONFIG = "test/vitest/vitest.tooling-docker.config.ts";
|
|
const TOOLING_VITEST_CONFIG = "test/vitest/vitest.tooling.config.ts";
|
|
const GATEWAY_SERVER_VITEST_CONFIG = "test/vitest/vitest.gateway-server.config.ts";
|
|
const E2E_VITEST_CONFIG = "test/vitest/vitest.e2e.config.ts";
|
|
const E2E_TEST_PROCESS_COUNT = 4;
|
|
const TOOLING_EXCLUDED_TESTS = new Set([
|
|
...boundaryTestFiles,
|
|
"test/scripts/docker-build-helper.test.ts",
|
|
...toolingIsolatedTestFiles,
|
|
]);
|
|
const EXPLICIT_FILE_TARGET_RE = /\.(?:[cm]?[jt]sx?)$/u;
|
|
const EXPLICIT_TEST_FILE_RE = /\.(?:test|e2e|live)\.(?:[cm]?[jt]sx?)$/u;
|
|
const GLOB_PATTERN_CHARS_RE = /[*?[\]{}]/u;
|
|
const UNBOUNDED_CONFIG_ONLY_OPTIONS = [
|
|
"--changed",
|
|
"--coverage",
|
|
"--dir",
|
|
"--mergeReports",
|
|
"--outputFile",
|
|
"--project",
|
|
"--root",
|
|
"--shard",
|
|
];
|
|
|
|
function isErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
return error instanceof Error && "code" in error && error.code === code;
|
|
}
|
|
|
|
function isNodeSignal(signal: string): signal is NodeSignal {
|
|
return Object.hasOwn(osConstants.signals, signal);
|
|
}
|
|
|
|
function normalizeNodeSignal(signal: string | null): NodeSignal | null {
|
|
if (!signal) {
|
|
return null;
|
|
}
|
|
const unknownSignalMessage = `child process exited with unknown signal: ${signal}`;
|
|
if (!isNodeSignal(signal)) {
|
|
throw new Error(unknownSignalMessage);
|
|
}
|
|
return signal;
|
|
}
|
|
|
|
function hasVitestOption(argv: string[], option: string): boolean {
|
|
for (const arg of argv) {
|
|
if (arg === "--") {
|
|
return false;
|
|
}
|
|
if (arg === option || arg.startsWith(option + "=") || arg.startsWith(option + ".")) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function insertVitestTargets(argv: string[], targets: string[]): string[] {
|
|
const separatorIndex = argv.indexOf("--");
|
|
const insertionIndex = separatorIndex < 0 ? argv.length : separatorIndex;
|
|
return [...argv.slice(0, insertionIndex), ...targets, ...argv.slice(insertionIndex)];
|
|
}
|
|
|
|
/**
|
|
* Bounds shared-worker lifetime with fresh processes so broad non-isolated
|
|
* runs do not exhaust the worker heap.
|
|
*/
|
|
export function resolveBoundedVitestInvocations(
|
|
argv: string[],
|
|
options: {
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
gatewayServerTargetChunks?: string[][];
|
|
} = {},
|
|
): string[][] {
|
|
const config = resolveVitestConfigArg(argv);
|
|
const normalizedConfig = config === null ? "" : normalizeVitestConfigPath(config);
|
|
const env = options.env ?? process.env;
|
|
const cwd = options.cwd ?? process.cwd();
|
|
const mode = resolveExplicitVitestMode(argv);
|
|
const isE2E = matchesVitestConfigPath(normalizedConfig, E2E_VITEST_CONFIG);
|
|
if (
|
|
(!isE2E && !matchesVitestConfigPath(normalizedConfig, GATEWAY_SERVER_VITEST_CONFIG)) ||
|
|
mode === "watch" ||
|
|
hasExplicitDisabledRunFlag(argv) ||
|
|
(mode !== "run" && parsePermissiveBooleanToken(env.CI) !== true) ||
|
|
hasNonRunVitestSubcommand(argv) ||
|
|
hasAlternateVitestRootArg(argv) ||
|
|
collectExplicitProjectRouterTargetArgs(argv, cwd).length > 0 ||
|
|
[...UNBOUNDED_CONFIG_ONLY_OPTIONS, "--bail"].some((option) => hasVitestOption(argv, option))
|
|
) {
|
|
return [argv];
|
|
}
|
|
if (isE2E) {
|
|
// Let Vitest own include/exclude and shard membership. Filtered and report-producing
|
|
// calls retain one process so small selections and aggregate outputs keep their semantics.
|
|
if (
|
|
collectExplicitFileTargetArgs(argv, (arg) => arg !== "run").length > 0 ||
|
|
argv.includes("--") ||
|
|
[
|
|
"--exclude",
|
|
"--testNamePattern",
|
|
"-t",
|
|
"--tagsFilter",
|
|
"--sequence.sequencer",
|
|
"--reporter",
|
|
"--reporters",
|
|
"--listTags",
|
|
"--clearCache",
|
|
"--standalone",
|
|
"--help",
|
|
"-h",
|
|
"--version",
|
|
"-v",
|
|
].some((option) => hasVitestOption(argv, option))
|
|
) {
|
|
return [argv];
|
|
}
|
|
return Array.from({ length: E2E_TEST_PROCESS_COUNT }, (_, index) => [
|
|
...argv,
|
|
`--shard=${index + 1}/${E2E_TEST_PROCESS_COUNT}`,
|
|
]);
|
|
}
|
|
const chunks = options.gatewayServerTargetChunks ?? createGatewayServerTestTargetChunks(cwd);
|
|
return chunks.length > 1 ? chunks.map((targets) => insertVitestTargets(argv, targets)) : [argv];
|
|
}
|
|
|
|
/**
|
|
* Builds spawn options for the primary Vitest child process.
|
|
*/
|
|
export function resolveVitestSpawnParams(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
platform: NodeJS.Platform = process.platform,
|
|
): PnpmRunnerParams {
|
|
return {
|
|
env: resolveVitestProcessEnv(env),
|
|
detached: shouldUseDetachedVitestProcessGroup(platform),
|
|
stdio: ["inherit", "pipe", "pipe"],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Filters known noisy Vitest stderr lines after stripping ANSI escapes.
|
|
*/
|
|
export function shouldSuppressVitestStderrLine(line: string): boolean {
|
|
const normalizedLine = stripVitestAnsi(line);
|
|
return SUPPRESSED_VITEST_STDERR_PATTERNS.some((pattern) => normalizedLine.includes(pattern));
|
|
}
|
|
|
|
/**
|
|
* Detects pnpm exec node invocations so the wrapper can spawn Node directly.
|
|
*/
|
|
function resolveDirectNodeVitestArgs(pnpmArgs: string[]): string[] | null {
|
|
return pnpmArgs[0] === "exec" && pnpmArgs[1] === "node" ? pnpmArgs.slice(2) : null;
|
|
}
|
|
|
|
function hasExplicitVitestConfigArg(argv: string[]): boolean {
|
|
// Delegation consumes separators; keep config-bearing argv direct so its tail
|
|
// cannot become an active override in the project runner.
|
|
return argv.some(
|
|
(arg) =>
|
|
arg === "--config" || arg === "-c" || arg.startsWith("--config=") || arg.startsWith("-c="),
|
|
);
|
|
}
|
|
|
|
function isPathLikeExplicitFileArg(arg: string): boolean {
|
|
return (
|
|
path.isAbsolute(arg) || arg.startsWith("./") || arg.startsWith("../") || /[/\\]/u.test(arg)
|
|
);
|
|
}
|
|
|
|
function isExplicitFileTargetArg(arg: string): boolean {
|
|
if (!EXPLICIT_FILE_TARGET_RE.test(arg) || GLOB_PATTERN_CHARS_RE.test(arg)) {
|
|
return false;
|
|
}
|
|
return isPathLikeExplicitFileArg(arg);
|
|
}
|
|
|
|
function isExplicitTestFileArg(arg: string): boolean {
|
|
return EXPLICIT_TEST_FILE_RE.test(arg) && isExplicitFileTargetArg(arg);
|
|
}
|
|
|
|
function validateNativeBunTestFiles(files: string[], cwd: string): string[] {
|
|
if (
|
|
files.length === 0 ||
|
|
files.some((file) => {
|
|
const relative = path.relative(cwd, path.resolve(cwd, file)).replaceAll(path.sep, "/");
|
|
return !isExplicitTestFileArg(file) || file !== `./${relative}` || relative.startsWith("../");
|
|
})
|
|
) {
|
|
throw new Error("Native Bun tests require explicit ./relative.test.ts file operands");
|
|
}
|
|
const missing = resolveMissingExplicitTestFiles(files, cwd);
|
|
if (missing.length > 0) {
|
|
throw new Error(`Native Bun test files not found: ${missing.join(", ")}`);
|
|
}
|
|
for (const file of files) {
|
|
if (!fs.statSync(path.resolve(cwd, file)).isFile()) {
|
|
throw new Error(`Native Bun test operand is not a file: ${file}`);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function isDelegableBroadProjectRouterTarget(arg: string, cwd: string): boolean {
|
|
const relative = toRepoRelativeArg(arg, cwd).replace(/\/+$/u, "");
|
|
return (
|
|
relative === "test/scripts" ||
|
|
relative === "test/scripts/*.test.ts" ||
|
|
relative === "test/scripts/**/*.test.ts"
|
|
);
|
|
}
|
|
|
|
function isPathAtOrUnder(value: string, root: string): boolean {
|
|
return value === root || value.startsWith(`${root}/`);
|
|
}
|
|
|
|
function isOwnedAgentDirectoryTarget(arg: string, cwd: string, fsImpl: VitestPathFs): boolean {
|
|
const relative = toRepoRelativeArg(arg, cwd).replace(/\/+$/u, "");
|
|
return (
|
|
isPathAtOrUnder(relative, agentVitestProjectOwners.all.root) &&
|
|
isExplicitDirectoryTargetArg(arg, cwd, fsImpl)
|
|
);
|
|
}
|
|
|
|
function isOwnedExtensionRootTarget(arg: string, cwd: string, fsImpl: VitestPathFs): boolean {
|
|
const relative = toRepoRelativeArg(arg, cwd).replace(/\/+$/u, "");
|
|
const [root, extensionId, ...remainder] = relative.split("/");
|
|
if (
|
|
root !== "extensions" ||
|
|
!extensionId ||
|
|
remainder.length > 0 ||
|
|
!isExplicitDirectoryTargetArg(arg, cwd, fsImpl)
|
|
) {
|
|
return false;
|
|
}
|
|
// Extension roots delegate so the bounded planner owns process lifetime (#124413).
|
|
// Raw Vitest would run the workspace config as one process.
|
|
return resolveExtensionTestConfig(relative).length > 0;
|
|
}
|
|
|
|
function isExplicitProjectRouterTargetArg(
|
|
arg: string,
|
|
cwd = process.cwd(),
|
|
fsImpl: VitestPathFs = fs,
|
|
): boolean {
|
|
if (!isPathLikeExplicitFileArg(arg)) {
|
|
return false;
|
|
}
|
|
if (GLOB_PATTERN_CHARS_RE.test(arg)) {
|
|
return isDelegableBroadProjectRouterTarget(arg, cwd);
|
|
}
|
|
if (isExplicitFileTargetArg(arg)) {
|
|
return true;
|
|
}
|
|
const filePath = path.isAbsolute(arg) ? arg : path.resolve(cwd, arg);
|
|
return fsImpl.existsSync(filePath)
|
|
? isDelegableBroadProjectRouterTarget(arg, cwd) ||
|
|
isOwnedAgentDirectoryTarget(arg, cwd, fsImpl) ||
|
|
isPluginControlUiPath(toRepoRelativeArg(arg, cwd)) ||
|
|
isOwnedExtensionRootTarget(arg, cwd, fsImpl)
|
|
: path.extname(arg) === "" &&
|
|
/^(?:src|test|extensions|ui|packages|apps)\//u.test(toRepoRelativeArg(arg, cwd));
|
|
}
|
|
|
|
function collectExplicitFileTargetArgs(
|
|
argv: string[],
|
|
predicate: (arg: string) => boolean = isExplicitFileTargetArg,
|
|
): string[] {
|
|
return collectVitestFileFilters(argv).filter(predicate);
|
|
}
|
|
|
|
function collectExplicitProjectRouterTargetArgs(
|
|
argv: string[],
|
|
cwd = process.cwd(),
|
|
fsImpl: VitestPathFs = fs,
|
|
): string[] {
|
|
return collectExplicitFileTargetArgs(argv, (arg) =>
|
|
isExplicitProjectRouterTargetArg(arg, cwd, fsImpl),
|
|
);
|
|
}
|
|
|
|
function isExplicitDirectoryTargetArg(
|
|
arg: string,
|
|
cwd = process.cwd(),
|
|
fsImpl: VitestPathFs = fs,
|
|
): boolean {
|
|
if (!isPathLikeExplicitFileArg(arg) || GLOB_PATTERN_CHARS_RE.test(arg)) {
|
|
return false;
|
|
}
|
|
const targetPath = path.isAbsolute(arg) ? arg : path.resolve(cwd, arg);
|
|
try {
|
|
return fsImpl.statSync(targetPath).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function collectExplicitDirectoryTargetArgs(
|
|
argv: string[],
|
|
cwd = process.cwd(),
|
|
fsImpl: VitestPathFs = fs,
|
|
): string[] {
|
|
return collectExplicitFileTargetArgs(argv, (arg) =>
|
|
isExplicitDirectoryTargetArg(arg, cwd, fsImpl),
|
|
);
|
|
}
|
|
|
|
function collectExplicitTestFileArgs(argv: string[]): string[] {
|
|
return collectExplicitFileTargetArgs(argv, isExplicitTestFileArg);
|
|
}
|
|
|
|
/**
|
|
* Forces explicit test-file targets to fail when Vitest finds no matching tests.
|
|
*/
|
|
export async function resolveExplicitTestFileNoPassArgs(argv: string[]): Promise<string[]> {
|
|
if (collectExplicitTestFileArgs(argv).length === 0) {
|
|
return argv;
|
|
}
|
|
const policyArgs: string[] = [];
|
|
const spellings = new Set(["passWithNoTests"]);
|
|
for (const [index, arg] of argv.entries()) {
|
|
if (arg === "--") {
|
|
break;
|
|
}
|
|
const spelling = /^(?:--(?!-)(?:no-)?|-+no-)([^=.]+)/u.exec(arg)?.[1];
|
|
if (
|
|
!spelling ||
|
|
spelling.replace(/([a-z])-([a-z])/gu, (_, a: string, b: string) => a + b.toUpperCase()) !==
|
|
"passWithNoTests"
|
|
) {
|
|
continue;
|
|
}
|
|
policyArgs.push(arg);
|
|
spellings.add(spelling);
|
|
const value = argv[index + 1];
|
|
if (arg.replace(/[=]$/u, "") === `--${spelling}` && (value === "true" || value === "false")) {
|
|
policyArgs.push(value);
|
|
}
|
|
}
|
|
if (policyArgs.length) {
|
|
const { parseCLI } = await import("vitest/node");
|
|
// Validate only the scalar policy can overwrite; the child owns help/version
|
|
// and unrelated errors. parseCLI mutates its input, so always give it a copy.
|
|
parseCLI(["vitest", ...policyArgs], { allowUnknownOptions: true });
|
|
}
|
|
// CAC camelcases after parsing. Negate every raw spelling so a later alias
|
|
// cannot overwrite strict policy, without erasing caller scalar errors.
|
|
return insertVitestTargets(
|
|
argv,
|
|
Array.from(spellings, (name) => `--no-${name}`),
|
|
);
|
|
}
|
|
|
|
function hasAlternateVitestRootArg(argv: string[]): boolean {
|
|
return argv.some(
|
|
(arg) =>
|
|
arg === "--root" ||
|
|
arg === "-r" ||
|
|
arg === "--dir" ||
|
|
arg.startsWith("--root=") ||
|
|
arg.startsWith("--dir="),
|
|
);
|
|
}
|
|
|
|
function hasExplicitVitestProjectArg(argv: string[]): boolean {
|
|
return argv.some((arg) => arg === "--project" || arg.startsWith("--project="));
|
|
}
|
|
|
|
function hasExplicitDisabledRunFlag(argv: string[]): boolean {
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === undefined) {
|
|
break;
|
|
}
|
|
if (arg === "--") {
|
|
break;
|
|
}
|
|
const runFlag = resolveBooleanModeFlag(argv, index, "run");
|
|
if (!runFlag) {
|
|
if (vitestOptionConsumesNextArg(arg, argv[index + 1])) {
|
|
index += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (runFlag.consumedNext) {
|
|
index += 1;
|
|
}
|
|
if (!runFlag.value) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function resolveDelegatedVitestArgs(argv: string[]): string[] {
|
|
const positionalArgs: string[] = [];
|
|
const optionArgs: string[] = [];
|
|
let canRemoveRunSubcommand = true;
|
|
let passthrough = false;
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === undefined) {
|
|
break;
|
|
}
|
|
if (arg === "--") {
|
|
passthrough = true;
|
|
canRemoveRunSubcommand = false;
|
|
continue;
|
|
}
|
|
if (passthrough) {
|
|
optionArgs.push(arg);
|
|
continue;
|
|
}
|
|
if (vitestOptionConsumesNextArg(arg, argv[index + 1])) {
|
|
optionArgs.push(arg);
|
|
const optionValue = argv[index + 1];
|
|
if (optionValue !== undefined) {
|
|
optionArgs.push(optionValue);
|
|
index += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (arg.startsWith("-")) {
|
|
optionArgs.push(arg);
|
|
continue;
|
|
}
|
|
if (canRemoveRunSubcommand && arg === "run") {
|
|
canRemoveRunSubcommand = false;
|
|
continue;
|
|
}
|
|
canRemoveRunSubcommand = false;
|
|
positionalArgs.push(arg);
|
|
}
|
|
return optionArgs.length > 0 ? [...positionalArgs, "--", ...optionArgs] : positionalArgs;
|
|
}
|
|
|
|
/**
|
|
* Delegates explicit path runs to the repo test-projects runner.
|
|
*/
|
|
export function resolveTestProjectsDelegationArgs(
|
|
argv: string[],
|
|
cwd = process.cwd(),
|
|
): string[] | null {
|
|
if (
|
|
hasExplicitVitestConfigArg(argv) ||
|
|
hasAlternateVitestRootArg(argv) ||
|
|
hasExplicitVitestProjectArg(argv) ||
|
|
resolveExplicitVitestMode(argv) === "watch" ||
|
|
hasNonRunVitestSubcommand(argv) ||
|
|
hasExplicitDisabledRunFlag(argv) ||
|
|
collectExplicitProjectRouterTargetArgs(argv, cwd).length === 0
|
|
) {
|
|
return null;
|
|
}
|
|
return resolveDelegatedVitestArgs(argv);
|
|
}
|
|
|
|
/**
|
|
* Lists explicit test file targets missing from the current checkout.
|
|
*/
|
|
export function resolveMissingExplicitTestFiles(
|
|
argv: string[],
|
|
cwd = process.cwd(),
|
|
fsImpl: { existsSync(filePath: string): boolean } = fs,
|
|
): string[] {
|
|
if (hasExplicitVitestConfigArg(argv) || hasAlternateVitestRootArg(argv)) {
|
|
return [];
|
|
}
|
|
return collectExplicitFileTargetArgs(argv)
|
|
.filter((arg) => {
|
|
const filePath = path.isAbsolute(arg) ? arg : path.resolve(cwd, arg);
|
|
return !fsImpl.existsSync(filePath);
|
|
})
|
|
.map((arg) => toRepoRelativeArg(arg, cwd));
|
|
}
|
|
|
|
function toRepoRelativeArg(arg: string, cwd: string): string {
|
|
const normalized = path.isAbsolute(arg) ? path.relative(cwd, arg) : arg;
|
|
return normalized.replaceAll(path.sep, "/").replace(/^\.\//u, "");
|
|
}
|
|
|
|
function withImplicitVitestConfig(argv: string[], config: string): string[] {
|
|
if (argv[0] === "run") {
|
|
return ["run", "--config", config, ...argv.slice(1)];
|
|
}
|
|
return ["--config", config, ...argv];
|
|
}
|
|
|
|
function isToolingTestTarget(target: string): boolean {
|
|
return (
|
|
target.startsWith("test/") && target.endsWith(".test.ts") && !TOOLING_EXCLUDED_TESTS.has(target)
|
|
);
|
|
}
|
|
|
|
function isToolingDockerTestTarget(target: string): boolean {
|
|
return target === "test/scripts/docker-build-helper.test.ts";
|
|
}
|
|
|
|
/**
|
|
* Resolves config defaults and explicit-file handling for wrapper-inferred runs.
|
|
*/
|
|
export function resolveImplicitVitestArgs(argv: string[], cwd = process.cwd()): string[] {
|
|
if (hasExplicitVitestConfigArg(argv)) {
|
|
return argv;
|
|
}
|
|
const separatorIndex = argv.indexOf("--");
|
|
const optionArgs = separatorIndex < 0 ? argv : argv.slice(0, separatorIndex);
|
|
const hasExplicitIsolation = optionArgs.some(
|
|
(arg) => arg === "--isolate" || arg === "--no-isolate" || arg.startsWith("--isolate="),
|
|
);
|
|
if (!hasExplicitIsolation && collectExplicitDirectoryTargetArgs(argv, cwd).length > 1) {
|
|
// Mixed directory selectors can activate overlapping Vitest projects.
|
|
// Isolate their module caches so one project's mocks cannot poison another.
|
|
const resolved = [...argv];
|
|
resolved.splice(separatorIndex < 0 ? resolved.length : separatorIndex, 0, "--isolate");
|
|
return resolved;
|
|
}
|
|
if (collectExplicitDirectoryTargetArgs(argv, cwd).length > 0) {
|
|
return argv;
|
|
}
|
|
const testTargets = collectVitestFileFilters(argv)
|
|
.filter((arg) => !arg.startsWith("-") && arg.endsWith(".test.ts"))
|
|
.map((arg) => toRepoRelativeArg(arg, cwd));
|
|
if (testTargets.length > 0 && testTargets.every(isToolingDockerTestTarget)) {
|
|
return withImplicitVitestConfig(argv, TOOLING_DOCKER_VITEST_CONFIG);
|
|
}
|
|
if (testTargets.length > 0 && testTargets.every(isToolingTestTarget)) {
|
|
return withImplicitVitestConfig(argv, TOOLING_VITEST_CONFIG);
|
|
}
|
|
// Glob selectors can span both browser owners; the root project matrix partitions them.
|
|
if (testTargets.some((target) => /[*?[\]{}]|[@+!]\(/u.test(target))) {
|
|
return argv;
|
|
}
|
|
if (testTargets.length > 0 && testTargets.every(isUiBrowserTestFile)) {
|
|
return withImplicitVitestConfig(argv, UI_BROWSER_VITEST_CONFIG);
|
|
}
|
|
if (
|
|
testTargets.length > 0 &&
|
|
testTargets.every((target) => isUiTestTarget(target) && !isUiBrowserTestFile(target))
|
|
) {
|
|
// Mixed timing/ordinary UI selection needs the root matrix to preserve groups.
|
|
if (testTargets.some((target) => uiTimingTestFiles.includes(target))) {
|
|
return argv;
|
|
}
|
|
return withImplicitVitestConfig(argv, UI_VITEST_CONFIG);
|
|
}
|
|
return argv;
|
|
}
|
|
|
|
/**
|
|
* Installs the no-output watchdog for long-running Vitest children.
|
|
*/
|
|
export function installVitestNoOutputWatchdog(params: {
|
|
streams?: Array<WatchdogStream | null>;
|
|
timeoutMs: number | null;
|
|
heartbeatMs?: number | null;
|
|
forceKillAfterMs?: number;
|
|
log?: (message: string) => void;
|
|
onTimeout?: () => void;
|
|
onForceKill?: () => void;
|
|
setTimeoutFn?: typeof setTimeout;
|
|
clearTimeoutFn?: typeof clearTimeout;
|
|
}): { recordActivity: () => void; teardown: () => void } {
|
|
const timeoutMs = params.timeoutMs;
|
|
if (!timeoutMs || timeoutMs <= 0) {
|
|
return { recordActivity: () => {}, teardown: () => {} };
|
|
}
|
|
|
|
const setTimeoutFn = params.setTimeoutFn ?? setTimeout;
|
|
const clearTimeoutFn = params.clearTimeoutFn ?? clearTimeout;
|
|
const forceKillAfterMs = params.forceKillAfterMs ?? 5_000;
|
|
const heartbeatMs =
|
|
params.heartbeatMs && params.heartbeatMs > 0 && params.heartbeatMs < timeoutMs
|
|
? params.heartbeatMs
|
|
: null;
|
|
const streams =
|
|
params.streams?.filter((stream): stream is WatchdogStream => stream !== null) ?? [];
|
|
|
|
let active = true;
|
|
let silenceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let silentForMs = 0;
|
|
let timedOut = false;
|
|
|
|
const clearHeartbeatTimer = () => {
|
|
if (heartbeatTimer !== null) {
|
|
clearTimeoutFn(heartbeatTimer);
|
|
heartbeatTimer = null;
|
|
}
|
|
};
|
|
|
|
const clearForceKillTimer = () => {
|
|
if (forceKillTimer !== null) {
|
|
clearTimeoutFn(forceKillTimer);
|
|
forceKillTimer = null;
|
|
}
|
|
};
|
|
|
|
const clearSilenceTimer = () => {
|
|
if (silenceTimer !== null) {
|
|
clearTimeoutFn(silenceTimer);
|
|
silenceTimer = null;
|
|
}
|
|
};
|
|
|
|
const scheduleHeartbeatTimer = () => {
|
|
if (!active || heartbeatMs === null) {
|
|
return;
|
|
}
|
|
clearHeartbeatTimer();
|
|
heartbeatTimer = setTimeoutFn(() => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
silentForMs += heartbeatMs;
|
|
params.log?.(`[vitest] still running with no output for ${silentForMs}ms.`);
|
|
if (silentForMs + heartbeatMs < timeoutMs) {
|
|
scheduleHeartbeatTimer();
|
|
}
|
|
}, heartbeatMs);
|
|
};
|
|
|
|
const resetSilenceTimer = () => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
clearSilenceTimer();
|
|
silentForMs = 0;
|
|
scheduleHeartbeatTimer();
|
|
silenceTimer = setTimeoutFn(() => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
clearHeartbeatTimer();
|
|
timedOut = true;
|
|
params.log?.(
|
|
`[vitest] no output for ${timeoutMs}ms; terminating stalled Vitest process group.`,
|
|
);
|
|
if (forceKillAfterMs > 0) {
|
|
clearForceKillTimer();
|
|
forceKillTimer = setTimeoutFn(() => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
params.log?.(
|
|
`[vitest] process group still alive after ${forceKillAfterMs}ms; sending SIGKILL.`,
|
|
);
|
|
params.onForceKill?.();
|
|
}, forceKillAfterMs);
|
|
}
|
|
params.onTimeout?.();
|
|
}, timeoutMs);
|
|
};
|
|
|
|
const handleActivity = () => {
|
|
if (timedOut) {
|
|
return;
|
|
}
|
|
clearForceKillTimer();
|
|
resetSilenceTimer();
|
|
};
|
|
|
|
const listeners = streams.map((stream) => {
|
|
const handler = () => {
|
|
handleActivity();
|
|
};
|
|
stream.on("data", handler);
|
|
return { stream, handler };
|
|
});
|
|
|
|
resetSilenceTimer();
|
|
|
|
return {
|
|
recordActivity: handleActivity,
|
|
teardown() {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
active = false;
|
|
clearSilenceTimer();
|
|
clearForceKillTimer();
|
|
clearHeartbeatTimer();
|
|
for (const { stream, handler } of listeners) {
|
|
stream.off("data", handler);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Forwards child output while optionally suppressing complete stderr lines.
|
|
*/
|
|
function forwardVitestOutput(
|
|
stream: VitestOutputStream | null,
|
|
target: VitestOutputTarget,
|
|
shouldSuppressLine: (line: string) => boolean = () => false,
|
|
observeLine: (line: string) => void = () => {},
|
|
): Promise<void> {
|
|
if (!stream) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
let buffered = "";
|
|
const forwardLine = (line: string) => {
|
|
if (shouldSuppressLine(line)) {
|
|
return;
|
|
}
|
|
observeLine(line);
|
|
target.write(line);
|
|
};
|
|
stream.setEncoding("utf8");
|
|
stream.on("data", (chunk) => {
|
|
buffered += chunk;
|
|
while (true) {
|
|
const newlineIndex = buffered.indexOf("\n");
|
|
if (newlineIndex === -1) {
|
|
break;
|
|
}
|
|
const line = buffered.slice(0, newlineIndex + 1);
|
|
buffered = buffered.slice(newlineIndex + 1);
|
|
forwardLine(line);
|
|
}
|
|
});
|
|
return new Promise((resolve) => {
|
|
stream.on("end", () => {
|
|
if (buffered.length > 0) {
|
|
forwardLine(buffered);
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Joins watched Vitest processes and keeps expired deadlines failed after cooperative exits.
|
|
*/
|
|
export function spawnWatchedVitestProcess({
|
|
pnpmArgs = [],
|
|
nativeBunFiles,
|
|
spawnParams,
|
|
env,
|
|
onNoOutputTimeout,
|
|
workerRun,
|
|
homeMode = nativeBunFiles
|
|
? "hermetic"
|
|
: resolveVitestHomeSelection(pnpmArgs, { cwd: spawnParams.cwd, env }),
|
|
}: {
|
|
spawnParams: PnpmRunnerParams;
|
|
env: NodeJS.ProcessEnv;
|
|
onNoOutputTimeout?: () => void;
|
|
} & (
|
|
| {
|
|
pnpmArgs: string[];
|
|
nativeBunFiles?: never;
|
|
workerRun?: VitestWorkerRun;
|
|
homeMode?: Parameters<typeof spawnOwnedVitestProcess>[0]["homeMode"];
|
|
}
|
|
| {
|
|
nativeBunFiles: string[];
|
|
pnpmArgs?: never;
|
|
workerRun?: never;
|
|
homeMode?: "hermetic";
|
|
}
|
|
)) {
|
|
if (homeMode !== "tooling") {
|
|
assertTestHomeSelection(env, homeMode);
|
|
}
|
|
let timeoutCompletion: Promise<boolean> | null = null;
|
|
const directNodeArgs = resolveDirectNodeVitestArgs(pnpmArgs);
|
|
const testCommand = nativeBunFiles
|
|
? resolveNativeBunTestCommand(
|
|
validateNativeBunTestFiles(nativeBunFiles, spawnParams.cwd ?? process.cwd()),
|
|
)
|
|
: directNodeArgs
|
|
? resolveVitestTestCommand(directNodeArgs, env)
|
|
: undefined;
|
|
if (workerRun && testCommand) {
|
|
// Give either runtime the same owned compiled-subprocess generation.
|
|
const cliIndex = testCommand.args.findIndex((arg) => path.basename(arg) === "vitest.mjs");
|
|
if (cliIndex < 0) {
|
|
throw new Error("Compiled subprocess owner requires a native Vitest CLI spec");
|
|
}
|
|
testCommand.args.splice(
|
|
cliIndex,
|
|
0,
|
|
path.join(resolveRepoRoot(import.meta.url), "scripts/lib/vitest-worker-bootstrap.mts"),
|
|
workerRun.descriptor.directory,
|
|
);
|
|
}
|
|
const childSpawnParams: PnpmRunnerParams = workerRun
|
|
? {
|
|
...spawnParams,
|
|
stdio: [
|
|
...(Array.isArray(spawnParams.stdio)
|
|
? spawnParams.stdio
|
|
: [
|
|
spawnParams.stdio ?? "pipe",
|
|
spawnParams.stdio ?? "pipe",
|
|
spawnParams.stdio ?? "pipe",
|
|
]),
|
|
"ipc",
|
|
],
|
|
}
|
|
: spawnParams;
|
|
const { child, completion: childCompletion } = spawnOwnedVitestProcess({
|
|
...(testCommand
|
|
? {
|
|
...testCommand,
|
|
options: testCommand.envOverrides
|
|
? {
|
|
...childSpawnParams,
|
|
env: {
|
|
...(childSpawnParams.env ?? process.env),
|
|
...testCommand.envOverrides,
|
|
},
|
|
}
|
|
: childSpawnParams,
|
|
}
|
|
: createPnpmRunnerSpawnSpec({ pnpmArgs, ...childSpawnParams })),
|
|
homeMode,
|
|
});
|
|
const childCleanup = installVitestProcessGroupCleanup({
|
|
child,
|
|
forceSignal: "SIGKILL",
|
|
forceSignalDelayMs: 100,
|
|
});
|
|
const noOutputWatchdog = installVitestNoOutputWatchdog({
|
|
streams: [child.stdout, child.stderr],
|
|
timeoutMs: resolveVitestNoOutputTimeoutMs(env),
|
|
heartbeatMs: resolveVitestNoOutputHeartbeatMs(env),
|
|
log: (message) => {
|
|
console.error(message);
|
|
},
|
|
onTimeout: () => {
|
|
const termination = terminateVitestProcessGroupForTimeout({
|
|
child,
|
|
kill: process.kill.bind(process),
|
|
log: (message) => {
|
|
console.error(message);
|
|
},
|
|
onTimeout: onNoOutputTimeout,
|
|
});
|
|
timeoutCompletion = termination.diagnostics.then(() => true);
|
|
},
|
|
onForceKill: () => {
|
|
forwardSignalToVitestProcessGroup({
|
|
child,
|
|
signal: "SIGKILL",
|
|
kill: process.kill.bind(process),
|
|
});
|
|
},
|
|
});
|
|
const unhandledErrors = createVitestUnhandledErrorDetector();
|
|
const forwardedOutput = Promise.all([
|
|
forwardVitestOutput(child.stdout, process.stdout, undefined, unhandledErrors.observe),
|
|
forwardVitestOutput(
|
|
child.stderr,
|
|
process.stderr,
|
|
shouldSuppressVitestStderrLine,
|
|
unhandledErrors.observe,
|
|
),
|
|
]);
|
|
|
|
const teardown = () => {
|
|
childCleanup.teardown();
|
|
noOutputWatchdog.teardown();
|
|
};
|
|
const completion = Promise.all([childCompletion, forwardedOutput])
|
|
.then(async ([{ code: childCode, signal, groupJoined }]) => {
|
|
const code = (await timeoutCompletion) && childCode === 0 ? 1 : childCode;
|
|
const result = unhandledErrors.finish();
|
|
if (result) {
|
|
writeVitestUnhandledErrorSummary(result, env);
|
|
}
|
|
return { code, signal: normalizeNodeSignal(signal), groupJoined };
|
|
})
|
|
.finally(teardown);
|
|
|
|
return {
|
|
child,
|
|
completion: workerRun
|
|
? workerRun.borrow(child, completion, noOutputWatchdog.recordActivity)
|
|
: completion,
|
|
getForwardedSignal: childCleanup.getForwardedSignal,
|
|
teardown,
|
|
};
|
|
}
|
|
|
|
export async function runVitest(
|
|
exitBySignal: typeof exitVitestBySignal,
|
|
argv: string[] = process.argv.slice(2),
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Promise<void> {
|
|
if (argv[0] === "--native-bun") {
|
|
if (env.OPENCLAW_NATIVE_BUN_PARENT_IPC === "1" && !process.connected) {
|
|
throw new Error("Native Bun test parent IPC disconnected before admission");
|
|
}
|
|
const spawnEnv = resolveRunVitestSpawnEnv(env, ["run"]);
|
|
let watched: ReturnType<typeof spawnWatchedVitestProcess> | undefined;
|
|
let interrupted: NodeJS.Signals | undefined;
|
|
const onSignal = (signal: NodeJS.Signals) => {
|
|
interrupted ??= signal;
|
|
};
|
|
const onParentDisconnect = () => {
|
|
interrupted ??= "SIGTERM";
|
|
process.kill(process.pid, "SIGTERM");
|
|
};
|
|
process.on("SIGINT", onSignal);
|
|
process.on("SIGTERM", onSignal);
|
|
if (process.connected) {
|
|
process.once("disconnect", onParentDisconnect);
|
|
process.channel?.unref();
|
|
}
|
|
try {
|
|
watched = spawnWatchedVitestProcess({
|
|
nativeBunFiles: argv.slice(1),
|
|
spawnParams: resolveVitestSpawnParams(spawnEnv),
|
|
env: spawnEnv,
|
|
});
|
|
const result = await watched.completion;
|
|
interrupted ??= result.signal ?? undefined;
|
|
process.exitCode = result.code ?? 1;
|
|
} finally {
|
|
const signal = interrupted ?? watched?.getForwardedSignal();
|
|
process.off("disconnect", onParentDisconnect);
|
|
process.off("SIGINT", onSignal);
|
|
process.off("SIGTERM", onSignal);
|
|
if (signal) {
|
|
await exitBySignal(signal);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (argv.some((arg) => arg === "--isolated-image" || arg.startsWith("--isolated-image="))) {
|
|
const { parseIsolatedVitestArgs, runIsolatedVitest } =
|
|
await import("./lib/vitest-isolated.mts");
|
|
const isolated = parseIsolatedVitestArgs(argv);
|
|
if (isolated) {
|
|
process.exitCode = await runIsolatedVitest(
|
|
resolveRepoRoot(import.meta.url),
|
|
isolated.image,
|
|
isolated.args,
|
|
env,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
if (argv.length === 0) {
|
|
console.error("usage: node scripts/run-vitest.mjs <vitest args...>");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const missingTestFiles = resolveMissingExplicitTestFiles(argv);
|
|
if (missingTestFiles.length > 0) {
|
|
console.error(
|
|
[
|
|
"[vitest] explicit test/source file(s) not found:",
|
|
...missingTestFiles.map((file) => ` - ${file}`),
|
|
].join("\n"),
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const delegatedArgs = resolveTestProjectsDelegationArgs(argv);
|
|
if (delegatedArgs) {
|
|
const { runTestProjects } = await import("./test-projects-run.mts");
|
|
return runTestProjects(exitBySignal, delegatedArgs, env);
|
|
}
|
|
|
|
const vitestArgs = resolveImplicitVitestArgs(argv);
|
|
assertTestHomeSelection(env, resolveVitestHomeSelection(vitestArgs, { env }));
|
|
const repoRoot = resolveRepoRoot(import.meta.url);
|
|
let vitestCliEntry;
|
|
try {
|
|
// Resolution owns hydrated-module linking and missing-dependency guidance;
|
|
// it must finish before the execution parser imports Vitest.
|
|
vitestCliEntry = resolveVitestCliEntry({ baseDir: repoRoot });
|
|
} catch (error) {
|
|
if (isErrorWithCode(error, "OPENCLAW_MISSING_VITEST")) {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
const { parseCLI } = await import("vitest/node");
|
|
const execution = parseVitestExecutionArgs(vitestArgs, parseCLI);
|
|
const invocations = execution
|
|
? resolveBoundedVitestInvocations(vitestArgs, { env })
|
|
: [vitestArgs];
|
|
const sourceMode =
|
|
!execution || execution.options.watch || resolveExplicitVitestMode(vitestArgs) === "watch";
|
|
const config = resolveVitestConfigArg(vitestArgs);
|
|
const relativeConfig = config
|
|
? toRepoRelativeArg(path.resolve(config), repoRoot)
|
|
: config === null && !sourceMode && process.cwd() === repoRoot
|
|
? "vitest.config.ts"
|
|
: "";
|
|
const invocationEnv =
|
|
invocations.length > 1 && relativeConfig === E2E_VITEST_CONFIG
|
|
? { ...env, ...(await prepareE2eVitestRuntime(env)) }
|
|
: env;
|
|
// Canonical configs have known project scopes. Custom roots/projects keep
|
|
// their own setup; never infer their runtime selection from a config name.
|
|
const canonicalSelection =
|
|
execution &&
|
|
!hasAlternateVitestRootArg(vitestArgs) &&
|
|
!hasExplicitVitestProjectArg(vitestArgs) &&
|
|
!hasNonRunVitestSubcommand(vitestArgs) &&
|
|
!hasExplicitDisabledRunFlag(vitestArgs);
|
|
if (canonicalSelection && relativeConfig) {
|
|
const code = await prepareVitestRuntime(
|
|
invocations.flatMap((cliArgs) =>
|
|
resolveVitestRuntimeCliSelections(relativeConfig, cliArgs, invocationEnv),
|
|
),
|
|
invocationEnv,
|
|
);
|
|
if (code !== 0) {
|
|
process.exitCode = code;
|
|
return;
|
|
}
|
|
}
|
|
const workers = sourceMode
|
|
? undefined
|
|
: createVitestWorkerRun(resolveVitestProcessEnv(invocationEnv));
|
|
const withCacheSlot = createVitestCacheSlots();
|
|
let interrupted: NodeJS.Signals | undefined;
|
|
let preparingWorkers = false;
|
|
const onSignal = (signal: NodeJS.Signals) => {
|
|
interrupted ??= signal;
|
|
if (preparingWorkers) {
|
|
// No borrower can finish admission yet; cancel through the existing owner.
|
|
void workers?.dispose().catch(() => {});
|
|
}
|
|
};
|
|
// The invocation outlives child-scoped handlers when admission or final
|
|
// verification is still reading. Retain signal ownership through disposal.
|
|
process.on("SIGINT", onSignal);
|
|
process.on("SIGTERM", onSignal);
|
|
try {
|
|
if (
|
|
workers &&
|
|
canonicalSelection &&
|
|
config &&
|
|
invocations.some((args) =>
|
|
shouldPrepareVitestCoreWorkers(relativeConfig, args, invocationEnv),
|
|
)
|
|
) {
|
|
preparingWorkers = true;
|
|
try {
|
|
await workers.prepare();
|
|
} finally {
|
|
preparingWorkers = false;
|
|
}
|
|
}
|
|
if (interrupted) {
|
|
return;
|
|
}
|
|
let failedExitCode = 0;
|
|
for (const [index, invocation] of invocations.entries()) {
|
|
const guardedVitestArgs = await resolveExplicitTestFileNoPassArgs(invocation);
|
|
const spawnEnv = resolveRunVitestSpawnEnv(invocationEnv, guardedVitestArgs);
|
|
if (invocations.length > 1) {
|
|
console.error("[vitest] bounded process " + (index + 1) + "/" + invocations.length);
|
|
}
|
|
const { code, signal } = await withCacheSlot(
|
|
{
|
|
config: config ?? "",
|
|
env: spawnEnv,
|
|
watchMode: sourceMode,
|
|
...(!sourceMode &&
|
|
config &&
|
|
!spawnEnv.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH?.trim() &&
|
|
!hasVitestOption(guardedVitestArgs, "--fsModuleCachePath")
|
|
? {
|
|
cacheAssignment: {
|
|
kind: "scheduler" as const,
|
|
root: resolveVitestCacheRoot(spawnEnv),
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
async ({ env: cacheEnv }) => {
|
|
const handle = spawnWatchedVitestProcess({
|
|
workerRun: workers,
|
|
pnpmArgs: [
|
|
"exec",
|
|
"node",
|
|
...resolveVitestNodeArgs(invocationEnv),
|
|
vitestCliEntry,
|
|
...guardedVitestArgs,
|
|
],
|
|
spawnParams: resolveVitestSpawnParams(cacheEnv),
|
|
env: cacheEnv,
|
|
});
|
|
const result = await handle.completion;
|
|
return { ...result, signal: handle.getForwardedSignal() ?? result.signal };
|
|
},
|
|
);
|
|
interrupted ??= signal ?? undefined;
|
|
const exitCode = code ?? 1;
|
|
// Ordinary test failures must not hide later files; interruptions stop
|
|
// admission and are re-raised only after the invocation has been disposed.
|
|
if (interrupted || (exitCode !== 0 && exitCode !== 1)) {
|
|
process.exitCode = exitCode;
|
|
return;
|
|
}
|
|
failedExitCode ||= exitCode;
|
|
}
|
|
process.exitCode = failedExitCode;
|
|
} finally {
|
|
try {
|
|
await workers?.dispose().catch((error: unknown) => {
|
|
process.exitCode ||= 1;
|
|
console.error(error);
|
|
});
|
|
} finally {
|
|
process.off("SIGINT", onSignal);
|
|
process.off("SIGTERM", onSignal);
|
|
if (interrupted) {
|
|
await exitBySignal(interrupted);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
// The project owner imports our spawn helpers; top-level await would deadlock
|
|
// its dynamic import when this module is also the native entrypoint.
|
|
void runVitestCli("vitest", runVitest);
|
|
}
|