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/README.md +342 -0
- package/bin/vitest-runner.mjs +86 -0
- package/index.cjs +17 -0
- package/index.mjs +5 -0
- package/package.json +60 -0
- package/src/cli/args.mjs +110 -0
- package/src/cli/help.mjs +80 -0
- package/src/core/discover.mjs +167 -0
- package/src/core/parse.mjs +167 -0
- package/src/core/progress.mjs +164 -0
- package/src/core/report.mjs +211 -0
- package/src/core/spawn.mjs +219 -0
- package/src/runner.mjs +504 -0
- package/src/utils/ansi.mjs +32 -0
- package/src/utils/duration.mjs +25 -0
- package/src/utils/env.mjs +38 -0
- package/src/utils/resolve.mjs +86 -0
- package/types/index.d.mts +1 -0
- package/types/src/cli/args.d.mts +56 -0
- package/types/src/cli/help.d.mts +7 -0
- package/types/src/core/discover.d.mts +75 -0
- package/types/src/core/parse.d.mts +73 -0
- package/types/src/core/progress.d.mts +30 -0
- package/types/src/core/report.d.mts +47 -0
- package/types/src/core/spawn.d.mts +114 -0
- package/types/src/runner.d.mts +97 -0
- package/types/src/utils/ansi.d.mts +22 -0
- package/types/src/utils/duration.d.mts +13 -0
- package/types/src/utils/env.d.mts +25 -0
- package/types/src/utils/resolve.d.mts +32 -0
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";
|