vitest-runner 1.0.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/src/runner.mjs ADDED
@@ -0,0 +1,504 @@
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/
15
+ * import { run } from 'vitest-runner';
16
+ * const code = await run({ cwd: process.cwd(), testDir: 'src/tests' });
17
+ * process.exit(code);
18
+ *
19
+ * @example
20
+ * // Run a subset with custom heap limit
21
+ * const code = await run({
22
+ * cwd: process.cwd(),
23
+ * testDir: 'src/tests',
24
+ * testPatterns: ['src/tests/config'],
25
+ * maxOldSpaceMb: 4096,
26
+ * });
27
+ */
28
+
29
+ import fs from "node:fs/promises";
30
+ import path from "node:path";
31
+ import chalk from "chalk";
32
+
33
+ import { resolveBin, resolveVitestConfig } from "./utils/resolve.mjs";
34
+ import { discoverVitestFiles } from "./core/discover.mjs";
35
+ import { runSingleFile, runMergeReports } from "./core/spawn.mjs";
36
+ import { deduplicateErrors } from "./core/parse.mjs";
37
+ import { createCoverageProgressTracker, noopProgressTracker } from "./core/progress.mjs";
38
+ import { printQuietCoverageFailureDetails, printMergeOutput, printCoverageSummary } from "./core/report.mjs";
39
+ import { formatDuration } from "./utils/duration.mjs";
40
+ import { colourPct } from "./utils/ansi.mjs";
41
+
42
+ /**
43
+ * @typedef {Object} PerFileHeapOverride
44
+ * @property {string} pattern - Substring matched against the normalised file path.
45
+ * @property {number} heapMb - Minimum heap ceiling in MB for matching files.
46
+ */
47
+
48
+ /**
49
+ * @typedef {Object} RunOptions
50
+ * @property {string} cwd - Absolute project root directory.
51
+ * @property {string} [testDir] - Directory to scan for test files (relative or absolute; defaults to `cwd`).
52
+ * @property {string} [vitestConfig] - Explicit vitest config path; auto-detected from `cwd` when omitted.
53
+ * @property {string[]} [testPatterns=[]] - File / folder patterns to filter (empty = all files in `testDir`).
54
+ * @property {string} [testListFile] - Path to a JSON array of test file paths; when set, scanning is skipped.
55
+ * @property {RegExp} [testFilePattern] - Regex matched against file names when scanning (default: `*.test.vitest.{js,mjs,cjs}`).
56
+ * @property {string[]} [vitestArgs=[]] - Extra CLI args forwarded verbatim to every vitest invocation.
57
+ * @property {boolean} [showErrorDetails=true] - Print inline error blocks under each failed file.
58
+ * @property {boolean} [coverageQuiet=false] - Suppress per-file output; show only progress bar + summaries.
59
+ * @property {number} [workers=4] - Maximum number of parallel worker slots.
60
+ * @property {number} [worstCoverageCount=10] - Rows in the worst-coverage table (0 = disable).
61
+ * @property {number} [maxOldSpaceMb] - Global `--max-old-space-size` ceiling; per-file overrides may raise it.
62
+ * @property {string[]} [earlyRunPatterns=[]] - Path substrings — matching files run solo before the worker pool.
63
+ * @property {PerFileHeapOverride[]} [perFileHeapOverrides=[]] - Per-file minimum heap overrides.
64
+ * @property {string[]} [conditions=[]] - Additional `--conditions` Node flags forwarded to children.
65
+ * @property {string} [nodeEnv='development'] - Value for `NODE_ENV` in child processes.
66
+ * @property {object[] | null} [_testResultsOverride=null] - @internal Inject pre-built results to bypass discovery and spawn (for testing final-report render paths).
67
+ */
68
+
69
+ /**
70
+ * Return the effective heap ceiling (MB) for a given test file.
71
+ *
72
+ * Takes the maximum of the global limit and the first matching per-file override.
73
+ * Returns `undefined` when neither source provides a value.
74
+ *
75
+ * @param {string} filePath - Test file path.
76
+ * @param {number|undefined} globalMaxMb - Global ceiling from options.
77
+ * @param {PerFileHeapOverride[]} overrides - Per-file override table.
78
+ * @returns {number|undefined}
79
+ */
80
+ function getHeapForFile(filePath, globalMaxMb, overrides) {
81
+ const normalized = filePath.replace(/\\/g, "/");
82
+ let perFileMb;
83
+ for (const { pattern, heapMb } of overrides) {
84
+ if (normalized.includes(pattern)) {
85
+ perFileMb = heapMb;
86
+ break;
87
+ }
88
+ }
89
+ if (perFileMb === undefined && globalMaxMb === undefined) return undefined;
90
+ if (perFileMb === undefined) return globalMaxMb;
91
+ if (globalMaxMb === undefined) return perFileMb;
92
+ return Math.max(perFileMb, globalMaxMb);
93
+ }
94
+
95
+ /**
96
+ * Run all discovered Vitest test files sequentially (with a configurable worker
97
+ * pool for the non-solo phase) and return an exit code.
98
+ *
99
+ * @param {RunOptions} opts
100
+ * @returns {Promise<number>} `0` on full pass, `1` on any failure.
101
+ */
102
+ export async function run(opts) {
103
+ const {
104
+ cwd,
105
+ testDir,
106
+ testPatterns = [],
107
+ testListFile,
108
+ testFilePattern,
109
+ vitestArgs: rawVitestArgs = [],
110
+ showErrorDetails = true,
111
+ coverageQuiet = false,
112
+ workers = parseInt(process.env.VITEST_WORKERS ?? "4", 10),
113
+ worstCoverageCount = 10,
114
+ earlyRunPatterns = [],
115
+ perFileHeapOverrides = [],
116
+ conditions = [],
117
+ nodeEnv = "development",
118
+ /** @internal Inject pre-built results to bypass discovery and spawn (for testing final-report render paths). */
119
+ _testResultsOverride = null
120
+ } = opts;
121
+
122
+ const maxOldSpaceMb = opts.maxOldSpaceMb ?? (process.env.VITEST_HEAP_MB ? parseInt(process.env.VITEST_HEAP_MB, 10) : undefined);
123
+
124
+ // Resolve vitest binary and config
125
+ const vitestBin = resolveBin(cwd, "vitest", "vitest");
126
+ const vitestConfig = await resolveVitestConfig(cwd, opts.vitestConfig);
127
+
128
+ /** Common spawn options shared across all child invocations. */
129
+ const spawnBase = { cwd, vitestBin, vitestConfig, conditions, nodeEnv };
130
+
131
+ const vitestArgs = [...rawVitestArgs];
132
+
133
+ // --coverage-quiet implies --coverage
134
+ if (coverageQuiet && !vitestArgs.some((a) => a === "--coverage" || a.startsWith("--coverage."))) {
135
+ vitestArgs.unshift("--coverage");
136
+ }
137
+
138
+ const hasCoverage = vitestArgs.some((a) => a === "--coverage" || a.startsWith("--coverage."));
139
+
140
+ // ─── COVERAGE MODE ───────────────────────────────────────────────────────────
141
+ if (hasCoverage) {
142
+ const blobsDir = path.resolve(cwd, ".vitest-coverage-blobs");
143
+ // Temp coverage dirs live OUTSIDE blobsDir — vitest --mergeReports errors on
144
+ // any non-blob entry found inside the blobs directory.
145
+ const coverageTmpBase = path.resolve(cwd, ".vitest-coverage-tmp");
146
+
147
+ await Promise.all([fs.rm(blobsDir, { recursive: true, force: true }), fs.rm(coverageTmpBase, { recursive: true, force: true })]);
148
+ await Promise.all([fs.mkdir(blobsDir, { recursive: true }), fs.mkdir(coverageTmpBase, { recursive: true })]);
149
+
150
+ const allTestFiles = await discoverVitestFiles({ cwd, testDir, testPatterns, testListFile, testFilePattern, earlyRunPatterns });
151
+
152
+ if (allTestFiles.length === 0) {
153
+ console.log(
154
+ testPatterns.length > 0 ? `❌ No Vitest test files found matching: ${testPatterns.join(", ")}` : "❌ No Vitest test files found"
155
+ );
156
+ return 1;
157
+ }
158
+
159
+ // Separate --coverage / --coverage.* args (merge step only) from other passthroughs
160
+ const extraCoverageArgs = vitestArgs.filter((a) => a !== "--coverage" && a.startsWith("--coverage."));
161
+ const nonCoveragePassthrough = vitestArgs.filter((a) => a !== "--coverage" && !a.startsWith("--coverage."));
162
+
163
+ const soloFiles = allTestFiles.filter((f) => earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
164
+ const parallelFiles = allTestFiles.filter((f) => !earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
165
+
166
+ if (!coverageQuiet) {
167
+ console.log(`\n🧪 Running ${allTestFiles.length} test files for coverage (blob + merge mode)`);
168
+ console.log(`⚙️ Workers: ${workers} (${soloFiles.length} solo first, then parallel)`);
169
+ if (maxOldSpaceMb) console.log(`🧠 Heap limit: ${maxOldSpaceMb} MB`);
170
+ console.log("");
171
+ }
172
+
173
+ const progress = coverageQuiet ? createCoverageProgressTracker(allTestFiles.length) : noopProgressTracker;
174
+ const coverageResults = [];
175
+ let blobIndex = 0;
176
+
177
+ /**
178
+ * Run one file with the blob reporter and push its result.
179
+ * @param {string} filePath
180
+ * @returns {Promise<void>}
181
+ */
182
+ const runCoverageFile = async (filePath) => {
183
+ const blobPath = path.join(blobsDir, `run-${blobIndex}.blob`);
184
+ blobIndex++;
185
+
186
+ if (!coverageQuiet) {
187
+ console.log(`\n${"=".repeat(80)}`);
188
+ console.log(`▶️ ${filePath}`);
189
+ console.log("=".repeat(80));
190
+ }
191
+
192
+ progress.onStart();
193
+
194
+ const tmpCoverageDir = path.join(coverageTmpBase, `run-${blobIndex}`);
195
+ const blobArgs = [
196
+ ...nonCoveragePassthrough,
197
+ "--coverage",
198
+ `--coverage.reportsDirectory=${tmpCoverageDir}`,
199
+ "--reporter=default",
200
+ "--reporter=blob",
201
+ `--outputFile=${blobPath}`
202
+ ];
203
+
204
+ const result = await runSingleFile(filePath, {
205
+ ...spawnBase,
206
+ maxOldSpaceMb: getHeapForFile(filePath, maxOldSpaceMb, perFileHeapOverrides),
207
+ vitestArgs: blobArgs,
208
+ streamOutput: !coverageQuiet
209
+ });
210
+
211
+ coverageResults.push(result);
212
+ progress.onComplete(result.code !== 0);
213
+
214
+ if (!coverageQuiet) {
215
+ const durationSec = (result.duration / 1000).toFixed(2);
216
+ if (result.code === 0) {
217
+ const heapInfo = result.heapMb ? ` | ${result.heapMb} MB heap` : "";
218
+ console.log(`\n✅ PASSED (${durationSec}s${heapInfo})\n`);
219
+ } else {
220
+ console.log(`\n❌ FAILED (exit code ${result.code}, ${durationSec}s)\n`);
221
+ }
222
+ }
223
+ };
224
+
225
+ // Phase 1: solo files — one at a time
226
+ for (const filePath of soloFiles) {
227
+ await runCoverageFile(filePath).catch((err) => console.error(`Error running ${filePath}:`, err));
228
+ }
229
+
230
+ // Phase 2: parallel files with worker pool
231
+ let coverageFileIndex = 0;
232
+ const coverageActivePromises = new Set();
233
+
234
+ while (coverageFileIndex < parallelFiles.length || coverageActivePromises.size > 0) {
235
+ while (coverageFileIndex < parallelFiles.length && coverageActivePromises.size < workers) {
236
+ const filePath = parallelFiles[coverageFileIndex++];
237
+ const promise = runCoverageFile(filePath)
238
+ .catch((err) => console.error(`Error running ${filePath}:`, err))
239
+ .finally(() => coverageActivePromises.delete(promise));
240
+ coverageActivePromises.add(promise);
241
+ }
242
+ // c8 ignore next -- size is always >0 when the outer while body is reached
243
+ if (coverageActivePromises.size > 0) await Promise.race(coverageActivePromises);
244
+ }
245
+
246
+ progress.finish();
247
+
248
+ const blobFiles = (await fs.readdir(blobsDir).catch(() => [])).filter((f) => f.endsWith(".blob"));
249
+ if (blobFiles.length === 0) {
250
+ console.error("❌ No coverage blobs were generated — coverage report cannot be produced");
251
+ return 1;
252
+ }
253
+
254
+ if (!coverageQuiet) {
255
+ console.log(`\n${"=".repeat(80)}`);
256
+ console.log(`📊 Merging ${blobFiles.length} coverage blobs into final report...`);
257
+ console.log("=".repeat(80));
258
+ }
259
+
260
+ const { exitCode: mergeExitCode, output: mergeOutput } = await runMergeReports(blobsDir, {
261
+ ...spawnBase,
262
+ maxOldSpaceMb,
263
+ extraCoverageArgs,
264
+ quietOutput: coverageQuiet
265
+ });
266
+
267
+ if (coverageQuiet) {
268
+ printMergeOutput(mergeExitCode, mergeOutput);
269
+ }
270
+
271
+ await printCoverageSummary(cwd, extraCoverageArgs, worstCoverageCount);
272
+
273
+ // Clean up blobs and temp dirs
274
+ await Promise.all([
275
+ fs.rm(blobsDir, { recursive: true, force: true }).catch(() => {}),
276
+ fs.rm(coverageTmpBase, { recursive: true, force: true }).catch(() => {})
277
+ ]);
278
+
279
+ const coverageFailed = coverageResults.filter((r) => r.code !== 0);
280
+ if (coverageQuiet) printQuietCoverageFailureDetails(coverageFailed);
281
+
282
+ return coverageFailed.length > 0 ? 1 : mergeExitCode;
283
+ }
284
+
285
+ // ─── STANDARD (NON-COVERAGE) MODE ────────────────────────────────────────────
286
+ const testFiles = await discoverVitestFiles({ cwd, testDir, testPatterns, testListFile, testFilePattern, earlyRunPatterns });
287
+
288
+ if (testFiles.length === 0) {
289
+ console.log(
290
+ testPatterns.length > 0 ? `❌ No Vitest test files found matching: ${testPatterns.join(", ")}` : "❌ No Vitest test files found"
291
+ );
292
+ return 1;
293
+ }
294
+
295
+ const soloFiles = testFiles.filter((f) => earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
296
+ const parallelFiles = testFiles.filter((f) => !earlyRunPatterns.some((p) => f.replace(/\\/g, "/").includes(p)));
297
+
298
+ const scriptStartTime = Date.now();
299
+ const scriptStartTimeFormatted = new Date().toLocaleTimeString("en-US", { hour12: false });
300
+
301
+ if (testPatterns.length > 0) {
302
+ console.log(`\n🧪 Running ${testFiles.length} test files matching: ${testPatterns.join(", ")}`);
303
+ } else {
304
+ console.log(`\n🧪 Running ${testFiles.length} test files (${soloFiles.length} solo first, then parallel)`);
305
+ }
306
+ console.log(`⚙️ Workers: ${workers}`);
307
+ if (maxOldSpaceMb) console.log(`🧠 Heap limit: ${maxOldSpaceMb} MB`);
308
+ if (vitestArgs.length > 0) console.log(`🔧 Vitest args: ${vitestArgs.join(" ")}`);
309
+ console.log("");
310
+
311
+ const results = [...(_testResultsOverride ?? [])];
312
+
313
+ /**
314
+ * Run one test file, log progress, and return the result.
315
+ * @param {string} filePath
316
+ * @returns {Promise<import('./core/spawn.mjs').SingleFileResult>}
317
+ */
318
+ const runTestFile = async (filePath) => {
319
+ console.log(`\n${"=".repeat(80)}`);
320
+ console.log(`▶️ ${filePath}`);
321
+ console.log("=".repeat(80));
322
+
323
+ const result = await runSingleFile(filePath, {
324
+ ...spawnBase,
325
+ maxOldSpaceMb: getHeapForFile(filePath, maxOldSpaceMb, perFileHeapOverrides),
326
+ vitestArgs
327
+ });
328
+
329
+ const durationSec = (result.duration / 1000).toFixed(2);
330
+ if (result.code === 0) {
331
+ const heapInfo = result.heapMb ? ` | ${result.heapMb} MB heap` : "";
332
+ console.log(`\n✅ PASSED (${durationSec}s${heapInfo})\n`);
333
+ } else {
334
+ console.log(`\n❌ FAILED (exit code ${result.code}, ${durationSec}s)\n`);
335
+ }
336
+
337
+ return result;
338
+ };
339
+
340
+ if (!_testResultsOverride) {
341
+ // Phase 1: solo files
342
+ for (const filePath of soloFiles) {
343
+ const result = await runTestFile(filePath).catch((err) => {
344
+ console.error(`Error running ${filePath}:`, err);
345
+ return null;
346
+ });
347
+ if (result) results.push(result);
348
+ }
349
+
350
+ // Phase 2: parallel files with worker pool
351
+ let index = 0;
352
+ const activePromises = new Set();
353
+
354
+ while (index < parallelFiles.length || activePromises.size > 0) {
355
+ while (index < parallelFiles.length && activePromises.size < workers) {
356
+ const filePath = parallelFiles[index++];
357
+ const promise = runTestFile(filePath)
358
+ .then((result) => results.push(result))
359
+ .catch((err) => console.error(`Error running ${filePath}:`, err))
360
+ .finally(() => activePromises.delete(promise));
361
+ activePromises.add(promise);
362
+ }
363
+ // c8 ignore next -- size is always >0 when the outer while body is reached
364
+ if (activePromises.size > 0) await Promise.race(activePromises);
365
+ }
366
+ }
367
+
368
+ // ─── FINAL REPORT ────────────────────────────────────────────────────────────
369
+ const totalTestFilesPass = results.reduce((s, r) => s + r.testFilesPass, 0);
370
+ const totalTestFilesFail = results.reduce((s, r) => s + r.testFilesFail, 0);
371
+ const totalTestsPass = results.reduce((s, r) => s + r.testsPass, 0);
372
+ const totalTestsFail = results.reduce((s, r) => s + r.testsFail, 0);
373
+ const totalTestsSkip = results.reduce((s, r) => s + (r.testsSkip || 0), 0);
374
+ const totalDuration = results.reduce((s, r) => s + r.duration, 0);
375
+ const failedFiles = results.filter((r) => r.code !== 0);
376
+ const passedFiles = results.filter((r) => r.code === 0);
377
+
378
+ console.log("\n" + "=".repeat(80));
379
+
380
+ // Top memory users
381
+ const withHeap = results.filter((r) => r.heapMb !== null);
382
+ if (withHeap.length > 0) {
383
+ console.log("\n" + chalk.bold("🧠 TOP MEMORY USERS"));
384
+ console.log("-".repeat(80));
385
+ [...withHeap]
386
+ .sort((a, b) => b.heapMb - a.heapMb)
387
+ .slice(0, 10)
388
+ .forEach((r) => {
389
+ console.log(` ${String(r.heapMb).padStart(4)} MB ${chalk.dim(r.file)}`);
390
+ });
391
+ }
392
+
393
+ // Top duration
394
+ if (results.length > 0) {
395
+ console.log("\n" + chalk.bold("⏱️ TOP DURATION"));
396
+ console.log("-".repeat(80));
397
+ [...results]
398
+ .sort((a, b) => b.duration - a.duration)
399
+ .slice(0, 10)
400
+ .forEach((r) => {
401
+ const sec = (r.duration / 1000).toFixed(2);
402
+ console.log(` ${(sec + "s").padStart(8)} ${chalk.dim(r.file)}`);
403
+ });
404
+ }
405
+
406
+ // Passed files
407
+ if (passedFiles.length > 0) {
408
+ console.log("\n" + "=".repeat(80));
409
+ console.log(chalk.bold.green("✓ PASSED TEST FILES"));
410
+ console.log("=".repeat(80));
411
+ passedFiles.forEach((r) => {
412
+ const durationSec = (r.duration / 1000).toFixed(2);
413
+ const statsInfo = [...(r.heapMb ? [`${r.heapMb} MB`] : []), `${durationSec}s`];
414
+ const testInfo = r.testsPass > 0 ? ` - ${r.testsPass} tests` : "";
415
+ console.log(chalk.green(`✓ ${r.file}${testInfo}`) + chalk.dim(` (${statsInfo.join(", ")})`));
416
+ });
417
+ }
418
+
419
+ // Summary banner
420
+ console.log("\n" + chalk.bold("=".repeat(80)));
421
+
422
+ if (failedFiles.length > 0) {
423
+ console.log(`\n❌ ${failedFiles.length} test file(s) failed`);
424
+ console.log(chalk.bold.red("\nFailed Test Files:"));
425
+
426
+ failedFiles.forEach((r) => {
427
+ const durationSec = (r.duration / 1000).toFixed(2);
428
+ const testCounts = [
429
+ ...(r.testsFail > 0 ? [chalk.red(`${r.testsFail} failed`)] : []),
430
+ ...(r.testsPass > 0 ? [chalk.green(`${r.testsPass} passed`)] : []),
431
+ ...(r.testsSkip > 0 ? [chalk.yellow(`${r.testsSkip} skipped`)] : [])
432
+ ];
433
+ const statsInfo = [...(r.heapMb ? [`${r.heapMb} MB`] : []), `${durationSec}s`];
434
+ const countStr = testCounts.length > 0 ? ` (${testCounts.join(", ")})` : "";
435
+ console.log(` ${chalk.red("✖")} ${r.file}${countStr}` + chalk.dim(` [${statsInfo.join(", ")}]`));
436
+
437
+ if (showErrorDetails && r.errors.length > 0) {
438
+ const deduped = deduplicateErrors(r.errors)
439
+ .split("\n")
440
+ .map((line) => (line.trim() ? ` ${line}` : ""))
441
+ .join("\n");
442
+ console.log(deduped);
443
+ console.log("");
444
+ }
445
+ });
446
+
447
+ console.log("");
448
+ }
449
+
450
+ console.log(chalk.bold("=".repeat(80)));
451
+
452
+ // Vitest-style summary lines
453
+ if (totalTestFilesFail > 0 && totalTestFilesPass > 0) {
454
+ console.log(
455
+ ` ${chalk.bold("Test Files")} ${chalk.red(`${totalTestFilesFail} failed`)} ${chalk.dim("|")} ${chalk.green(`${totalTestFilesPass} passed`)} ${chalk.dim(`(${totalTestFilesPass + totalTestFilesFail})`)}`
456
+ );
457
+ } else if (totalTestFilesFail > 0) {
458
+ console.log(` ${chalk.bold("Test Files")} ${chalk.red(`${totalTestFilesFail} failed`)} ${chalk.dim(`(${totalTestFilesFail})`)}`);
459
+ } else {
460
+ console.log(` ${chalk.bold("Test Files")} ${chalk.green(`${totalTestFilesPass} passed`)} ${chalk.dim(`(${totalTestFilesPass})`)}`);
461
+ }
462
+
463
+ const totalTests = totalTestsPass + totalTestsFail + totalTestsSkip;
464
+ const testsParts = [
465
+ ...(totalTestsFail > 0 ? [chalk.red(`${totalTestsFail} failed`)] : []),
466
+ ...(totalTestsPass > 0 ? [chalk.green(`${totalTestsPass} passed`)] : []),
467
+ ...(totalTestsSkip > 0 ? [chalk.yellow(`${totalTestsSkip} skipped`)] : [])
468
+ ];
469
+ console.log(` ${chalk.bold("Tests")} ${testsParts.join(` ${chalk.dim("|")} `)} ${chalk.dim(`(${totalTests})`)}`);
470
+ console.log(` ${chalk.bold("Start at")} ${scriptStartTimeFormatted}`);
471
+
472
+ const actualDurationSec = ((Date.now() - scriptStartTime) / 1000).toFixed(2);
473
+ const testsDurationSec = (totalDuration / 1000).toFixed(2);
474
+ console.log(` ${chalk.bold("Duration")} ${actualDurationSec}s ${chalk.dim(`(tests ${testsDurationSec}s)`)}`);
475
+
476
+ const scriptMemory = process.memoryUsage();
477
+ const scriptHeapMb = Math.round(scriptMemory.heapUsed / 1024 / 1024);
478
+ const scriptRssMb = Math.round(scriptMemory.rss / 1024 / 1024);
479
+ if (withHeap.length > 0) {
480
+ const maxHeap = Math.max(...withHeap.map((r) => r.heapMb));
481
+ const avgHeap = (withHeap.reduce((s, r) => s + r.heapMb, 0) / withHeap.length).toFixed(0);
482
+ console.log(` ${chalk.bold("Heap")} max ${maxHeap} MB | avg ${avgHeap} MB | script ${scriptHeapMb} MB (RSS ${scriptRssMb} MB)`);
483
+ } else {
484
+ console.log(` ${chalk.bold("Heap")} script ${scriptHeapMb} MB (RSS ${scriptRssMb} MB)`);
485
+ }
486
+
487
+ if (failedFiles.length > 0) {
488
+ return 1;
489
+ }
490
+
491
+ console.log(`\n✅ All ${passedFiles.length} test files passed\n`);
492
+ return 0;
493
+ }
494
+
495
+ // Re-export sub-module utilities so callers can use them without deep imports
496
+ export { resolveBin, resolveVitestConfig } from "./utils/resolve.mjs";
497
+ export { discoverVitestFiles, sortWithPriority, discoverFilesInDir } from "./core/discover.mjs";
498
+ export { parseVitestOutput, deduplicateErrors } from "./core/parse.mjs";
499
+ export { runSingleFile, runVitestDirect, runMergeReports } from "./core/spawn.mjs";
500
+ export { createCoverageProgressTracker, noopProgressTracker } from "./core/progress.mjs";
501
+ export { printCoverageSummary, printMergeOutput, printQuietCoverageFailureDetails } from "./core/report.mjs";
502
+ export { formatDuration } from "./utils/duration.mjs";
503
+ export { stripAnsi, colourPct } from "./utils/ansi.mjs";
504
+ export { buildNodeOptions } from "./utils/env.mjs";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @fileoverview ANSI escape-code helpers.
3
+ * @module vitest-runner/src/utils/ansi
4
+ */
5
+
6
+ /**
7
+ * Strip ANSI colour/style escape codes from a string.
8
+ * @param {string} text - Input text that may contain ANSI codes.
9
+ * @returns {string} Clean text without escape codes.
10
+ * @example
11
+ * stripAnsi('\x1B[32mhello\x1B[0m'); // 'hello'
12
+ */
13
+ export function stripAnsi(text) {
14
+ // eslint-disable-next-line no-control-regex
15
+ return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
16
+ }
17
+
18
+ /**
19
+ * Colour-code a coverage percentage value using chalk.
20
+ * ≥ 80 % → green, ≥ 50 % → yellow, < 50 % → red.
21
+ * @param {import('chalk').ChalkInstance} chalk - Chalk instance supplied by the caller.
22
+ * @param {number} pct - Coverage percentage 0–100.
23
+ * @returns {string} Chalk-coloured, right-aligned percentage string.
24
+ * @example
25
+ * colourPct(chalk, 75.5); // yellow ' 75.50'
26
+ */
27
+ export function colourPct(chalk, pct) {
28
+ const str = pct.toFixed(2).padStart(6);
29
+ if (pct >= 80) return chalk.green(str);
30
+ if (pct >= 50) return chalk.yellow(str);
31
+ return chalk.red(str);
32
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @fileoverview Duration formatting utilities.
3
+ * @module vitest-runner/src/utils/duration
4
+ */
5
+
6
+ /**
7
+ * Format a millisecond duration as a human-readable `m:ss` or `h:mm:ss` string.
8
+ * @param {number} ms - Duration in milliseconds.
9
+ * @returns {string} Formatted duration string.
10
+ * @example
11
+ * formatDuration(65000); // '1:05'
12
+ * formatDuration(3661000); // '1:01:01'
13
+ */
14
+ export function formatDuration(ms) {
15
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
16
+ const hours = Math.floor(totalSeconds / 3600);
17
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
18
+ const seconds = totalSeconds % 60;
19
+
20
+ if (hours > 0) {
21
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
22
+ }
23
+
24
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
25
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @fileoverview NODE_OPTIONS / environment helpers.
3
+ * @module vitest-runner/src/utils/env
4
+ */
5
+
6
+ /**
7
+ * Build a `NODE_OPTIONS` string suitable for passing to child vitest processes.
8
+ *
9
+ * Merges any existing base value with optional `--max-old-space-size` and
10
+ * `--conditions` flags. Each flag is only added once even if called multiple
11
+ * times with the same value.
12
+ *
13
+ * @param {Object} opts
14
+ * @param {number|undefined} opts.maxOldSpaceMb - Heap ceiling to add (omit or `undefined` to skip).
15
+ * @param {string[]} [opts.conditions=[]] - Additional `--conditions` values to append.
16
+ * @param {string} [opts.base] - Starting `NODE_OPTIONS` string; defaults to `process.env.NODE_OPTIONS`.
17
+ * @returns {string} Combined `NODE_OPTIONS` string (trimmed).
18
+ * @example
19
+ * buildNodeOptions({ maxOldSpaceMb: 4096, conditions: ['my-dev'] });
20
+ * // '--conditions=my-dev --max-old-space-size=4096'
21
+ */
22
+ export function buildNodeOptions({ maxOldSpaceMb, conditions = [], base = process.env.NODE_OPTIONS ?? "" }) {
23
+ let opts = base;
24
+
25
+ for (const cond of conditions) {
26
+ const flag = `--conditions=${cond}`;
27
+ if (!opts.includes(flag)) {
28
+ opts = opts ? `${opts} ${flag}` : flag;
29
+ }
30
+ }
31
+
32
+ if (maxOldSpaceMb && !opts.includes("--max-old-space-size")) {
33
+ const flag = `--max-old-space-size=${maxOldSpaceMb}`;
34
+ opts = opts ? `${opts} ${flag}` : flag;
35
+ }
36
+
37
+ return opts.trim();
38
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @fileoverview Package-bin and vitest-config resolution helpers.
3
+ * @module vitest-runner/src/utils/resolve
4
+ */
5
+
6
+ import { createRequire } from "node:module";
7
+ import path from "node:path";
8
+ import fs from "node:fs/promises";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ /**
12
+ * Resolve the absolute path of a binary shipped with an npm package.
13
+ *
14
+ * The `require` instance is rooted at `cwd` so the package is found in the
15
+ * consumer project's `node_modules`, not the runner's own.
16
+ *
17
+ * @param {string} cwd - Consumer project root to search from.
18
+ * @param {string} pkgName - The npm package name (e.g. `'vitest'`).
19
+ * @param {string} [binName=pkgName] - The bin alias key to look up.
20
+ * @returns {string} Absolute path to the binary entry point.
21
+ * @throws {Error} When the bin entry is not found in the package manifest.
22
+ * @example
23
+ * resolveBin('/my/project', 'vitest');
24
+ * // '/my/project/node_modules/vitest/dist/cli.mjs'
25
+ */
26
+ export function resolveBin(cwd, pkgName, binName = pkgName) {
27
+ const require = createRequire(pathToFileURL(path.join(cwd, "package.json")));
28
+ const pkgJsonPath = require.resolve(`${pkgName}/package.json`);
29
+ const pkg = require(pkgJsonPath);
30
+
31
+ const rel = pkg.bin?.[binName] ?? pkg.bin;
32
+ if (!rel) throw new Error(`No bin "${binName}" found in ${pkgName}/package.json`);
33
+
34
+ return path.join(path.dirname(pkgJsonPath), rel);
35
+ }
36
+
37
+ /**
38
+ * Ordered list of default vitest / vite config file names.
39
+ * The search walks this list in declaration order, relative to `cwd`.
40
+ * @type {readonly string[]}
41
+ */
42
+ const DEFAULT_CONFIG_NAMES = Object.freeze([
43
+ "vitest.config.ts",
44
+ "vitest.config.mts",
45
+ "vitest.config.cts",
46
+ "vitest.config.mjs",
47
+ "vitest.config.js",
48
+ "vitest.config.cjs",
49
+ "vite.config.ts",
50
+ "vite.config.mts",
51
+ "vite.config.mjs",
52
+ "vite.config.js"
53
+ ]);
54
+
55
+ /**
56
+ * Resolve the vitest config path to use for a project.
57
+ *
58
+ * - If `configPath` is provided it is returned as-is (resolved absolute if relative).
59
+ * - Otherwise the function walks {@link DEFAULT_CONFIG_NAMES} relative to `cwd`
60
+ * and returns the first file that exists.
61
+ * - Returns `undefined` when nothing is found, letting vitest use its own defaults.
62
+ *
63
+ * @param {string} cwd - Project root directory.
64
+ * @param {string|undefined} configPath - Explicit config path, or `undefined` for auto-detect.
65
+ * @returns {Promise<string|undefined>} Resolved absolute config path, or `undefined`.
66
+ * @example
67
+ * const cfg = await resolveVitestConfig('/my/project', undefined);
68
+ * // '/my/project/vitest.config.ts' (if that file exists)
69
+ */
70
+ export async function resolveVitestConfig(cwd, configPath) {
71
+ if (configPath) {
72
+ return path.isAbsolute(configPath) ? configPath : path.resolve(cwd, configPath);
73
+ }
74
+
75
+ for (const name of DEFAULT_CONFIG_NAMES) {
76
+ const candidate = path.join(cwd, name);
77
+ try {
78
+ await fs.access(candidate);
79
+ return candidate;
80
+ } catch {
81
+ // not found — try next
82
+ }
83
+ }
84
+
85
+ return undefined;
86
+ }
@@ -0,0 +1 @@
1
+ export * from "./src/runner.mjs";