Files
openclaw/scripts/run-vitest.mjs
2026-05-27 09:41:53 +02:00

624 lines
16 KiB
JavaScript

import { spawn } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { isUiTestTarget, isUnitUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs";
import { resolveLocalVitestEnv } from "./lib/vitest-local-scheduling.mjs";
import { spawnPnpmRunner } from "./pnpm-runner.mjs";
import {
forwardSignalToVitestProcessGroup,
installVitestProcessGroupCleanup,
shouldUseDetachedVitestProcessGroup,
} from "./vitest-process-group.mjs";
const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
const ANSI_CSI_PREFIX = `${String.fromCharCode(27)}[`;
const ANSI_CSI_SUFFIX_RE = /^[0-?]*[ -/]*[@-~]/u;
const SUPPRESSED_VITEST_STDERR_PATTERNS = ["[PLUGIN_TIMINGS]"];
export const DEFAULT_VITEST_NO_OUTPUT_TIMEOUT_MS = 300_000;
const UI_VITEST_CONFIG = "test/vitest/vitest.ui.config.ts";
const UNIT_UI_VITEST_CONFIG = "test/vitest/vitest.unit-ui.config.ts";
const EXPLICIT_TEST_FILE_RE = /\.(?:test|e2e|live)\.(?:[cm]?[jt]sx?)$/u;
const GLOB_PATTERN_CHARS_RE = /[*?[\]{}]/u;
const VITEST_OPTIONS_WITH_VALUE = new Set([
"--attachmentsDir",
"--bail",
"--browser",
"--config",
"--configLoader",
"-c",
"--changed",
"--dir",
"--environment",
"--exclude",
"--execArgv",
"--hookTimeout",
"--inspect",
"--inspect-brk",
"--listTags",
"--maxConcurrency",
"--maxWorkers",
"--mergeReports",
"--mode",
"--outputFile",
"--pool",
"--project",
"--reporter",
"--reporters",
"--retry",
"--root",
"-r",
"--sequence.shuffle.seed",
"--shard",
"--silent",
"--slowTestThreshold",
"--tagsFilter",
"--teardownTimeout",
"--testNamePattern",
"-t",
"--testTimeout",
"--update",
"-u",
"--vmMemoryLimit",
]);
const VITEST_DOTTED_OPTIONS_WITH_VALUE_PREFIXES = [
"--browser.",
"--coverage.",
"--diff.",
"--expect.",
"--experimental.",
"--outputFile.",
"--retry.",
"--sequence.",
"--typecheck.",
];
const require = createRequire(import.meta.url);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
function isTruthyEnvValue(value) {
return TRUTHY_ENV_VALUES.has(value?.trim().toLowerCase() ?? "");
}
function parsePositiveInt(value) {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
export function resolveVitestNodeArgs(env = process.env) {
if (isTruthyEnvValue(env.OPENCLAW_VITEST_ENABLE_MAGLEV)) {
return [];
}
return ["--no-maglev"];
}
function isMissingVitestResolveError(error) {
return (
error instanceof Error &&
error.code === "MODULE_NOT_FOUND" &&
error.message.includes("vitest/package.json")
);
}
export function resolveMissingVitestDependencyMessage(baseDir = repoRoot, fsImpl = fs) {
const hasNodeModules = fsImpl.existsSync(path.join(baseDir, "node_modules"));
const reason = hasNodeModules
? "[vitest] Vitest is not installed in node_modules."
: "[vitest] node_modules is missing; Vitest cannot be resolved.";
return [
reason,
"Install dependencies before running scripts/run-vitest.mjs:",
" pnpm install --frozen-lockfile",
"For raw Crabbox/AWS macOS source syncs, hydrate or install dependencies before this runner.",
].join("\n");
}
export function resolveVitestCliEntry({
baseDir = repoRoot,
fsImpl = fs,
requireResolve = require.resolve.bind(require),
} = {}) {
let vitestPackageJson;
try {
vitestPackageJson = requireResolve("vitest/package.json");
} catch (error) {
if (isMissingVitestResolveError(error)) {
const wrappedError = new Error(resolveMissingVitestDependencyMessage(baseDir, fsImpl));
wrappedError.code = "OPENCLAW_MISSING_VITEST";
throw wrappedError;
}
throw error;
}
return path.join(path.dirname(vitestPackageJson), "vitest.mjs");
}
export function resolveVitestNoOutputTimeoutMs(env = process.env) {
return parsePositiveInt(env.OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS);
}
function resolveBooleanModeFlag(argv, index, longName, shortName = null) {
const arg = argv[index];
const parseValue = (rawValue) => rawValue !== "false";
for (const flag of [`--${longName}`, shortName].filter(Boolean)) {
if (arg === `--no-${longName}`) {
return { value: false, consumedNext: false };
}
if (arg === flag) {
const next = argv[index + 1];
if (next !== undefined && !next.startsWith("-")) {
return { value: parseValue(next), consumedNext: true };
}
return { value: true, consumedNext: false };
}
if (arg.startsWith(`${flag}=`)) {
return { value: parseValue(arg.slice(flag.length + 1)), consumedNext: false };
}
}
return null;
}
function resolveExplicitVitestMode(argv) {
let mode = null;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
break;
}
const watchFlag = resolveBooleanModeFlag(argv, index, "watch", "-w");
if (watchFlag) {
if (watchFlag.consumedNext) {
index += 1;
}
if (watchFlag.value) {
return "watch";
}
mode = "run";
continue;
}
const runFlag = resolveBooleanModeFlag(argv, index, "run");
if (runFlag) {
if (runFlag.consumedNext) {
index += 1;
}
if (runFlag.value) {
mode = "run";
}
continue;
}
if (optionConsumesNextArg(arg)) {
index += 1;
continue;
}
if (arg.startsWith("-")) {
continue;
}
if (mode !== null) {
continue;
}
if (arg === "watch" || arg === "dev") {
return "watch";
}
if (arg === "run") {
mode = "run";
continue;
}
return null;
}
return mode;
}
export function resolveRunVitestSpawnEnv(env = process.env, argv = []) {
if (Object.hasOwn(env, "OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS")) {
return env;
}
const explicitMode = resolveExplicitVitestMode(argv);
if (explicitMode === "watch") {
return env;
}
if (explicitMode !== "run" && !isTruthyEnvValue(env.CI)) {
return env;
}
return {
...env,
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: String(DEFAULT_VITEST_NO_OUTPUT_TIMEOUT_MS),
};
}
export function resolveVitestSpawnParams(env = process.env, platform = process.platform) {
return {
env: resolveVitestSpawnEnv(env),
detached: shouldUseDetachedVitestProcessGroup(platform),
stdio: ["inherit", "pipe", "pipe"],
};
}
export function resolveVitestSpawnEnv(env = process.env) {
const nextEnv = resolveLocalVitestEnv(env);
if (!shouldApplyNativeWorkerBudget(nextEnv)) {
return nextEnv;
}
const nativeWorkerCount = String(resolveNativeWorkerCount(nextEnv));
return {
...nextEnv,
RAYON_NUM_THREADS: nextEnv.RAYON_NUM_THREADS?.trim() || nativeWorkerCount,
TOKIO_WORKER_THREADS: nextEnv.TOKIO_WORKER_THREADS?.trim() || nativeWorkerCount,
};
}
function shouldApplyNativeWorkerBudget(env) {
if (env.RAYON_NUM_THREADS?.trim() && env.TOKIO_WORKER_THREADS?.trim()) {
return false;
}
return (
env.OPENCLAW_TEST_PROJECTS_SERIAL === "1" || resolveExplicitVitestWorkerBudget(env) !== null
);
}
function resolveNativeWorkerCount(env) {
return Math.min(resolveExplicitVitestWorkerBudget(env) ?? 1, 4);
}
function resolveExplicitVitestWorkerBudget(env) {
return parsePositiveInt(env.OPENCLAW_VITEST_MAX_WORKERS ?? env.OPENCLAW_TEST_WORKERS);
}
export function shouldSuppressVitestStderrLine(line) {
const normalizedLine = line
.split(ANSI_CSI_PREFIX)
.map((segment, index) => (index === 0 ? segment : segment.replace(ANSI_CSI_SUFFIX_RE, "")))
.join("");
return SUPPRESSED_VITEST_STDERR_PATTERNS.some((pattern) => normalizedLine.includes(pattern));
}
export function resolveDirectNodeVitestArgs(pnpmArgs) {
return pnpmArgs[0] === "exec" && pnpmArgs[1] === "node" ? pnpmArgs.slice(2) : null;
}
function hasExplicitVitestConfigArg(argv) {
return argv.some((arg) => arg === "--config" || arg === "-c" || arg.startsWith("--config="));
}
function optionConsumesNextArg(arg) {
if (arg.includes("=")) {
return false;
}
return (
VITEST_OPTIONS_WITH_VALUE.has(arg) ||
VITEST_DOTTED_OPTIONS_WITH_VALUE_PREFIXES.some((prefix) => arg.startsWith(prefix))
);
}
function isExplicitTestFileArg(arg) {
if (!EXPLICIT_TEST_FILE_RE.test(arg) || GLOB_PATTERN_CHARS_RE.test(arg)) {
return false;
}
return (
path.isAbsolute(arg) || arg.startsWith("./") || arg.startsWith("../") || /[/\\]/u.test(arg)
);
}
function collectExplicitTestFileArgs(argv) {
const files = [];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
break;
}
if (optionConsumesNextArg(arg)) {
index += 1;
continue;
}
if (arg.startsWith("-")) {
continue;
}
if (isExplicitTestFileArg(arg)) {
files.push(arg);
}
}
return files;
}
export function resolveExplicitTestFileNoPassArgs(argv) {
if (collectExplicitTestFileArgs(argv).length === 0) {
return argv;
}
const sentinelIndex = argv.indexOf("--");
if (sentinelIndex === -1) {
return [...argv, "--passWithNoTests=false"];
}
return [...argv.slice(0, sentinelIndex), "--passWithNoTests=false", ...argv.slice(sentinelIndex)];
}
function hasAlternateVitestRootArg(argv) {
return argv.some(
(arg) =>
arg === "--root" ||
arg === "-r" ||
arg === "--dir" ||
arg.startsWith("--root=") ||
arg.startsWith("--dir="),
);
}
export function resolveMissingExplicitTestFiles(argv, cwd = process.cwd(), fsImpl = fs) {
if (hasExplicitVitestConfigArg(argv) || hasAlternateVitestRootArg(argv)) {
return [];
}
return collectExplicitTestFileArgs(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, cwd) {
const normalized = path.isAbsolute(arg) ? path.relative(cwd, arg) : arg;
return normalized.replaceAll(path.sep, "/").replace(/^\.\//u, "");
}
function withImplicitVitestConfig(argv, config) {
if (argv[0] === "run") {
return ["run", "--config", config, ...argv.slice(1)];
}
return ["--config", config, ...argv];
}
export function resolveImplicitVitestArgs(argv, cwd = process.cwd()) {
if (hasExplicitVitestConfigArg(argv)) {
return argv;
}
const testTargets = argv
.filter((arg) => !arg.startsWith("-") && arg.endsWith(".test.ts"))
.map((arg) => toRepoRelativeArg(arg, cwd));
if (testTargets.length === 0 || !testTargets.every(isUnitUiTestTarget)) {
if (
testTargets.length > 0 &&
testTargets.every((target) => isUiTestTarget(target) && !isUnitUiTestTarget(target))
) {
return withImplicitVitestConfig(argv, UI_VITEST_CONFIG);
}
return argv;
}
return withImplicitVitestConfig(argv, UNIT_UI_VITEST_CONFIG);
}
function spawnVitestProcess({ pnpmArgs, spawnParams }) {
const directNodeArgs = resolveDirectNodeVitestArgs(pnpmArgs);
if (directNodeArgs) {
return spawn(process.execPath, directNodeArgs, spawnParams);
}
return spawnPnpmRunner({
pnpmArgs,
...spawnParams,
});
}
export function installVitestNoOutputWatchdog(params) {
const timeoutMs = params.timeoutMs;
if (!timeoutMs || timeoutMs <= 0) {
return () => {};
}
const setTimeoutFn = params.setTimeoutFn ?? setTimeout;
const clearTimeoutFn = params.clearTimeoutFn ?? clearTimeout;
const forceKillAfterMs = params.forceKillAfterMs ?? 5_000;
const streams = params.streams?.filter(Boolean) ?? [];
const label = params.label?.trim();
const suffix = label ? ` (${label})` : "";
let active = true;
let silenceTimer = null;
let forceKillTimer = null;
const clearForceKillTimer = () => {
if (forceKillTimer !== null) {
clearTimeoutFn(forceKillTimer);
forceKillTimer = null;
}
};
const clearSilenceTimer = () => {
if (silenceTimer !== null) {
clearTimeoutFn(silenceTimer);
silenceTimer = null;
}
};
const resetSilenceTimer = () => {
if (!active) {
return;
}
clearSilenceTimer();
silenceTimer = setTimeoutFn(() => {
if (!active) {
return;
}
params.log?.(
`[vitest] no output for ${timeoutMs}ms; terminating stalled Vitest process group${suffix}.`,
);
params.onTimeout?.();
if (forceKillAfterMs > 0) {
clearForceKillTimer();
forceKillTimer = setTimeoutFn(() => {
if (!active) {
return;
}
params.log?.(
`[vitest] process group still alive after ${forceKillAfterMs}ms; sending SIGKILL${suffix}.`,
);
params.onForceKill?.();
}, forceKillAfterMs);
}
}, timeoutMs);
};
const handleActivity = () => {
clearForceKillTimer();
resetSilenceTimer();
};
const listeners = streams.map((stream) => {
const handler = () => {
handleActivity();
};
stream.on("data", handler);
return { stream, handler };
});
resetSilenceTimer();
return () => {
if (!active) {
return;
}
active = false;
clearSilenceTimer();
clearForceKillTimer();
for (const { stream, handler } of listeners) {
stream.off("data", handler);
}
};
}
export function forwardVitestOutput(stream, target, shouldSuppressLine = () => false) {
if (!stream) {
return;
}
let buffered = "";
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);
if (!shouldSuppressLine(line)) {
target.write(line);
}
}
});
stream.on("end", () => {
if (buffered.length > 0 && !shouldSuppressLine(buffered)) {
target.write(buffered);
}
});
}
export function spawnWatchedVitestProcess({
pnpmArgs,
spawnParams,
env,
label,
onNoOutputTimeout,
}) {
const child = spawnVitestProcess({
pnpmArgs,
spawnParams,
});
const teardownChildCleanup = installVitestProcessGroupCleanup({ child });
const teardownNoOutputWatchdog = installVitestNoOutputWatchdog({
streams: [child.stdout, child.stderr],
timeoutMs: resolveVitestNoOutputTimeoutMs(env),
label,
log: (message) => {
console.error(message);
},
onTimeout: () => {
onNoOutputTimeout?.();
forwardSignalToVitestProcessGroup({
child,
signal: "SIGTERM",
kill: process.kill.bind(process),
});
},
onForceKill: () => {
forwardSignalToVitestProcessGroup({
child,
signal: "SIGKILL",
kill: process.kill.bind(process),
});
},
});
forwardVitestOutput(child.stdout, process.stdout);
forwardVitestOutput(child.stderr, process.stderr, shouldSuppressVitestStderrLine);
return {
child,
teardown: () => {
teardownChildCleanup();
teardownNoOutputWatchdog();
},
};
}
function main(argv = process.argv.slice(2), env = process.env) {
if (argv.length === 0) {
console.error("usage: node scripts/run-vitest.mjs <vitest args...>");
process.exit(1);
}
const missingTestFiles = resolveMissingExplicitTestFiles(argv);
if (missingTestFiles.length > 0) {
console.error(
[
"[vitest] explicit test file(s) not found:",
...missingTestFiles.map((file) => ` - ${file}`),
].join("\n"),
);
process.exit(1);
}
const vitestArgs = resolveImplicitVitestArgs(argv);
const guardedVitestArgs = resolveExplicitTestFileNoPassArgs(vitestArgs);
const spawnEnv = resolveRunVitestSpawnEnv(env, guardedVitestArgs);
let vitestCliEntry;
try {
vitestCliEntry = resolveVitestCliEntry();
} catch (error) {
if (error instanceof Error && error.code === "OPENCLAW_MISSING_VITEST") {
console.error(error.message);
process.exit(1);
}
throw error;
}
const { child, teardown } = spawnWatchedVitestProcess({
pnpmArgs: [
"exec",
"node",
...resolveVitestNodeArgs(env),
vitestCliEntry,
...guardedVitestArgs,
],
spawnParams: resolveVitestSpawnParams(spawnEnv),
env: spawnEnv,
label: guardedVitestArgs.join(" "),
});
child.on("exit", (code, signal) => {
teardown();
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
child.on("error", (error) => {
teardown();
console.error(error);
process.exit(1);
});
}
if (import.meta.main) {
main();
}