mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-05 07:39:40 +00:00
Reserve wrapper usage for a sole help flag and let compound requests reach the existing Vitest parser. Delete the premature raw-token scanner so negated help executes, invalid scalar options fail, and native help keeps its own metadata behavior. Production is net -12 lines. Real CLI regressions fail before the repair and pass afterward; standalone wrapper help remains unchanged.
562 lines
19 KiB
TypeScript
562 lines
19 KiB
TypeScript
// Dispatches Vitest project shards for explicit targets, changed files, or the
|
|
// full local suite.
|
|
import type { SpawnOptions } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import { performance } from "node:perf_hooks";
|
|
import pMap from "p-map";
|
|
import { assertTestHomeSelection, combineTestHomeSelections } from "../test/test-home-policy.mts";
|
|
import { formatMs } from "./lib/check-timing-summary.mts";
|
|
import { signalExitCode } from "./lib/managed-child-process.mts";
|
|
import {
|
|
prepareE2eVitestRuntime,
|
|
prepareVitestRuntime,
|
|
resolveVitestCliEntry,
|
|
resolveVitestRuntimeCliSelections,
|
|
} from "./lib/vitest-build-prerequisites.mts";
|
|
import { hasNonRunVitestSubcommand } from "./lib/vitest-cli-mode.mts";
|
|
import { parseVitestExecutionArgs } from "./lib/vitest-cli.mts";
|
|
import { resolveVitestHomeSelection } from "./lib/vitest-home-selection.mts";
|
|
import {
|
|
isCiLikeEnv,
|
|
resolveLocalFullSuiteProfile,
|
|
resolveLocalVitestEnv,
|
|
} from "./lib/vitest-local-scheduling.mts";
|
|
import { resolveVitestNodeArgs } from "./lib/vitest-process-env.mts";
|
|
import type { exitVitestBySignal } from "./lib/vitest-process.mts";
|
|
import { createVitestReportOwner, type VitestReportOwner } from "./lib/vitest-report-owner.mts";
|
|
import {
|
|
createShardTimingSample,
|
|
readShardTimings,
|
|
writeShardTimings,
|
|
} from "./lib/vitest-shard-timings.mts";
|
|
import { createVitestWorkerRun, type VitestWorkerRun } from "./lib/vitest-worker-run.mts";
|
|
import { resolveVitestSpawnParams, spawnWatchedVitestProcess } from "./run-vitest.mts";
|
|
import {
|
|
applyDefaultMultiSpecVitestCachePaths,
|
|
applyDefaultVitestNoOutputTimeout,
|
|
applyFullExtensionsHeapBudget,
|
|
applyParallelVitestCachePaths,
|
|
buildFullSuiteVitestRunPlans,
|
|
createVitestPreflightPnpmArgs,
|
|
createVitestRunSpecs,
|
|
findUnmatchedExplicitTestTargets,
|
|
formatFailedShardDigest,
|
|
formatNoChangedTestTargetLines,
|
|
isTestFileTarget,
|
|
listFullExtensionVitestProjectConfigs,
|
|
orderFullSuiteSpecsForParallelRun,
|
|
parseTestProjectsArgs,
|
|
resolveParallelFullSuiteConcurrency,
|
|
resolveChangedTestTargetPlanForArgs,
|
|
resolveChangedTargetArgs,
|
|
shouldRetryVitestNoOutputTimeout,
|
|
type FailedVitestShard,
|
|
type VitestRunSpec as BaseVitestRunSpec,
|
|
withRetryNoOutputTimeout,
|
|
writeVitestIncludeFile,
|
|
} from "./test-projects.test-support.mts";
|
|
|
|
type VitestRunSpec = BaseVitestRunSpec & {
|
|
continueOnFailure?: boolean;
|
|
reportIndex?: number;
|
|
workerRun?: VitestWorkerRun;
|
|
};
|
|
type VitestCommandOutcome = {
|
|
code: number;
|
|
noOutputTimedOut: boolean;
|
|
signal: NodeJS.Signals | null;
|
|
};
|
|
|
|
type ShardTiming = NonNullable<ReturnType<typeof createShardTimingSample>>;
|
|
|
|
function printHelp() {
|
|
console.log(`Usage: node --import tsx scripts/test-projects.mts [--changed <base>] [--watch] [targets...] [-- vitest-args...]
|
|
|
|
Runs the Vitest project shards that own the requested targets. With no targets,
|
|
this runs the full local suite. Use explicit targets for local edit loops.`);
|
|
}
|
|
|
|
function cleanupVitestRunSpec(spec: VitestRunSpec) {
|
|
if (!spec.includeFilePath) {
|
|
return;
|
|
}
|
|
try {
|
|
fs.rmSync(spec.includeFilePath, { force: true });
|
|
} catch {
|
|
// Best-effort cleanup for temp include lists.
|
|
}
|
|
}
|
|
|
|
function runPnpmSpecCommand(
|
|
spec: VitestRunSpec,
|
|
pnpmArgs: string[],
|
|
workerRun?: VitestWorkerRun,
|
|
homeMode?: Parameters<typeof spawnWatchedVitestProcess>[0]["homeMode"],
|
|
) {
|
|
let noOutputTimedOut = false;
|
|
return new Promise<VitestCommandOutcome>((resolve, reject) => {
|
|
const { completion, getForwardedSignal } = spawnWatchedVitestProcess({
|
|
workerRun,
|
|
homeMode,
|
|
pnpmArgs,
|
|
env: spec.env,
|
|
onNoOutputTimeout: () => {
|
|
noOutputTimedOut = true;
|
|
},
|
|
spawnParams: {
|
|
cwd: process.cwd(),
|
|
...resolveVitestSpawnParams(spec.env),
|
|
stdio: ["inherit", "pipe", "pipe"] satisfies SpawnOptions["stdio"],
|
|
},
|
|
});
|
|
|
|
completion.then(
|
|
({ code, signal }) => {
|
|
const exitSignal = getForwardedSignal() ?? signal;
|
|
resolve({
|
|
code: exitSignal ? signalExitCode(exitSignal) : (code ?? 1),
|
|
noOutputTimedOut,
|
|
signal: exitSignal,
|
|
});
|
|
},
|
|
(error: unknown) => {
|
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
async function runVitestSpec(spec: VitestRunSpec, reports: VitestReportOwner) {
|
|
if (spec.includeFilePath && spec.includePatterns) {
|
|
writeVitestIncludeFile(spec.includeFilePath, spec.includePatterns, {
|
|
expandGlobs: !spec.watchMode,
|
|
});
|
|
}
|
|
try {
|
|
if (spec.preflightPnpmArgs) {
|
|
console.error(`[test] preflight ${spec.config}`);
|
|
const preflightResult = await runPnpmSpecCommand(
|
|
spec,
|
|
spec.preflightPnpmArgs,
|
|
undefined,
|
|
"tooling",
|
|
);
|
|
if (preflightResult.code !== 0 || preflightResult.signal) {
|
|
return preflightResult;
|
|
}
|
|
}
|
|
const attempt = reports?.attempt(spec.reportIndex!, spec.pnpmArgs);
|
|
try {
|
|
const result = await runPnpmSpecCommand(spec, attempt?.args ?? spec.pnpmArgs, spec.workerRun);
|
|
attempt?.complete(result);
|
|
return result;
|
|
} catch (error) {
|
|
attempt?.fail(error);
|
|
throw error;
|
|
}
|
|
} finally {
|
|
cleanupVitestRunSpec(spec);
|
|
}
|
|
}
|
|
|
|
function applyDefaultParallelVitestWorkerBudget(specs: VitestRunSpec[], env: NodeJS.ProcessEnv) {
|
|
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: String(vitestMaxWorkers),
|
|
},
|
|
}));
|
|
}
|
|
|
|
async function runLoggedVitestSpec(spec: VitestRunSpec, reports: VitestReportOwner) {
|
|
console.error(`[test] starting ${spec.config}`);
|
|
const startedAt = performance.now();
|
|
let result = await runVitestSpec(spec, reports);
|
|
if (result.noOutputTimedOut && !spec.watchMode && shouldRetryVitestNoOutputTimeout(spec.env)) {
|
|
console.error(`[test] retrying ${spec.config} after no-output timeout`);
|
|
result = await runVitestSpec(withRetryNoOutputTimeout(spec), reports);
|
|
}
|
|
const durationMs = performance.now() - startedAt;
|
|
if (result.noOutputTimedOut && result.signal) {
|
|
console.error(`[test] ${spec.config} exceeded no-output timeout`);
|
|
return {
|
|
...result,
|
|
code: result.code || 143,
|
|
signal: null,
|
|
timing: null,
|
|
};
|
|
}
|
|
if (result.signal) {
|
|
console.error(`[test] ${spec.config} exited by signal ${result.signal}`);
|
|
return { ...result, timing: null };
|
|
}
|
|
return {
|
|
...result,
|
|
timing: createShardTimingSample(spec, durationMs),
|
|
};
|
|
}
|
|
|
|
function isFullExtensionsProjectRun(specs: VitestRunSpec[]) {
|
|
const fullExtensionProjectConfigs = new Set(listFullExtensionVitestProjectConfigs());
|
|
return (
|
|
specs.length > 1 &&
|
|
specs.every(
|
|
(spec) =>
|
|
!spec.watchMode &&
|
|
spec.includePatterns === null &&
|
|
fullExtensionProjectConfigs.has(spec.config),
|
|
)
|
|
);
|
|
}
|
|
|
|
function printNoChangedTestTargets(args: string[], cwd: string, baseEnv: NodeJS.ProcessEnv) {
|
|
const plan = resolveChangedTestTargetPlanForArgs(args, cwd, undefined, { env: baseEnv });
|
|
const skippedBroadFallbackPaths = plan?.skippedBroadFallbackPaths ?? [];
|
|
for (const line of formatNoChangedTestTargetLines(skippedBroadFallbackPaths)) {
|
|
console.error(line);
|
|
}
|
|
}
|
|
|
|
async function runVitestSpecs(
|
|
specs: VitestRunSpec[],
|
|
concurrency: number,
|
|
reports: VitestReportOwner,
|
|
termination: { signal: NodeJS.Signals | null },
|
|
) {
|
|
let exitCode = 0;
|
|
let stopScheduling = false;
|
|
const failures: FailedVitestShard[] = [];
|
|
const timings: ShardTiming[] = [];
|
|
await pMap(
|
|
specs,
|
|
async (spec, index) => {
|
|
if (stopScheduling || termination.signal) {
|
|
return;
|
|
}
|
|
let result: Awaited<ReturnType<typeof runLoggedVitestSpec>>;
|
|
try {
|
|
result = await runLoggedVitestSpec(spec, reports);
|
|
} catch (error) {
|
|
stopScheduling = true;
|
|
throw error;
|
|
}
|
|
if (result.signal) {
|
|
// A forwarded termination signal must not admit replacement shards during shutdown.
|
|
termination.signal ??= result.signal;
|
|
stopScheduling = true;
|
|
}
|
|
if (result.code !== 0) {
|
|
exitCode ||= result.code;
|
|
if (concurrency === 1 && spec.continueOnFailure !== true) {
|
|
stopScheduling = true;
|
|
}
|
|
failures.push({
|
|
code: result.code,
|
|
config: spec.config,
|
|
includePatterns: spec.includePatterns,
|
|
noOutputTimedOut: result.noOutputTimedOut,
|
|
order: index,
|
|
signal: result.signal,
|
|
});
|
|
}
|
|
if (result.timing) {
|
|
timings.push(result.timing);
|
|
}
|
|
},
|
|
// Join already-admitted shards even when another shard's group join fails.
|
|
{ concurrency, stopOnError: false },
|
|
);
|
|
return { exitCode, failures, timings, stopScheduling };
|
|
}
|
|
|
|
export async function runTestProjects(exitBySignal: typeof exitVitestBySignal) {
|
|
const suiteStartedAt = performance.now();
|
|
const args = process.argv.slice(2);
|
|
if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
const baseEnv = resolveLocalVitestEnv(process.env);
|
|
const { targetArgs, forwardedArgs } = parseTestProjectsArgs(args, process.cwd());
|
|
const unmatchedExplicitTargets = findUnmatchedExplicitTestTargets(args, process.cwd());
|
|
if (unmatchedExplicitTargets.length > 0) {
|
|
for (const unmatched of unmatchedExplicitTargets) {
|
|
const suffix = unmatched.includePattern ? ` (tried: ${unmatched.includePattern})` : "";
|
|
console.error(
|
|
`[test] explicit test target matched no test files: ${unmatched.target}${suffix}`,
|
|
);
|
|
}
|
|
printTestSummary("failed", 1, performance.now() - suiteStartedAt);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const changedTargetArgs =
|
|
targetArgs.length === 0
|
|
? resolveChangedTargetArgs(args, process.cwd(), undefined, { env: baseEnv })
|
|
: null;
|
|
const rawRunSpecs: VitestRunSpec[] =
|
|
targetArgs.length === 0 && changedTargetArgs === null
|
|
? buildFullSuiteVitestRunPlans(args, process.cwd()).map((plan) => ({
|
|
config: plan.config,
|
|
continueOnFailure: true,
|
|
env: baseEnv,
|
|
includeFilePath: null,
|
|
includePatterns: null,
|
|
pnpmArgs: [
|
|
"exec",
|
|
"node",
|
|
...resolveVitestNodeArgs(process.env),
|
|
resolveVitestCliEntry(),
|
|
...(plan.watchMode ? [] : ["run"]),
|
|
"--config",
|
|
plan.config,
|
|
...plan.forwardedArgs,
|
|
],
|
|
preflightPnpmArgs: createVitestPreflightPnpmArgs(plan.config),
|
|
watchMode: plan.watchMode,
|
|
}))
|
|
: createVitestRunSpecs(args, {
|
|
baseEnv,
|
|
cwd: process.cwd(),
|
|
});
|
|
const runSpecs: VitestRunSpec[] = applyDefaultMultiSpecVitestCachePaths(
|
|
applyDefaultVitestNoOutputTimeout(
|
|
applyFullExtensionsHeapBudget(rawRunSpecs, { env: baseEnv }),
|
|
{
|
|
env: baseEnv,
|
|
},
|
|
),
|
|
{ cwd: process.cwd(), env: baseEnv },
|
|
);
|
|
|
|
if (runSpecs.length === 0) {
|
|
printNoChangedTestTargets(args, process.cwd(), baseEnv);
|
|
printTestSummary("skipped", 0, performance.now() - suiteStartedAt);
|
|
return;
|
|
}
|
|
|
|
const { parseCLI } = await import("vitest/node");
|
|
if (
|
|
targetArgs.length &&
|
|
!runSpecs.some((spec) => spec.watchMode) &&
|
|
!hasNonRunVitestSubcommand(forwardedArgs)
|
|
) {
|
|
// Native parsing stays in the execution owner. Original filters distinguish
|
|
// explicit files from broad selections that also lower to literal include files.
|
|
const execution = parseVitestExecutionArgs(["run", ...forwardedArgs], parseCLI);
|
|
if (
|
|
execution &&
|
|
!execution.options.watch &&
|
|
execution.options.run !== false &&
|
|
execution.filter.length > 0 &&
|
|
execution.filter.every(
|
|
(file) => isTestFileTarget(file) && /[/\\]/u.test(file) && !/[*?[\]{}]|[@+!]\(/u.test(file),
|
|
) &&
|
|
!Object.hasOwn(execution.options, "passWithNoTests")
|
|
) {
|
|
for (const spec of runSpecs) {
|
|
const separator = spec.pnpmArgs.indexOf("--");
|
|
spec.pnpmArgs.splice(
|
|
separator < 0 ? spec.pnpmArgs.length : separator,
|
|
0,
|
|
"--passWithNoTests=false",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
runSpecs.forEach((spec, index) => {
|
|
spec.reportIndex = index;
|
|
});
|
|
const homeMode = combineTestHomeSelections(
|
|
runSpecs.map((spec) => resolveVitestHomeSelection(spec.pnpmArgs, { env: spec.env })),
|
|
);
|
|
// Refuse a mixed real-home run before report setup or runtime preparation imports code.
|
|
assertTestHomeSelection(baseEnv, homeMode);
|
|
const reports = await createVitestReportOwner(
|
|
runSpecs.map((spec) => ({
|
|
config: spec.config,
|
|
includePatterns: spec.includePatterns,
|
|
args: spec.pnpmArgs.slice(spec.pnpmArgs.indexOf(resolveVitestCliEntry()) + 1),
|
|
})),
|
|
process.cwd(),
|
|
);
|
|
const termination: { signal: NodeJS.Signals | null } = { signal: null };
|
|
const onSignal = (signal: NodeJS.Signals) => {
|
|
termination.signal ??= signal;
|
|
};
|
|
process.on("SIGTERM", onSignal);
|
|
process.on("SIGINT", onSignal);
|
|
let workers: VitestWorkerRun | undefined;
|
|
let reportFailure: string | undefined;
|
|
let printCompletedSummary: (() => void) | undefined;
|
|
try {
|
|
const admitted = runSpecs.map((spec) => {
|
|
const cliArgs = spec.pnpmArgs.slice(spec.pnpmArgs.indexOf(resolveVitestCliEntry()) + 1);
|
|
const execution = parseVitestExecutionArgs(cliArgs, parseCLI);
|
|
// Metadata and invalid input must not install browser prerequisites either.
|
|
if (!execution) {
|
|
spec.preflightPnpmArgs = null;
|
|
}
|
|
return { spec, cliArgs, execution };
|
|
});
|
|
const runnable = admitted.filter(({ execution }) => execution !== null);
|
|
const e2eSpecs = runnable
|
|
.map(({ spec }) => spec)
|
|
.filter((spec) => spec.config === "test/vitest/vitest.e2e.config.ts");
|
|
if (e2eSpecs.length > 0) {
|
|
const preparedEnv = await prepareE2eVitestRuntime(baseEnv);
|
|
for (const spec of e2eSpecs) {
|
|
spec.env = { ...spec.env, ...preparedEnv };
|
|
}
|
|
} else {
|
|
const code = await prepareVitestRuntime(
|
|
runnable.flatMap(({ spec, cliArgs }) => {
|
|
const selections = resolveVitestRuntimeCliSelections(spec.config, cliArgs, spec.env);
|
|
// These selections are invocation-owned; their include files are not written yet.
|
|
for (const selection of selections) {
|
|
selection.includePatterns = spec.includePatterns;
|
|
}
|
|
return selections;
|
|
}),
|
|
baseEnv,
|
|
);
|
|
if (code !== 0) {
|
|
printTestSummary("failed", 0, performance.now() - suiteStartedAt);
|
|
process.exitCode = code;
|
|
return;
|
|
}
|
|
}
|
|
|
|
const compiled = runnable.filter(
|
|
({ spec, execution }) => !spec.watchMode && !execution?.options.watch,
|
|
);
|
|
if (compiled.length) {
|
|
workers = createVitestWorkerRun();
|
|
for (const { spec } of compiled) {
|
|
spec.workerRun = workers;
|
|
}
|
|
}
|
|
const isFullSuiteRun =
|
|
targetArgs.length === 0 &&
|
|
changedTargetArgs === null &&
|
|
!runSpecs.some((spec) => spec.watchMode);
|
|
const isExplicitParallelMultiConfigRun =
|
|
Boolean(baseEnv.OPENCLAW_TEST_PROJECTS_PARALLEL) &&
|
|
runSpecs.length > 1 &&
|
|
!runSpecs.some((spec) => spec.watchMode);
|
|
const isParallelShardRun =
|
|
isFullSuiteRun || isFullExtensionsProjectRun(runSpecs) || isExplicitParallelMultiConfigRun;
|
|
let scheduledSpecs = runSpecs;
|
|
const concurrency = isParallelShardRun
|
|
? resolveParallelFullSuiteConcurrency(runSpecs.length, baseEnv)
|
|
: 1;
|
|
if (isParallelShardRun) {
|
|
if (!isCiLikeEnv(baseEnv) && runSpecs.length > 1) {
|
|
console.warn(
|
|
`[test] warning: broad local run will start ${runSpecs.length} Vitest shards; use \`pnpm test:changed\` for routine checks.`,
|
|
);
|
|
}
|
|
if (concurrency > 1) {
|
|
const shardTimings = readShardTimings(process.cwd(), baseEnv);
|
|
const orderedSpecs = orderFullSuiteSpecsForParallelRun(runSpecs, shardTimings).filter(
|
|
(spec): spec is VitestRunSpec => spec !== undefined,
|
|
);
|
|
scheduledSpecs = applyDefaultParallelVitestWorkerBudget(
|
|
applyParallelVitestCachePaths(orderedSpecs, {
|
|
cwd: process.cwd(),
|
|
env: baseEnv,
|
|
}),
|
|
baseEnv,
|
|
);
|
|
console.error(
|
|
`[test] running ${scheduledSpecs.length} Vitest shards with parallelism ${concurrency}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const result = await runVitestSpecs(scheduledSpecs, concurrency, reports, termination);
|
|
if (concurrency === 1 && termination.signal) {
|
|
return;
|
|
}
|
|
if (concurrency > 1 || !result.stopScheduling) {
|
|
writeShardTimings(result.timings, process.cwd(), baseEnv);
|
|
}
|
|
printCompletedSummary = () =>
|
|
printTestSummary(
|
|
process.exitCode ? "failed" : "passed",
|
|
concurrency > 1 ? scheduledSpecs.length : result.timings.length,
|
|
performance.now() - suiteStartedAt,
|
|
concurrency > 1 ? "Vitest summaries above are per-shard, not aggregate totals." : undefined,
|
|
);
|
|
if (concurrency > 1) {
|
|
for (const line of formatFailedShardDigest(result.failures)) {
|
|
console.error(line);
|
|
}
|
|
}
|
|
if (result.exitCode !== 0) {
|
|
process.exitCode = result.exitCode;
|
|
}
|
|
} catch (error) {
|
|
reportFailure = String(error);
|
|
throw error;
|
|
} finally {
|
|
try {
|
|
await workers?.dispose().catch((error: unknown) => {
|
|
reportFailure ??= String(error);
|
|
process.exitCode ||= 1;
|
|
console.error(error);
|
|
});
|
|
if (reports) {
|
|
const reportCode = await reports.finish(
|
|
async (mergeArgs) => {
|
|
// Replay is source-only: selected configs load after all compiled
|
|
// borrowers close; report blobs own the exact executed selection.
|
|
const outcome = await runPnpmSpecCommand(
|
|
{ ...runSpecs[0]!, env: baseEnv },
|
|
[
|
|
"exec",
|
|
"node",
|
|
...resolveVitestNodeArgs(baseEnv),
|
|
resolveVitestCliEntry(),
|
|
...mergeArgs,
|
|
],
|
|
undefined,
|
|
homeMode,
|
|
);
|
|
termination.signal ??= outcome.signal;
|
|
return outcome;
|
|
},
|
|
termination.signal ? `Cancelled by ${termination.signal}` : reportFailure,
|
|
);
|
|
if (reportCode) {
|
|
process.exitCode ||= reportCode;
|
|
}
|
|
}
|
|
printCompletedSummary?.();
|
|
} finally {
|
|
process.off("SIGTERM", onSignal);
|
|
process.off("SIGINT", onSignal);
|
|
if (termination.signal) {
|
|
await exitBySignal(termination.signal);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function printTestSummary(
|
|
status: "failed" | "passed" | "skipped",
|
|
shardCount: number,
|
|
durationMs: number,
|
|
detail?: string,
|
|
) {
|
|
const suffix = detail ? `; ${detail}` : "";
|
|
console.error(
|
|
`[test] ${status} ${shardCount} Vitest shard${shardCount === 1 ? "" : "s"} in ${formatMs(durationMs)}${suffix}`,
|
|
);
|
|
}
|