import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; const workingDirectory = 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, 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.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] [-- ] Options: --batch-size Spec files per batch. Default: 5 -h, --help Show help Examples: npm run test:unit 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 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 selectedFiles = applyFilters(discoveredFiles, 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; });