@cldmv/vitest-runner 1.1.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,696 +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
- import { formatDuration } from "./utils/duration.mjs";
39
- import { colourPct } from "./utils/ansi.mjs";
40
-
41
- /**
42
- * @typedef {Object} PerFileHeapOverride
43
- * @property {string} pattern - Substring matched against the normalised file path.
44
- * @property {number} heapMb - Minimum heap ceiling in MB for matching files.
45
- */
46
-
47
- /**
48
- * @typedef {Object} RunOptions
49
- * @property {string} [cwd=process.cwd()] - Absolute project root directory (defaults to `process.cwd()`).
50
- * @property {string} [testDir] - Directory to scan for test files (relative or absolute; defaults to `cwd`).
51
- * @property {string} [vitestConfig] - Explicit vitest config path; auto-detected from `cwd` when omitted.
52
- * @property {string[]} [testPatterns=[]] - File / folder patterns to filter (empty = all files in `testDir`).
53
- * @property {string} [testListFile] - Path to a JSON array of test file paths; when set, scanning is skipped.
54
- * @property {RegExp} [testFilePattern] - Regex matched against file names when scanning (default: `*.test.vitest.{js,mjs,cjs}`).
55
- * @property {string[]} [vitestArgs=[]] - Extra CLI args forwarded verbatim to every vitest invocation.
56
- * @property {boolean} [showErrorDetails=true] - Print inline error blocks under each failed file.
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.
62
- * @property {number} [workers=4] - Maximum number of parallel worker slots.
63
- * @property {number} [worstCoverageCount=10] - Rows in the worst-coverage table (0 = disable).
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
- earlyRunPatterns = [],
122
- perFileHeapOverrides = [],
123
- conditions = [],
124
- nodeEnv = "development",
125
- /** @internal Inject pre-built results to bypass discovery and spawn (for testing final-report render paths). */
126
- _testResultsOverride = null
127
- } = opts;
128
-
129
- const maxOldSpaceMb = opts.maxOldSpaceMb ?? (process.env.VITEST_HEAP_MB ? parseInt(process.env.VITEST_HEAP_MB, 10) : undefined);
130
- const emitTextOutput = !json;
131
-
132
- // Resolve vitest binary and config
133
- const vitestBin = resolveBin(cwd, "vitest", "vitest");
134
- const vitestConfig = await resolveVitestConfig(cwd, opts.vitestConfig);
135
-
136
- /** Common spawn options shared across all child invocations. */
137
- const spawnBase = { cwd, vitestBin, vitestConfig, conditions, nodeEnv };
138
-
139
- const vitestArgs = [...rawVitestArgs];
140
-
141
- // --coverage-quiet implies --coverage
142
- if (coverageQuiet && !vitestArgs.some((a) => a === "--coverage" || a.startsWith("--coverage."))) {
143
- vitestArgs.unshift("--coverage");
144
- }
145
-
146
- const hasCoverage = vitestArgs.some((a) => a === "--coverage" || a.startsWith("--coverage."));
147
-
148
- // ─── COVERAGE MODE ───────────────────────────────────────────────────────────
149
- if (hasCoverage) {
150
- const blobsDir = path.resolve(cwd, ".vitest-coverage-blobs");
151
- // Temp coverage dirs live OUTSIDE blobsDir — vitest --mergeReports errors on
152
- // any non-blob entry found inside the blobs directory.
153
- const coverageTmpBase = path.resolve(cwd, ".vitest-coverage-tmp");
154
-
155
- await Promise.all([fs.rm(blobsDir, { recursive: true, force: true }), fs.rm(coverageTmpBase, { recursive: true, force: true })]);
156
- await Promise.all([fs.mkdir(blobsDir, { recursive: true }), fs.mkdir(coverageTmpBase, { recursive: true })]);
157
-
158
- const allTestFiles = await discoverVitestFiles({ cwd, testDir, testPatterns, testListFile, testFilePattern, earlyRunPatterns });
159
-
160
- if (allTestFiles.length === 0) {
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
- }
183
- return 1;
184
- }
185
-
186
- // Separate --coverage / --coverage.* args (merge step only) from other passthroughs
187
- const extraCoverageArgs = vitestArgs.filter((a) => a !== "--coverage" && a.startsWith("--coverage."));
188
- const nonCoveragePassthrough = vitestArgs.filter((a) => a !== "--coverage" && !a.startsWith("--coverage."));
189
-
190
- const soloFiles = allTestFiles.filter((f) => earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
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;
194
-
195
- if (!coverageQuiet && !suppressFileOutput && emitTextOutput) {
196
- console.log(`\n🧪 Running ${allTestFiles.length} test files for coverage (blob + merge mode)`);
197
- console.log(`āš™ļø Workers: ${workers} (${soloFiles.length} solo first, then parallel)`);
198
- if (maxOldSpaceMb) console.log(`🧠 Heap limit: ${maxOldSpaceMb} MB`);
199
- console.log("");
200
- }
201
-
202
- const progress = showCoverageProgress ? createCoverageProgressTracker(allTestFiles.length) : noopProgressTracker;
203
- const coverageResults = [];
204
- let blobIndex = 0;
205
-
206
- /**
207
- * Run one file with the blob reporter and push its result.
208
- * @param {string} filePath
209
- * @returns {Promise<void>}
210
- */
211
- const runCoverageFile = async (filePath) => {
212
- const blobPath = path.join(blobsDir, `run-${blobIndex}.blob`);
213
- blobIndex++;
214
-
215
- if (!suppressPerFileCoverageOutput && emitTextOutput) {
216
- console.log(`\n${"=".repeat(80)}`);
217
- console.log(`ā–¶ļø ${filePath}`);
218
- console.log("=".repeat(80));
219
- }
220
-
221
- progress.onStart();
222
-
223
- const tmpCoverageDir = path.join(coverageTmpBase, `run-${blobIndex}`);
224
- const blobArgs = [
225
- ...nonCoveragePassthrough,
226
- "--coverage",
227
- `--coverage.reportsDirectory=${tmpCoverageDir}`,
228
- "--reporter=default",
229
- "--reporter=blob",
230
- `--outputFile=${blobPath}`
231
- ];
232
-
233
- const result = await runSingleFile(filePath, {
234
- ...spawnBase,
235
- maxOldSpaceMb: getHeapForFile(filePath, maxOldSpaceMb, perFileHeapOverrides),
236
- vitestArgs: blobArgs,
237
- streamOutput: !suppressPerFileCoverageOutput
238
- });
239
-
240
- coverageResults.push(result);
241
- progress.onComplete(result.code !== 0);
242
-
243
- if (!suppressPerFileCoverageOutput && emitTextOutput) {
244
- const durationSec = (result.duration / 1000).toFixed(2);
245
- if (result.code === 0) {
246
- const heapInfo = result.heapMb ? ` | ${result.heapMb} MB heap` : "";
247
- console.log(`\nāœ… PASSED (${durationSec}s${heapInfo})\n`);
248
- } else {
249
- console.log(`\nāŒ FAILED (exit code ${result.code}, ${durationSec}s)\n`);
250
- }
251
- }
252
- };
253
-
254
- // Phase 1: solo files — one at a time
255
- for (const filePath of soloFiles) {
256
- await runCoverageFile(filePath).catch((err) => {
257
- if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
258
- });
259
- }
260
-
261
- // Phase 2: parallel files with worker pool
262
- let coverageFileIndex = 0;
263
- const coverageActivePromises = new Set();
264
-
265
- while (coverageFileIndex < parallelFiles.length || coverageActivePromises.size > 0) {
266
- while (coverageFileIndex < parallelFiles.length && coverageActivePromises.size < workers) {
267
- const filePath = parallelFiles[coverageFileIndex++];
268
- const promise = runCoverageFile(filePath)
269
- .catch((err) => {
270
- if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
271
- })
272
- .finally(() => coverageActivePromises.delete(promise));
273
- coverageActivePromises.add(promise);
274
- }
275
- // c8 ignore next -- size is always >0 when the outer while body is reached
276
- if (coverageActivePromises.size > 0) await Promise.race(coverageActivePromises);
277
- }
278
-
279
- progress.finish();
280
-
281
- const blobFiles = (await fs.readdir(blobsDir).catch(() => [])).filter((f) => f.endsWith(".blob"));
282
- if (blobFiles.length === 0) {
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
- }
321
- return 1;
322
- }
323
-
324
- if (!coverageQuiet && !suppressFileOutput && emitTextOutput) {
325
- console.log(`\n${"=".repeat(80)}`);
326
- console.log(`šŸ“Š Merging ${blobFiles.length} coverage blobs into final report...`);
327
- console.log("=".repeat(80));
328
- }
329
-
330
- const { exitCode: mergeExitCode, output: mergeOutput } = await runMergeReports(blobsDir, {
331
- ...spawnBase,
332
- maxOldSpaceMb,
333
- extraCoverageArgs,
334
- quietOutput: coverageQuiet || json
335
- });
336
-
337
- if (coverageQuiet && emitTextOutput) {
338
- printMergeOutput(mergeExitCode, mergeOutput);
339
- }
340
-
341
- const coverageSummary = await printCoverageSummary(cwd, extraCoverageArgs, worstCoverageCount, { silent: json });
342
-
343
- // Clean up blobs and temp dirs
344
- await Promise.all([
345
- fs.rm(blobsDir, { recursive: true, force: true }).catch(() => {}),
346
- fs.rm(coverageTmpBase, { recursive: true, force: true }).catch(() => {})
347
- ]);
348
-
349
- const coverageFailed = coverageResults.filter((r) => r.code !== 0);
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
- }
395
-
396
- return exitCode;
397
- }
398
-
399
- // ─── STANDARD (NON-COVERAGE) MODE ────────────────────────────────────────────
400
- const testFiles = await discoverVitestFiles({ cwd, testDir, testPatterns, testListFile, testFilePattern, earlyRunPatterns });
401
-
402
- if (testFiles.length === 0) {
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
- }
425
- return 1;
426
- }
427
-
428
- const soloFiles = testFiles.filter((f) => earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
429
- const parallelFiles = testFiles.filter((f) => !earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
430
-
431
- const scriptStartTime = Date.now();
432
- const scriptStartTimeFormatted = new Date().toLocaleTimeString("en-US", { hour12: false });
433
-
434
- if (emitTextOutput && !suppressFileOutput && testPatterns.length > 0) {
435
- console.log(`\n🧪 Running ${testFiles.length} test files matching: ${testPatterns.join(", ")}`);
436
- } else if (emitTextOutput && !suppressFileOutput) {
437
- console.log(`\n🧪 Running ${testFiles.length} test files (${soloFiles.length} solo first, then parallel)`);
438
- }
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
- }
445
-
446
- const results = [...(_testResultsOverride ?? [])];
447
-
448
- /**
449
- * Run one test file, log progress, and return the result.
450
- * @param {string} filePath
451
- * @returns {Promise<import('./core/spawn.mjs').SingleFileResult>}
452
- */
453
- const runTestFile = async (filePath) => {
454
- if (!suppressFileOutput && emitTextOutput) {
455
- console.log(`\n${"=".repeat(80)}`);
456
- console.log(`ā–¶ļø ${filePath}`);
457
- console.log("=".repeat(80));
458
- }
459
-
460
- const result = await runSingleFile(filePath, {
461
- ...spawnBase,
462
- maxOldSpaceMb: getHeapForFile(filePath, maxOldSpaceMb, perFileHeapOverrides),
463
- vitestArgs,
464
- streamOutput: !suppressFileOutput && !json
465
- });
466
-
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
- }
475
- }
476
-
477
- return result;
478
- };
479
-
480
- if (!_testResultsOverride) {
481
- // Phase 1: solo files
482
- for (const filePath of soloFiles) {
483
- const result = await runTestFile(filePath).catch((err) => {
484
- if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
485
- return null;
486
- });
487
- if (result) results.push(result);
488
- }
489
-
490
- // Phase 2: parallel files with worker pool
491
- let index = 0;
492
- const activePromises = new Set();
493
-
494
- while (index < parallelFiles.length || activePromises.size > 0) {
495
- while (index < parallelFiles.length && activePromises.size < workers) {
496
- const filePath = parallelFiles[index++];
497
- const promise = runTestFile(filePath)
498
- .then((result) => results.push(result))
499
- .catch((err) => {
500
- if (emitTextOutput) console.error(`Error running ${filePath}:`, err);
501
- })
502
- .finally(() => activePromises.delete(promise));
503
- activePromises.add(promise);
504
- }
505
- // c8 ignore next -- size is always >0 when the outer while body is reached
506
- if (activePromises.size > 0) await Promise.race(activePromises);
507
- }
508
- }
509
-
510
- // ─── FINAL REPORT ────────────────────────────────────────────────────────────
511
- const totalTestFilesPass = results.reduce((s, r) => s + r.testFilesPass, 0);
512
- const totalTestFilesFail = results.reduce((s, r) => s + r.testFilesFail, 0);
513
- const totalTestsPass = results.reduce((s, r) => s + r.testsPass, 0);
514
- const totalTestsFail = results.reduce((s, r) => s + r.testsFail, 0);
515
- const totalTestsSkip = results.reduce((s, r) => s + (r.testsSkip || 0), 0);
516
- const totalDuration = results.reduce((s, r) => s + r.duration, 0);
517
- const failedFiles = results.filter((r) => r.code !== 0);
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
- }
577
-
578
- console.log("\n" + "=".repeat(80));
579
-
580
- // Top memory users
581
- if (topSummary && withHeap.length > 0) {
582
- console.log("\n" + chalk.bold("🧠 TOP MEMORY USERS"));
583
- console.log("-".repeat(80));
584
- [...withHeap]
585
- .sort((a, b) => b.heapMb - a.heapMb)
586
- .slice(0, 10)
587
- .forEach((r) => {
588
- console.log(` ${String(r.heapMb).padStart(4)} MB ${chalk.dim(r.file)}`);
589
- });
590
- }
591
-
592
- // Top duration
593
- if (topSummary && results.length > 0) {
594
- console.log("\n" + chalk.bold("ā±ļø TOP DURATION"));
595
- console.log("-".repeat(80));
596
- [...results]
597
- .sort((a, b) => b.duration - a.duration)
598
- .slice(0, 10)
599
- .forEach((r) => {
600
- const sec = (r.duration / 1000).toFixed(2);
601
- console.log(` ${(sec + "s").padStart(8)} ${chalk.dim(r.file)}`);
602
- });
603
- }
604
-
605
- // Passed files
606
- if (passedFiles.length > 0 && !suppressPassingFiles) {
607
- console.log("\n" + "=".repeat(80));
608
- console.log(chalk.bold.green("āœ“ PASSED TEST FILES"));
609
- console.log("=".repeat(80));
610
- passedFiles.forEach((r) => {
611
- const durationSec = (r.duration / 1000).toFixed(2);
612
- const statsInfo = [...(r.heapMb ? [`${r.heapMb} MB`] : []), `${durationSec}s`];
613
- const testInfo = r.testsPass > 0 ? ` - ${r.testsPass} tests` : "";
614
- console.log(chalk.green(`āœ“ ${r.file}${testInfo}`) + chalk.dim(` (${statsInfo.join(", ")})`));
615
- });
616
- }
617
-
618
- // Summary banner
619
- console.log("\n" + chalk.bold("=".repeat(80)));
620
-
621
- if (failedFiles.length > 0) {
622
- console.log(`\nāŒ ${failedFiles.length} test file(s) failed`);
623
- console.log(chalk.bold.red("\nFailed Test Files:"));
624
-
625
- failedFiles.forEach((r) => {
626
- const durationSec = (r.duration / 1000).toFixed(2);
627
- const testCounts = [
628
- ...(r.testsFail > 0 ? [chalk.red(`${r.testsFail} failed`)] : []),
629
- ...(r.testsPass > 0 ? [chalk.green(`${r.testsPass} passed`)] : []),
630
- ...(r.testsSkip > 0 ? [chalk.yellow(`${r.testsSkip} skipped`)] : [])
631
- ];
632
- const statsInfo = [...(r.heapMb ? [`${r.heapMb} MB`] : []), `${durationSec}s`];
633
- const countStr = testCounts.length > 0 ? ` (${testCounts.join(", ")})` : "";
634
- console.log(` ${chalk.red("āœ–")} ${r.file}${countStr}` + chalk.dim(` [${statsInfo.join(", ")}]`));
635
-
636
- if (showErrorDetails && r.errors.length > 0) {
637
- const deduped = deduplicateErrors(r.errors)
638
- .split("\n")
639
- .map((line) => (line.trim() ? ` ${line}` : ""))
640
- .join("\n");
641
- console.log(deduped);
642
- console.log("");
643
- }
644
- });
645
-
646
- console.log("");
647
- }
648
-
649
- console.log(chalk.bold("=".repeat(80)));
650
-
651
- // Vitest-style summary lines
652
- if (totalTestFilesFail > 0 && totalTestFilesPass > 0) {
653
- console.log(
654
- ` ${chalk.bold("Test Files")} ${chalk.red(`${totalTestFilesFail} failed`)} ${chalk.dim("|")} ${chalk.green(`${totalTestFilesPass} passed`)} ${chalk.dim(`(${totalTestFilesPass + totalTestFilesFail})`)}`
655
- );
656
- } else if (totalTestFilesFail > 0) {
657
- console.log(` ${chalk.bold("Test Files")} ${chalk.red(`${totalTestFilesFail} failed`)} ${chalk.dim(`(${totalTestFilesFail})`)}`);
658
- } else {
659
- console.log(` ${chalk.bold("Test Files")} ${chalk.green(`${totalTestFilesPass} passed`)} ${chalk.dim(`(${totalTestFilesPass})`)}`);
660
- }
661
-
662
- const totalTests = totalTestsPass + totalTestsFail + totalTestsSkip;
663
- const testsParts = [
664
- ...(totalTestsFail > 0 ? [chalk.red(`${totalTestsFail} failed`)] : []),
665
- ...(totalTestsPass > 0 ? [chalk.green(`${totalTestsPass} passed`)] : []),
666
- ...(totalTestsSkip > 0 ? [chalk.yellow(`${totalTestsSkip} skipped`)] : [])
667
- ];
668
- console.log(` ${chalk.bold("Tests")} ${testsParts.join(` ${chalk.dim("|")} `)} ${chalk.dim(`(${totalTests})`)}`);
669
- console.log(` ${chalk.bold("Start at")} ${scriptStartTimeFormatted}`);
670
-
671
- console.log(` ${chalk.bold("Duration")} ${actualDurationSec}s ${chalk.dim(`(tests ${testsDurationSec}s)`)}`);
672
-
673
- if (withHeap.length > 0) {
674
- const maxHeap = Math.max(...withHeap.map((r) => r.heapMb));
675
- const avgHeap = (withHeap.reduce((s, r) => s + r.heapMb, 0) / withHeap.length).toFixed(0);
676
- console.log(` ${chalk.bold("Heap")} max ${maxHeap} MB | avg ${avgHeap} MB | script ${scriptHeapMb} MB (RSS ${scriptRssMb} MB)`);
677
- } else {
678
- console.log(` ${chalk.bold("Heap")} script ${scriptHeapMb} MB (RSS ${scriptRssMb} MB)`);
679
- }
680
-
681
- if (failedFiles.length > 0) return 1;
682
-
683
- console.log(`\nāœ… All ${passedFiles.length} test files passed\n`);
684
- return 0;
685
- }
686
-
687
- // Re-export sub-module utilities so callers can use them without deep imports
688
- export { resolveBin, resolveVitestConfig } from "./utils/resolve.mjs";
689
- export { discoverVitestFiles, sortWithPriority, discoverFilesInDir } from "./core/discover.mjs";
690
- export { parseVitestOutput, deduplicateErrors } from "./core/parse.mjs";
691
- export { runSingleFile, runVitestDirect, runMergeReports } from "./core/spawn.mjs";
692
- export { createCoverageProgressTracker, noopProgressTracker } from "./core/progress.mjs";
693
- export { printCoverageSummary, printMergeOutput, printQuietCoverageFailureDetails } from "./core/report.mjs";
694
- export { formatDuration } from "./utils/duration.mjs";
695
- export { stripAnsi, colourPct } from "./utils/ansi.mjs";
696
- export { buildNodeOptions } from "./utils/env.mjs";