@cldmv/vitest-runner
v1.5.6
Published
Sequential Vitest runner to avoid OOM issues with large test suites
Maintainers
Readme
@cldmv/vitest-runner
@cldmv/vitest-runner is a sequential Vitest runner that spawns each test file in its own child process, so a large test suite never has to fit in one Vitest process's memory. It runs files one at a time or in a parallel worker pool, gives heavy files their own heap ceiling or a solo slot, and prints one combined summary of results, memory use and duration at the end.
Coverage stays out-of-memory-safe too: each file writes a coverage blob, and a single vitest --mergeReports step combines them into one report, with a worst-coverage table after it. The runner auto-detects your Vitest config, forwards every standard Vitest flag unchanged, and works as a vitest-runner CLI or as a programmatic run() API from ESM and CommonJS.
Big suites, small processes: every test file gets its own Vitest, and the results come back as one run.
✨ What's New
Latest: v1.5.6 (October 2026)
- Dev-tooling dependency update, nothing new in the package — all open dependency updates in one change:
tsup9 (withesbuild0.28.2), Vitest 5.0.5 and the patch updates, plus an override that moves the dev-onlykatexto a fixed release. Both open Dependabot alerts are cleared, and the built files are byte-identical to v1.5.5 (#104). - View full v1.5.6 Changelog
Recent Releases
- v1.5.5 (October 2026) — No more
ENOTEMPTYstartup errors from Vitest 5's module cache: a cache directory per package, one lockfile check per run, and a newfsModuleCachePathoption (Changelog) - v1.5.4 (October 2026) — Dev-tooling dependency update (
@cldmv/fix-headers2.2.0,@cldmv/configs1.2.4); the published package is identical to v1.5.3 (Changelog) - v1.5.3 (October 2026) — A bundler-friendly CommonJS entry that fails clearly on Node.js without
require(esm), andengines.noderaised to>=22.12.0(Changelog) - v1.5.2 (October 2026) —
chalkmoves to 6.0.1, a skipped PR run can no longer satisfy✅ Required PR Check, and the repository adopts the shared CLDMV fix-headers config (Changelog)
📚 For complete release notes, see the docs/changelog/ folder.
🚀 Key Features
- Runs every test file in its own child process, one at a time or in a configurable parallel worker pool
- Solo slots for heavy files (
--solo-pattern) and per-file heap ceilings (perFileHeapOverrides) - Full coverage mode via blob-per-file +
--mergeReports(no OOM), with a worst-coverage table - Auto-detects your vitest config; accepts an explicit path if needed
- All standard Vitest CLI flags are forwarded unchanged
- Usable as a CLI binary or as a programmatic Node.js API, with an optional JSON run report
- A per-run scratch directory for every test file (
VITEST_RUNNER_TMP/makeRunTmpDir), cleaned up on exit - ES module build with a thin CommonJS entry for
require()
📦 Installation
Requirements
- Node.js 22.12.0 or later (
engines.nodeis>=22.12.0, matching thechalk6 dependency, thevitest5 peer and the CI matrix). Node.js 20 is not supported from v1.5.3; stay on v1.5.1 there. - The package is an ES module and loads with
import.require("@cldmv/vitest-runner")loads the ES module build synchronously, which needs Node.js ^20.19.0 or >=22.12.0; on older Node.js, useimport()instead. vitest≥ 1.0 (peer dependency, installed in your project)chalk(bundled dependency — no action needed)
Install
npm install --save-dev @cldmv/vitest-runnerOr to use the CLI globally:
npm install -g @cldmv/vitest-runner🚀 Quick Start
Run every discovered *.test.vitest.{js,mjs,cjs} file, each in its own process:
npx vitest-runnerRun with OOM-safe coverage and a live progress bar:
npx vitest-runner --coverage-quietFrom code:
import { run } from "@cldmv/vitest-runner";
const code = await run({ testDir: "src/tests" });
process.exit(code);The full flag list is under CLI usage and every option under Programmatic API.
💻 CLI usage
vitest-runner [OPTIONS] [PATTERNS...]Runner flags
| Flag | Description |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| --test-list <file> | Run only the files listed in a JSON array file instead of scanning |
| --file-pattern <regex> | Override the file discovery regex (default: \.test\.vitest\.(?:js\|mjs\|cjs)$) |
| --workers <n> | Number of parallel workers (default: 4 or VITEST_WORKERS) |
| --solo-pattern <pat> | Run files matching this path substring solo (one at a time) before the worker pool; repeatable |
| --exclude <glob> | Directory / file glob, relative to cwd, that discovery never enters (e.g. tmp/**); repeatable |
| --no-error-details | Hide inline error blocks — show only counts in the summary |
| --coverage-quiet | Implies --coverage; suppress per-file output and show only a live progress bar and final summaries |
| --log-file <path> | Write a clean (ANSI-stripped) copy of all output to this file. Defaults to coverage/coverage-run.log when --coverage-quiet is active |
| --suppress-file-output | Suppress per-file runner output blocks in any mode |
| --suppress-passing-files | Hide the PASSED TEST FILES section in the final summary |
| --no-top-summary | Hide TOP MEMORY USERS and TOP DURATION summary sections |
| --json | Print a JSON run report (no runner text output) |
| --blobs-dir <path> | Directory for per-file coverage blobs (default: .vitest-coverage-blobs, relative to cwd) |
| --no-merge-reports | Produce the coverage blobs but skip the merge and summary, leaving them in --blobs-dir for an external merge step |
| --keep-tmp | Keep this run's scratch directory instead of removing it on completion |
| --scratch-dir <path> | Per-run scratch root, relative to cwd (default: tmp/vitest-runner) |
| --help, -h | Print this help and exit |
Test patterns
Patterns are resolved against cwd. Any of the following forms work:
# Absolute or relative file path
vitest-runner src/tests/config/background.test.vitest.mjs
# Partial path or filename — matched against all discovered test files
vitest-runner background.test.vitest.mjs
vitest-runner config/background.test.vitest.mjs
# Directory — all test files inside it are run
vitest-runner src/tests/metadataMultiple patterns can be combined:
vitest-runner src/tests/config src/tests/metadataVitest passthrough flags
All unrecognised flags are forwarded verbatim to every vitest child process:
vitest-runner --reporter=verbose
vitest-runner -t "lazy materialization"
vitest-runner --coverage
vitest-runner --bailEnvironment variables
| Variable | Default | Description |
| ---------------- | -------- | ------------------------------------------------------------------------------- |
| VITEST_HEAP_MB | (none) | --max-old-space-size ceiling passed to every child process |
| VITEST_WORKERS | 4 | Maximum parallel worker slots in the non-solo phase (overridden by --workers) |
Examples
# Run all test files discovered under the default testDir
vitest-runner
# Run all tests, filter by name
vitest-runner -t "should handle null input"
# Run a specific folder
vitest-runner src/tests/auth
# Run with coverage (blob + merge — OOM-safe)
vitest-runner --coverage
# Coverage with quiet output and live progress bar (ideal for CI)
vitest-runner --coverage --coverage-quiet
# Run only files listed in a JSON file
vitest-runner --test-list my-tests.json
# Use a custom file discovery pattern
vitest-runner --file-pattern '\.spec\.ts$'
# Run 2 workers, with certain files running solo first
vitest-runner --workers 2 --solo-pattern heavy/ --solo-pattern listener-cleanup/
# Skip scratch worktrees / build output when discovery walks the repo root
vitest-runner --exclude 'tmp/**' --exclude 'dist/**'
# Custom heap and worker count
VITEST_HEAP_MB=8192 vitest-runner --workers 2 src/tests/heavy
# Suppress error details in the summary
vitest-runner --no-error-details
# JSON output for automation
vitest-runner --json
# JSON output without top summary arrays
vitest-runner --json --no-top-summary🔧 Programmatic API
import { run } from "@cldmv/vitest-runner";
// CommonJS
const { run } = require("@cldmv/vitest-runner");require() loads the ES module build synchronously through Node's require(esm), which needs Node.js ^20.19.0 or >=22.12.0; on older Node.js it throws a clear ERR_REQUIRE_ESM error, and import() is the way to load the package there.
When developing against a local checkout or workspace copy instead of the published package, run Node with --conditions=vitest-runner-dev so that import of the package resolves to its src/ entry (src/index.mjs) instead of the built dist/.
run(options) → Promise<number | object>
Runs the test suite and resolves with an exit code (0 = all passed, 1 = any failure) by default. When json: true is passed, it returns a structured JSON report object (including exitCode) instead of printing runner text output.
import { run } from "@cldmv/vitest-runner";
const code = await run({
testDir: "src/tests"
});
process.exit(code);Options
| Option | Type | Default | Description |
| ---------------------- | ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| cwd | string | process.cwd() | Absolute project root directory |
| testDir | string | cwd | Directory (absolute or relative to cwd) to scan for *.test.vitest.{js,mjs,cjs} files |
| vitestConfig | string | auto-detect | Explicit vitest config path; when omitted the runner walks standard config names (vitest.config.ts, vite.config.ts, etc.) relative to cwd |
| testPatterns | string[] | [] | File / folder patterns to filter — empty means all files in testDir |
| testListFile | string | undefined | Path to a JSON array of test file paths; when set, scanning is skipped entirely |
| testFilePattern | RegExp | DEFAULT_TEST_FILE_PATTERN | Regex matched against file names during discovery (*.test.vitest.{js,mjs,cjs} by default) |
| exclude | string[] | [] | Directory / file globs, relative to cwd, that discovery never enters (e.g. ['tmp/**']). Applies to both the default scan and partial-path pattern resolution |
| vitestArgs | string[] | [] | Extra CLI args forwarded verbatim to every vitest invocation |
| showErrorDetails | boolean | true | Print inline error blocks under each failed file in the summary |
| coverageQuiet | boolean | false | Suppress per-file output; show only the progress bar and final summaries |
| suppressFileOutput | boolean | false | Suppress per-file runner output blocks in all modes |
| suppressPassingFiles | boolean | false | Hide passed-file rows in the final summary |
| topSummary | boolean | true | Show or hide top memory/duration summary sections (and JSON arrays) |
| json | boolean | false | Return a JSON report object instead of printing text output |
| workers | number | 4 | Maximum parallel worker slots (overrides VITEST_WORKERS) |
| worstCoverageCount | number | 10 | Rows in the worst-coverage table after a coverage run (0 disables it) |
| blobsDir | string | <cwd>/.vitest-coverage-blobs | Directory for per-file coverage blobs. Relative paths resolve against cwd. Always cleared at the start of a coverage run |
| mergeReports | boolean | true | When true, blobs are merged via vitest --mergeReports, the coverage summary is printed, and blobsDir is deleted. When false, the run stops after producing blobs — no merge, no summary — and blobsDir is left populated for an external merge |
| maxOldSpaceMb | number | undefined | Global --max-old-space-size ceiling in MB (overrides VITEST_HEAP_MB) |
| earlyRunPatterns | string[] | [] | Path substrings — matching files run solo (one at a time) before the parallel worker pool starts |
| perFileHeapOverrides | PerFileHeapOverride[] | [] | Per-file minimum heap ceilings; the maximum of this and maxOldSpaceMb wins |
| conditions | string[] | [] | Additional --conditions Node flags forwarded to children |
| nodeEnv | string | 'development' | Value written to NODE_ENV in child processes |
| scratchDir | string | 'tmp/vitest-runner' | Per-run scratch root, relative to cwd (or absolute). A subdirectory is created per file invocation and exposed to it via VITEST_RUNNER_TMP |
| keepTmp | boolean | false | Keep the run's scratch root instead of removing it on completion (normal exit, failure, or SIGINT/SIGTERM) |
| fsModuleCachePath | string \| false | <cwd>/node_modules/.vitest-cache | Vitest 5+ fsModuleCache directory for every child, relative to cwd (or absolute). Overrides a path in the vitest config; a --fsModuleCachePath passthrough arg overrides this. When unset, the default applies only if the resolved config (root or any project) sets no path. false adds nothing. See Vitest's module cache |
PerFileHeapOverride
{
pattern: string;
heapMb: number;
}pattern is a substring matched against the normalised (forward-slash) file path. The first match wins and is compared against the global maxOldSpaceMb; the larger value is used.
Examples
// Run all tests under src/tests/ (cwd defaults to process.cwd())
await run({ testDir: "src/tests" });
// Run only the config and metadata suites
await run({
testDir: "src/tests",
testPatterns: ["src/tests/config", "src/tests/metadata"]
});
// Coverage run (OOM-safe blob + merge mode)
await run({
testDir: "src/tests",
vitestArgs: ["--coverage"]
});
// Quiet coverage with live progress bar
await run({
testDir: "src/tests",
coverageQuiet: true
});
// Machine-readable output (no text logs)
const report = await run({
testDir: "src/tests",
json: true
});
console.log(report.exitCode);
// Give heap-heavy files a larger ceiling while keeping the global limit lower
await run({
testDir: "src/tests",
maxOldSpaceMb: 2048,
earlyRunPatterns: ["listener-cleanup/"],
perFileHeapOverrides: [{ pattern: "listener-cleanup/", heapMb: 6144 }]
});Scratch directories (VITEST_RUNNER_TMP / makeRunTmpDir)
Every run gets its own scratch root (<scratchDir>/<pid>-<timestamp>/), created before any file runs and removed once the run completes — on success, on failure, and on SIGINT/SIGTERM — unless keepTmp is set. Each file invocation gets its own subdirectory under that root, exposed to the child as process.env.VITEST_RUNNER_TMP. Stale roots left by a crashed prior run (dead PID) are swept at the start of the next run.
From a test file, use makeRunTmpDir(label) to get a fresh, uniquely-named subdirectory instead of managing your own mkdtemp base:
import { makeRunTmpDir } from "@cldmv/vitest-runner";
const dir = makeRunTmpDir("my-fixture"); // a fresh directory under VITEST_RUNNER_TMPCLI flags: --scratch-dir <path> and --keep-tmp (see Runner flags above).
Vitest's module cache
Vitest 5 caches transformed modules on disk (fsModuleCache, on by default from 5.0.5). Its default location is node_modules/.vitest-cache at the workspace root, shared by every package in the workspace, and each vitest process deletes the whole directory on startup when the lockfile differs from the one the cache was written for. Several processes starting at once after a lockfile change all try to delete it, and some fail with ENOTEMPTY. Packages in one workspace that have their own lockfile also keep clearing each other's cache.
Before any file runs, the runner starts one short helper process that loads the project's Vitest config (the root and every project) the way the workers will, and:
- picks a cache per package: when the cache is on and nothing sets a path, every child gets
--fsModuleCachePath=<cwd>/node_modules/.vitest-cache, so one package's run never clears another's cache; - checks the lockfile once: it starts Vitest once with the workers' args, which clears a stale cache, and exits. The workers then start on a cache that matches the lockfile. This step is skipped when solo files run first (the first solo file does the same job), when files never run in parallel, or when the cache is off.
The cache path is chosen in this order:
- a
--fsModuleCachePathpassthrough arg; - the
fsModuleCachePathoption (falseadds nothing and leaves Vitest's own setting in place); fsModuleCachePathset anywhere in the resolved config, including a project's own config;<cwd>/node_modules/.vitest-cache.
None of this applies to Vitest before 5, and if the helper fails or takes longer than a minute, the run goes ahead without it. To turn the cache off, pass --fsModuleCache=false or set fsModuleCache: false in the vitest config.
📊 Coverage mode
When --coverage (or coverageQuiet: true) is passed, the runner uses a blob-per-file strategy:
- Each file receives
--coverage --reporter=blobwith its own temp output directory. - After all files complete,
vitest --mergeReportscombines the blobs into a single report. - Temporary blob and coverage-tmp directories are cleaned up automatically.
This avoids the OOM crash that occurs when a single vitest process holds coverage data for thousands of files simultaneously.
Coverage quiet mode
--coverage-quiet / coverageQuiet: true suppresses all per-file output and renders a live progress bar instead. On completion it prints the coverage table and any failures verbosely. When running in this mode, output is also mirrored to coverage/coverage-run.log (CLI only) with ANSI colour codes stripped so the file is human-readable in any editor.
The log file path can be overridden with --log-file <path>. Passing --log-file by itself only enables log mirroring (it does not enable coverage mode).
When no files are measured
If the coverage include matches no files (for example, a scaffold repo with a test but no source yet), istanbul reports every percentage as Unknown rather than a number. The runner prints those metrics as Unknown — Coverage Unknown% lines | Unknown% statements | … — adds a note that no files were measured, and exits 0 if the tests passed. The same applies to any individual non-numeric percentage in the worst-coverage table.
Coverage thresholds are skipped in this case, because there is nothing to measure: vitest's own threshold check does not fail on an Unknown percentage, and the runner does not add a check of its own. Thresholds apply again as soon as at least one file is measured. The JSON report's coverageSummary.noFilesMeasured is true for such a run.
Producing blobs for an external merge
Set mergeReports: false (CLI: --no-merge-reports) to stop the run after the per-file blobs are written. The internal vitest --mergeReports call and the coverage summary are skipped, and blobsDir is left intact instead of being deleted. The blobs directory is still cleared at the start of each run, so it only ever contains the current run's output.
This is useful when a second coverage blob set (for example, a browser-mode run with a different coverage transform) needs to be merged together with the node-mode blobs. Point both runs at known directories with blobsDir, then merge them in one external vitest --mergeReports step:
// Node-mode blobs, no internal merge
await run({
testDir: "src/tests",
vitestArgs: ["--coverage"],
blobsDir: ".coverage-blobs/node",
mergeReports: false
});
// (separately produce browser-mode blobs into .coverage-blobs/browser)
// then merge both blob sets in a single external step:
// vitest --mergeReports .coverage-blobs --coverageThe exit code still reflects test pass/fail; there is just no coverage-merge result to fold in.
📋 Test list files
A test list file is a plain JSON array of test file paths (relative to cwd):
["src/tests/auth/login.test.vitest.mjs", "src/tests/auth/register.test.vitest.mjs", "src/tests/config/defaults.test.vitest.mjs"]Pass --test-list <file> (CLI) or testListFile: 'path/to/list.json' (API) to run exactly those files instead of scanning testDir.
🔍 Test file naming
By default, the runner discovers files matching:
*.test.vitest.js
*.test.vitest.mjs
*.test.vitest.cjsFiles in node_modules or hidden directories (names starting with .) are always skipped.
The pattern can be overridden with --file-pattern <regex> (CLI) or the testFilePattern option (API):
# Match .spec.ts files instead
vitest-runner --file-pattern '\.spec\.ts$'await run({ cwd, testDir: "src", testFilePattern: /\.spec\.ts$/i });📁 Source layout
dist/ ← built library entry (npm run build / tsup) — generated, not committed
index.mjs ← bundled ESM entry
index.cjs ← thin CJS entry, copied verbatim from src/index.cjs; require()s index.mjs
bin/ ← built CLI binary (npm run build / tsup) — generated, not committed
vitest-runner.mjs ← bundled CLI, from src/bin/vitest-runner.mjs (shebang preserved)
src/
index.mjs ← main run() API + re-exports
index.cjs ← CommonJS entry source (copied to dist/index.cjs by the build)
bin/
vitest-runner.mjs ← CLI entry SOURCE — run this directly for source-level dev/testing
utils/
ansi.mjs ← stripAnsi, colourPct
duration.mjs ← formatDuration
env.mjs ← buildNodeOptions, buildEnv
resolve.mjs ← resolveBin, resolveVitestConfig
core/
discover.mjs ← discoverVitestFiles, sortWithPriority, computeFilterConflicts
parse.mjs ← parseVitestOutput, deduplicateErrors
spawn.mjs ← runSingleFile, runVitestDirect, runMergeReports
report.mjs ← printCoverageSummary, printMergeOutput
progress.mjs ← createCoverageProgressTracker
scratch.mjs ← makeRunTmpDir + the scratch-directory lifecycle
fs-cache.mjs ← prepareFsModuleCache: Vitest module cache path + one-time lockfile check
fs-cache-helper.mjs ← helper process started by fs-cache.mjs (built to dist/ and bin/)
cli/
args.mjs ← parseArguments
help.mjs ← showHelpsrc/ is not published — only dist/, bin/, and types/ ship (see Programmatic API for the vitest-runner-dev export condition, used when developing against a workspace/local checkout instead of the published package). All sub-module utilities are re-exported from the root entry point, so deep imports are optional.
📚 Documentation
- Changelog — release notes for every version
- Release notes on GitHub — the same notes attached to each release tag
🤝 Contributing
Bug reports and pull requests are welcome on GitHub. Pull requests target the next branch; releases ship from next to master.
🔗 Links
- npm: @cldmv/vitest-runner
- GitHub: CLDMV/vitest-runner
- Issues: GitHub Issues
- Changelog: docs/changelog/
- Releases: GitHub Releases
📄 License
Apache-2.0 © Shinrai / CLDMV. See LICENSE for the full text.
