295 lines
7.5 KiB
JavaScript
295 lines
7.5 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
const workingDirectory = await fs.realpath(process.cwd());
|
|
const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs");
|
|
const unitTestsRoot = path.join(workingDirectory, "tests", "unit");
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const batchSize = normalizePositiveInt(args.batchSize ?? process.env.VITEST_BATCH_SIZE, 5);
|
|
|
|
let activeChild = null;
|
|
let isShuttingDown = false;
|
|
|
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
process.on(signal, () => {
|
|
void shutdown(signal).finally(() => {
|
|
process.exit(130);
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseArgs(rawArgs) {
|
|
const parsed = {
|
|
help: false,
|
|
batchSize: null,
|
|
fromList: null,
|
|
specFilters: [],
|
|
forwardedArgs: [],
|
|
};
|
|
|
|
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
const value = rawArgs[index];
|
|
|
|
if (value === "--") {
|
|
parsed.forwardedArgs.push(...rawArgs.slice(index + 1));
|
|
break;
|
|
}
|
|
|
|
if (value === "--help" || value === "-h") {
|
|
parsed.help = true;
|
|
continue;
|
|
}
|
|
|
|
if (value === "--batch-size" && rawArgs[index + 1]) {
|
|
parsed.batchSize = rawArgs[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (value.startsWith("--batch-size=")) {
|
|
parsed.batchSize = value.slice("--batch-size=".length);
|
|
continue;
|
|
}
|
|
|
|
if (value === "--from-list" && rawArgs[index + 1]) {
|
|
parsed.fromList = rawArgs[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (value.startsWith("--from-list=")) {
|
|
parsed.fromList = value.slice("--from-list=".length);
|
|
continue;
|
|
}
|
|
|
|
if (value.startsWith("-")) {
|
|
parsed.forwardedArgs.push(...rawArgs.slice(index));
|
|
break;
|
|
}
|
|
|
|
parsed.specFilters.push(value);
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function normalizePositiveInt(value, fallback) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function normalizePathSegments(value) {
|
|
return value.replace(/\\/g, "/");
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log(`
|
|
Vitest unit batch runner
|
|
|
|
Usage:
|
|
node scripts/run-vitest-unit-batches.mjs [spec-filter ...] [options] [-- <vitest args>]
|
|
|
|
Options:
|
|
--batch-size <n> Spec files per batch. Default: 5
|
|
--from-list <file> Read additional spec filters from a newline-delimited file
|
|
-h, --help Show help
|
|
|
|
Examples:
|
|
npm run test:unit
|
|
npm run test:unit:serial
|
|
npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js
|
|
npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js -- --reporter=verbose
|
|
`);
|
|
}
|
|
|
|
async function readSpecFilterList(listFile) {
|
|
if (!listFile) {
|
|
return [];
|
|
}
|
|
|
|
const listPath = path.isAbsolute(listFile) ? listFile : path.join(workingDirectory, listFile);
|
|
const contents = await fs.readFile(listPath, "utf8");
|
|
|
|
return contents
|
|
.split(/\r?\n/u)
|
|
.map((line) => line.replace(/#.*/u, "").trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function collectSpecFiles(targetDirectory) {
|
|
const directoryEntries = await fs.readdir(targetDirectory, { withFileTypes: true });
|
|
const files = [];
|
|
|
|
for (const entry of directoryEntries) {
|
|
const absolutePath = path.join(targetDirectory, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
files.push(...(await collectSpecFiles(absolutePath)));
|
|
continue;
|
|
}
|
|
|
|
if (entry.isFile() && entry.name.endsWith(".spec.js")) {
|
|
files.push(absolutePath);
|
|
}
|
|
}
|
|
|
|
return files.sort((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function matchesFilter(relativePath, rawFilter) {
|
|
const normalizedRelativePath = normalizePathSegments(relativePath);
|
|
const normalizedFilter = normalizePathSegments(rawFilter.trim());
|
|
|
|
if (!normalizedFilter) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
normalizedRelativePath === normalizedFilter ||
|
|
normalizedRelativePath.endsWith(normalizedFilter) ||
|
|
normalizedRelativePath.includes(normalizedFilter)
|
|
);
|
|
}
|
|
|
|
function applyFilters(files, specFilters) {
|
|
if (specFilters.length === 0) {
|
|
return files;
|
|
}
|
|
|
|
return files.filter((absolutePath) => {
|
|
const relativePath = path.relative(workingDirectory, absolutePath);
|
|
return specFilters.some((filter) => matchesFilter(relativePath, filter));
|
|
});
|
|
}
|
|
|
|
function chunkFiles(files, size) {
|
|
const chunks = [];
|
|
|
|
for (let index = 0; index < files.length; index += size) {
|
|
chunks.push({
|
|
batch: Math.floor(index / size) + 1,
|
|
files: files.slice(index, index + size),
|
|
});
|
|
}
|
|
|
|
return chunks;
|
|
}
|
|
|
|
function formatDuration(durationMs) {
|
|
return `${(durationMs / 1000).toFixed(1)}s`;
|
|
}
|
|
|
|
function hasForwardedArg(name) {
|
|
return args.forwardedArgs.some((value) => value === name || value.startsWith(`${name}=`));
|
|
}
|
|
|
|
async function runBatch(chunk, totalBatches) {
|
|
console.log(`[vitest-batch] Running batch ${chunk.batch}/${totalBatches} with ${chunk.files.length} spec file(s).`);
|
|
|
|
const startedAt = Date.now();
|
|
const enforcedArgs = [];
|
|
|
|
if (!hasForwardedArg("--maxWorkers")) {
|
|
enforcedArgs.push("--maxWorkers=1");
|
|
}
|
|
|
|
if (!hasForwardedArg("--fileParallelism") && !hasForwardedArg("--no-file-parallelism")) {
|
|
enforcedArgs.push("--no-file-parallelism");
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
activeChild = spawn(
|
|
process.execPath,
|
|
[vitestCliPath, "run", ...chunk.files, ...enforcedArgs, ...args.forwardedArgs],
|
|
{
|
|
cwd: workingDirectory,
|
|
env: process.env,
|
|
stdio: "inherit",
|
|
windowsHide: true,
|
|
}
|
|
);
|
|
|
|
activeChild.on("close", (code) => {
|
|
activeChild = null;
|
|
resolve({
|
|
batch: chunk.batch,
|
|
files: chunk.files.length,
|
|
code: code ?? 1,
|
|
durationMs: Date.now() - startedAt,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function printSummary(results, totalFiles) {
|
|
console.log("\nVitest batch summary");
|
|
console.log(`Total spec files: ${totalFiles}`);
|
|
console.log("batch\tstatus\tfiles\tduration");
|
|
|
|
for (const result of results) {
|
|
const status = result.code === 0 ? "passed" : "failed";
|
|
console.log(`${result.batch}\t${status}\t${result.files}\t${formatDuration(result.durationMs)}`);
|
|
}
|
|
}
|
|
|
|
async function shutdown(signal) {
|
|
if (isShuttingDown) {
|
|
return;
|
|
}
|
|
|
|
isShuttingDown = true;
|
|
console.error(`Received ${signal}. Stopping active Vitest batch...`);
|
|
|
|
if (activeChild?.pid) {
|
|
if (process.platform === "win32") {
|
|
spawn("taskkill", ["/PID", String(activeChild.pid), "/T", "/F"], {
|
|
stdio: "ignore",
|
|
windowsHide: true,
|
|
});
|
|
} else {
|
|
activeChild.kill("SIGTERM");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
if (args.help) {
|
|
printUsage();
|
|
return;
|
|
}
|
|
|
|
await fs.access(vitestCliPath);
|
|
|
|
const discoveredFiles = await collectSpecFiles(unitTestsRoot);
|
|
const listFilters = await readSpecFilterList(args.fromList);
|
|
const selectedFiles = applyFilters(discoveredFiles, [...listFilters, ...args.specFilters]);
|
|
|
|
if (selectedFiles.length === 0) {
|
|
throw new Error("No unit spec files matched the provided filters.");
|
|
}
|
|
|
|
const chunks = chunkFiles(selectedFiles, batchSize);
|
|
console.log(
|
|
`Running ${selectedFiles.length} unit spec file(s) in ${chunks.length} batch(es) at ${batchSize} file(s) per batch.`
|
|
);
|
|
|
|
const results = [];
|
|
|
|
for (const chunk of chunks) {
|
|
results.push(await runBatch(chunk, chunks.length));
|
|
}
|
|
|
|
printSummary(results, selectedFiles.length);
|
|
|
|
const failedBatches = results.filter((result) => result.code !== 0);
|
|
if (failedBatches.length > 0) {
|
|
throw new Error(`Vitest batches failed: ${failedBatches.map((result) => result.batch).join(", ")}`);
|
|
}
|
|
}
|
|
|
|
await main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
});
|