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/cli/help.mjs
ADDED
|
@@ -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
|
+
};
|