@cldmv/vitest-runner 1.2.0 → 1.4.2

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/src/runner.mjs DELETED
@@ -1,708 +0,0 @@
1
- /**
2
- * @fileoverview Main sequential Vitest runner orchestration.
3
- * @module vitest-runner/src/runner
4
- *
5
- * @description
6
- * Runs each Vitest test file in its own child process to avoid OOM issues in
7
- * large test suites, collects results, and produces a Vitest-style summary.
8
- *
9
- * Supports coverage mode (blob-per-file + mergeReports) and a quiet progress-bar
10
- * variant so CI pipelines can keep output concise. All standard Vitest CLI flags
11
- * are forwarded to child processes unchanged.
12
- *
13
- * @example
14
- * // Run every test file found under src/tests/ (cwd defaults to process.cwd())
15
- * import { run } from 'vitest-runner';
16
- * const code = await run({ testDir: 'src/tests' });
17
- * process.exit(code);
18
- *
19
- * @example
20
- * // Run a subset with custom heap limit
21
- * const code = await run({
22
- * testDir: 'src/tests',
23
- * testPatterns: ['src/tests/config'],
24
- * maxOldSpaceMb: 4096,
25
- * });
26
- */
27
-
28
- import fs from "node:fs/promises";
29
- import path from "node:path";
30
- import chalk from "chalk";
31
-
32
- import { resolveBin, resolveVitestConfig } from "./utils/resolve.mjs";
33
- import { discoverVitestFiles } from "./core/discover.mjs";
34
- import { runSingleFile, runMergeReports } from "./core/spawn.mjs";
35
- import { deduplicateErrors } from "./core/parse.mjs";
36
- import { createCoverageProgressTracker, noopProgressTracker } from "./core/progress.mjs";
37
- import { printQuietCoverageFailureDetails, printMergeOutput, printCoverageSummary } from "./core/report.mjs";
38
-
39
- /**
40
- * @typedef {Object} PerFileHeapOverride
41
- * @property {string} pattern - Substring matched against the normalised file path.
42
- * @property {number} heapMb - Minimum heap ceiling in MB for matching files.
43
- */
44
-
45
- /**
46
- * @typedef {Object} RunOptions
47
- * @property {string} [cwd=process.cwd()] - Absolute project root directory (defaults to `process.cwd()`).
48
- * @property {string} [testDir] - Directory to scan for test files (relative or absolute; defaults to `cwd`).
49
- * @property {string} [vitestConfig] - Explicit vitest config path; auto-detected from `cwd` when omitted.
50
- * @property {string[]} [testPatterns=[]] - File / folder patterns to filter (empty = all files in `testDir`).
51
- * @property {string} [testListFile] - Path to a JSON array of test file paths; when set, scanning is skipped.
52
- * @property {RegExp} [testFilePattern] - Regex matched against file names when scanning (default: `*.test.vitest.{js,mjs,cjs}`).
53
- * @property {string[]} [vitestArgs=[]] - Extra CLI args forwarded verbatim to every vitest invocation.
54
- * @property {boolean} [showErrorDetails=true] - Print inline error blocks under each failed file.
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.
60
- * @property {number} [workers=4] - Maximum number of parallel worker slots.
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.
64
- * @property {number} [maxOldSpaceMb] - Global `--max-old-space-size` ceiling; per-file overrides may raise it.
65
- * @property {string[]} [earlyRunPatterns=[]] - Path substrings — matching files run solo before the worker pool.
66
- * @property {PerFileHeapOverride[]} [perFileHeapOverrides=[]] - Per-file minimum heap overrides.
67
- * @property {string[]} [conditions=[]] - Additional `--conditions` Node flags forwarded to children.
68
- * @property {string} [nodeEnv='development'] - Value for `NODE_ENV` in child processes.
69
- * @property {object[] | null} [_testResultsOverride=null] - @internal Inject pre-built results to bypass discovery and spawn (for testing final-report render paths).
70
- */
71
-
72
- /**
73
- * Return the effective heap ceiling (MB) for a given test file.
74
- *
75
- * Takes the maximum of the global limit and the first matching per-file override.
76
- * Returns `undefined` when neither source provides a value.
77
- *
78
- * @param {string} filePath - Test file path.
79
- * @param {number|undefined} globalMaxMb - Global ceiling from options.
80
- * @param {PerFileHeapOverride[]} overrides - Per-file override table.
81
- * @returns {number|undefined}
82
- */
83
- function getHeapForFile(filePath, globalMaxMb, overrides) {
84
- const normalized = filePath.replace(/\\/g, "/");
85
- let perFileMb;
86
- for (const { pattern, heapMb } of overrides) {
87
- if (normalized.includes(pattern)) {
88
- perFileMb = heapMb;
89
- break;
90
- }
91
- }
92
- if (perFileMb === undefined && globalMaxMb === undefined) return undefined;
93
- if (perFileMb === undefined) return globalMaxMb;
94
- if (globalMaxMb === undefined) return perFileMb;
95
- return Math.max(perFileMb, globalMaxMb);
96
- }
97
-
98
- /**
99
- * Run all discovered Vitest test files sequentially (with a configurable worker
100
- * pool for the non-solo phase) and return an exit code.
101
- *
102
- * @param {RunOptions} opts
103
- * @returns {Promise<number|object>} `0`/`1` by default; JSON report object when `opts.json` is true.
104
- */
105
- export async function run(opts) {
106
- const {
107
- cwd = process.cwd(),
108
- testDir,
109
- testPatterns = [],
110
- testListFile,
111
- testFilePattern,
112
- vitestArgs: rawVitestArgs = [],
113
- showErrorDetails = true,
114
- coverageQuiet = false,
115
- suppressFileOutput = false,
116
- suppressPassingFiles = false,
117
- topSummary = true,
118
- json = false,
119
- workers = parseInt(process.env.VITEST_WORKERS ?? "4", 10),
120
- worstCoverageCount = 10,
121
- blobsDir: blobsDirOpt,
122
- mergeReports = true,
123
- earlyRunPatterns = [],
124
- perFileHeapOverrides = [],
125
- conditions = [],
126
- nodeEnv = "development",
127
- /** @internal Inject pre-built results to bypass discovery and spawn (for testing final-report render paths). */
128
- _testResultsOverride = null
129
- } = opts;
130
-
131
- const maxOldSpaceMb = opts.maxOldSpaceMb ?? (process.env.VITEST_HEAP_MB ? parseInt(process.env.VITEST_HEAP_MB, 10) : undefined);
132
- const emitTextOutput = !json;
133
-
134
- // Resolve vitest binary and config
135
- const vitestBin = resolveBin(cwd, "vitest", "vitest");
136
- const vitestConfig = await resolveVitestConfig(cwd, opts.vitestConfig);
137
-
138
- /** Common spawn options shared across all child invocations. */
139
- const spawnBase = { cwd, vitestBin, vitestConfig, conditions, nodeEnv };
140
-
141
- const vitestArgs = [...rawVitestArgs];
142
-
143
- // --coverage-quiet implies --coverage
144
- if (coverageQuiet && !vitestArgs.some((a) => a === "--coverage" || a.startsWith("--coverage."))) {
145
- vitestArgs.unshift("--coverage");
146
- }
147
-
148
- const hasCoverage = vitestArgs.some((a) => a === "--coverage" || a.startsWith("--coverage."));
149
-
150
- // ─── COVERAGE MODE ───────────────────────────────────────────────────────────
151
- if (hasCoverage) {
152
- const blobsDir = path.resolve(cwd, blobsDirOpt ?? ".vitest-coverage-blobs");
153
- // Temp coverage dirs live OUTSIDE blobsDir — vitest --mergeReports errors on
154
- // any non-blob entry found inside the blobs directory.
155
- const coverageTmpBase = path.resolve(cwd, ".vitest-coverage-tmp");
156
-
157
- await Promise.all([fs.rm(blobsDir, { recursive: true, force: true }), fs.rm(coverageTmpBase, { recursive: true, force: true })]);
158
- await Promise.all([fs.mkdir(blobsDir, { recursive: true }), fs.mkdir(coverageTmpBase, { recursive: true })]);
159
-
160
- const allTestFiles = await discoverVitestFiles({ cwd, testDir, testPatterns, testListFile, testFilePattern, earlyRunPatterns });
161
-
162
- if (allTestFiles.length === 0) {
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
- }
185
- return 1;
186
- }
187
-
188
- // Separate --coverage / --coverage.* args (merge step only) from other passthroughs
189
- const extraCoverageArgs = vitestArgs.filter((a) => a !== "--coverage" && a.startsWith("--coverage."));
190
- const nonCoveragePassthrough = vitestArgs.filter((a) => a !== "--coverage" && !a.startsWith("--coverage."));
191
-
192
- const soloFiles = allTestFiles.filter((f) => earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
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;
196
-
197
- if (!coverageQuiet && !suppressFileOutput && emitTextOutput) {
198
- console.log(`\n🧪 Running ${allTestFiles.length} test files for coverage (blob + merge mode)`);
199
- console.log(`āš™ļø Workers: ${workers} (${soloFiles.length} solo first, then parallel)`);
200
- if (maxOldSpaceMb) console.log(`🧠 Heap limit: ${maxOldSpaceMb} MB`);
201
- console.log("");
202
- }
203
-
204
- const progress = showCoverageProgress ? createCoverageProgressTracker(allTestFiles.length) : noopProgressTracker;
205
- const coverageResults = [];
206
- let blobIndex = 0;
207
-
208
- /**
209
- * Run one file with the blob reporter and push its result.
210
- * @param {string} filePath
211
- * @returns {Promise<void>}
212
- */
213
- const runCoverageFile = async (filePath) => {
214
- const blobPath = path.join(blobsDir, `run-${blobIndex}.blob`);
215
- blobIndex++;
216
-
217
- if (!suppressPerFileCoverageOutput && emitTextOutput) {
218
- console.log(`\n${"=".repeat(80)}`);
219
- console.log(`ā–¶ļø ${filePath}`);
220
- console.log("=".repeat(80));
221
- }
222
-
223
- progress.onStart();
224
-
225
- const tmpCoverageDir = path.join(coverageTmpBase, `run-${blobIndex}`);
226
- const blobArgs = [
227
- ...nonCoveragePassthrough,
228
- "--coverage",
229
- `--coverage.reportsDirectory=${tmpCoverageDir}`,
230
- "--reporter=default",
231
- "--reporter=blob",
232
- `--outputFile=${blobPath}`
233
- ];
234
-
235
- const result = await runSingleFile(filePath, {
236
- ...spawnBase,
237
- maxOldSpaceMb: getHeapForFile(filePath, maxOldSpaceMb, perFileHeapOverrides),
238
- vitestArgs: blobArgs,
239
- streamOutput: !suppressPerFileCoverageOutput
240
- });
241
-
242
- coverageResults.push(result);
243
- progress.onComplete(result.code !== 0);
244
-
245
- if (!suppressPerFileCoverageOutput && emitTextOutput) {
246
- const durationSec = (result.duration / 1000).toFixed(2);
247
- if (result.code === 0) {
248
- const heapInfo = result.heapMb ? ` | ${result.heapMb} MB heap` : "";
249
- console.log(`\nāœ… PASSED (${durationSec}s${heapInfo})\n`);
250
- } else {
251
- console.log(`\nāŒ FAILED (exit code ${result.code}, ${durationSec}s)\n`);
252
- }
253
- }
254
- };
255
-
256
- // Phase 1: solo files — one at a time
257
- for (const filePath of soloFiles) {
258
- await runCoverageFile(filePath).catch((err) => {
259
- if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
260
- });
261
- }
262
-
263
- // Phase 2: parallel files with worker pool
264
- let coverageFileIndex = 0;
265
- const coverageActivePromises = new Set();
266
-
267
- while (coverageFileIndex < parallelFiles.length || coverageActivePromises.size > 0) {
268
- while (coverageFileIndex < parallelFiles.length && coverageActivePromises.size < workers) {
269
- const filePath = parallelFiles[coverageFileIndex++];
270
- const promise = runCoverageFile(filePath)
271
- .catch((err) => {
272
- if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
273
- })
274
- .finally(() => coverageActivePromises.delete(promise));
275
- coverageActivePromises.add(promise);
276
- }
277
- // c8 ignore next -- size is always >0 when the outer while body is reached
278
- if (coverageActivePromises.size > 0) await Promise.race(coverageActivePromises);
279
- }
280
-
281
- progress.finish();
282
-
283
- const blobFiles = (await fs.readdir(blobsDir).catch(() => [])).filter((f) => f.endsWith(".blob"));
284
- if (blobFiles.length === 0) {
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
- }
323
- return 1;
324
- }
325
-
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;
332
-
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
- }
339
-
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
- }
350
-
351
- coverageSummary = await printCoverageSummary(cwd, extraCoverageArgs, worstCoverageCount, { silent: json });
352
- }
353
-
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.
356
- await Promise.all([
357
- ...(mergeReports ? [fs.rm(blobsDir, { recursive: true, force: true }).catch(() => {})] : []),
358
- fs.rm(coverageTmpBase, { recursive: true, force: true }).catch(() => {})
359
- ]);
360
-
361
- const coverageFailed = coverageResults.filter((r) => r.code !== 0);
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
- }
407
-
408
- return exitCode;
409
- }
410
-
411
- // ─── STANDARD (NON-COVERAGE) MODE ────────────────────────────────────────────
412
- const testFiles = await discoverVitestFiles({ cwd, testDir, testPatterns, testListFile, testFilePattern, earlyRunPatterns });
413
-
414
- if (testFiles.length === 0) {
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
- }
437
- return 1;
438
- }
439
-
440
- const soloFiles = testFiles.filter((f) => earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
441
- const parallelFiles = testFiles.filter((f) => !earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
442
-
443
- const scriptStartTime = Date.now();
444
- const scriptStartTimeFormatted = new Date().toLocaleTimeString("en-US", { hour12: false });
445
-
446
- if (emitTextOutput && !suppressFileOutput && testPatterns.length > 0) {
447
- console.log(`\n🧪 Running ${testFiles.length} test files matching: ${testPatterns.join(", ")}`);
448
- } else if (emitTextOutput && !suppressFileOutput) {
449
- console.log(`\n🧪 Running ${testFiles.length} test files (${soloFiles.length} solo first, then parallel)`);
450
- }
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
- }
457
-
458
- const results = [...(_testResultsOverride ?? [])];
459
-
460
- /**
461
- * Run one test file, log progress, and return the result.
462
- * @param {string} filePath
463
- * @returns {Promise<import('./core/spawn.mjs').SingleFileResult>}
464
- */
465
- const runTestFile = async (filePath) => {
466
- if (!suppressFileOutput && emitTextOutput) {
467
- console.log(`\n${"=".repeat(80)}`);
468
- console.log(`ā–¶ļø ${filePath}`);
469
- console.log("=".repeat(80));
470
- }
471
-
472
- const result = await runSingleFile(filePath, {
473
- ...spawnBase,
474
- maxOldSpaceMb: getHeapForFile(filePath, maxOldSpaceMb, perFileHeapOverrides),
475
- vitestArgs,
476
- streamOutput: !suppressFileOutput && !json
477
- });
478
-
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
- }
487
- }
488
-
489
- return result;
490
- };
491
-
492
- if (!_testResultsOverride) {
493
- // Phase 1: solo files
494
- for (const filePath of soloFiles) {
495
- const result = await runTestFile(filePath).catch((err) => {
496
- if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
497
- return null;
498
- });
499
- if (result) results.push(result);
500
- }
501
-
502
- // Phase 2: parallel files with worker pool
503
- let index = 0;
504
- const activePromises = new Set();
505
-
506
- while (index < parallelFiles.length || activePromises.size > 0) {
507
- while (index < parallelFiles.length && activePromises.size < workers) {
508
- const filePath = parallelFiles[index++];
509
- const promise = runTestFile(filePath)
510
- .then((result) => results.push(result))
511
- .catch((err) => {
512
- if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
513
- })
514
- .finally(() => activePromises.delete(promise));
515
- activePromises.add(promise);
516
- }
517
- // c8 ignore next -- size is always >0 when the outer while body is reached
518
- if (activePromises.size > 0) await Promise.race(activePromises);
519
- }
520
- }
521
-
522
- // ─── FINAL REPORT ────────────────────────────────────────────────────────────
523
- const totalTestFilesPass = results.reduce((s, r) => s + r.testFilesPass, 0);
524
- const totalTestFilesFail = results.reduce((s, r) => s + r.testFilesFail, 0);
525
- const totalTestsPass = results.reduce((s, r) => s + r.testsPass, 0);
526
- const totalTestsFail = results.reduce((s, r) => s + r.testsFail, 0);
527
- const totalTestsSkip = results.reduce((s, r) => s + (r.testsSkip || 0), 0);
528
- const totalDuration = results.reduce((s, r) => s + r.duration, 0);
529
- const failedFiles = results.filter((r) => r.code !== 0);
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
- }
589
-
590
- console.log("\n" + "=".repeat(80));
591
-
592
- // Top memory users
593
- if (topSummary && withHeap.length > 0) {
594
- console.log("\n" + chalk.bold("🧠 TOP MEMORY USERS"));
595
- console.log("-".repeat(80));
596
- [...withHeap]
597
- .sort((a, b) => b.heapMb - a.heapMb)
598
- .slice(0, 10)
599
- .forEach((r) => {
600
- console.log(` ${String(r.heapMb).padStart(4)} MB ${chalk.dim(r.file)}`);
601
- });
602
- }
603
-
604
- // Top duration
605
- if (topSummary && results.length > 0) {
606
- console.log("\n" + chalk.bold("ā±ļø TOP DURATION"));
607
- console.log("-".repeat(80));
608
- [...results]
609
- .sort((a, b) => b.duration - a.duration)
610
- .slice(0, 10)
611
- .forEach((r) => {
612
- const sec = (r.duration / 1000).toFixed(2);
613
- console.log(` ${(sec + "s").padStart(8)} ${chalk.dim(r.file)}`);
614
- });
615
- }
616
-
617
- // Passed files
618
- if (passedFiles.length > 0 && !suppressPassingFiles) {
619
- console.log("\n" + "=".repeat(80));
620
- console.log(chalk.bold.green("āœ“ PASSED TEST FILES"));
621
- console.log("=".repeat(80));
622
- passedFiles.forEach((r) => {
623
- const durationSec = (r.duration / 1000).toFixed(2);
624
- const statsInfo = [...(r.heapMb ? [`${r.heapMb} MB`] : []), `${durationSec}s`];
625
- const testInfo = r.testsPass > 0 ? ` - ${r.testsPass} tests` : "";
626
- console.log(chalk.green(`āœ“ ${r.file}${testInfo}`) + chalk.dim(` (${statsInfo.join(", ")})`));
627
- });
628
- }
629
-
630
- // Summary banner
631
- console.log("\n" + chalk.bold("=".repeat(80)));
632
-
633
- if (failedFiles.length > 0) {
634
- console.log(`\nāŒ ${failedFiles.length} test file(s) failed`);
635
- console.log(chalk.bold.red("\nFailed Test Files:"));
636
-
637
- failedFiles.forEach((r) => {
638
- const durationSec = (r.duration / 1000).toFixed(2);
639
- const testCounts = [
640
- ...(r.testsFail > 0 ? [chalk.red(`${r.testsFail} failed`)] : []),
641
- ...(r.testsPass > 0 ? [chalk.green(`${r.testsPass} passed`)] : []),
642
- ...(r.testsSkip > 0 ? [chalk.yellow(`${r.testsSkip} skipped`)] : [])
643
- ];
644
- const statsInfo = [...(r.heapMb ? [`${r.heapMb} MB`] : []), `${durationSec}s`];
645
- const countStr = testCounts.length > 0 ? ` (${testCounts.join(", ")})` : "";
646
- console.log(` ${chalk.red("āœ–")} ${r.file}${countStr}` + chalk.dim(` [${statsInfo.join(", ")}]`));
647
-
648
- if (showErrorDetails && r.errors.length > 0) {
649
- const deduped = deduplicateErrors(r.errors)
650
- .split("\n")
651
- .map((line) => (line.trim() ? ` ${line}` : ""))
652
- .join("\n");
653
- console.log(deduped);
654
- console.log("");
655
- }
656
- });
657
-
658
- console.log("");
659
- }
660
-
661
- console.log(chalk.bold("=".repeat(80)));
662
-
663
- // Vitest-style summary lines
664
- if (totalTestFilesFail > 0 && totalTestFilesPass > 0) {
665
- console.log(
666
- ` ${chalk.bold("Test Files")} ${chalk.red(`${totalTestFilesFail} failed`)} ${chalk.dim("|")} ${chalk.green(`${totalTestFilesPass} passed`)} ${chalk.dim(`(${totalTestFilesPass + totalTestFilesFail})`)}`
667
- );
668
- } else if (totalTestFilesFail > 0) {
669
- console.log(` ${chalk.bold("Test Files")} ${chalk.red(`${totalTestFilesFail} failed`)} ${chalk.dim(`(${totalTestFilesFail})`)}`);
670
- } else {
671
- console.log(` ${chalk.bold("Test Files")} ${chalk.green(`${totalTestFilesPass} passed`)} ${chalk.dim(`(${totalTestFilesPass})`)}`);
672
- }
673
-
674
- const totalTests = totalTestsPass + totalTestsFail + totalTestsSkip;
675
- const testsParts = [
676
- ...(totalTestsFail > 0 ? [chalk.red(`${totalTestsFail} failed`)] : []),
677
- ...(totalTestsPass > 0 ? [chalk.green(`${totalTestsPass} passed`)] : []),
678
- ...(totalTestsSkip > 0 ? [chalk.yellow(`${totalTestsSkip} skipped`)] : [])
679
- ];
680
- console.log(` ${chalk.bold("Tests")} ${testsParts.join(` ${chalk.dim("|")} `)} ${chalk.dim(`(${totalTests})`)}`);
681
- console.log(` ${chalk.bold("Start at")} ${scriptStartTimeFormatted}`);
682
-
683
- console.log(` ${chalk.bold("Duration")} ${actualDurationSec}s ${chalk.dim(`(tests ${testsDurationSec}s)`)}`);
684
-
685
- if (withHeap.length > 0) {
686
- const maxHeap = Math.max(...withHeap.map((r) => r.heapMb));
687
- const avgHeap = (withHeap.reduce((s, r) => s + r.heapMb, 0) / withHeap.length).toFixed(0);
688
- console.log(` ${chalk.bold("Heap")} max ${maxHeap} MB | avg ${avgHeap} MB | script ${scriptHeapMb} MB (RSS ${scriptRssMb} MB)`);
689
- } else {
690
- console.log(` ${chalk.bold("Heap")} script ${scriptHeapMb} MB (RSS ${scriptRssMb} MB)`);
691
- }
692
-
693
- if (failedFiles.length > 0) return 1;
694
-
695
- console.log(`\nāœ… All ${passedFiles.length} test files passed\n`);
696
- return 0;
697
- }
698
-
699
- // Re-export sub-module utilities so callers can use them without deep imports
700
- export { resolveBin, resolveVitestConfig } from "./utils/resolve.mjs";
701
- export { discoverVitestFiles, sortWithPriority, discoverFilesInDir } from "./core/discover.mjs";
702
- export { parseVitestOutput, deduplicateErrors } from "./core/parse.mjs";
703
- export { runSingleFile, runVitestDirect, runMergeReports } from "./core/spawn.mjs";
704
- export { createCoverageProgressTracker, noopProgressTracker } from "./core/progress.mjs";
705
- export { printCoverageSummary, printMergeOutput, printQuietCoverageFailureDetails } from "./core/report.mjs";
706
- export { formatDuration } from "./utils/duration.mjs";
707
- export { stripAnsi, colourPct } from "./utils/ansi.mjs";
708
- export { buildNodeOptions } from "./utils/env.mjs";