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,80 @@
1
+ /**
2
+ * @fileoverview CLI help text for the vitest-runner binary.
3
+ * @module vitest-runner/src/cli/help
4
+ */
5
+
6
+ import chalk from "chalk";
7
+
8
+ /**
9
+ * Print the full CLI help message to stdout.
10
+ * @returns {void}
11
+ * @example
12
+ * showHelp();
13
+ */
14
+ export function showHelp() {
15
+ console.log(`
16
+ ${chalk.bold("Vitest Sequential Runner")}
17
+ Runs each test file in its own Vitest process to avoid OOM issues.
18
+
19
+ ${chalk.bold("USAGE:")}
20
+ vitest-runner [OPTIONS] [PATTERNS]
21
+
22
+ ${chalk.bold("SPECIAL FLAGS:")}
23
+ --test-list <file> Run only the files listed in a JSON array file
24
+ --file-pattern <regex> Override the file discovery regex (default: \.test\.vitest\.(?:js|mjs|cjs)$)
25
+ --workers <n> Number of parallel workers (default: 4 or VITEST_WORKERS)
26
+ --solo-pattern <pat> Run files matching this path substring solo first (repeatable)
27
+ --no-error-details Hide detailed error output (show only counts)
28
+ --coverage-quiet Implies --coverage; show progress bar + final summaries only
29
+ --log-file <path> Path for the coverage run log (default: coverage/coverage-run.log; implies --coverage-quiet)
30
+ --help, -h Show this help message
31
+
32
+ ${chalk.bold("TEST PATTERNS:")}
33
+ [file] Run a specific test file (supports partial paths)
34
+ [folder] Run all tests in a folder
35
+
36
+ Examples:
37
+ src/tests/config/background.test.vitest.mjs
38
+ src/tests/metadata
39
+ background.test.vitest.mjs
40
+
41
+ ${chalk.bold("VITEST FLAGS:")}
42
+ All standard Vitest CLI flags are supported and passed through:
43
+ -t, --testNamePattern Filter tests by name pattern (regex)
44
+ --reporter Change reporter (verbose, dot, json, etc.)
45
+ --coverage Run full-suite coverage (blob-per-file + mergeReports)
46
+ --bail Stop on first failure
47
+
48
+ See the Vitest documentation for the full list.
49
+
50
+ ${chalk.bold("ENVIRONMENT VARIABLES:")}
51
+ VITEST_HEAP_MB Set max heap size per test (default: Node.js default)
52
+ VITEST_WORKERS Number of parallel workers (default: 4, overridden by --workers)
53
+ # Run all tests
54
+ vitest-runner
55
+
56
+ # Run a specific file (partial path ok)
57
+ vitest-runner src/tests/config/background.test.vitest.mjs
58
+
59
+ # Run only the files listed in a JSON file
60
+ vitest-runner --test-list my-tests.json
61
+
62
+ # Filter by test name
63
+ vitest-runner src/tests/metadata -t "lazy materialization"
64
+
65
+ # Run with 2 workers
66
+ vitest-runner --workers 2
67
+
68
+ # Run files matching a pattern solo first, then the rest in parallel
69
+ vitest-runner --solo-pattern listener-cleanup/ --solo-pattern heavy/
70
+
71
+ # Hide detailed errors
72
+ vitest-runner --no-error-details
73
+
74
+ # Coverage with quiet output + progress bar
75
+ vitest-runner --coverage --coverage-quiet
76
+
77
+ # Custom heap and workers
78
+ VITEST_HEAP_MB=8192 vitest-runner --workers 2 src/tests/heavy
79
+ `);
80
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @fileoverview Test-file discovery utilities.
3
+ * @module vitest-runner/src/core/discover
4
+ */
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+
9
+ /** Default pattern matching all supported Vitest test file extensions. */
10
+ export const DEFAULT_TEST_FILE_PATTERN = /\.test\.vitest\.(?:js|mjs|cjs)$/i;
11
+
12
+ /**
13
+ * Recursively discover all Vitest test files under a directory.
14
+ * Skips `node_modules` and hidden directories (names starting with `.`).
15
+ *
16
+ * @param {string} dir - Absolute path of the directory to scan.
17
+ * @param {string} cwd - Project root used to compute relative paths.
18
+ * @param {RegExp} [pattern=DEFAULT_TEST_FILE_PATTERN] - Regex tested against the file name.
19
+ * @returns {Promise<string[]>} Paths relative to `cwd`.
20
+ * @example
21
+ * const files = await discoverFilesInDir('/project/src/tests', '/project');
22
+ */
23
+ export async function discoverFilesInDir(dir, cwd, pattern = DEFAULT_TEST_FILE_PATTERN) {
24
+ const queue = [dir];
25
+ const files = [];
26
+
27
+ while (queue.length) {
28
+ const current = queue.pop();
29
+ let entries;
30
+ try {
31
+ entries = await fs.readdir(current, { withFileTypes: true });
32
+ } catch {
33
+ continue;
34
+ }
35
+
36
+ for (const entry of entries) {
37
+ if (entry.isDirectory()) {
38
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
39
+ queue.push(path.join(current, entry.name));
40
+ continue;
41
+ }
42
+
43
+ if (entry.isFile() && pattern.test(entry.name)) {
44
+ files.push(path.relative(cwd, path.join(current, entry.name)));
45
+ }
46
+ }
47
+ }
48
+
49
+ return files;
50
+ }
51
+
52
+ /**
53
+ * Sort test files alphabetically while hoisting files matching `earlyRunPatterns`
54
+ * to the front (in pattern-declaration order, then alphabetically within each group).
55
+ *
56
+ * @param {string[]} files - File paths to sort.
57
+ * @param {string[]} [earlyRunPatterns=[]] - Substrings — files whose path contains one run first.
58
+ * @returns {string[]} Sorted file paths.
59
+ * @example
60
+ * sortWithPriority(files, ['listener-cleanup/']);
61
+ */
62
+ export function sortWithPriority(files, earlyRunPatterns = []) {
63
+ const early = [];
64
+ const rest = [];
65
+
66
+ for (const file of files) {
67
+ const normalized = file.replace(/\\/g, "/");
68
+ const priorityIndex = earlyRunPatterns.findIndex((pat) => normalized.includes(pat));
69
+ if (priorityIndex !== -1) {
70
+ early.push({ file, priorityIndex });
71
+ } else {
72
+ rest.push(file);
73
+ }
74
+ }
75
+
76
+ early.sort((a, b) => a.priorityIndex - b.priorityIndex || a.file.localeCompare(b.file));
77
+ rest.sort((a, b) => a.localeCompare(b));
78
+
79
+ return [...early.map((e) => e.file), ...rest];
80
+ }
81
+
82
+ /**
83
+ * @typedef {Object} DiscoverOptions
84
+ * @property {string} cwd - Project root directory.
85
+ * @property {string} [testDir] - Root directory to search for test files (defaults to `cwd`).
86
+ * @property {string[]} [testPatterns=[]] - File / folder patterns to filter (empty = all files).
87
+ * @property {string} [testListFile] - Path to a JSON array of test file paths to run instead of scanning.
88
+ * @property {RegExp} [testFilePattern] - Regex to match file names (default: `DEFAULT_TEST_FILE_PATTERN`).
89
+ * @property {string[]} [earlyRunPatterns=[]] - Path substrings for files that must run solo first.
90
+ */
91
+
92
+ /**
93
+ * Discover Vitest test files according to the provided options.
94
+ *
95
+ * | Scenario | Behaviour |
96
+ * |---|---|
97
+ * | `testListFile` set | Reads the exact file list from that JSON file. |
98
+ * | Patterns provided | Resolves each as file / directory, falls back to partial-path match. |
99
+ * | No patterns | Returns all test files found under `testDir`. |
100
+ *
101
+ * @param {DiscoverOptions} opts
102
+ * @returns {Promise<string[]>} Sorted array of test file paths relative to `cwd`.
103
+ * @example
104
+ * const files = await discoverVitestFiles({ cwd: '/project', testDir: '/project/src/tests' });
105
+ */
106
+ export async function discoverVitestFiles(opts) {
107
+ const { cwd, testDir, testPatterns = [], testListFile, testFilePattern = DEFAULT_TEST_FILE_PATTERN, earlyRunPatterns = [] } = opts;
108
+
109
+ const resolvedTestDir = testDir ? (path.isAbsolute(testDir) ? testDir : path.resolve(cwd, testDir)) : cwd;
110
+
111
+ if (testListFile) {
112
+ const resolvedListPath = path.isAbsolute(testListFile) ? testListFile : path.resolve(cwd, testListFile);
113
+
114
+ let testList;
115
+ try {
116
+ const content = await fs.readFile(resolvedListPath, "utf8");
117
+ testList = JSON.parse(content);
118
+ } catch (err) {
119
+ throw new Error(`Failed to read test list file "${resolvedListPath}": ${err.message}`);
120
+ }
121
+
122
+ if (!Array.isArray(testList)) {
123
+ throw new Error(`Test list file "${resolvedListPath}" must contain a JSON array of test file paths`);
124
+ }
125
+
126
+ console.log(`📋 Loading test list from: ${path.relative(cwd, resolvedListPath)}`);
127
+ return sortWithPriority(testList, earlyRunPatterns);
128
+ }
129
+
130
+ if (testPatterns.length === 0) {
131
+ const files = await discoverFilesInDir(resolvedTestDir, cwd, testFilePattern);
132
+ return sortWithPriority(files, earlyRunPatterns);
133
+ }
134
+
135
+ const files = [];
136
+
137
+ for (const pattern of testPatterns) {
138
+ const absPath = path.isAbsolute(pattern) ? pattern : path.resolve(cwd, pattern);
139
+
140
+ let stat = null;
141
+ try {
142
+ stat = await fs.stat(absPath);
143
+ } catch {
144
+ // path doesn't exist — fall through to partial-match
145
+ }
146
+
147
+ if (stat?.isFile()) {
148
+ if (testFilePattern.test(absPath)) {
149
+ files.push(path.relative(cwd, absPath));
150
+ }
151
+ } else if (stat?.isDirectory()) {
152
+ files.push(...(await discoverFilesInDir(absPath, cwd, testFilePattern)));
153
+ } else {
154
+ // Partial-path matching against all files in testDir
155
+ const allFiles = await discoverFilesInDir(resolvedTestDir, cwd, testFilePattern);
156
+ const matched = allFiles.filter((f) => f.replace(/\\/g, "/").includes(pattern.replace(/\\/g, "/")));
157
+
158
+ if (matched.length > 0) {
159
+ files.push(...matched);
160
+ } else {
161
+ console.warn(`⚠️ No matches found for: ${pattern}`);
162
+ }
163
+ }
164
+ }
165
+
166
+ return sortWithPriority([...new Set(files)], earlyRunPatterns);
167
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @fileoverview Vitest output parsing and error deduplication utilities.
3
+ * @module vitest-runner/src/core/parse
4
+ */
5
+
6
+ import { stripAnsi } from "../utils/ansi.mjs";
7
+
8
+ /**
9
+ * @typedef {Object} ParsedVitestResult
10
+ * @property {number} testFilesPass - Number of test files that passed.
11
+ * @property {number} testFilesFail - Number of test files that failed.
12
+ * @property {number} testsPass - Number of individual tests that passed.
13
+ * @property {number} testsFail - Number of individual tests that failed.
14
+ * @property {number} testsSkip - Number of individual tests that were skipped.
15
+ * @property {number} duration - Duration in milliseconds (from Vitest output).
16
+ * @property {number|null} heapMb - Peak heap usage in MB, or `null` if not reported.
17
+ * @property {string[]} errors - Array of raw error blocks (with ANSI codes).
18
+ */
19
+
20
+ /**
21
+ * Parse raw Vitest stdout/stderr output into structured result data.
22
+ *
23
+ * Extracts test-file counts, individual test counts, duration, heap usage,
24
+ * and error blocks. Counts are parsed from ANSI-stripped output; error blocks
25
+ * are captured from the original coloured output.
26
+ *
27
+ * @param {string} output - Combined raw stdout + stderr from a vitest child process.
28
+ * @returns {ParsedVitestResult}
29
+ * @example
30
+ * const result = parseVitestOutput(rawOutput);
31
+ * console.log(result.testsPass, result.testsFail);
32
+ */
33
+ export function parseVitestOutput(output) {
34
+ const cleanOutput = stripAnsi(output);
35
+
36
+ const result = {
37
+ testFilesPass: 0,
38
+ testFilesFail: 0,
39
+ testsPass: 0,
40
+ testsFail: 0,
41
+ testsSkip: 0,
42
+ duration: 0,
43
+ heapMb: null,
44
+ errors: []
45
+ };
46
+
47
+ // "Test Files 1 passed (1)" / "Test Files 1 failed | 2 passed (3)"
48
+ const testFilesLineMatch = cleanOutput.match(/Test Files\s+(.+)/);
49
+ if (testFilesLineMatch) {
50
+ const line = testFilesLineMatch[1];
51
+ const passMatch = line.match(/(\d+)\s+passed/);
52
+ const failMatch = line.match(/(\d+)\s+failed/);
53
+ if (passMatch) result.testFilesPass = parseInt(passMatch[1], 10);
54
+ if (failMatch) result.testFilesFail = parseInt(failMatch[1], 10);
55
+ }
56
+
57
+ // "Tests 82 passed (82)" / "Tests 2 failed | 4 passed (6)"
58
+ const testsLineMatch = cleanOutput.match(/^\s*Tests\s+(.+)$/m);
59
+ if (testsLineMatch) {
60
+ const line = testsLineMatch[1];
61
+ const passMatch = line.match(/(\d+)\s+passed/);
62
+ const failMatch = line.match(/(\d+)\s+failed/);
63
+ const skipMatch = line.match(/(\d+)\s+skipped/);
64
+ if (passMatch) result.testsPass = parseInt(passMatch[1], 10);
65
+ if (failMatch) result.testsFail = parseInt(failMatch[1], 10);
66
+ if (skipMatch) result.testsSkip = parseInt(skipMatch[1], 10);
67
+ }
68
+
69
+ // "Duration Xs"
70
+ const durationMatch = cleanOutput.match(/Duration\s+([\d.]+)s/);
71
+ if (durationMatch) {
72
+ result.duration = parseFloat(durationMatch[1]) * 1000;
73
+ }
74
+
75
+ // "N MB heap used"
76
+ const heapMatch = cleanOutput.match(/(\d+)\s*MB\s+heap\s+used/i);
77
+ if (heapMatch) {
78
+ result.heapMb = parseInt(heapMatch[1], 10);
79
+ }
80
+
81
+ // Error blocks — captured from the raw (coloured) output
82
+ const failedSectionStart = output.indexOf("Failed Tests");
83
+ if (failedSectionStart !== -1) {
84
+ const failedSectionEnd = output.indexOf("\n Test Files", failedSectionStart);
85
+ const errorSection =
86
+ failedSectionEnd !== -1 ? output.substring(failedSectionStart, failedSectionEnd) : output.substring(failedSectionStart);
87
+
88
+ // eslint-disable-next-line no-control-regex
89
+ const failPattern = /FAIL\s*(?:\x1B\[[0-9;]*[a-zA-Z]|\s)*tests\//g;
90
+ const matches = [...errorSection.matchAll(failPattern)];
91
+
92
+ for (let i = 0; i < matches.length; i++) {
93
+ const matchPos = matches[i].index;
94
+ const lineStart = errorSection.lastIndexOf("\n", matchPos);
95
+ const actualStart = lineStart === -1 ? 0 : lineStart;
96
+
97
+ const matchEnd = i < matches.length - 1 ? matches[i + 1].index : errorSection.length;
98
+ const nextLineStart = errorSection.lastIndexOf("\n", matchEnd);
99
+ const actualEnd = nextLineStart === -1 ? matchEnd : nextLineStart;
100
+
101
+ const errorBlock = errorSection.substring(actualStart, actualEnd).trim();
102
+ if (errorBlock) result.errors.push(errorBlock);
103
+ }
104
+ }
105
+
106
+ return result;
107
+ }
108
+
109
+ /**
110
+ * Deduplicate similar FAIL lines that differ only by their `Config:` value.
111
+ *
112
+ * When the same test file fails across multiple matrix configs vitest emits
113
+ * one FAIL line per config. This collapses them into a single line listing
114
+ * all configs as an array, keeping output concise.
115
+ *
116
+ * @param {string[]} errors - Array of raw error blocks (each a complete FAIL section).
117
+ * @returns {string} Deduplicated error text joined as a single string.
118
+ * @example
119
+ * const deduped = deduplicateErrors(result.errors);
120
+ * console.log(deduped);
121
+ */
122
+ export function deduplicateErrors(errors) {
123
+ const fullText = errors.join("\n");
124
+ const lines = fullText.split("\n");
125
+
126
+ const failLineMap = new Map();
127
+ const lineIndices = new Map();
128
+
129
+ lines.forEach((line, idx) => {
130
+ if (line.includes("FAIL") && line.includes("Config:")) {
131
+ lineIndices.set(line, idx);
132
+
133
+ const cleaned = stripAnsi(line);
134
+ const match = cleaned.match(/^(.+Config:\s+)'*([^'>]+)'*(.+)$/);
135
+ if (match) {
136
+ const [, before, config, after] = match;
137
+ const pattern = `${before.trim()}|||${after.trim()}`;
138
+
139
+ if (!failLineMap.has(pattern)) {
140
+ failLineMap.set(pattern, { lines: [], configs: [] });
141
+ }
142
+ failLineMap.get(pattern).lines.push(line);
143
+ failLineMap.get(pattern).configs.push(config.replace(/'/g, ""));
144
+ }
145
+ }
146
+ });
147
+
148
+ const skipIndices = new Set();
149
+
150
+ for (const [, data] of failLineMap.entries()) {
151
+ if (data.configs.length > 1) {
152
+ for (let i = 1; i < data.lines.length; i++) {
153
+ const idx = lineIndices.get(data.lines[i]);
154
+ skipIndices.add(idx);
155
+ }
156
+
157
+ const firstLine = data.lines[0];
158
+ const configArray = `[${data.configs.map((c) => `'${c}'`).join(",")}]`;
159
+ const consolidated = stripAnsi(firstLine).replace(/(Config:\s+)'*[^'>]+'*/, `$1${configArray}`);
160
+
161
+ const firstIdx = lineIndices.get(firstLine);
162
+ lines[firstIdx] = consolidated;
163
+ }
164
+ }
165
+
166
+ return lines.filter((_, idx) => !skipIndices.has(idx)).join("\n");
167
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @fileoverview Live progress tracker for coverage runs.
3
+ * @module vitest-runner/src/core/progress
4
+ */
5
+
6
+ import chalk from "chalk";
7
+ import { formatDuration } from "../utils/duration.mjs";
8
+
9
+ /**
10
+ * Create a live progress tracker that renders to stdout.
11
+ *
12
+ * In TTY mode a spinner + bar overwrites the current line on each update.
13
+ * In non-TTY mode a plain-text line is printed at most once every two seconds,
14
+ * plus once on every `onComplete` call.
15
+ *
16
+ * @param {number} total - Total number of files to process.
17
+ * @returns {{ onStart: () => void, onComplete: (failedRun: boolean) => void, finish: () => void }}
18
+ * @example
19
+ * const progress = createCoverageProgressTracker(120);
20
+ * progress.onStart();
21
+ * progress.onComplete(false);
22
+ * progress.finish();
23
+ */
24
+ export function createCoverageProgressTracker(total) {
25
+ const isTTY = Boolean(process.stdout.isTTY);
26
+ const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
27
+ const barWidth = 26;
28
+ const startTime = Date.now();
29
+ let completed = 0;
30
+ let active = 0;
31
+ let failed = 0;
32
+ let frameIndex = 0;
33
+ let maxLineLength = 0;
34
+ let lastPlainLog = 0;
35
+ let spinnerTimer = null;
36
+
37
+ /**
38
+ * Build the current progress line text.
39
+ * @returns {string}
40
+ */
41
+ function buildLine() {
42
+ const percent = total === 0 ? 100 : (completed / total) * 100;
43
+ const elapsedMs = Date.now() - startTime;
44
+ const avgPerFile = completed > 0 ? elapsedMs / completed : 0;
45
+ const etaMs = avgPerFile * Math.max(total - completed, 0);
46
+ const filled = Math.round((percent / 100) * barWidth);
47
+ const spinner = spinnerFrames[frameIndex % spinnerFrames.length];
48
+ const bar = `[${"=".repeat(filled)}${"-".repeat(Math.max(0, barWidth - filled))}]`;
49
+
50
+ let _percent = percent.toFixed(1).padStart(5);
51
+ if (percent < 30) {
52
+ _percent = chalk.red(_percent + "%");
53
+ } else if (percent < 70) {
54
+ _percent = chalk.rgb(255, 136, 0)(_percent + "%");
55
+ } else if (percent > 99) {
56
+ _percent = chalk.green(_percent + "%");
57
+ } else {
58
+ _percent = chalk.yellow(_percent + "%");
59
+ }
60
+
61
+ if (isTTY) {
62
+ return `${chalk.green(spinner)} ${chalk.green(bar)} ${chalk.bold(_percent)} ${completed}/${total} | active ${active} | failed ${failed} | ETA ${formatDuration(etaMs)} | elapsed ${formatDuration(elapsedMs)}`;
63
+ }
64
+
65
+ return `progress ${percent.toFixed(1)}% ${completed}/${total} | active ${active} | failed ${failed} | eta ${formatDuration(etaMs)} | elapsed ${formatDuration(elapsedMs)}`;
66
+ }
67
+
68
+ /**
69
+ * Render the progress line to stdout.
70
+ * @param {boolean} [forcePlainLog=false] - Force a plain log line in non-TTY mode.
71
+ * @returns {void}
72
+ */
73
+ function render(forcePlainLog = false) {
74
+ const line = buildLine();
75
+ frameIndex++;
76
+
77
+ if (isTTY) {
78
+ // eslint-disable-next-line no-control-regex
79
+ const visibleLength = line.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "").length;
80
+ maxLineLength = Math.max(maxLineLength, visibleLength);
81
+ process.stdout.write(`\r${line.padEnd(maxLineLength, " ")}`);
82
+ return;
83
+ }
84
+
85
+ const now = Date.now();
86
+ if (forcePlainLog || now - lastPlainLog >= 2000) {
87
+ console.log(line);
88
+ lastPlainLog = now;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Start the periodic spinner redraw for TTY output.
94
+ * @returns {void}
95
+ */
96
+ function startSpinnerLoop() {
97
+ if (!isTTY || spinnerTimer) return;
98
+ spinnerTimer = setInterval(() => {
99
+ if (completed >= total && active === 0) return;
100
+ render();
101
+ }, 120);
102
+ spinnerTimer.unref?.();
103
+ }
104
+
105
+ /**
106
+ * Stop the periodic spinner redraw loop.
107
+ * @returns {void}
108
+ */
109
+ function stopSpinnerLoop() {
110
+ if (!spinnerTimer) return;
111
+ clearInterval(spinnerTimer);
112
+ spinnerTimer = null;
113
+ }
114
+
115
+ render(true);
116
+ startSpinnerLoop();
117
+
118
+ return {
119
+ /**
120
+ * Call when a file run starts (increments active count).
121
+ * @returns {void}
122
+ */
123
+ onStart() {
124
+ active++;
125
+ render();
126
+ },
127
+
128
+ /**
129
+ * Call when a file run completes.
130
+ * @param {boolean} failedRun - Whether the run exited with a non-zero code.
131
+ * @returns {void}
132
+ */
133
+ onComplete(failedRun) {
134
+ active = Math.max(0, active - 1);
135
+ completed++;
136
+ if (failedRun) failed++;
137
+ render(true);
138
+ },
139
+
140
+ /**
141
+ * Stop the spinner and print the final progress line.
142
+ * @returns {void}
143
+ */
144
+ finish() {
145
+ stopSpinnerLoop();
146
+ render(true);
147
+ if (isTTY) process.stdout.write("\n");
148
+ }
149
+ };
150
+ }
151
+
152
+ /**
153
+ * A no-op progress tracker used when quiet mode is disabled (progress is
154
+ * handled via `console.log` inline).
155
+ * @type {{ onStart: () => void, onComplete: (failedRun: boolean) => void, finish: () => void }}
156
+ */
157
+ export const noopProgressTracker = {
158
+ /** @returns {void} */
159
+ onStart() {},
160
+ /** @param {boolean} _failedRun @returns {void} */
161
+ onComplete(_failedRun) {},
162
+ /** @returns {void} */
163
+ finish() {}
164
+ };