@cldmv/vitest-runner 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,16 +45,22 @@ vitest-runner [OPTIONS] [PATTERNS...]
45
45
 
46
46
  ### Runner flags
47
47
 
48
- | Flag | Description |
49
- |------|-------------|
50
- | `--test-list <file>` | Run only the files listed in a JSON array file instead of scanning |
51
- | `--file-pattern <regex>` | Override the file discovery regex (default: `\.test\.vitest\.(?:js\|mjs\|cjs)$`) |
52
- | `--workers <n>` | Number of parallel workers (default: `4` or `VITEST_WORKERS`) |
53
- | `--solo-pattern <pat>` | Run files matching this path substring solo (one at a time) before the worker pool; repeatable |
54
- | `--no-error-details` | Hide inline error blocks — show only counts in the summary |
55
- | `--coverage-quiet` | Implies `--coverage`; suppress per-file output and show only a live progress bar and final summaries |
56
- | `--log-file <path>` | Write a clean (ANSI-stripped) copy of all output to this file; implies `--coverage-quiet` when set without it. Defaults to `coverage/coverage-run.log` when `--coverage-quiet` is active |
57
- | `--help`, `-h` | Print this help and exit |
48
+ | Flag | Description |
49
+ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
50
+ | `--test-list <file>` | Run only the files listed in a JSON array file instead of scanning |
51
+ | `--file-pattern <regex>` | Override the file discovery regex (default: `\.test\.vitest\.(?:js\|mjs\|cjs)$`) |
52
+ | `--workers <n>` | Number of parallel workers (default: `4` or `VITEST_WORKERS`) |
53
+ | `--solo-pattern <pat>` | Run files matching this path substring solo (one at a time) before the worker pool; repeatable |
54
+ | `--no-error-details` | Hide inline error blocks — show only counts in the summary |
55
+ | `--coverage-quiet` | Implies `--coverage`; suppress per-file output and show only a live progress bar and final summaries |
56
+ | `--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 |
57
+ | `--suppress-file-output` | Suppress per-file runner output blocks in any mode |
58
+ | `--suppress-passing-files` | Hide the `PASSED TEST FILES` section in the final summary |
59
+ | `--no-top-summary` | Hide `TOP MEMORY USERS` and `TOP DURATION` summary sections |
60
+ | `--json` | Print a JSON run report (no runner text output) |
61
+ | `--blobs-dir <path>` | Directory for per-file coverage blobs (default: `.vitest-coverage-blobs`, relative to `cwd`) |
62
+ | `--no-merge-reports` | Produce the coverage blobs but skip the merge and summary, leaving them in `--blobs-dir` for an external merge step |
63
+ | `--help`, `-h` | Print this help and exit |
58
64
 
59
65
  ### Test patterns
60
66
 
@@ -91,10 +97,10 @@ vitest-runner --bail
91
97
 
92
98
  ### Environment variables
93
99
 
94
- | Variable | Default | Description |
95
- |----------|---------|-------------|
96
- | `VITEST_HEAP_MB` | *(none)* | `--max-old-space-size` ceiling passed to every child process |
97
- | `VITEST_WORKERS` | `4` | Maximum parallel worker slots in the non-solo phase (overridden by `--workers`) |
100
+ | Variable | Default | Description |
101
+ | ---------------- | -------- | ------------------------------------------------------------------------------- |
102
+ | `VITEST_HEAP_MB` | _(none)_ | `--max-old-space-size` ceiling passed to every child process |
103
+ | `VITEST_WORKERS` | `4` | Maximum parallel worker slots in the non-solo phase (overridden by `--workers`) |
98
104
 
99
105
  ### Examples
100
106
 
@@ -128,6 +134,12 @@ VITEST_HEAP_MB=8192 vitest-runner --workers 2 src/tests/heavy
128
134
 
129
135
  # Suppress error details in the summary
130
136
  vitest-runner --no-error-details
137
+
138
+ # JSON output for automation
139
+ vitest-runner --json
140
+
141
+ # JSON output without top summary arrays
142
+ vitest-runner --json --no-top-summary
131
143
  ```
132
144
 
133
145
  ---
@@ -135,21 +147,21 @@ vitest-runner --no-error-details
135
147
  ## Programmatic API
136
148
 
137
149
  ```js
138
- import { run } from 'vitest-runner';
150
+ import { run } from "vitest-runner";
139
151
 
140
152
  // CommonJS
141
- const { run } = await require('vitest-runner');
153
+ const { run } = await require("vitest-runner");
142
154
  ```
143
155
 
144
- ### `run(options)` → `Promise<number>`
156
+ ### `run(options)` → `Promise<number | object>`
145
157
 
146
- Runs the test suite and resolves with an exit code (`0` = all passed, `1` = any failure). Does **not** call `process.exit` — that is the caller's responsibility.
158
+ 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.
147
159
 
148
160
  ```js
149
- import { run } from 'vitest-runner';
161
+ import { run } from "vitest-runner";
150
162
 
151
163
  const code = await run({
152
- testDir: 'src/tests',
164
+ testDir: "src/tests"
153
165
  });
154
166
 
155
167
  process.exit(code);
@@ -157,29 +169,38 @@ process.exit(code);
157
169
 
158
170
  #### Options
159
171
 
160
- | Option | Type | Default | Description |
161
- |--------|------|---------|-------------|
162
- | `cwd` | `string` | `process.cwd()` | Absolute project root directory |
163
- | `testDir` | `string` | `cwd` | Directory (absolute or relative to `cwd`) to scan for `*.test.vitest.{js,mjs}` files |
164
- | `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` |
165
- | `testPatterns` | `string[]` | `[]` | File / folder patterns to filter — empty means all files in `testDir` |
166
- | `testListFile` | `string` | `undefined` | Path to a JSON array of test file paths; when set, scanning is skipped entirely |
167
- | `testFilePattern` | `RegExp` | `DEFAULT_TEST_FILE_PATTERN` | Regex matched against file names during discovery (`*.test.vitest.{js,mjs,cjs}` by default) |
168
- | `vitestArgs` | `string[]` | `[]` | Extra CLI args forwarded verbatim to every vitest invocation |
169
- | `showErrorDetails` | `boolean` | `true` | Print inline error blocks under each failed file in the summary |
170
- | `coverageQuiet` | `boolean` | `false` | Suppress per-file output; show only the progress bar and final summaries |
171
- | `workers` | `number` | `4` | Maximum parallel worker slots (overrides `VITEST_WORKERS`) |
172
- | `worstCoverageCount` | `number` | `10` | Rows in the worst-coverage table after a coverage run (`0` disables it) |
173
- | `maxOldSpaceMb` | `number` | `undefined` | Global `--max-old-space-size` ceiling in MB (overrides `VITEST_HEAP_MB`) |
174
- | `earlyRunPatterns` | `string[]` | `[]` | Path substrings — matching files run solo (one at a time) before the parallel worker pool starts |
175
- | `perFileHeapOverrides` | `PerFileHeapOverride[]` | `[]` | Per-file minimum heap ceilings; the maximum of this and `maxOldSpaceMb` wins |
176
- | `conditions` | `string[]` | `[]` | Additional `--conditions` Node flags forwarded to children |
177
- | `nodeEnv` | `string` | `'development'` | Value written to `NODE_ENV` in child processes |
172
+ | Option | Type | Default | Description |
173
+ | ---------------------- | ----------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
174
+ | `cwd` | `string` | `process.cwd()` | Absolute project root directory |
175
+ | `testDir` | `string` | `cwd` | Directory (absolute or relative to `cwd`) to scan for `*.test.vitest.{js,mjs}` files |
176
+ | `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` |
177
+ | `testPatterns` | `string[]` | `[]` | File / folder patterns to filter — empty means all files in `testDir` |
178
+ | `testListFile` | `string` | `undefined` | Path to a JSON array of test file paths; when set, scanning is skipped entirely |
179
+ | `testFilePattern` | `RegExp` | `DEFAULT_TEST_FILE_PATTERN` | Regex matched against file names during discovery (`*.test.vitest.{js,mjs,cjs}` by default) |
180
+ | `vitestArgs` | `string[]` | `[]` | Extra CLI args forwarded verbatim to every vitest invocation |
181
+ | `showErrorDetails` | `boolean` | `true` | Print inline error blocks under each failed file in the summary |
182
+ | `coverageQuiet` | `boolean` | `false` | Suppress per-file output; show only the progress bar and final summaries |
183
+ | `suppressFileOutput` | `boolean` | `false` | Suppress per-file runner output blocks in all modes |
184
+ | `suppressPassingFiles` | `boolean` | `false` | Hide passed-file rows in the final summary |
185
+ | `topSummary` | `boolean` | `true` | Show or hide top memory/duration summary sections (and JSON arrays) |
186
+ | `json` | `boolean` | `false` | Return a JSON report object instead of printing text output |
187
+ | `workers` | `number` | `4` | Maximum parallel worker slots (overrides `VITEST_WORKERS`) |
188
+ | `worstCoverageCount` | `number` | `10` | Rows in the worst-coverage table after a coverage run (`0` disables it) |
189
+ | `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 |
190
+ | `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 |
191
+ | `maxOldSpaceMb` | `number` | `undefined` | Global `--max-old-space-size` ceiling in MB (overrides `VITEST_HEAP_MB`) |
192
+ | `earlyRunPatterns` | `string[]` | `[]` | Path substrings — matching files run solo (one at a time) before the parallel worker pool starts |
193
+ | `perFileHeapOverrides` | `PerFileHeapOverride[]` | `[]` | Per-file minimum heap ceilings; the maximum of this and `maxOldSpaceMb` wins |
194
+ | `conditions` | `string[]` | `[]` | Additional `--conditions` Node flags forwarded to children |
195
+ | `nodeEnv` | `string` | `'development'` | Value written to `NODE_ENV` in child processes |
178
196
 
179
197
  #### `PerFileHeapOverride`
180
198
 
181
199
  ```ts
182
- { pattern: string; heapMb: number }
200
+ {
201
+ pattern: string;
202
+ heapMb: number;
203
+ }
183
204
  ```
184
205
 
185
206
  `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.
@@ -188,34 +209,40 @@ process.exit(code);
188
209
 
189
210
  ```js
190
211
  // Run all tests under src/tests/ (cwd defaults to process.cwd())
191
- await run({ testDir: 'src/tests' });
212
+ await run({ testDir: "src/tests" });
192
213
 
193
214
  // Run only the config and metadata suites
194
215
  await run({
195
- testDir: 'src/tests',
196
- testPatterns: ['src/tests/config', 'src/tests/metadata'],
216
+ testDir: "src/tests",
217
+ testPatterns: ["src/tests/config", "src/tests/metadata"]
197
218
  });
198
219
 
199
220
  // Coverage run (OOM-safe blob + merge mode)
200
221
  await run({
201
- testDir: 'src/tests',
202
- vitestArgs: ['--coverage'],
222
+ testDir: "src/tests",
223
+ vitestArgs: ["--coverage"]
203
224
  });
204
225
 
205
226
  // Quiet coverage with live progress bar
206
227
  await run({
207
- testDir: 'src/tests',
208
- coverageQuiet: true,
228
+ testDir: "src/tests",
229
+ coverageQuiet: true
209
230
  });
210
231
 
232
+ // Machine-readable output (no text logs)
233
+ const report = await run({
234
+ testDir: "src/tests",
235
+ json: true
236
+ });
237
+
238
+ console.log(report.exitCode);
239
+
211
240
  // Give heap-heavy files a larger ceiling while keeping the global limit lower
212
241
  await run({
213
- testDir: 'src/tests',
214
- maxOldSpaceMb: 2048,
215
- earlyRunPatterns: ['listener-cleanup/'],
216
- perFileHeapOverrides: [
217
- { pattern: 'listener-cleanup/', heapMb: 6144 },
218
- ],
242
+ testDir: "src/tests",
243
+ maxOldSpaceMb: 2048,
244
+ earlyRunPatterns: ["listener-cleanup/"],
245
+ perFileHeapOverrides: [{ pattern: "listener-cleanup/", heapMb: 6144 }]
219
246
  });
220
247
  ```
221
248
 
@@ -235,7 +262,29 @@ This avoids the OOM crash that occurs when a single vitest process holds coverag
235
262
 
236
263
  `--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.
237
264
 
238
- The log file path can be overridden with `--log-file <path>`. Passing `--log-file` alone (without `--coverage-quiet`) also enables quiet mode and log mirroring.
265
+ 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).
266
+
267
+ ### Producing blobs for an external merge
268
+
269
+ 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.
270
+
271
+ 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:
272
+
273
+ ```js
274
+ // Node-mode blobs, no internal merge
275
+ await run({
276
+ testDir: "src/tests",
277
+ vitestArgs: ["--coverage"],
278
+ blobsDir: ".coverage-blobs/node",
279
+ mergeReports: false
280
+ });
281
+
282
+ // (separately produce browser-mode blobs into .coverage-blobs/browser)
283
+ // then merge both blob sets in a single external step:
284
+ // vitest --mergeReports .coverage-blobs --coverage
285
+ ```
286
+
287
+ The exit code still reflects test pass/fail; there is just no coverage-merge result to fold in.
239
288
 
240
289
  ---
241
290
 
@@ -244,11 +293,7 @@ The log file path can be overridden with `--log-file <path>`. Passing `--log-fil
244
293
  A test list file is a plain JSON array of test file paths (relative to `cwd`):
245
294
 
246
295
  ```json
247
- [
248
- "src/tests/auth/login.test.vitest.mjs",
249
- "src/tests/auth/register.test.vitest.mjs",
250
- "src/tests/config/defaults.test.vitest.mjs"
251
- ]
296
+ ["src/tests/auth/login.test.vitest.mjs", "src/tests/auth/register.test.vitest.mjs", "src/tests/config/defaults.test.vitest.mjs"]
252
297
  ```
253
298
 
254
299
  Pass `--test-list <file>` (CLI) or `testListFile: 'path/to/list.json'` (API) to run exactly those files instead of scanning `testDir`.
@@ -259,7 +304,7 @@ Pass `--test-list <file>` (CLI) or `testListFile: 'path/to/list.json'` (API) to
259
304
 
260
305
  By default, the runner discovers files matching:
261
306
 
262
- ```
307
+ ```text
263
308
  *.test.vitest.js
264
309
  *.test.vitest.mjs
265
310
  *.test.vitest.cjs
@@ -275,14 +320,14 @@ vitest-runner --file-pattern '\.spec\.ts$'
275
320
  ```
276
321
 
277
322
  ```js
278
- await run({ cwd, testDir: 'src', testFilePattern: /\.spec\.ts$/i });
323
+ await run({ cwd, testDir: "src", testFilePattern: /\.spec\.ts$/i });
279
324
  ```
280
325
 
281
326
  ---
282
327
 
283
328
  ## Source layout
284
329
 
285
- ```
330
+ ```text
286
331
  index.mjs ← ESM entry (re-exports src/runner.mjs)
287
332
  index.cjs ← CJS shim (dynamic import of index.mjs)
288
333
  bin/
@@ -3,7 +3,7 @@
3
3
  * @fileoverview CLI entry point for the vitest-runner binary.
4
4
  * @module vitest-runner/bin/vitest-runner
5
5
  *
6
- * Mirrors its own output to a log file when --coverage-quiet is set, then
6
+ * Mirrors its own output to a log file when --coverage-quiet or --log-file is set, then
7
7
  * delegates all logic to `src/runner.mjs` via the programmatic `run()` API.
8
8
  */
9
9
 
@@ -23,7 +23,7 @@ if (args.help) {
23
23
 
24
24
  // Mirror all output (excluding progress bar lines) to a log file
25
25
  // when running in --coverage-quiet mode (or when --log-file is set).
26
- if (args.coverageQuiet || args.logFile) {
26
+ if ((args.coverageQuiet || args.logFile) && !args.json) {
27
27
  const cwd = process.cwd();
28
28
  const resolvedLogFile = args.logFile
29
29
  ? path.isAbsolute(args.logFile)
@@ -62,25 +62,36 @@ if (args.coverageQuiet || args.logFile) {
62
62
  const cwd = process.cwd();
63
63
 
64
64
  const vitestArgs = [...args.vitestPassthroughArgs];
65
- if ((args.coverageQuiet || args.logFile) && !vitestArgs.some((a) => a === "--coverage" || a.startsWith("--coverage."))) {
65
+ if (args.coverageQuiet && !vitestArgs.some((a) => a === "--coverage" || a.startsWith("--coverage."))) {
66
66
  vitestArgs.unshift("--coverage");
67
67
  }
68
68
 
69
- run({
70
- cwd,
71
- testPatterns: args.testPatterns,
72
- testListFile: args.testListFile,
73
- testFilePattern: args.testFilePattern,
74
- vitestArgs,
75
- showErrorDetails: args.showErrorDetails,
76
- coverageQuiet: args.coverageQuiet,
77
- ...(args.workers !== undefined && { workers: args.workers }),
78
- ...(args.soloPatterns.length > 0 && { earlyRunPatterns: args.soloPatterns })
79
- })
80
- .then((code) => {
81
- process.exit(code);
82
- })
83
- .catch((err) => {
84
- console.error("Fatal error:", err);
85
- process.exit(1);
69
+ try {
70
+ const runResult = await run({
71
+ cwd,
72
+ testPatterns: args.testPatterns,
73
+ testListFile: args.testListFile,
74
+ testFilePattern: args.testFilePattern,
75
+ vitestArgs,
76
+ showErrorDetails: args.showErrorDetails,
77
+ coverageQuiet: args.coverageQuiet,
78
+ suppressFileOutput: args.suppressFileOutput,
79
+ suppressPassingFiles: args.suppressPassingFiles,
80
+ topSummary: args.topSummary,
81
+ json: args.json,
82
+ mergeReports: args.mergeReports,
83
+ ...(args.blobsDir !== undefined && { blobsDir: args.blobsDir }),
84
+ ...(args.workers !== undefined && { workers: args.workers }),
85
+ ...(args.soloPatterns.length > 0 && { earlyRunPatterns: args.soloPatterns })
86
86
  });
87
+
88
+ if (args.json) {
89
+ process.stdout.write(`${JSON.stringify(runResult, null, 2)}\n`);
90
+ process.exit(typeof runResult === "object" && runResult !== null && "exitCode" in runResult ? runResult.exitCode : 1);
91
+ }
92
+
93
+ process.exit(typeof runResult === "number" ? runResult : 1);
94
+ } catch (err) {
95
+ console.error("Fatal error:", err);
96
+ process.exit(1);
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cldmv/vitest-runner",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "description": "Sequential Vitest runner to avoid OOM issues with large test suites",
5
5
  "type": "module",
6
6
  "main": "index.cjs",
@@ -24,7 +24,9 @@
24
24
  "bin/"
25
25
  ],
26
26
  "scripts": {
27
- "lint": "eslint src/ bin/ index.mjs",
27
+ "lint": "eslint --config .configs/eslint.config.mjs .",
28
+ "format": "prettier --write . --config .configs/.prettierrc",
29
+ "format:check": "prettier --check . --config .configs/.prettierrc",
28
30
  "test": "vitest run --config .configs/vitest.config.mjs",
29
31
  "test:watch": "vitest --config .configs/vitest.config.mjs",
30
32
  "test:coverage": "vitest run --coverage --config .configs/vitest.config.mjs",
@@ -39,10 +41,16 @@
39
41
  "oom"
40
42
  ],
41
43
  "devDependencies": {
44
+ "@eslint/js": "^10.0.1",
45
+ "@eslint/json": "^2.0.0",
46
+ "@eslint/markdown": "^8.0.2",
42
47
  "@types/node": "^25.3.0",
43
- "@vitest/coverage-v8": "^4.0.18",
48
+ "@vitest/coverage-v8": "^4.1.9",
49
+ "eslint": "^10.5.0",
50
+ "globals": "^17.6.0",
51
+ "prettier": "^3.8.4",
44
52
  "typescript": "^5.9.3",
45
- "vitest": "^4.0.18"
53
+ "vitest": "^4.1.9"
46
54
  },
47
55
  "dependencies": {
48
56
  "chalk": "^5.4.1"
@@ -51,7 +59,7 @@
51
59
  "vitest": ">=1.0.0"
52
60
  },
53
61
  "engines": {
54
- "node": ">=18.0.0"
62
+ "node": ">=20.19.0"
55
63
  },
56
64
  "publishConfig": {
57
65
  "access": "public"
package/src/cli/args.mjs CHANGED
@@ -9,6 +9,12 @@
9
9
  * @property {boolean} showErrorDetails - `false` when `--no-error-details` was passed.
10
10
  * @property {boolean} coverageQuiet - Whether `--coverage-quiet` was passed.
11
11
  * @property {string|undefined} logFile - Path for the coverage run log (`--log-file`); defaults to `coverage/coverage-run.log`.
12
+ * @property {boolean} suppressFileOutput - Suppress per-file runner output blocks (`--suppress-file-output`).
13
+ * @property {boolean} suppressPassingFiles - Suppress the passed-files section in the final summary (`--suppress-passing-files`).
14
+ * @property {boolean} topSummary - Show top-summary sections for memory and duration (`true` by default, disabled by `--no-top-summary`).
15
+ * @property {boolean} json - Emit a JSON run report instead of text output (`--json`).
16
+ * @property {string|undefined} blobsDir - Directory for per-file coverage blobs (`--blobs-dir`); defaults to `<cwd>/.vitest-coverage-blobs`.
17
+ * @property {boolean} mergeReports - `false` when `--no-merge-reports` was passed; leaves blobs in `blobsDir` without merging.
12
18
  * @property {boolean} help - Whether `--help` / `-h` was passed.
13
19
  * @property {number|undefined} workers - Worker count from `--workers <n>`, or undefined.
14
20
  * @property {string[]} soloPatterns - Path substrings from `--solo-pattern <pattern>` (repeatable).
@@ -23,6 +29,12 @@ const RUNNER_FLAGS = new Set([
23
29
  "--no-error-details",
24
30
  "--coverage-quiet",
25
31
  "--log-file",
32
+ "--suppress-file-output",
33
+ "--suppress-passing-files",
34
+ "--no-top-summary",
35
+ "--json",
36
+ "--blobs-dir",
37
+ "--no-merge-reports",
26
38
  "--workers",
27
39
  "--solo-pattern",
28
40
  "--file-pattern",
@@ -51,6 +63,12 @@ export function parseArguments(args) {
51
63
  let showErrorDetails = true;
52
64
  let coverageQuiet = false;
53
65
  let logFile;
66
+ let suppressFileOutput = false;
67
+ let suppressPassingFiles = false;
68
+ let topSummary = true;
69
+ let json = false;
70
+ let blobsDir;
71
+ let mergeReports = true;
54
72
  let workers;
55
73
  let help = false;
56
74
  let testFilePattern;
@@ -69,6 +87,20 @@ export function parseArguments(args) {
69
87
  logFile = args[++i];
70
88
  } else if (arg.startsWith("--log-file=")) {
71
89
  logFile = arg.slice("--log-file=".length);
90
+ } else if (arg === "--suppress-file-output") {
91
+ suppressFileOutput = true;
92
+ } else if (arg === "--suppress-passing-files") {
93
+ suppressPassingFiles = true;
94
+ } else if (arg === "--no-top-summary") {
95
+ topSummary = false;
96
+ } else if (arg === "--json") {
97
+ json = true;
98
+ } else if (arg === "--blobs-dir") {
99
+ blobsDir = args[++i];
100
+ } else if (arg.startsWith("--blobs-dir=")) {
101
+ blobsDir = arg.slice("--blobs-dir=".length);
102
+ } else if (arg === "--no-merge-reports") {
103
+ mergeReports = false;
72
104
  } else if (arg === "--workers") {
73
105
  workers = parseInt(args[++i], 10);
74
106
  } else if (arg.startsWith("--workers=")) {
@@ -100,6 +132,12 @@ export function parseArguments(args) {
100
132
  showErrorDetails,
101
133
  coverageQuiet,
102
134
  logFile,
135
+ suppressFileOutput,
136
+ suppressPassingFiles,
137
+ topSummary,
138
+ json,
139
+ blobsDir,
140
+ mergeReports,
103
141
  help,
104
142
  workers,
105
143
  soloPatterns,
package/src/cli/help.mjs CHANGED
@@ -21,12 +21,18 @@ ${chalk.bold("USAGE:")}
21
21
 
22
22
  ${chalk.bold("SPECIAL FLAGS:")}
23
23
  --test-list <file> Run only the files listed in a JSON array file
24
- --file-pattern <regex> Override the file discovery regex (default: \.test\.vitest\.(?:js|mjs|cjs)$)
24
+ --file-pattern <regex> Override the file discovery regex (default: \\.test\\.vitest\\.(?:js|mjs|cjs)$)
25
25
  --workers <n> Number of parallel workers (default: 4 or VITEST_WORKERS)
26
26
  --solo-pattern <pat> Run files matching this path substring solo first (repeatable)
27
27
  --no-error-details Hide detailed error output (show only counts)
28
28
  --coverage-quiet Implies --coverage; show progress bar + final summaries only
29
- --log-file <path> Path for the coverage run log (default: coverage/coverage-run.log; implies --coverage-quiet)
29
+ --log-file <path> Path for mirrored runner output (default: coverage/coverage-run.log with --coverage-quiet)
30
+ --suppress-file-output Suppress per-file runner output blocks
31
+ --suppress-passing-files Hide the PASSED TEST FILES section in the final summary
32
+ --no-top-summary Hide TOP MEMORY USERS and TOP DURATION sections
33
+ --json Emit JSON summary output instead of text logs
34
+ --blobs-dir <path> Directory for per-file coverage blobs (default: .vitest-coverage-blobs)
35
+ --no-merge-reports Leave coverage blobs in --blobs-dir without merging (for an external merge)
30
36
  --help, -h Show this help message
31
37
 
32
38
  ${chalk.bold("TEST PATTERNS:")}
@@ -116,7 +116,7 @@ export async function discoverVitestFiles(opts) {
116
116
  const content = await fs.readFile(resolvedListPath, "utf8");
117
117
  testList = JSON.parse(content);
118
118
  } catch (err) {
119
- throw new Error(`Failed to read test list file "${resolvedListPath}": ${err.message}`);
119
+ throw new Error(`Failed to read test list file "${resolvedListPath}": ${err.message}`, { cause: err });
120
120
  }
121
121
 
122
122
  if (!Array.isArray(testList)) {
@@ -157,8 +157,8 @@ export function createCoverageProgressTracker(total) {
157
157
  export const noopProgressTracker = {
158
158
  /** @returns {void} */
159
159
  onStart() {},
160
- /** @param {boolean} _failedRun @returns {void} */
161
- onComplete(_failedRun) {},
160
+ /** @param {boolean} ___failedRun @returns {void} */
161
+ onComplete(___failedRun) {},
162
162
  /** @returns {void} */
163
163
  finish() {}
164
164
  };
@@ -150,9 +150,18 @@ export function computeSummaryFromFinal(finalData) {
150
150
  * @param {string} cwd - Project root (used to make absolute file paths relative).
151
151
  * @param {string[]} extraCoverageArgs - Passthrough `--coverage.*` args (checked for `reportsDirectory`).
152
152
  * @param {number} [worstCount=10] - Number of worst-coverage files to show (0 = skip table).
153
- * @returns {Promise<void>}
153
+ * @param {{silent?: boolean}} [options] - Output controls.
154
+ * @returns {Promise<{
155
+ * coverageDir: string,
156
+ * total: object,
157
+ * worstFiles: Array<{file: string, lines: number, stmts: number, fns: number, branches: number}>,
158
+ * worstFilesShown: number,
159
+ * worstFilesTotal: number,
160
+ * summary: object
161
+ * }|null>}
154
162
  */
155
- export async function printCoverageSummary(cwd, extraCoverageArgs, worstCount = 10) {
163
+ export async function printCoverageSummary(cwd, extraCoverageArgs, worstCount = 10, options = {}) {
164
+ const { silent = false } = options;
156
165
  let coverageDir = path.resolve(cwd, "coverage");
157
166
  const repoDirArg = extraCoverageArgs.find((a) => a.startsWith("--coverage.reportsDirectory="));
158
167
  if (repoDirArg) {
@@ -170,8 +179,8 @@ export async function printCoverageSummary(cwd, extraCoverageArgs, worstCount =
170
179
  const content = await fs.readFile(path.join(coverageDir, "coverage-final.json"), "utf8");
171
180
  summary = computeSummaryFromFinal(JSON.parse(content));
172
181
  } catch {
173
- console.log(chalk.dim(" (no coverage JSON found — skipping summary)"));
174
- return;
182
+ if (!silent) console.log(chalk.dim(" (no coverage JSON found — skipping summary)"));
183
+ return null;
175
184
  }
176
185
  }
177
186
 
@@ -188,29 +197,59 @@ export async function printCoverageSummary(cwd, extraCoverageArgs, worstCount =
188
197
  }))
189
198
  .filter((r) => Math.min(r.lines, r.stmts, r.fns, r.branches) < 100)
190
199
  .sort((a, b) => Math.min(a.lines, a.stmts, a.fns, a.branches) - Math.min(b.lines, b.stmts, b.fns, b.branches));
200
+ const rowsToShow = fileRows.slice(0, worstCount);
191
201
 
192
- console.log("\n" + chalk.bold("📉 WORST COVERAGE FILES (lowest metric)"));
193
- console.log("-".repeat(80));
202
+ if (!silent) {
203
+ console.log("\n" + chalk.bold("📉 WORST COVERAGE FILES (lowest metric)"));
204
+ console.log("-".repeat(80));
194
205
 
195
- const rowsToShow = fileRows.slice(0, worstCount);
196
- rowsToShow.forEach(({ file, lines, stmts, fns, branches }) => {
197
- const worst = Math.min(lines, stmts, fns, branches);
198
- const extras = chalk.dim(
199
- `lines ${lines.toFixed(0)}% | stmts ${stmts.toFixed(0)}% | fns ${fns.toFixed(0)}% | branches ${branches.toFixed(0)}%`
200
- );
201
- console.log(` ${colourPct(chalk, worst)}% ${chalk.dim(file)} ${extras}`);
202
- });
206
+ rowsToShow.forEach(({ file, lines, stmts, fns, branches }) => {
207
+ const worst = Math.min(lines, stmts, fns, branches);
208
+ const extras = chalk.dim(
209
+ `lines ${lines.toFixed(0)}% | stmts ${stmts.toFixed(0)}% | fns ${fns.toFixed(0)}% | branches ${branches.toFixed(0)}%`
210
+ );
211
+ console.log(` ${colourPct(chalk, worst)}% ${chalk.dim(file)} ${extras}`);
212
+ });
213
+
214
+ if (fileRows.length > worstCount) {
215
+ console.log(chalk.dim(` ... and ${fileRows.length - worstCount} more files`));
216
+ }
217
+ }
203
218
 
204
- if (fileRows.length > worstCount) {
205
- console.log(chalk.dim(` ... and ${fileRows.length - worstCount} more files`));
219
+ const tl = total.lines?.pct ?? 0;
220
+ const ts = total.statements?.pct ?? 0;
221
+ const tf = total.functions?.pct ?? 0;
222
+ const tb = total.branches?.pct ?? 0;
223
+ if (!silent) {
224
+ console.log(
225
+ `\n ${chalk.bold("Coverage")} ${colourPct(chalk, tl)}% lines ${chalk.dim("|")} ${colourPct(chalk, ts)}% statements ${chalk.dim("|")} ${colourPct(chalk, tf)}% functions ${chalk.dim("|")} ${colourPct(chalk, tb)}% branches`
226
+ );
206
227
  }
228
+ return {
229
+ coverageDir,
230
+ total,
231
+ worstFiles: rowsToShow,
232
+ worstFilesShown: rowsToShow.length,
233
+ worstFilesTotal: fileRows.length,
234
+ summary
235
+ };
207
236
  }
208
237
 
209
238
  const tl = total.lines?.pct ?? 0;
210
239
  const ts = total.statements?.pct ?? 0;
211
240
  const tf = total.functions?.pct ?? 0;
212
241
  const tb = total.branches?.pct ?? 0;
213
- console.log(
214
- `\n ${chalk.bold("Coverage")} ${colourPct(chalk, tl)}% lines ${chalk.dim("|")} ${colourPct(chalk, ts)}% statements ${chalk.dim("|")} ${colourPct(chalk, tf)}% functions ${chalk.dim("|")} ${colourPct(chalk, tb)}% branches`
215
- );
242
+ if (!silent) {
243
+ console.log(
244
+ `\n ${chalk.bold("Coverage")} ${colourPct(chalk, tl)}% lines ${chalk.dim("|")} ${colourPct(chalk, ts)}% statements ${chalk.dim("|")} ${colourPct(chalk, tf)}% functions ${chalk.dim("|")} ${colourPct(chalk, tb)}% branches`
245
+ );
246
+ }
247
+ return {
248
+ coverageDir,
249
+ total,
250
+ worstFiles: [],
251
+ worstFilesShown: 0,
252
+ worstFilesTotal: 0,
253
+ summary
254
+ };
216
255
  }
package/src/runner.mjs CHANGED
@@ -35,8 +35,6 @@ import { runSingleFile, runMergeReports } from "./core/spawn.mjs";
35
35
  import { deduplicateErrors } from "./core/parse.mjs";
36
36
  import { createCoverageProgressTracker, noopProgressTracker } from "./core/progress.mjs";
37
37
  import { printQuietCoverageFailureDetails, printMergeOutput, printCoverageSummary } from "./core/report.mjs";
38
- import { formatDuration } from "./utils/duration.mjs";
39
- import { colourPct } from "./utils/ansi.mjs";
40
38
 
41
39
  /**
42
40
  * @typedef {Object} PerFileHeapOverride
@@ -55,8 +53,14 @@ import { colourPct } from "./utils/ansi.mjs";
55
53
  * @property {string[]} [vitestArgs=[]] - Extra CLI args forwarded verbatim to every vitest invocation.
56
54
  * @property {boolean} [showErrorDetails=true] - Print inline error blocks under each failed file.
57
55
  * @property {boolean} [coverageQuiet=false] - Suppress per-file output; show only progress bar + summaries.
56
+ * @property {boolean} [suppressFileOutput=false] - Suppress per-file runner output blocks in all modes.
57
+ * @property {boolean} [suppressPassingFiles=false] - Hide passed-file rows in the final summary.
58
+ * @property {boolean} [topSummary=true] - Show top memory and duration summary sections.
59
+ * @property {boolean} [json=false] - Return a JSON run report instead of printing text output.
58
60
  * @property {number} [workers=4] - Maximum number of parallel worker slots.
59
61
  * @property {number} [worstCoverageCount=10] - Rows in the worst-coverage table (0 = disable).
62
+ * @property {string} [blobsDir] - Directory for per-file coverage blobs (default `<cwd>/.vitest-coverage-blobs`). Relative paths resolve against `cwd`. Always cleared at the start of a coverage run.
63
+ * @property {boolean} [mergeReports=true] - When `true`, blobs are merged via `vitest --mergeReports`, the coverage summary is printed, and `blobsDir` is deleted at the end. When `false`, the run stops after producing blobs: no merge, no summary, and `blobsDir` is left populated for an external merge step.
60
64
  * @property {number} [maxOldSpaceMb] - Global `--max-old-space-size` ceiling; per-file overrides may raise it.
61
65
  * @property {string[]} [earlyRunPatterns=[]] - Path substrings — matching files run solo before the worker pool.
62
66
  * @property {PerFileHeapOverride[]} [perFileHeapOverrides=[]] - Per-file minimum heap overrides.
@@ -96,7 +100,7 @@ function getHeapForFile(filePath, globalMaxMb, overrides) {
96
100
  * pool for the non-solo phase) and return an exit code.
97
101
  *
98
102
  * @param {RunOptions} opts
99
- * @returns {Promise<number>} `0` on full pass, `1` on any failure.
103
+ * @returns {Promise<number|object>} `0`/`1` by default; JSON report object when `opts.json` is true.
100
104
  */
101
105
  export async function run(opts) {
102
106
  const {
@@ -108,8 +112,14 @@ export async function run(opts) {
108
112
  vitestArgs: rawVitestArgs = [],
109
113
  showErrorDetails = true,
110
114
  coverageQuiet = false,
115
+ suppressFileOutput = false,
116
+ suppressPassingFiles = false,
117
+ topSummary = true,
118
+ json = false,
111
119
  workers = parseInt(process.env.VITEST_WORKERS ?? "4", 10),
112
120
  worstCoverageCount = 10,
121
+ blobsDir: blobsDirOpt,
122
+ mergeReports = true,
113
123
  earlyRunPatterns = [],
114
124
  perFileHeapOverrides = [],
115
125
  conditions = [],
@@ -119,6 +129,7 @@ export async function run(opts) {
119
129
  } = opts;
120
130
 
121
131
  const maxOldSpaceMb = opts.maxOldSpaceMb ?? (process.env.VITEST_HEAP_MB ? parseInt(process.env.VITEST_HEAP_MB, 10) : undefined);
132
+ const emitTextOutput = !json;
122
133
 
123
134
  // Resolve vitest binary and config
124
135
  const vitestBin = resolveBin(cwd, "vitest", "vitest");
@@ -138,7 +149,7 @@ export async function run(opts) {
138
149
 
139
150
  // ─── COVERAGE MODE ───────────────────────────────────────────────────────────
140
151
  if (hasCoverage) {
141
- const blobsDir = path.resolve(cwd, ".vitest-coverage-blobs");
152
+ const blobsDir = path.resolve(cwd, blobsDirOpt ?? ".vitest-coverage-blobs");
142
153
  // Temp coverage dirs live OUTSIDE blobsDir — vitest --mergeReports errors on
143
154
  // any non-blob entry found inside the blobs directory.
144
155
  const coverageTmpBase = path.resolve(cwd, ".vitest-coverage-tmp");
@@ -149,9 +160,28 @@ export async function run(opts) {
149
160
  const allTestFiles = await discoverVitestFiles({ cwd, testDir, testPatterns, testListFile, testFilePattern, earlyRunPatterns });
150
161
 
151
162
  if (allTestFiles.length === 0) {
152
- console.log(
153
- testPatterns.length > 0 ? `❌ No Vitest test files found matching: ${testPatterns.join(", ")}` : "❌ No Vitest test files found"
154
- );
163
+ const noTestsMessage =
164
+ testPatterns.length > 0 ? `❌ No Vitest test files found matching: ${testPatterns.join(", ")}` : "❌ No Vitest test files found";
165
+ if (emitTextOutput) console.log(noTestsMessage);
166
+ if (json) {
167
+ return {
168
+ exitCode: 1,
169
+ mode: "coverage",
170
+ message: noTestsMessage,
171
+ options: {
172
+ cwd,
173
+ testDir,
174
+ testPatterns,
175
+ testListFile,
176
+ workers,
177
+ coverageQuiet,
178
+ suppressFileOutput,
179
+ suppressPassingFiles,
180
+ topSummary,
181
+ vitestArgs
182
+ }
183
+ };
184
+ }
155
185
  return 1;
156
186
  }
157
187
 
@@ -161,15 +191,17 @@ export async function run(opts) {
161
191
 
162
192
  const soloFiles = allTestFiles.filter((f) => earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
163
193
  const parallelFiles = allTestFiles.filter((f) => !earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
194
+ const suppressPerFileCoverageOutput = coverageQuiet || suppressFileOutput || json;
195
+ const showCoverageProgress = coverageQuiet && !json;
164
196
 
165
- if (!coverageQuiet) {
197
+ if (!coverageQuiet && !suppressFileOutput && emitTextOutput) {
166
198
  console.log(`\n🧪 Running ${allTestFiles.length} test files for coverage (blob + merge mode)`);
167
199
  console.log(`⚙️ Workers: ${workers} (${soloFiles.length} solo first, then parallel)`);
168
200
  if (maxOldSpaceMb) console.log(`🧠 Heap limit: ${maxOldSpaceMb} MB`);
169
201
  console.log("");
170
202
  }
171
203
 
172
- const progress = coverageQuiet ? createCoverageProgressTracker(allTestFiles.length) : noopProgressTracker;
204
+ const progress = showCoverageProgress ? createCoverageProgressTracker(allTestFiles.length) : noopProgressTracker;
173
205
  const coverageResults = [];
174
206
  let blobIndex = 0;
175
207
 
@@ -182,7 +214,7 @@ export async function run(opts) {
182
214
  const blobPath = path.join(blobsDir, `run-${blobIndex}.blob`);
183
215
  blobIndex++;
184
216
 
185
- if (!coverageQuiet) {
217
+ if (!suppressPerFileCoverageOutput && emitTextOutput) {
186
218
  console.log(`\n${"=".repeat(80)}`);
187
219
  console.log(`▶️ ${filePath}`);
188
220
  console.log("=".repeat(80));
@@ -204,13 +236,13 @@ export async function run(opts) {
204
236
  ...spawnBase,
205
237
  maxOldSpaceMb: getHeapForFile(filePath, maxOldSpaceMb, perFileHeapOverrides),
206
238
  vitestArgs: blobArgs,
207
- streamOutput: !coverageQuiet
239
+ streamOutput: !suppressPerFileCoverageOutput
208
240
  });
209
241
 
210
242
  coverageResults.push(result);
211
243
  progress.onComplete(result.code !== 0);
212
244
 
213
- if (!coverageQuiet) {
245
+ if (!suppressPerFileCoverageOutput && emitTextOutput) {
214
246
  const durationSec = (result.duration / 1000).toFixed(2);
215
247
  if (result.code === 0) {
216
248
  const heapInfo = result.heapMb ? ` | ${result.heapMb} MB heap` : "";
@@ -223,7 +255,9 @@ export async function run(opts) {
223
255
 
224
256
  // Phase 1: solo files — one at a time
225
257
  for (const filePath of soloFiles) {
226
- await runCoverageFile(filePath).catch((err) => console.error(`Error running ${filePath}:`, err));
258
+ await runCoverageFile(filePath).catch((err) => {
259
+ if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
260
+ });
227
261
  }
228
262
 
229
263
  // Phase 2: parallel files with worker pool
@@ -234,7 +268,9 @@ export async function run(opts) {
234
268
  while (coverageFileIndex < parallelFiles.length && coverageActivePromises.size < workers) {
235
269
  const filePath = parallelFiles[coverageFileIndex++];
236
270
  const promise = runCoverageFile(filePath)
237
- .catch((err) => console.error(`Error running ${filePath}:`, err))
271
+ .catch((err) => {
272
+ if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
273
+ })
238
274
  .finally(() => coverageActivePromises.delete(promise));
239
275
  coverageActivePromises.add(promise);
240
276
  }
@@ -246,48 +282,158 @@ export async function run(opts) {
246
282
 
247
283
  const blobFiles = (await fs.readdir(blobsDir).catch(() => [])).filter((f) => f.endsWith(".blob"));
248
284
  if (blobFiles.length === 0) {
249
- console.error("❌ No coverage blobs were generated — coverage report cannot be produced");
285
+ const message = "❌ No coverage blobs were generated — coverage report cannot be produced";
286
+ if (emitTextOutput) console.error(message);
287
+ if (json) {
288
+ return {
289
+ exitCode: 1,
290
+ mode: "coverage",
291
+ message,
292
+ options: {
293
+ cwd,
294
+ testDir,
295
+ testPatterns,
296
+ testListFile,
297
+ workers,
298
+ coverageQuiet,
299
+ suppressFileOutput,
300
+ suppressPassingFiles,
301
+ topSummary,
302
+ showErrorDetails,
303
+ worstCoverageCount,
304
+ vitestArgs
305
+ },
306
+ totals: {
307
+ testFiles: allTestFiles.length,
308
+ failedFiles: coverageResults.filter((r) => r.code !== 0).length,
309
+ passedFiles: coverageResults.filter((r) => r.code === 0).length
310
+ },
311
+ results: {
312
+ all: coverageResults,
313
+ failed: coverageResults.filter((r) => r.code !== 0)
314
+ },
315
+ merge: {
316
+ blobFiles: 0,
317
+ exitCode: 1,
318
+ output: ""
319
+ },
320
+ coverageSummary: null
321
+ };
322
+ }
250
323
  return 1;
251
324
  }
252
325
 
253
- if (!coverageQuiet) {
254
- console.log(`\n${"=".repeat(80)}`);
255
- console.log(`📊 Merging ${blobFiles.length} coverage blobs into final report...`);
256
- console.log("=".repeat(80));
257
- }
326
+ // When mergeReports is false, stop after producing the blobs: skip the merge
327
+ // step and the coverage summary, and leave blobsDir intact so an external step
328
+ // can merge these blobs together with a separately-produced blob set.
329
+ let mergeExitCode = 0;
330
+ let mergeOutput = "";
331
+ let coverageSummary = null;
258
332
 
259
- const { exitCode: mergeExitCode, output: mergeOutput } = await runMergeReports(blobsDir, {
260
- ...spawnBase,
261
- maxOldSpaceMb,
262
- extraCoverageArgs,
263
- quietOutput: coverageQuiet
264
- });
333
+ if (mergeReports) {
334
+ if (!coverageQuiet && !suppressFileOutput && emitTextOutput) {
335
+ console.log(`\n${"=".repeat(80)}`);
336
+ console.log(`📊 Merging ${blobFiles.length} coverage blobs into final report...`);
337
+ console.log("=".repeat(80));
338
+ }
265
339
 
266
- if (coverageQuiet) {
267
- printMergeOutput(mergeExitCode, mergeOutput);
268
- }
340
+ ({ exitCode: mergeExitCode, output: mergeOutput } = await runMergeReports(blobsDir, {
341
+ ...spawnBase,
342
+ maxOldSpaceMb,
343
+ extraCoverageArgs,
344
+ quietOutput: coverageQuiet || json
345
+ }));
346
+
347
+ if (coverageQuiet && emitTextOutput) {
348
+ printMergeOutput(mergeExitCode, mergeOutput);
349
+ }
269
350
 
270
- await printCoverageSummary(cwd, extraCoverageArgs, worstCoverageCount);
351
+ coverageSummary = await printCoverageSummary(cwd, extraCoverageArgs, worstCoverageCount, { silent: json });
352
+ }
271
353
 
272
- // Clean up blobs and temp dirs
354
+ // Clean up temp dirs always; only delete blobsDir when we consumed it via merge.
355
+ // With mergeReports:false the blobs must survive for the caller's external merge.
273
356
  await Promise.all([
274
- fs.rm(blobsDir, { recursive: true, force: true }).catch(() => {}),
357
+ ...(mergeReports ? [fs.rm(blobsDir, { recursive: true, force: true }).catch(() => {})] : []),
275
358
  fs.rm(coverageTmpBase, { recursive: true, force: true }).catch(() => {})
276
359
  ]);
277
360
 
278
361
  const coverageFailed = coverageResults.filter((r) => r.code !== 0);
279
- if (coverageQuiet) printQuietCoverageFailureDetails(coverageFailed);
362
+ if (coverageQuiet && emitTextOutput) printQuietCoverageFailureDetails(coverageFailed);
363
+
364
+ const exitCode = coverageFailed.length > 0 ? 1 : mergeExitCode;
365
+ if (json) {
366
+ const coverageWithHeap = coverageResults.filter((r) => r.heapMb !== null);
367
+ return {
368
+ exitCode,
369
+ mode: "coverage",
370
+ options: {
371
+ cwd,
372
+ testDir,
373
+ testPatterns,
374
+ testListFile,
375
+ workers,
376
+ coverageQuiet,
377
+ suppressFileOutput,
378
+ suppressPassingFiles,
379
+ topSummary,
380
+ showErrorDetails,
381
+ worstCoverageCount,
382
+ vitestArgs
383
+ },
384
+ totals: {
385
+ testFiles: allTestFiles.length,
386
+ failedFiles: coverageFailed.length,
387
+ passedFiles: coverageResults.length - coverageFailed.length
388
+ },
389
+ results: {
390
+ all: coverageResults,
391
+ failed: coverageFailed
392
+ },
393
+ merge: {
394
+ blobFiles: blobFiles.length,
395
+ exitCode: mergeExitCode,
396
+ output: mergeOutput
397
+ },
398
+ coverageSummary,
399
+ ...(topSummary
400
+ ? {
401
+ topMemoryUsers: [...coverageWithHeap].sort((a, b) => b.heapMb - a.heapMb).slice(0, 10),
402
+ topDuration: [...coverageResults].sort((a, b) => b.duration - a.duration).slice(0, 10)
403
+ }
404
+ : {})
405
+ };
406
+ }
280
407
 
281
- return coverageFailed.length > 0 ? 1 : mergeExitCode;
408
+ return exitCode;
282
409
  }
283
410
 
284
411
  // ─── STANDARD (NON-COVERAGE) MODE ────────────────────────────────────────────
285
412
  const testFiles = await discoverVitestFiles({ cwd, testDir, testPatterns, testListFile, testFilePattern, earlyRunPatterns });
286
413
 
287
414
  if (testFiles.length === 0) {
288
- console.log(
289
- testPatterns.length > 0 ? `❌ No Vitest test files found matching: ${testPatterns.join(", ")}` : "❌ No Vitest test files found"
290
- );
415
+ const noTestsMessage =
416
+ testPatterns.length > 0 ? `❌ No Vitest test files found matching: ${testPatterns.join(", ")}` : "❌ No Vitest test files found";
417
+ if (emitTextOutput) console.log(noTestsMessage);
418
+ if (json) {
419
+ return {
420
+ exitCode: 1,
421
+ mode: "standard",
422
+ message: noTestsMessage,
423
+ options: {
424
+ cwd,
425
+ testDir,
426
+ testPatterns,
427
+ testListFile,
428
+ workers,
429
+ coverageQuiet,
430
+ suppressFileOutput,
431
+ suppressPassingFiles,
432
+ topSummary,
433
+ vitestArgs
434
+ }
435
+ };
436
+ }
291
437
  return 1;
292
438
  }
293
439
 
@@ -297,15 +443,17 @@ export async function run(opts) {
297
443
  const scriptStartTime = Date.now();
298
444
  const scriptStartTimeFormatted = new Date().toLocaleTimeString("en-US", { hour12: false });
299
445
 
300
- if (testPatterns.length > 0) {
446
+ if (emitTextOutput && !suppressFileOutput && testPatterns.length > 0) {
301
447
  console.log(`\n🧪 Running ${testFiles.length} test files matching: ${testPatterns.join(", ")}`);
302
- } else {
448
+ } else if (emitTextOutput && !suppressFileOutput) {
303
449
  console.log(`\n🧪 Running ${testFiles.length} test files (${soloFiles.length} solo first, then parallel)`);
304
450
  }
305
- console.log(`⚙️ Workers: ${workers}`);
306
- if (maxOldSpaceMb) console.log(`🧠 Heap limit: ${maxOldSpaceMb} MB`);
307
- if (vitestArgs.length > 0) console.log(`🔧 Vitest args: ${vitestArgs.join(" ")}`);
308
- console.log("");
451
+ if (emitTextOutput && !suppressFileOutput) {
452
+ console.log(`⚙️ Workers: ${workers}`);
453
+ if (maxOldSpaceMb) console.log(`🧠 Heap limit: ${maxOldSpaceMb} MB`);
454
+ if (vitestArgs.length > 0) console.log(`🔧 Vitest args: ${vitestArgs.join(" ")}`);
455
+ console.log("");
456
+ }
309
457
 
310
458
  const results = [...(_testResultsOverride ?? [])];
311
459
 
@@ -315,22 +463,27 @@ export async function run(opts) {
315
463
  * @returns {Promise<import('./core/spawn.mjs').SingleFileResult>}
316
464
  */
317
465
  const runTestFile = async (filePath) => {
318
- console.log(`\n${"=".repeat(80)}`);
319
- console.log(`▶️ ${filePath}`);
320
- console.log("=".repeat(80));
466
+ if (!suppressFileOutput && emitTextOutput) {
467
+ console.log(`\n${"=".repeat(80)}`);
468
+ console.log(`▶️ ${filePath}`);
469
+ console.log("=".repeat(80));
470
+ }
321
471
 
322
472
  const result = await runSingleFile(filePath, {
323
473
  ...spawnBase,
324
474
  maxOldSpaceMb: getHeapForFile(filePath, maxOldSpaceMb, perFileHeapOverrides),
325
- vitestArgs
475
+ vitestArgs,
476
+ streamOutput: !suppressFileOutput && !json
326
477
  });
327
478
 
328
- const durationSec = (result.duration / 1000).toFixed(2);
329
- if (result.code === 0) {
330
- const heapInfo = result.heapMb ? ` | ${result.heapMb} MB heap` : "";
331
- console.log(`\n✅ PASSED (${durationSec}s${heapInfo})\n`);
332
- } else {
333
- console.log(`\n❌ FAILED (exit code ${result.code}, ${durationSec}s)\n`);
479
+ if (!suppressFileOutput && emitTextOutput) {
480
+ const durationSec = (result.duration / 1000).toFixed(2);
481
+ if (result.code === 0) {
482
+ const heapInfo = result.heapMb ? ` | ${result.heapMb} MB heap` : "";
483
+ console.log(`\n✅ PASSED (${durationSec}s${heapInfo})\n`);
484
+ } else {
485
+ console.log(`\n❌ FAILED (exit code ${result.code}, ${durationSec}s)\n`);
486
+ }
334
487
  }
335
488
 
336
489
  return result;
@@ -340,7 +493,7 @@ export async function run(opts) {
340
493
  // Phase 1: solo files
341
494
  for (const filePath of soloFiles) {
342
495
  const result = await runTestFile(filePath).catch((err) => {
343
- console.error(`Error running ${filePath}:`, err);
496
+ if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
344
497
  return null;
345
498
  });
346
499
  if (result) results.push(result);
@@ -355,7 +508,9 @@ export async function run(opts) {
355
508
  const filePath = parallelFiles[index++];
356
509
  const promise = runTestFile(filePath)
357
510
  .then((result) => results.push(result))
358
- .catch((err) => console.error(`Error running ${filePath}:`, err))
511
+ .catch((err) => {
512
+ if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
513
+ })
359
514
  .finally(() => activePromises.delete(promise));
360
515
  activePromises.add(promise);
361
516
  }
@@ -373,12 +528,69 @@ export async function run(opts) {
373
528
  const totalDuration = results.reduce((s, r) => s + r.duration, 0);
374
529
  const failedFiles = results.filter((r) => r.code !== 0);
375
530
  const passedFiles = results.filter((r) => r.code === 0);
531
+ const exitCode = failedFiles.length > 0 ? 1 : 0;
532
+ const scriptMemory = process.memoryUsage();
533
+ const scriptHeapMb = Math.round(scriptMemory.heapUsed / 1024 / 1024);
534
+ const scriptRssMb = Math.round(scriptMemory.rss / 1024 / 1024);
535
+ const withHeap = results.filter((r) => r.heapMb !== null);
536
+ const actualDurationSec = ((Date.now() - scriptStartTime) / 1000).toFixed(2);
537
+ const testsDurationSec = (totalDuration / 1000).toFixed(2);
538
+
539
+ if (json) {
540
+ return {
541
+ exitCode,
542
+ mode: "standard",
543
+ options: {
544
+ cwd,
545
+ testDir,
546
+ testPatterns,
547
+ testListFile,
548
+ workers,
549
+ coverageQuiet,
550
+ suppressFileOutput,
551
+ suppressPassingFiles,
552
+ topSummary,
553
+ showErrorDetails,
554
+ worstCoverageCount,
555
+ vitestArgs
556
+ },
557
+ totals: {
558
+ testFilesPass: totalTestFilesPass,
559
+ testFilesFail: totalTestFilesFail,
560
+ testsPass: totalTestsPass,
561
+ testsFail: totalTestsFail,
562
+ testsSkip: totalTestsSkip,
563
+ totalTests: totalTestsPass + totalTestsFail + totalTestsSkip
564
+ },
565
+ timing: {
566
+ startAt: scriptStartTimeFormatted,
567
+ wallClockSeconds: Number(actualDurationSec),
568
+ testsSeconds: Number(testsDurationSec)
569
+ },
570
+ heap: {
571
+ scriptHeapMb,
572
+ scriptRssMb,
573
+ maxHeapMb: withHeap.length > 0 ? Math.max(...withHeap.map((r) => r.heapMb)) : null,
574
+ avgHeapMb: withHeap.length > 0 ? Number((withHeap.reduce((sum, r) => sum + r.heapMb, 0) / withHeap.length).toFixed(0)) : null
575
+ },
576
+ results: {
577
+ all: results,
578
+ passed: passedFiles,
579
+ failed: failedFiles
580
+ },
581
+ ...(topSummary
582
+ ? {
583
+ topMemoryUsers: [...withHeap].sort((a, b) => b.heapMb - a.heapMb).slice(0, 10),
584
+ topDuration: [...results].sort((a, b) => b.duration - a.duration).slice(0, 10)
585
+ }
586
+ : {})
587
+ };
588
+ }
376
589
 
377
590
  console.log("\n" + "=".repeat(80));
378
591
 
379
592
  // Top memory users
380
- const withHeap = results.filter((r) => r.heapMb !== null);
381
- if (withHeap.length > 0) {
593
+ if (topSummary && withHeap.length > 0) {
382
594
  console.log("\n" + chalk.bold("🧠 TOP MEMORY USERS"));
383
595
  console.log("-".repeat(80));
384
596
  [...withHeap]
@@ -390,7 +602,7 @@ export async function run(opts) {
390
602
  }
391
603
 
392
604
  // Top duration
393
- if (results.length > 0) {
605
+ if (topSummary && results.length > 0) {
394
606
  console.log("\n" + chalk.bold("⏱️ TOP DURATION"));
395
607
  console.log("-".repeat(80));
396
608
  [...results]
@@ -403,7 +615,7 @@ export async function run(opts) {
403
615
  }
404
616
 
405
617
  // Passed files
406
- if (passedFiles.length > 0) {
618
+ if (passedFiles.length > 0 && !suppressPassingFiles) {
407
619
  console.log("\n" + "=".repeat(80));
408
620
  console.log(chalk.bold.green("✓ PASSED TEST FILES"));
409
621
  console.log("=".repeat(80));
@@ -468,13 +680,8 @@ export async function run(opts) {
468
680
  console.log(` ${chalk.bold("Tests")} ${testsParts.join(` ${chalk.dim("|")} `)} ${chalk.dim(`(${totalTests})`)}`);
469
681
  console.log(` ${chalk.bold("Start at")} ${scriptStartTimeFormatted}`);
470
682
 
471
- const actualDurationSec = ((Date.now() - scriptStartTime) / 1000).toFixed(2);
472
- const testsDurationSec = (totalDuration / 1000).toFixed(2);
473
683
  console.log(` ${chalk.bold("Duration")} ${actualDurationSec}s ${chalk.dim(`(tests ${testsDurationSec}s)`)}`);
474
684
 
475
- const scriptMemory = process.memoryUsage();
476
- const scriptHeapMb = Math.round(scriptMemory.heapUsed / 1024 / 1024);
477
- const scriptRssMb = Math.round(scriptMemory.rss / 1024 / 1024);
478
685
  if (withHeap.length > 0) {
479
686
  const maxHeap = Math.max(...withHeap.map((r) => r.heapMb));
480
687
  const avgHeap = (withHeap.reduce((s, r) => s + r.heapMb, 0) / withHeap.length).toFixed(0);
@@ -483,9 +690,7 @@ export async function run(opts) {
483
690
  console.log(` ${chalk.bold("Heap")} script ${scriptHeapMb} MB (RSS ${scriptRssMb} MB)`);
484
691
  }
485
692
 
486
- if (failedFiles.length > 0) {
487
- return 1;
488
- }
693
+ if (failedFiles.length > 0) return 1;
489
694
 
490
695
  console.log(`\n✅ All ${passedFiles.length} test files passed\n`);
491
696
  return 0;
@@ -29,6 +29,30 @@ export type ParsedArgs = {
29
29
  * - Path for the coverage run log (`--log-file`); defaults to `coverage/coverage-run.log`.
30
30
  */
31
31
  logFile: string | undefined;
32
+ /**
33
+ * - Suppress per-file runner output blocks (`--suppress-file-output`).
34
+ */
35
+ suppressFileOutput: boolean;
36
+ /**
37
+ * - Suppress the passed-files section in the final summary (`--suppress-passing-files`).
38
+ */
39
+ suppressPassingFiles: boolean;
40
+ /**
41
+ * - Show top-summary sections for memory and duration (`true` by default, disabled by `--no-top-summary`).
42
+ */
43
+ topSummary: boolean;
44
+ /**
45
+ * - Emit a JSON run report instead of text output (`--json`).
46
+ */
47
+ json: boolean;
48
+ /**
49
+ * - Directory for per-file coverage blobs (`--blobs-dir`); defaults to `<cwd>/.vitest-coverage-blobs`.
50
+ */
51
+ blobsDir: string | undefined;
52
+ /**
53
+ * - `false` when `--no-merge-reports` was passed; leaves blobs in `blobsDir` without merging.
54
+ */
55
+ mergeReports: boolean;
32
56
  /**
33
57
  * - Whether `--help` / `-h` was passed.
34
58
  */
@@ -42,6 +42,29 @@ export function computeSummaryFromFinal(finalData: Record<string, object>): {
42
42
  * @param {string} cwd - Project root (used to make absolute file paths relative).
43
43
  * @param {string[]} extraCoverageArgs - Passthrough `--coverage.*` args (checked for `reportsDirectory`).
44
44
  * @param {number} [worstCount=10] - Number of worst-coverage files to show (0 = skip table).
45
- * @returns {Promise<void>}
45
+ * @param {{silent?: boolean}} [options] - Output controls.
46
+ * @returns {Promise<{
47
+ * coverageDir: string,
48
+ * total: object,
49
+ * worstFiles: Array<{file: string, lines: number, stmts: number, fns: number, branches: number}>,
50
+ * worstFilesShown: number,
51
+ * worstFilesTotal: number,
52
+ * summary: object
53
+ * }|null>}
46
54
  */
47
- export function printCoverageSummary(cwd: string, extraCoverageArgs: string[], worstCount?: number): Promise<void>;
55
+ export function printCoverageSummary(cwd: string, extraCoverageArgs: string[], worstCount?: number, options?: {
56
+ silent?: boolean;
57
+ }): Promise<{
58
+ coverageDir: string;
59
+ total: object;
60
+ worstFiles: Array<{
61
+ file: string;
62
+ lines: number;
63
+ stmts: number;
64
+ fns: number;
65
+ branches: number;
66
+ }>;
67
+ worstFilesShown: number;
68
+ worstFilesTotal: number;
69
+ summary: object;
70
+ } | null>;
@@ -3,9 +3,9 @@
3
3
  * pool for the non-solo phase) and return an exit code.
4
4
  *
5
5
  * @param {RunOptions} opts
6
- * @returns {Promise<number>} `0` on full pass, `1` on any failure.
6
+ * @returns {Promise<number|object>} `0`/`1` by default; JSON report object when `opts.json` is true.
7
7
  */
8
- export function run(opts: RunOptions): Promise<number>;
8
+ export function run(opts: RunOptions): Promise<number | object>;
9
9
  export { formatDuration } from "./utils/duration.mjs";
10
10
  export { buildNodeOptions } from "./utils/env.mjs";
11
11
  export type PerFileHeapOverride = {
@@ -55,6 +55,22 @@ export type RunOptions = {
55
55
  * - Suppress per-file output; show only progress bar + summaries.
56
56
  */
57
57
  coverageQuiet?: boolean;
58
+ /**
59
+ * - Suppress per-file runner output blocks in all modes.
60
+ */
61
+ suppressFileOutput?: boolean;
62
+ /**
63
+ * - Hide passed-file rows in the final summary.
64
+ */
65
+ suppressPassingFiles?: boolean;
66
+ /**
67
+ * - Show top memory and duration summary sections.
68
+ */
69
+ topSummary?: boolean;
70
+ /**
71
+ * - Return a JSON run report instead of printing text output.
72
+ */
73
+ json?: boolean;
58
74
  /**
59
75
  * - Maximum number of parallel worker slots.
60
76
  */
@@ -63,6 +79,14 @@ export type RunOptions = {
63
79
  * - Rows in the worst-coverage table (0 = disable).
64
80
  */
65
81
  worstCoverageCount?: number;
82
+ /**
83
+ * - Directory for per-file coverage blobs (default `<cwd>/.vitest-coverage-blobs`). Relative paths resolve against `cwd`. Always cleared at the start of a coverage run.
84
+ */
85
+ blobsDir?: string;
86
+ /**
87
+ * - When `true`, blobs are merged via `vitest --mergeReports`, the coverage summary is printed, and `blobsDir` is deleted at the end. When `false`, the run stops after producing blobs: no merge, no summary, and `blobsDir` is left populated for an external merge step.
88
+ */
89
+ mergeReports?: boolean;
66
90
  /**
67
91
  * - Global `--max-old-space-size` ceiling; per-file overrides may raise it.
68
92
  */