refactor: route direct extension test targets

This commit is contained in:
Peter Steinberger
2026-04-04 02:34:07 +09:00
parent d0d5b34b44
commit 1bee69f79b
7 changed files with 473 additions and 81 deletions

View File

@@ -1,8 +1,8 @@
import fs from "node:fs";
import { acquireLocalHeavyCheckLockSync } from "./lib/local-heavy-check-runtime.mjs";
import { spawnPnpmRunner } from "./pnpm-runner.mjs";
import { buildVitestArgs } from "./test-projects.test-support.mjs";
import { createVitestRunSpecs, writeVitestIncludeFile } from "./test-projects.test-support.mjs";
const vitestArgs = buildVitestArgs(process.argv.slice(2));
const releaseLock = acquireLocalHeavyCheckLockSync({
cwd: process.cwd(),
env: process.env,
@@ -18,21 +18,62 @@ const releaseLockOnce = () => {
releaseLock();
};
const child = spawnPnpmRunner({
pnpmArgs: vitestArgs,
env: process.env,
});
child.on("exit", (code, signal) => {
releaseLockOnce();
if (signal) {
process.kill(process.pid, signal);
function cleanupVitestRunSpec(spec) {
if (!spec.includeFilePath) {
return;
}
process.exit(code ?? 1);
});
try {
fs.rmSync(spec.includeFilePath, { force: true });
} catch {
// Best-effort cleanup for temp include lists.
}
}
child.on("error", (error) => {
function runVitestSpec(spec) {
if (spec.includeFilePath && spec.includePatterns) {
writeVitestIncludeFile(spec.includeFilePath, spec.includePatterns);
}
return new Promise((resolve, reject) => {
const child = spawnPnpmRunner({
pnpmArgs: spec.pnpmArgs,
env: spec.env,
});
child.on("exit", (code, signal) => {
cleanupVitestRunSpec(spec);
resolve({ code: code ?? 1, signal });
});
child.on("error", (error) => {
cleanupVitestRunSpec(spec);
reject(error);
});
});
}
async function main() {
const runSpecs = createVitestRunSpecs(process.argv.slice(2), {
baseEnv: process.env,
cwd: process.cwd(),
});
for (const spec of runSpecs) {
const result = await runVitestSpec(spec);
if (result.signal) {
releaseLockOnce();
process.kill(process.pid, result.signal);
return;
}
if (result.code !== 0) {
releaseLockOnce();
process.exit(result.code);
}
}
releaseLockOnce();
}
main().catch((error) => {
releaseLockOnce();
console.error(error);
process.exit(1);

View File

@@ -0,0 +1,39 @@
export type VitestRunPlan = {
config: string;
forwardedArgs: string[];
includePatterns: string[] | null;
watchMode: boolean;
};
export type VitestRunSpec = {
config: string;
env: Record<string, string | undefined>;
includeFilePath: string | null;
includePatterns: string[] | null;
pnpmArgs: string[];
watchMode: boolean;
};
export function parseTestProjectsArgs(
args: string[],
cwd?: string,
): {
forwardedArgs: string[];
targetArgs: string[];
watchMode: boolean;
};
export function buildVitestRunPlans(args: string[], cwd?: string): VitestRunPlan[];
export function createVitestRunSpecs(
args: string[],
params?: {
baseEnv?: Record<string, string | undefined>;
cwd?: string;
tempDir?: string;
},
): VitestRunSpec[];
export function writeVitestIncludeFile(filePath: string, includePatterns: string[]): void;
export function buildVitestArgs(args: string[], cwd?: string): string[];

View File

@@ -1,5 +1,77 @@
export function parseTestProjectsArgs(args) {
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isChannelSurfaceTestFile } from "../vitest.channel-paths.mjs";
const DEFAULT_VITEST_CONFIG = "vitest.config.ts";
const CHANNEL_VITEST_CONFIG = "vitest.channels.config.ts";
const EXTENSIONS_VITEST_CONFIG = "vitest.extensions.config.ts";
const INCLUDE_FILE_ENV_KEY = "OPENCLAW_VITEST_INCLUDE_FILE";
function normalizePathPattern(value) {
return value.replaceAll("\\", "/");
}
function isExistingPathTarget(arg, cwd) {
return fs.existsSync(path.resolve(cwd, arg));
}
function isGlobTarget(arg) {
return /[*?[\]{}]/u.test(arg);
}
function isFileLikeTarget(arg) {
return /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(arg);
}
function isPathLikeTargetArg(arg, cwd) {
if (!arg || arg === "--" || arg.startsWith("-")) {
return false;
}
return isExistingPathTarget(arg, cwd) || isGlobTarget(arg) || isFileLikeTarget(arg);
}
function toRepoRelativeTarget(arg, cwd) {
if (isGlobTarget(arg)) {
return normalizePathPattern(arg.replace(/^\.\//u, ""));
}
const absolute = path.resolve(cwd, arg);
return normalizePathPattern(path.relative(cwd, absolute));
}
function toScopedIncludePattern(arg, cwd) {
const relative = toRepoRelativeTarget(arg, cwd);
if (isGlobTarget(relative) || isFileLikeTarget(relative)) {
return relative;
}
return `${relative.replace(/\/+$/u, "")}/**/*.test.ts`;
}
function classifyTarget(arg, cwd) {
const relative = toRepoRelativeTarget(arg, cwd);
if (relative.startsWith("extensions/")) {
return isChannelSurfaceTestFile(relative) ? "channel" : "extension";
}
if (isChannelSurfaceTestFile(relative)) {
return "channel";
}
return "default";
}
function createVitestArgs(params) {
return [
"exec",
"vitest",
...(params.watchMode ? [] : ["run"]),
"--config",
params.config,
...params.forwardedArgs,
];
}
export function parseTestProjectsArgs(args, cwd = process.cwd()) {
const forwardedArgs = [];
const targetArgs = [];
let watchMode = false;
for (const arg of args) {
@@ -10,20 +82,109 @@ export function parseTestProjectsArgs(args) {
watchMode = true;
continue;
}
if (isPathLikeTargetArg(arg, cwd)) {
targetArgs.push(arg);
}
forwardedArgs.push(arg);
}
return { forwardedArgs, watchMode };
return { forwardedArgs, targetArgs, watchMode };
}
export function buildVitestArgs(args) {
const { forwardedArgs, watchMode } = parseTestProjectsArgs(args);
return [
"exec",
"vitest",
...(watchMode ? [] : ["run"]),
"--config",
"vitest.config.ts",
...forwardedArgs,
];
export function buildVitestRunPlans(args, cwd = process.cwd()) {
const { forwardedArgs, targetArgs, watchMode } = parseTestProjectsArgs(args, cwd);
if (targetArgs.length === 0) {
return [
{
config: DEFAULT_VITEST_CONFIG,
forwardedArgs,
includePatterns: null,
watchMode,
},
];
}
const groupedTargets = new Map();
for (const targetArg of targetArgs) {
const kind = classifyTarget(targetArg, cwd);
const current = groupedTargets.get(kind) ?? [];
current.push(targetArg);
groupedTargets.set(kind, current);
}
if (watchMode && groupedTargets.size > 1) {
throw new Error(
"watch mode with mixed test suites is not supported; target one suite at a time or use a dedicated suite command",
);
}
const nonTargetArgs = forwardedArgs.filter((arg) => !targetArgs.includes(arg));
const orderedKinds = ["default", "channel", "extension"];
const plans = [];
for (const kind of orderedKinds) {
const grouped = groupedTargets.get(kind);
if (!grouped || grouped.length === 0) {
continue;
}
const config =
kind === "channel"
? CHANNEL_VITEST_CONFIG
: kind === "extension"
? EXTENSIONS_VITEST_CONFIG
: DEFAULT_VITEST_CONFIG;
const includePatterns =
kind === "default"
? null
: grouped.map((targetArg) => toScopedIncludePattern(targetArg, cwd));
const scopedTargetArgs = kind === "default" ? grouped : [];
plans.push({
config,
forwardedArgs: [...nonTargetArgs, ...scopedTargetArgs],
includePatterns,
watchMode,
});
}
return plans;
}
export function createVitestRunSpecs(args, params = {}) {
const cwd = params.cwd ?? process.cwd();
const plans = buildVitestRunPlans(args, cwd);
return plans.map((plan, index) => {
const includeFilePath = plan.includePatterns
? path.join(
params.tempDir ?? os.tmpdir(),
`openclaw-vitest-include-${process.pid}-${Date.now()}-${index}.json`,
)
: null;
return {
config: plan.config,
env: includeFilePath
? {
...(params.baseEnv ?? process.env),
[INCLUDE_FILE_ENV_KEY]: includeFilePath,
}
: (params.baseEnv ?? process.env),
includeFilePath,
includePatterns: plan.includePatterns,
pnpmArgs: createVitestArgs(plan),
watchMode: plan.watchMode,
};
});
}
export function writeVitestIncludeFile(filePath, includePatterns) {
fs.writeFileSync(filePath, `${JSON.stringify(includePatterns, null, 2)}\n`);
}
export function buildVitestArgs(args, cwd = process.cwd()) {
const [plan] = buildVitestRunPlans(args, cwd);
if (!plan) {
return createVitestArgs({
config: DEFAULT_VITEST_CONFIG,
forwardedArgs: [],
watchMode: false,
});
}
return createVitestArgs(plan);
}