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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @fileoverview Result reporting and coverage summary utilities.
3
+ * @module vitest-runner/src/core/report
4
+ */
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import chalk from "chalk";
9
+ import { stripAnsi, colourPct } from "../utils/ansi.mjs";
10
+
11
+ /**
12
+ * Print verbose output for files that failed during a quiet coverage run.
13
+ *
14
+ * @param {Array<{file: string, code: number, rawOutput: string}>} failedResults
15
+ * @returns {void}
16
+ * @example
17
+ * printQuietCoverageFailureDetails(failedResults);
18
+ */
19
+ export function printQuietCoverageFailureDetails(failedResults) {
20
+ if (failedResults.length === 0) return;
21
+
22
+ console.log(`\n${"=".repeat(80)}`);
23
+ console.log(chalk.bold.red("✖ FAILED TEST FILES (VERBOSE OUTPUT)"));
24
+ console.log("=".repeat(80));
25
+
26
+ for (const r of failedResults) {
27
+ console.log(`\n${chalk.red("✖")} ${chalk.red(r.file)} ${chalk.dim(`(exit ${r.code})`)}`);
28
+ if (r.rawOutput?.trim()) {
29
+ console.log(r.rawOutput.trimEnd());
30
+ } else {
31
+ console.log(chalk.dim("(no child output captured)"));
32
+ }
33
+ }
34
+
35
+ console.log(`\n${"=".repeat(80)}`);
36
+ }
37
+
38
+ /**
39
+ * Print the captured output from a quiet `--mergeReports` step.
40
+ *
41
+ * On success prints only the coverage block (from "% Coverage report from v8").
42
+ * On failure prints the full raw output to stderr.
43
+ *
44
+ * @param {number} exitCode - The merge process exit code.
45
+ * @param {string} output - Raw stdout + stderr from the merge process.
46
+ * @returns {void}
47
+ */
48
+ export function printMergeOutput(exitCode, output) {
49
+ if (exitCode === 0) {
50
+ const marker = "% Coverage report from v8";
51
+ const rawLines = output.split("\n");
52
+ const markerLineIndex = rawLines.findIndex((line) => stripAnsi(line).includes(marker));
53
+
54
+ if (markerLineIndex >= 0) {
55
+ let endLineIndex = rawLines.length;
56
+ for (let i = markerLineIndex + 1; i < rawLines.length; i++) {
57
+ const line = stripAnsi(rawLines[i]).trimStart();
58
+ if (line.startsWith("stderr |") || line.startsWith("stdout |")) {
59
+ endLineIndex = i;
60
+ break;
61
+ }
62
+ }
63
+
64
+ const coverageBody = rawLines
65
+ .slice(markerLineIndex + 1, endLineIndex)
66
+ .join("\n")
67
+ .trimEnd();
68
+ if (coverageBody) {
69
+ const fullBlock = rawLines.slice(markerLineIndex, endLineIndex).join("\n").trimEnd();
70
+ console.log(`\n${fullBlock}\n`);
71
+ }
72
+ }
73
+ } else {
74
+ const trimmed = output.trimEnd();
75
+ if (trimmed) console.error(trimmed);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Compute a coverage-summary-style object from a raw V8/Istanbul `coverage-final.json`.
81
+ *
82
+ * @param {Record<string, object>} finalData - Parsed `coverage-final.json` contents.
83
+ * @returns {{ total: object, [filePath: string]: object }} Istanbul coverage-summary format.
84
+ */
85
+ export function computeSummaryFromFinal(finalData) {
86
+ const pct = (covered, total) => (total === 0 ? 100 : parseFloat(((covered / total) * 100).toFixed(2)));
87
+ const summary = {
88
+ total: {
89
+ statements: { total: 0, covered: 0, pct: 0 },
90
+ branches: { total: 0, covered: 0, pct: 0 },
91
+ functions: { total: 0, covered: 0, pct: 0 },
92
+ lines: { total: 0, covered: 0, pct: 0 }
93
+ }
94
+ };
95
+
96
+ for (const [filePath, data] of Object.entries(finalData)) {
97
+ const sKeys = Object.keys(data.s ?? {});
98
+ const stmtTotal = sKeys.length;
99
+ const stmtCovered = sKeys.filter((k) => data.s[k] > 0).length;
100
+
101
+ const fKeys = Object.keys(data.f ?? {});
102
+ const fnTotal = fKeys.length;
103
+ const fnCovered = fKeys.filter((k) => data.f[k] > 0).length;
104
+
105
+ let branchTotal = 0,
106
+ branchCovered = 0;
107
+ for (const counts of Object.values(data.b ?? {})) {
108
+ branchTotal += counts.length;
109
+ branchCovered += counts.filter((c) => c > 0).length;
110
+ }
111
+
112
+ const coveredLines = new Set();
113
+ const totalLines = new Set();
114
+ for (const [sid, loc] of Object.entries(data.statementMap ?? {})) {
115
+ const line = loc?.start?.line;
116
+ if (line == null) continue;
117
+ totalLines.add(line);
118
+ if ((data.s ?? {})[sid] > 0) coveredLines.add(line);
119
+ }
120
+
121
+ const fileStats = {
122
+ statements: { total: stmtTotal, covered: stmtCovered, pct: pct(stmtCovered, stmtTotal) },
123
+ branches: { total: branchTotal, covered: branchCovered, pct: pct(branchCovered, branchTotal) },
124
+ functions: { total: fnTotal, covered: fnCovered, pct: pct(fnCovered, fnTotal) },
125
+ lines: { total: totalLines.size, covered: coveredLines.size, pct: pct(coveredLines.size, totalLines.size) }
126
+ };
127
+ summary[filePath] = fileStats;
128
+
129
+ for (const key of ["statements", "branches", "functions", "lines"]) {
130
+ summary.total[key].total += fileStats[key].total;
131
+ summary.total[key].covered += fileStats[key].covered;
132
+ }
133
+ }
134
+
135
+ for (const key of ["statements", "branches", "functions", "lines"]) {
136
+ const { total, covered } = summary.total[key];
137
+ summary.total[key].pct = pct(covered, total);
138
+ }
139
+
140
+ return summary;
141
+ }
142
+
143
+ /**
144
+ * Read the coverage JSON produced after a `mergeReports` run and print a
145
+ * worst-offenders table plus overall-coverage totals.
146
+ *
147
+ * Tries `coverage-summary.json` first; falls back to computing from
148
+ * `coverage-final.json` if that is not present.
149
+ *
150
+ * @param {string} cwd - Project root (used to make absolute file paths relative).
151
+ * @param {string[]} extraCoverageArgs - Passthrough `--coverage.*` args (checked for `reportsDirectory`).
152
+ * @param {number} [worstCount=10] - Number of worst-coverage files to show (0 = skip table).
153
+ * @returns {Promise<void>}
154
+ */
155
+ export async function printCoverageSummary(cwd, extraCoverageArgs, worstCount = 10) {
156
+ let coverageDir = path.resolve(cwd, "coverage");
157
+ const repoDirArg = extraCoverageArgs.find((a) => a.startsWith("--coverage.reportsDirectory="));
158
+ if (repoDirArg) {
159
+ const raw = repoDirArg.split("=").slice(1).join("=");
160
+ coverageDir = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
161
+ }
162
+
163
+ let summary;
164
+
165
+ try {
166
+ const content = await fs.readFile(path.join(coverageDir, "coverage-summary.json"), "utf8");
167
+ summary = JSON.parse(content);
168
+ } catch {
169
+ try {
170
+ const content = await fs.readFile(path.join(coverageDir, "coverage-final.json"), "utf8");
171
+ summary = computeSummaryFromFinal(JSON.parse(content));
172
+ } catch {
173
+ console.log(chalk.dim(" (no coverage JSON found — skipping summary)"));
174
+ return;
175
+ }
176
+ }
177
+
178
+ const { total, ...fileSummaries } = summary;
179
+
180
+ if (worstCount > 0) {
181
+ const fileRows = Object.entries(fileSummaries)
182
+ .map(([absFile, data]) => ({
183
+ file: path.relative(cwd, absFile),
184
+ lines: data.lines?.pct ?? 0,
185
+ stmts: data.statements?.pct ?? 0,
186
+ fns: data.functions?.pct ?? 0,
187
+ branches: data.branches?.pct ?? 0
188
+ }))
189
+ .sort((a, b) => a.lines - b.lines);
190
+
191
+ console.log("\n" + chalk.bold("📉 WORST COVERAGE FILES (lines)"));
192
+ console.log("-".repeat(80));
193
+
194
+ fileRows.slice(0, worstCount).forEach(({ file, lines, stmts, fns, branches }) => {
195
+ const extras = chalk.dim(`stmts ${stmts.toFixed(0)}% | fns ${fns.toFixed(0)}% | branches ${branches.toFixed(0)}%`);
196
+ console.log(` ${colourPct(chalk, lines)}% ${chalk.dim(file)} ${extras}`);
197
+ });
198
+
199
+ if (fileRows.length > worstCount) {
200
+ console.log(chalk.dim(` ... and ${fileRows.length - worstCount} more files`));
201
+ }
202
+ }
203
+
204
+ const tl = total.lines?.pct ?? 0;
205
+ const ts = total.statements?.pct ?? 0;
206
+ const tf = total.functions?.pct ?? 0;
207
+ const tb = total.branches?.pct ?? 0;
208
+ console.log(
209
+ `\n ${chalk.bold("Coverage")} ${colourPct(chalk, tl)}% lines ${chalk.dim("|")} ${colourPct(chalk, ts)}% statements ${chalk.dim("|")} ${colourPct(chalk, tf)}% functions ${chalk.dim("|")} ${colourPct(chalk, tb)}% branches`
210
+ );
211
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @fileoverview Child-process spawning helpers for running vitest.
3
+ * @module vitest-runner/src/core/spawn
4
+ */
5
+
6
+ import { spawn } from "node:child_process";
7
+ import { parseVitestOutput } from "./parse.mjs";
8
+ import { buildNodeOptions } from "../utils/env.mjs";
9
+
10
+ /**
11
+ * @typedef {Object} SpawnBaseOptions
12
+ * @property {string} cwd - Working directory for the child process.
13
+ * @property {string} vitestBin - Absolute path to the vitest binary.
14
+ * @property {string|undefined} vitestConfig - Vitest config path (omit to let vitest auto-detect).
15
+ * @property {number|undefined} maxOldSpaceMb - Optional `--max-old-space-size` ceiling.
16
+ * @property {string[]} [conditions=[]] - Additional `--conditions` flags.
17
+ * @property {string} [nodeEnv='development'] - Value for `NODE_ENV`.
18
+ */
19
+
20
+ /**
21
+ * Build the environment object for a vitest child process.
22
+ * @param {Pick<SpawnBaseOptions, 'maxOldSpaceMb'|'conditions'|'nodeEnv'>} opts
23
+ * @returns {NodeJS.ProcessEnv}
24
+ */
25
+ function buildEnv({ maxOldSpaceMb, conditions = [], nodeEnv = "development" }) {
26
+ const env = { ...process.env };
27
+ if (!env.NODE_ENV) env.NODE_ENV = nodeEnv;
28
+
29
+ const nodeOptions = buildNodeOptions({ maxOldSpaceMb, conditions, base: env.NODE_OPTIONS ?? "" });
30
+ if (nodeOptions) env.NODE_OPTIONS = nodeOptions;
31
+
32
+ return env;
33
+ }
34
+
35
+ /**
36
+ * Build the base argument list `[vitestBin, ...configArgs, 'run']`.
37
+ * @param {string} vitestBin
38
+ * @param {string|undefined} vitestConfig
39
+ * @returns {string[]}
40
+ */
41
+ function buildBaseArgs(vitestBin, vitestConfig) {
42
+ const configArgs = vitestConfig ? ["--config", vitestConfig] : [];
43
+ return [vitestBin, ...configArgs, "run"];
44
+ }
45
+
46
+ /**
47
+ * @typedef {Object} SingleFileResult
48
+ * @property {string} file - Test file path.
49
+ * @property {number} code - Process exit code.
50
+ * @property {number} duration - Run duration in milliseconds.
51
+ * @property {number} testFilesPass
52
+ * @property {number} testFilesFail
53
+ * @property {number} testsPass
54
+ * @property {number} testsFail
55
+ * @property {number} testsSkip
56
+ * @property {number|null} heapMb
57
+ * @property {string[]} errors
58
+ * @property {string} rawOutput
59
+ */
60
+
61
+ /**
62
+ * Run a single Vitest test file in a child process and return parsed results.
63
+ *
64
+ * @param {string} filePath - Test file path (relative to `cwd` or absolute).
65
+ * @param {SpawnBaseOptions & { vitestArgs?: string[], streamOutput?: boolean }} opts
66
+ * @returns {Promise<SingleFileResult>}
67
+ * @example
68
+ * const result = await runSingleFile('src/tests/foo.test.vitest.mjs', {
69
+ * cwd: '/project',
70
+ * vitestBin: '/project/node_modules/.bin/vitest',
71
+ * vitestConfig: '/project/vitest.config.ts',
72
+ * });
73
+ */
74
+ export function runSingleFile(filePath, opts) {
75
+ const {
76
+ cwd,
77
+ vitestBin,
78
+ vitestConfig,
79
+ maxOldSpaceMb,
80
+ conditions = [],
81
+ nodeEnv = "development",
82
+ vitestArgs = [],
83
+ streamOutput = true
84
+ } = opts;
85
+
86
+ return new Promise((resolve) => {
87
+ const startTime = Date.now();
88
+ const args = [...buildBaseArgs(vitestBin, vitestConfig), ...vitestArgs, filePath];
89
+ const env = buildEnv({ maxOldSpaceMb, conditions, nodeEnv });
90
+
91
+ const child = spawn(process.execPath, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env });
92
+
93
+ let stdout = "";
94
+ let stderr = "";
95
+
96
+ child.stdout?.on("data", (data) => {
97
+ stdout += data.toString();
98
+ if (streamOutput) process.stdout.write(data);
99
+ });
100
+
101
+ child.stderr?.on("data", (data) => {
102
+ stderr += data.toString();
103
+ if (streamOutput) process.stderr.write(data);
104
+ });
105
+
106
+ child.on("close", (code) => {
107
+ const spawnDuration = Date.now() - startTime;
108
+ const output = `${stdout}\n${stderr}`;
109
+ const parsed = parseVitestOutput(output);
110
+
111
+ resolve({
112
+ file: filePath,
113
+ code: code ?? 1,
114
+ // c8 ignore next -- false branch verified manually; V8 ternary probe mismatch inside object literal
115
+ duration: parsed.duration > 0 ? parsed.duration : spawnDuration,
116
+ testFilesPass: parsed.testFilesPass,
117
+ testFilesFail: parsed.testFilesFail,
118
+ testsPass: parsed.testsPass,
119
+ testsFail: parsed.testsFail,
120
+ testsSkip: parsed.testsSkip,
121
+ heapMb: parsed.heapMb,
122
+ errors: parsed.errors,
123
+ rawOutput: output
124
+ });
125
+ });
126
+
127
+ child.on("error", (err) => {
128
+ resolve({
129
+ file: filePath,
130
+ code: 1,
131
+ duration: Date.now() - startTime,
132
+ testFilesPass: 0,
133
+ testFilesFail: 1,
134
+ testsPass: 0,
135
+ testsFail: 0,
136
+ testsSkip: 0,
137
+ heapMb: null,
138
+ errors: [err.message],
139
+ rawOutput: err.toString()
140
+ });
141
+ });
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Run Vitest directly (all files in one process) with inherited stdio.
147
+ *
148
+ * @param {SpawnBaseOptions & { vitestArgs?: string[] }} opts
149
+ * @returns {Promise<number>} Process exit code.
150
+ * @example
151
+ * const code = await runVitestDirect({
152
+ * cwd: '/project',
153
+ * vitestBin: '/project/node_modules/.bin/vitest',
154
+ * vitestArgs: ['--reporter=verbose'],
155
+ * });
156
+ */
157
+ export function runVitestDirect(opts) {
158
+ const { cwd, vitestBin, vitestConfig, maxOldSpaceMb, conditions = [], nodeEnv = "development", vitestArgs = [] } = opts;
159
+
160
+ return new Promise((resolve) => {
161
+ const args = [...buildBaseArgs(vitestBin, vitestConfig), ...vitestArgs];
162
+ const env = buildEnv({ maxOldSpaceMb, conditions, nodeEnv });
163
+
164
+ const child = spawn(process.execPath, args, { cwd, stdio: "inherit", env });
165
+ child.on("close", (code) => resolve(code ?? 1));
166
+ child.on("error", () => resolve(1));
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Merge blob reports from individual coverage runs into a single coverage report
172
+ * using `vitest --mergeReports`.
173
+ *
174
+ * @param {string} blobsDir - Directory containing the `.blob` files to merge.
175
+ * @param {SpawnBaseOptions & { extraCoverageArgs?: string[], quietOutput?: boolean }} opts
176
+ * @returns {Promise<{ exitCode: number, output: string }>}
177
+ * @example
178
+ * const { exitCode } = await runMergeReports('/project/.vitest-blobs', {
179
+ * cwd: '/project',
180
+ * vitestBin: '/project/node_modules/.bin/vitest',
181
+ * });
182
+ */
183
+ export function runMergeReports(blobsDir, opts) {
184
+ const {
185
+ cwd,
186
+ vitestBin,
187
+ vitestConfig,
188
+ maxOldSpaceMb,
189
+ conditions = [],
190
+ nodeEnv = "development",
191
+ extraCoverageArgs = [],
192
+ quietOutput = false
193
+ } = opts;
194
+
195
+ return new Promise((resolve) => {
196
+ const configArgs = vitestConfig ? ["--config", vitestConfig] : [];
197
+ const mergeReporterArgs = quietOutput ? ["--color"] : [];
198
+
199
+ const args = [vitestBin, ...configArgs, "--mergeReports", blobsDir, "--run", "--coverage", ...mergeReporterArgs, ...extraCoverageArgs];
200
+
201
+ const env = buildEnv({ maxOldSpaceMb, conditions, nodeEnv });
202
+ const child = spawn(process.execPath, args, {
203
+ cwd,
204
+ stdio: quietOutput ? ["ignore", "pipe", "pipe"] : "inherit",
205
+ env
206
+ });
207
+
208
+ let stdout = "";
209
+ let stderr = "";
210
+
211
+ if (quietOutput) {
212
+ child.stdout?.on("data", (data) => (stdout += data.toString()));
213
+ child.stderr?.on("data", (data) => (stderr += data.toString()));
214
+ }
215
+
216
+ child.on("close", (code) => resolve({ exitCode: code ?? 1, output: `${stdout}\n${stderr}` }));
217
+ child.on("error", () => resolve({ exitCode: 1, output: "" }));
218
+ });
219
+ }