Skip to content

Repository files navigation

@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.

npm version npm downloads GitHub downloads Last commit npm last update Coverage

Contributors Sponsor shinrai


✨ What's New

Latest: v1.5.4 (October 2026)

  • Dev-tooling dependency update, nothing new in the package — @cldmv/fix-headers moves to 2.2.0 (#74, #78), @cldmv/configs to 1.2.4 (#78) and the dev-only brace-expansion to 5.0.12 (#76). What the package ships is identical to v1.5.3.
  • View full v1.5.4 Changelog

Recent Releases

  • v1.5.3 (October 2026) — A bundler-friendly CommonJS entry that fails clearly on Node.js without require(esm), and engines.node raised to >=22.12.0 (Changelog)
  • v1.5.2 (October 2026) — chalk moves 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)
  • v1.5.1 (September 2026) — Documentation-only release that backfilled the missing v1.5.0 changelog (Changelog)
  • v1.5.0 (September 2026) — Added an exclude option (API + repeatable --exclude <glob> CLI flag) so discovery can skip directories/files like tmp/** worktrees or dist/** build output (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.node is >=22.12.0, matching the chalk 6 dependency, the vitest 5 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, use import() instead.
  • vitest ≥ 1.0 (peer dependency, installed in your project)
  • chalk (bundled dependency — no action needed)

Install

npm install --save-dev @cldmv/vitest-runner

Or 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-runner

Run with OOM-safe coverage and a live progress bar:

npx vitest-runner --coverage-quiet

From 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/metadata

Multiple patterns can be combined:

vitest-runner src/tests/config src/tests/metadata

Vitest 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 --bail

Environment 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/runner.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)

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_TMP

CLI flags: --scratch-dir <path> and --keep-tmp (see Runner flags above).

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 }]
});

📊 Coverage mode

When --coverage (or coverageQuiet: true) is passed, the runner uses a blob-per-file strategy:

  1. Each file receives --coverage --reporter=blob with its own temp output directory.
  2. After all files complete, vitest --mergeReports combines the blobs into a single report.
  3. 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 --coverage

The 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.cjs

Files 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/cjs-shim.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/
  runner.mjs           ← main run() API + re-exports
  cjs-shim.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
    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
  cli/
    args.mjs           ← parseArguments
    help.mjs           ← showHelp

src/ 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

CodeFactor OpenSSF Scorecard npms.io score npm unpacked size Repo size


🤝 Contributing

Bug reports and pull requests are welcome on GitHub. Pull requests target the next branch; releases ship from next to master.

Contributors Sponsor shinrai


🔗 Links


📄 License

npm license

Apache-2.0 © Shinrai / CLDMV. See LICENSE for the full text.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages