@cldmv/vitest-runner 1.0.3 → 1.1.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
@@ -53,7 +53,11 @@ vitest-runner [OPTIONS] [PATTERNS...]
53
53
  | `--solo-pattern <pat>` | Run files matching this path substring solo (one at a time) before the worker pool; repeatable |
54
54
  | `--no-error-details` | Hide inline error blocks — show only counts in the summary |
55
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 |
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) |
57
61
  | `--help`, `-h` | Print this help and exit |
58
62
 
59
63
  ### Test patterns
@@ -128,6 +132,12 @@ VITEST_HEAP_MB=8192 vitest-runner --workers 2 src/tests/heavy
128
132
 
129
133
  # Suppress error details in the summary
130
134
  vitest-runner --no-error-details
135
+
136
+ # JSON output for automation
137
+ vitest-runner --json
138
+
139
+ # JSON output without top summary arrays
140
+ vitest-runner --json --no-top-summary
131
141
  ```
132
142
 
133
143
  ---
@@ -141,9 +151,9 @@ import { run } from 'vitest-runner';
141
151
  const { run } = await require('vitest-runner');
142
152
  ```
143
153
 
144
- ### `run(options)` → `Promise<number>`
154
+ ### `run(options)` → `Promise<number | object>`
145
155
 
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.
156
+ 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
157
 
148
158
  ```js
149
159
  import { run } from 'vitest-runner';
@@ -168,6 +178,10 @@ process.exit(code);
168
178
  | `vitestArgs` | `string[]` | `[]` | Extra CLI args forwarded verbatim to every vitest invocation |
169
179
  | `showErrorDetails` | `boolean` | `true` | Print inline error blocks under each failed file in the summary |
170
180
  | `coverageQuiet` | `boolean` | `false` | Suppress per-file output; show only the progress bar and final summaries |
181
+ | `suppressFileOutput` | `boolean` | `false` | Suppress per-file runner output blocks in all modes |
182
+ | `suppressPassingFiles` | `boolean` | `false` | Hide passed-file rows in the final summary |
183
+ | `topSummary` | `boolean` | `true` | Show or hide top memory/duration summary sections (and JSON arrays) |
184
+ | `json` | `boolean` | `false` | Return a JSON report object instead of printing text output |
171
185
  | `workers` | `number` | `4` | Maximum parallel worker slots (overrides `VITEST_WORKERS`) |
172
186
  | `worstCoverageCount` | `number` | `10` | Rows in the worst-coverage table after a coverage run (`0` disables it) |
173
187
  | `maxOldSpaceMb` | `number` | `undefined` | Global `--max-old-space-size` ceiling in MB (overrides `VITEST_HEAP_MB`) |
@@ -208,6 +222,14 @@ await run({
208
222
  coverageQuiet: true,
209
223
  });
210
224
 
225
+ // Machine-readable output (no text logs)
226
+ const report = await run({
227
+ testDir: 'src/tests',
228
+ json: true,
229
+ });
230
+
231
+ console.log(report.exitCode);
232
+
211
233
  // Give heap-heavy files a larger ceiling while keeping the global limit lower
212
234
  await run({
213
235
  testDir: 'src/tests',
@@ -235,7 +257,7 @@ This avoids the OOM crash that occurs when a single vitest process holds coverag
235
257
 
236
258
  `--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
259
 
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.
260
+ 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).
239
261
 
240
262
  ---
241
263
 
@@ -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,34 @@ 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
+ ...(args.workers !== undefined && { workers: args.workers }),
83
+ ...(args.soloPatterns.length > 0 && { earlyRunPatterns: args.soloPatterns })
86
84
  });
85
+
86
+ if (args.json) {
87
+ process.stdout.write(`${JSON.stringify(runResult, null, 2)}\n`);
88
+ process.exit(typeof runResult === "object" && runResult !== null && "exitCode" in runResult ? runResult.exitCode : 1);
89
+ }
90
+
91
+ process.exit(typeof runResult === "number" ? runResult : 1);
92
+ } catch (err) {
93
+ console.error("Fatal error:", err);
94
+ process.exit(1);
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cldmv/vitest-runner",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Sequential Vitest runner to avoid OOM issues with large test suites",
5
5
  "type": "module",
6
6
  "main": "index.cjs",
package/src/cli/args.mjs CHANGED
@@ -9,6 +9,10 @@
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`).
12
16
  * @property {boolean} help - Whether `--help` / `-h` was passed.
13
17
  * @property {number|undefined} workers - Worker count from `--workers <n>`, or undefined.
14
18
  * @property {string[]} soloPatterns - Path substrings from `--solo-pattern <pattern>` (repeatable).
@@ -23,6 +27,10 @@ const RUNNER_FLAGS = new Set([
23
27
  "--no-error-details",
24
28
  "--coverage-quiet",
25
29
  "--log-file",
30
+ "--suppress-file-output",
31
+ "--suppress-passing-files",
32
+ "--no-top-summary",
33
+ "--json",
26
34
  "--workers",
27
35
  "--solo-pattern",
28
36
  "--file-pattern",
@@ -51,6 +59,10 @@ export function parseArguments(args) {
51
59
  let showErrorDetails = true;
52
60
  let coverageQuiet = false;
53
61
  let logFile;
62
+ let suppressFileOutput = false;
63
+ let suppressPassingFiles = false;
64
+ let topSummary = true;
65
+ let json = false;
54
66
  let workers;
55
67
  let help = false;
56
68
  let testFilePattern;
@@ -69,6 +81,14 @@ export function parseArguments(args) {
69
81
  logFile = args[++i];
70
82
  } else if (arg.startsWith("--log-file=")) {
71
83
  logFile = arg.slice("--log-file=".length);
84
+ } else if (arg === "--suppress-file-output") {
85
+ suppressFileOutput = true;
86
+ } else if (arg === "--suppress-passing-files") {
87
+ suppressPassingFiles = true;
88
+ } else if (arg === "--no-top-summary") {
89
+ topSummary = false;
90
+ } else if (arg === "--json") {
91
+ json = true;
72
92
  } else if (arg === "--workers") {
73
93
  workers = parseInt(args[++i], 10);
74
94
  } else if (arg.startsWith("--workers=")) {
@@ -100,6 +120,10 @@ export function parseArguments(args) {
100
120
  showErrorDetails,
101
121
  coverageQuiet,
102
122
  logFile,
123
+ suppressFileOutput,
124
+ suppressPassingFiles,
125
+ topSummary,
126
+ json,
103
127
  help,
104
128
  workers,
105
129
  soloPatterns,
package/src/cli/help.mjs CHANGED
@@ -26,7 +26,11 @@ ${chalk.bold("SPECIAL FLAGS:")}
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
30
34
  --help, -h Show this help message
31
35
 
32
36
  ${chalk.bold("TEST PATTERNS:")}
@@ -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
@@ -55,6 +55,10 @@ import { colourPct } from "./utils/ansi.mjs";
55
55
  * @property {string[]} [vitestArgs=[]] - Extra CLI args forwarded verbatim to every vitest invocation.
56
56
  * @property {boolean} [showErrorDetails=true] - Print inline error blocks under each failed file.
57
57
  * @property {boolean} [coverageQuiet=false] - Suppress per-file output; show only progress bar + summaries.
58
+ * @property {boolean} [suppressFileOutput=false] - Suppress per-file runner output blocks in all modes.
59
+ * @property {boolean} [suppressPassingFiles=false] - Hide passed-file rows in the final summary.
60
+ * @property {boolean} [topSummary=true] - Show top memory and duration summary sections.
61
+ * @property {boolean} [json=false] - Return a JSON run report instead of printing text output.
58
62
  * @property {number} [workers=4] - Maximum number of parallel worker slots.
59
63
  * @property {number} [worstCoverageCount=10] - Rows in the worst-coverage table (0 = disable).
60
64
  * @property {number} [maxOldSpaceMb] - Global `--max-old-space-size` ceiling; per-file overrides may raise it.
@@ -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,6 +112,10 @@ 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,
113
121
  earlyRunPatterns = [],
@@ -119,6 +127,7 @@ export async function run(opts) {
119
127
  } = opts;
120
128
 
121
129
  const maxOldSpaceMb = opts.maxOldSpaceMb ?? (process.env.VITEST_HEAP_MB ? parseInt(process.env.VITEST_HEAP_MB, 10) : undefined);
130
+ const emitTextOutput = !json;
122
131
 
123
132
  // Resolve vitest binary and config
124
133
  const vitestBin = resolveBin(cwd, "vitest", "vitest");
@@ -149,9 +158,28 @@ export async function run(opts) {
149
158
  const allTestFiles = await discoverVitestFiles({ cwd, testDir, testPatterns, testListFile, testFilePattern, earlyRunPatterns });
150
159
 
151
160
  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
- );
161
+ const noTestsMessage =
162
+ testPatterns.length > 0 ? `❌ No Vitest test files found matching: ${testPatterns.join(", ")}` : "❌ No Vitest test files found";
163
+ if (emitTextOutput) console.log(noTestsMessage);
164
+ if (json) {
165
+ return {
166
+ exitCode: 1,
167
+ mode: "coverage",
168
+ message: noTestsMessage,
169
+ options: {
170
+ cwd,
171
+ testDir,
172
+ testPatterns,
173
+ testListFile,
174
+ workers,
175
+ coverageQuiet,
176
+ suppressFileOutput,
177
+ suppressPassingFiles,
178
+ topSummary,
179
+ vitestArgs
180
+ }
181
+ };
182
+ }
155
183
  return 1;
156
184
  }
157
185
 
@@ -161,15 +189,17 @@ export async function run(opts) {
161
189
 
162
190
  const soloFiles = allTestFiles.filter((f) => earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
163
191
  const parallelFiles = allTestFiles.filter((f) => !earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
192
+ const suppressPerFileCoverageOutput = coverageQuiet || suppressFileOutput || json;
193
+ const showCoverageProgress = coverageQuiet && !json;
164
194
 
165
- if (!coverageQuiet) {
195
+ if (!coverageQuiet && !suppressFileOutput && emitTextOutput) {
166
196
  console.log(`\n🧪 Running ${allTestFiles.length} test files for coverage (blob + merge mode)`);
167
197
  console.log(`⚙️ Workers: ${workers} (${soloFiles.length} solo first, then parallel)`);
168
198
  if (maxOldSpaceMb) console.log(`🧠 Heap limit: ${maxOldSpaceMb} MB`);
169
199
  console.log("");
170
200
  }
171
201
 
172
- const progress = coverageQuiet ? createCoverageProgressTracker(allTestFiles.length) : noopProgressTracker;
202
+ const progress = showCoverageProgress ? createCoverageProgressTracker(allTestFiles.length) : noopProgressTracker;
173
203
  const coverageResults = [];
174
204
  let blobIndex = 0;
175
205
 
@@ -182,7 +212,7 @@ export async function run(opts) {
182
212
  const blobPath = path.join(blobsDir, `run-${blobIndex}.blob`);
183
213
  blobIndex++;
184
214
 
185
- if (!coverageQuiet) {
215
+ if (!suppressPerFileCoverageOutput && emitTextOutput) {
186
216
  console.log(`\n${"=".repeat(80)}`);
187
217
  console.log(`▶️ ${filePath}`);
188
218
  console.log("=".repeat(80));
@@ -204,13 +234,13 @@ export async function run(opts) {
204
234
  ...spawnBase,
205
235
  maxOldSpaceMb: getHeapForFile(filePath, maxOldSpaceMb, perFileHeapOverrides),
206
236
  vitestArgs: blobArgs,
207
- streamOutput: !coverageQuiet
237
+ streamOutput: !suppressPerFileCoverageOutput
208
238
  });
209
239
 
210
240
  coverageResults.push(result);
211
241
  progress.onComplete(result.code !== 0);
212
242
 
213
- if (!coverageQuiet) {
243
+ if (!suppressPerFileCoverageOutput && emitTextOutput) {
214
244
  const durationSec = (result.duration / 1000).toFixed(2);
215
245
  if (result.code === 0) {
216
246
  const heapInfo = result.heapMb ? ` | ${result.heapMb} MB heap` : "";
@@ -223,7 +253,9 @@ export async function run(opts) {
223
253
 
224
254
  // Phase 1: solo files — one at a time
225
255
  for (const filePath of soloFiles) {
226
- await runCoverageFile(filePath).catch((err) => console.error(`Error running ${filePath}:`, err));
256
+ await runCoverageFile(filePath).catch((err) => {
257
+ if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
258
+ });
227
259
  }
228
260
 
229
261
  // Phase 2: parallel files with worker pool
@@ -234,7 +266,9 @@ export async function run(opts) {
234
266
  while (coverageFileIndex < parallelFiles.length && coverageActivePromises.size < workers) {
235
267
  const filePath = parallelFiles[coverageFileIndex++];
236
268
  const promise = runCoverageFile(filePath)
237
- .catch((err) => console.error(`Error running ${filePath}:`, err))
269
+ .catch((err) => {
270
+ if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
271
+ })
238
272
  .finally(() => coverageActivePromises.delete(promise));
239
273
  coverageActivePromises.add(promise);
240
274
  }
@@ -246,11 +280,48 @@ export async function run(opts) {
246
280
 
247
281
  const blobFiles = (await fs.readdir(blobsDir).catch(() => [])).filter((f) => f.endsWith(".blob"));
248
282
  if (blobFiles.length === 0) {
249
- console.error("❌ No coverage blobs were generated — coverage report cannot be produced");
283
+ const message = "❌ No coverage blobs were generated — coverage report cannot be produced";
284
+ if (emitTextOutput) console.error(message);
285
+ if (json) {
286
+ return {
287
+ exitCode: 1,
288
+ mode: "coverage",
289
+ message,
290
+ options: {
291
+ cwd,
292
+ testDir,
293
+ testPatterns,
294
+ testListFile,
295
+ workers,
296
+ coverageQuiet,
297
+ suppressFileOutput,
298
+ suppressPassingFiles,
299
+ topSummary,
300
+ showErrorDetails,
301
+ worstCoverageCount,
302
+ vitestArgs
303
+ },
304
+ totals: {
305
+ testFiles: allTestFiles.length,
306
+ failedFiles: coverageResults.filter((r) => r.code !== 0).length,
307
+ passedFiles: coverageResults.filter((r) => r.code === 0).length
308
+ },
309
+ results: {
310
+ all: coverageResults,
311
+ failed: coverageResults.filter((r) => r.code !== 0)
312
+ },
313
+ merge: {
314
+ blobFiles: 0,
315
+ exitCode: 1,
316
+ output: ""
317
+ },
318
+ coverageSummary: null
319
+ };
320
+ }
250
321
  return 1;
251
322
  }
252
323
 
253
- if (!coverageQuiet) {
324
+ if (!coverageQuiet && !suppressFileOutput && emitTextOutput) {
254
325
  console.log(`\n${"=".repeat(80)}`);
255
326
  console.log(`📊 Merging ${blobFiles.length} coverage blobs into final report...`);
256
327
  console.log("=".repeat(80));
@@ -260,14 +331,14 @@ export async function run(opts) {
260
331
  ...spawnBase,
261
332
  maxOldSpaceMb,
262
333
  extraCoverageArgs,
263
- quietOutput: coverageQuiet
334
+ quietOutput: coverageQuiet || json
264
335
  });
265
336
 
266
- if (coverageQuiet) {
337
+ if (coverageQuiet && emitTextOutput) {
267
338
  printMergeOutput(mergeExitCode, mergeOutput);
268
339
  }
269
340
 
270
- await printCoverageSummary(cwd, extraCoverageArgs, worstCoverageCount);
341
+ const coverageSummary = await printCoverageSummary(cwd, extraCoverageArgs, worstCoverageCount, { silent: json });
271
342
 
272
343
  // Clean up blobs and temp dirs
273
344
  await Promise.all([
@@ -276,18 +347,81 @@ export async function run(opts) {
276
347
  ]);
277
348
 
278
349
  const coverageFailed = coverageResults.filter((r) => r.code !== 0);
279
- if (coverageQuiet) printQuietCoverageFailureDetails(coverageFailed);
350
+ if (coverageQuiet && emitTextOutput) printQuietCoverageFailureDetails(coverageFailed);
351
+
352
+ const exitCode = coverageFailed.length > 0 ? 1 : mergeExitCode;
353
+ if (json) {
354
+ const coverageWithHeap = coverageResults.filter((r) => r.heapMb !== null);
355
+ return {
356
+ exitCode,
357
+ mode: "coverage",
358
+ options: {
359
+ cwd,
360
+ testDir,
361
+ testPatterns,
362
+ testListFile,
363
+ workers,
364
+ coverageQuiet,
365
+ suppressFileOutput,
366
+ suppressPassingFiles,
367
+ topSummary,
368
+ showErrorDetails,
369
+ worstCoverageCount,
370
+ vitestArgs
371
+ },
372
+ totals: {
373
+ testFiles: allTestFiles.length,
374
+ failedFiles: coverageFailed.length,
375
+ passedFiles: coverageResults.length - coverageFailed.length
376
+ },
377
+ results: {
378
+ all: coverageResults,
379
+ failed: coverageFailed
380
+ },
381
+ merge: {
382
+ blobFiles: blobFiles.length,
383
+ exitCode: mergeExitCode,
384
+ output: mergeOutput
385
+ },
386
+ coverageSummary,
387
+ ...(topSummary
388
+ ? {
389
+ topMemoryUsers: [...coverageWithHeap].sort((a, b) => b.heapMb - a.heapMb).slice(0, 10),
390
+ topDuration: [...coverageResults].sort((a, b) => b.duration - a.duration).slice(0, 10)
391
+ }
392
+ : {})
393
+ };
394
+ }
280
395
 
281
- return coverageFailed.length > 0 ? 1 : mergeExitCode;
396
+ return exitCode;
282
397
  }
283
398
 
284
399
  // ─── STANDARD (NON-COVERAGE) MODE ────────────────────────────────────────────
285
400
  const testFiles = await discoverVitestFiles({ cwd, testDir, testPatterns, testListFile, testFilePattern, earlyRunPatterns });
286
401
 
287
402
  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
- );
403
+ const noTestsMessage =
404
+ testPatterns.length > 0 ? `❌ No Vitest test files found matching: ${testPatterns.join(", ")}` : "❌ No Vitest test files found";
405
+ if (emitTextOutput) console.log(noTestsMessage);
406
+ if (json) {
407
+ return {
408
+ exitCode: 1,
409
+ mode: "standard",
410
+ message: noTestsMessage,
411
+ options: {
412
+ cwd,
413
+ testDir,
414
+ testPatterns,
415
+ testListFile,
416
+ workers,
417
+ coverageQuiet,
418
+ suppressFileOutput,
419
+ suppressPassingFiles,
420
+ topSummary,
421
+ vitestArgs
422
+ }
423
+ };
424
+ }
291
425
  return 1;
292
426
  }
293
427
 
@@ -297,15 +431,17 @@ export async function run(opts) {
297
431
  const scriptStartTime = Date.now();
298
432
  const scriptStartTimeFormatted = new Date().toLocaleTimeString("en-US", { hour12: false });
299
433
 
300
- if (testPatterns.length > 0) {
434
+ if (emitTextOutput && !suppressFileOutput && testPatterns.length > 0) {
301
435
  console.log(`\n🧪 Running ${testFiles.length} test files matching: ${testPatterns.join(", ")}`);
302
- } else {
436
+ } else if (emitTextOutput && !suppressFileOutput) {
303
437
  console.log(`\n🧪 Running ${testFiles.length} test files (${soloFiles.length} solo first, then parallel)`);
304
438
  }
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("");
439
+ if (emitTextOutput && !suppressFileOutput) {
440
+ console.log(`⚙️ Workers: ${workers}`);
441
+ if (maxOldSpaceMb) console.log(`🧠 Heap limit: ${maxOldSpaceMb} MB`);
442
+ if (vitestArgs.length > 0) console.log(`🔧 Vitest args: ${vitestArgs.join(" ")}`);
443
+ console.log("");
444
+ }
309
445
 
310
446
  const results = [...(_testResultsOverride ?? [])];
311
447
 
@@ -315,22 +451,27 @@ export async function run(opts) {
315
451
  * @returns {Promise<import('./core/spawn.mjs').SingleFileResult>}
316
452
  */
317
453
  const runTestFile = async (filePath) => {
318
- console.log(`\n${"=".repeat(80)}`);
319
- console.log(`▶️ ${filePath}`);
320
- console.log("=".repeat(80));
454
+ if (!suppressFileOutput && emitTextOutput) {
455
+ console.log(`\n${"=".repeat(80)}`);
456
+ console.log(`▶️ ${filePath}`);
457
+ console.log("=".repeat(80));
458
+ }
321
459
 
322
460
  const result = await runSingleFile(filePath, {
323
461
  ...spawnBase,
324
462
  maxOldSpaceMb: getHeapForFile(filePath, maxOldSpaceMb, perFileHeapOverrides),
325
- vitestArgs
463
+ vitestArgs,
464
+ streamOutput: !suppressFileOutput && !json
326
465
  });
327
466
 
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`);
467
+ if (!suppressFileOutput && emitTextOutput) {
468
+ const durationSec = (result.duration / 1000).toFixed(2);
469
+ if (result.code === 0) {
470
+ const heapInfo = result.heapMb ? ` | ${result.heapMb} MB heap` : "";
471
+ console.log(`\n✅ PASSED (${durationSec}s${heapInfo})\n`);
472
+ } else {
473
+ console.log(`\n❌ FAILED (exit code ${result.code}, ${durationSec}s)\n`);
474
+ }
334
475
  }
335
476
 
336
477
  return result;
@@ -340,7 +481,7 @@ export async function run(opts) {
340
481
  // Phase 1: solo files
341
482
  for (const filePath of soloFiles) {
342
483
  const result = await runTestFile(filePath).catch((err) => {
343
- console.error(`Error running ${filePath}:`, err);
484
+ if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
344
485
  return null;
345
486
  });
346
487
  if (result) results.push(result);
@@ -355,7 +496,9 @@ export async function run(opts) {
355
496
  const filePath = parallelFiles[index++];
356
497
  const promise = runTestFile(filePath)
357
498
  .then((result) => results.push(result))
358
- .catch((err) => console.error(`Error running ${filePath}:`, err))
499
+ .catch((err) => {
500
+ if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
501
+ })
359
502
  .finally(() => activePromises.delete(promise));
360
503
  activePromises.add(promise);
361
504
  }
@@ -373,12 +516,69 @@ export async function run(opts) {
373
516
  const totalDuration = results.reduce((s, r) => s + r.duration, 0);
374
517
  const failedFiles = results.filter((r) => r.code !== 0);
375
518
  const passedFiles = results.filter((r) => r.code === 0);
519
+ const exitCode = failedFiles.length > 0 ? 1 : 0;
520
+ const scriptMemory = process.memoryUsage();
521
+ const scriptHeapMb = Math.round(scriptMemory.heapUsed / 1024 / 1024);
522
+ const scriptRssMb = Math.round(scriptMemory.rss / 1024 / 1024);
523
+ const withHeap = results.filter((r) => r.heapMb !== null);
524
+ const actualDurationSec = ((Date.now() - scriptStartTime) / 1000).toFixed(2);
525
+ const testsDurationSec = (totalDuration / 1000).toFixed(2);
526
+
527
+ if (json) {
528
+ return {
529
+ exitCode,
530
+ mode: "standard",
531
+ options: {
532
+ cwd,
533
+ testDir,
534
+ testPatterns,
535
+ testListFile,
536
+ workers,
537
+ coverageQuiet,
538
+ suppressFileOutput,
539
+ suppressPassingFiles,
540
+ topSummary,
541
+ showErrorDetails,
542
+ worstCoverageCount,
543
+ vitestArgs
544
+ },
545
+ totals: {
546
+ testFilesPass: totalTestFilesPass,
547
+ testFilesFail: totalTestFilesFail,
548
+ testsPass: totalTestsPass,
549
+ testsFail: totalTestsFail,
550
+ testsSkip: totalTestsSkip,
551
+ totalTests: totalTestsPass + totalTestsFail + totalTestsSkip
552
+ },
553
+ timing: {
554
+ startAt: scriptStartTimeFormatted,
555
+ wallClockSeconds: Number(actualDurationSec),
556
+ testsSeconds: Number(testsDurationSec)
557
+ },
558
+ heap: {
559
+ scriptHeapMb,
560
+ scriptRssMb,
561
+ maxHeapMb: withHeap.length > 0 ? Math.max(...withHeap.map((r) => r.heapMb)) : null,
562
+ avgHeapMb: withHeap.length > 0 ? Number((withHeap.reduce((sum, r) => sum + r.heapMb, 0) / withHeap.length).toFixed(0)) : null
563
+ },
564
+ results: {
565
+ all: results,
566
+ passed: passedFiles,
567
+ failed: failedFiles
568
+ },
569
+ ...(topSummary
570
+ ? {
571
+ topMemoryUsers: [...withHeap].sort((a, b) => b.heapMb - a.heapMb).slice(0, 10),
572
+ topDuration: [...results].sort((a, b) => b.duration - a.duration).slice(0, 10)
573
+ }
574
+ : {})
575
+ };
576
+ }
376
577
 
377
578
  console.log("\n" + "=".repeat(80));
378
579
 
379
580
  // Top memory users
380
- const withHeap = results.filter((r) => r.heapMb !== null);
381
- if (withHeap.length > 0) {
581
+ if (topSummary && withHeap.length > 0) {
382
582
  console.log("\n" + chalk.bold("🧠 TOP MEMORY USERS"));
383
583
  console.log("-".repeat(80));
384
584
  [...withHeap]
@@ -390,7 +590,7 @@ export async function run(opts) {
390
590
  }
391
591
 
392
592
  // Top duration
393
- if (results.length > 0) {
593
+ if (topSummary && results.length > 0) {
394
594
  console.log("\n" + chalk.bold("⏱️ TOP DURATION"));
395
595
  console.log("-".repeat(80));
396
596
  [...results]
@@ -403,7 +603,7 @@ export async function run(opts) {
403
603
  }
404
604
 
405
605
  // Passed files
406
- if (passedFiles.length > 0) {
606
+ if (passedFiles.length > 0 && !suppressPassingFiles) {
407
607
  console.log("\n" + "=".repeat(80));
408
608
  console.log(chalk.bold.green("✓ PASSED TEST FILES"));
409
609
  console.log("=".repeat(80));
@@ -468,13 +668,8 @@ export async function run(opts) {
468
668
  console.log(` ${chalk.bold("Tests")} ${testsParts.join(` ${chalk.dim("|")} `)} ${chalk.dim(`(${totalTests})`)}`);
469
669
  console.log(` ${chalk.bold("Start at")} ${scriptStartTimeFormatted}`);
470
670
 
471
- const actualDurationSec = ((Date.now() - scriptStartTime) / 1000).toFixed(2);
472
- const testsDurationSec = (totalDuration / 1000).toFixed(2);
473
671
  console.log(` ${chalk.bold("Duration")} ${actualDurationSec}s ${chalk.dim(`(tests ${testsDurationSec}s)`)}`);
474
672
 
475
- const scriptMemory = process.memoryUsage();
476
- const scriptHeapMb = Math.round(scriptMemory.heapUsed / 1024 / 1024);
477
- const scriptRssMb = Math.round(scriptMemory.rss / 1024 / 1024);
478
673
  if (withHeap.length > 0) {
479
674
  const maxHeap = Math.max(...withHeap.map((r) => r.heapMb));
480
675
  const avgHeap = (withHeap.reduce((s, r) => s + r.heapMb, 0) / withHeap.length).toFixed(0);
@@ -483,9 +678,7 @@ export async function run(opts) {
483
678
  console.log(` ${chalk.bold("Heap")} script ${scriptHeapMb} MB (RSS ${scriptRssMb} MB)`);
484
679
  }
485
680
 
486
- if (failedFiles.length > 0) {
487
- return 1;
488
- }
681
+ if (failedFiles.length > 0) return 1;
489
682
 
490
683
  console.log(`\n✅ All ${passedFiles.length} test files passed\n`);
491
684
  return 0;
@@ -29,6 +29,22 @@ 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;
32
48
  /**
33
49
  * - Whether `--help` / `-h` was passed.
34
50
  */
@@ -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
  */