UNPKG

vitest

Version:

Next generation testing framework powered by Vite

1,345 lines (1,322 loc) 80.8 kB
import { performance as performance$1 } from 'node:perf_hooks'; import { getTestName, hasFailed, getFullName, getTests, getSuites, getTasks } from '@vitest/runner/utils'; import { slash, toArray, isPrimitive, inspect, positionToOffset, lineSplitRE } from '@vitest/utils'; import { parseStacktrace, parseErrorStacktrace } from '@vitest/utils/source-map'; import { isAbsolute, relative, dirname, basename, resolve, normalize } from 'pathe'; import c from 'tinyrainbow'; import { i as isTTY } from './env.D4Lgay0q.js'; import { h as hasFailedSnapshot, g as getOutputFile, T as TypeCheckError } from './typechecker.DRKU1-1g.js'; import { stripVTControlCharacters } from 'node:util'; import { existsSync, readFileSync, promises } from 'node:fs'; import { mkdir, writeFile, readdir, stat, readFile } from 'node:fs/promises'; import { Console } from 'node:console'; import { Writable } from 'node:stream'; import { createRequire } from 'node:module'; import { hostname } from 'node:os'; const F_RIGHT = "→"; const F_DOWN = "↓"; const F_DOWN_RIGHT = "↳"; const F_POINTER = "❯"; const F_DOT = "·"; const F_CHECK = "✓"; const F_CROSS = "×"; const F_LONG_DASH = "⎯"; const F_TREE_NODE_MIDDLE = "├──"; const F_TREE_NODE_END = "└──"; const pointer = c.yellow(F_POINTER); const skipped = c.dim(c.gray(F_DOWN)); const benchmarkPass = c.green(F_DOT); const testPass = c.green(F_CHECK); const taskFail = c.red(F_CROSS); const suiteFail = c.red(F_POINTER); const pending$1 = c.gray("·"); const labelDefaultColors = [ c.bgYellow, c.bgCyan, c.bgGreen, c.bgMagenta ]; function getCols(delta = 0) { let length = process.stdout?.columns; if (!length || Number.isNaN(length)) length = 30; return Math.max(length + delta, 0); } function errorBanner(message) { return divider(c.bold(c.bgRed(` ${message} `)), null, null, c.red); } function divider(text, left, right, color) { const cols = getCols(); const c = color || ((text) => text); if (text) { const textLength = stripVTControlCharacters(text).length; if (left == null && right != null) left = cols - textLength - right; else { left = left ?? Math.floor((cols - textLength) / 2); right = cols - textLength - left; } left = Math.max(0, left); right = Math.max(0, right); return `${c(F_LONG_DASH.repeat(left))}${text}${c(F_LONG_DASH.repeat(right))}`; } return F_LONG_DASH.repeat(cols); } function formatTestPath(root, path) { if (isAbsolute(path)) path = relative(root, path); const dir = dirname(path); const ext = path.match(/(\.(spec|test)\.[cm]?[tj]sx?)$/)?.[0] || ""; const base = basename(path, ext); return slash(c.dim(`${dir}/`) + c.bold(base)) + c.dim(ext); } function renderSnapshotSummary(rootDir, snapshots) { const summary = []; if (snapshots.added) summary.push(c.bold(c.green(`${snapshots.added} written`))); if (snapshots.unmatched) summary.push(c.bold(c.red(`${snapshots.unmatched} failed`))); if (snapshots.updated) summary.push(c.bold(c.green(`${snapshots.updated} updated `))); if (snapshots.filesRemoved) if (snapshots.didUpdate) summary.push(c.bold(c.green(`${snapshots.filesRemoved} files removed `))); else summary.push(c.bold(c.yellow(`${snapshots.filesRemoved} files obsolete `))); if (snapshots.filesRemovedList && snapshots.filesRemovedList.length) { const [head, ...tail] = snapshots.filesRemovedList; summary.push(`${c.gray(F_DOWN_RIGHT)} ${formatTestPath(rootDir, head)}`); tail.forEach((key) => { summary.push(` ${c.gray(F_DOT)} ${formatTestPath(rootDir, key)}`); }); } if (snapshots.unchecked) { if (snapshots.didUpdate) summary.push(c.bold(c.green(`${snapshots.unchecked} removed`))); else summary.push(c.bold(c.yellow(`${snapshots.unchecked} obsolete`))); snapshots.uncheckedKeysByFile.forEach((uncheckedFile) => { summary.push(`${c.gray(F_DOWN_RIGHT)} ${formatTestPath(rootDir, uncheckedFile.filePath)}`); uncheckedFile.keys.forEach((key) => summary.push(` ${c.gray(F_DOT)} ${key}`)); }); } return summary; } function countTestErrors(tasks) { return tasks.reduce((c, i) => c + (i.result?.errors?.length || 0), 0); } function getStateString$1(tasks, name = "tests", showTotal = true) { if (tasks.length === 0) return c.dim(`no ${name}`); const passed = tasks.filter((i) => i.result?.state === "pass"); const failed = tasks.filter((i) => i.result?.state === "fail"); const skipped = tasks.filter((i) => i.mode === "skip"); const todo = tasks.filter((i) => i.mode === "todo"); return [ failed.length ? c.bold(c.red(`${failed.length} failed`)) : null, passed.length ? c.bold(c.green(`${passed.length} passed`)) : null, skipped.length ? c.yellow(`${skipped.length} skipped`) : null, todo.length ? c.gray(`${todo.length} todo`) : null ].filter(Boolean).join(c.dim(" | ")) + (showTotal ? c.gray(` (${tasks.length})`) : ""); } function getStateSymbol(task) { if (task.mode === "skip" || task.mode === "todo") return skipped; if (!task.result) return pending$1; if (task.result.state === "run" || task.result.state === "queued") { if (task.type === "suite") return pointer; } if (task.result.state === "pass") return task.meta?.benchmark ? benchmarkPass : testPass; if (task.result.state === "fail") return task.type === "suite" ? suiteFail : taskFail; return " "; } function formatTimeString(date) { return date.toTimeString().split(" ")[0]; } function formatTime(time) { if (time > 1e3) return `${(time / 1e3).toFixed(2)}s`; return `${Math.round(time)}ms`; } function formatProjectName(project, suffix = " ") { if (!project?.name) return ""; if (!c.isColorSupported) return `|${project.name}|${suffix}`; let background = project.color && c[`bg${capitalize(project.color)}`]; if (!background) { const index = project.name.split("").reduce((acc, v, idx) => acc + v.charCodeAt(0) + idx, 0); background = labelDefaultColors[index % labelDefaultColors.length]; } return c.black(background(` ${project.name} `)) + suffix; } function withLabel(color, label, message) { const bgColor = `bg${color.charAt(0).toUpperCase()}${color.slice(1)}`; return `${c.bold(c[bgColor](` ${label} `))} ${message ? c[color](message) : ""}`; } function padSummaryTitle(str) { return c.dim(`${str.padStart(11)} `); } function truncateString(text, maxLength) { const plainText = stripVTControlCharacters(text); if (plainText.length <= maxLength) return text; return `${plainText.slice(0, maxLength - 1)}…`; } function capitalize(text) { return `${text[0].toUpperCase()}${text.slice(1)}`; } var utils = /*#__PURE__*/Object.freeze({ __proto__: null, benchmarkPass: benchmarkPass, countTestErrors: countTestErrors, divider: divider, errorBanner: errorBanner, formatProjectName: formatProjectName, formatTestPath: formatTestPath, formatTime: formatTime, formatTimeString: formatTimeString, getStateString: getStateString$1, getStateSymbol: getStateSymbol, padSummaryTitle: padSummaryTitle, pending: pending$1, pointer: pointer, renderSnapshotSummary: renderSnapshotSummary, skipped: skipped, suiteFail: suiteFail, taskFail: taskFail, testPass: testPass, truncateString: truncateString, withLabel: withLabel }); const BADGE_PADDING = " "; class BaseReporter { start = 0; end = 0; watchFilters; failedUnwatchedFiles = []; isTTY; ctx = void 0; renderSucceed = false; verbose = false; _filesInWatchMode = /* @__PURE__ */ new Map(); _timeStart = formatTimeString(/* @__PURE__ */ new Date()); constructor(options = {}) { this.isTTY = options.isTTY ?? isTTY; } onInit(ctx) { this.ctx = ctx; this.ctx.logger.printBanner(); this.start = performance$1.now(); } log(...messages) { this.ctx.logger.log(...messages); } error(...messages) { this.ctx.logger.error(...messages); } relative(path) { return relative(this.ctx.config.root, path); } onFinished(files = this.ctx.state.getFiles(), errors = this.ctx.state.getUnhandledErrors()) { this.end = performance$1.now(); if (!files.length && !errors.length) this.ctx.logger.printNoTestFound(this.ctx.filenamePattern); else this.reportSummary(files, errors); } onTestCaseResult(testCase) { if (testCase.result().state === "failed") this.logFailedTask(testCase.task); } onTestSuiteResult(testSuite) { if (testSuite.state() === "failed") this.logFailedTask(testSuite.task); } onTestModuleEnd(testModule) { if (testModule.state() === "failed") this.logFailedTask(testModule.task); this.printTestModule(testModule); } logFailedTask(task) { if (this.ctx.config.silent === "passed-only") for (const log of task.logs || []) this.onUserConsoleLog(log, "failed"); } printTestModule(testModule) { const moduleState = testModule.state(); if (moduleState === "queued" || moduleState === "pending") return; let testsCount = 0; let failedCount = 0; let skippedCount = 0; // delaying logs to calculate the test stats first // which minimizes the amount of for loops const logs = []; const originalLog = this.log.bind(this); this.log = (msg) => logs.push(msg); const visit = (suiteState, children) => { for (const child of children) if (child.type === "suite") { const suiteState = child.state(); // Skipped suites are hidden when --hideSkippedTests, print otherwise if (!this.ctx.config.hideSkippedTests || suiteState !== "skipped") this.printTestSuite(child); visit(suiteState, child.children); } else { const testResult = child.result(); testsCount++; if (testResult.state === "failed") failedCount++; else if (testResult.state === "skipped") skippedCount++; if (this.ctx.config.hideSkippedTests && suiteState === "skipped") // Skipped suites are hidden when --hideSkippedTests continue; this.printTestCase(moduleState, child); } }; try { visit(moduleState, testModule.children); } finally { this.log = originalLog; } this.log(this.getModuleLog(testModule, { tests: testsCount, failed: failedCount, skipped: skippedCount })); logs.forEach((log) => this.log(log)); } printTestCase(moduleState, test) { const testResult = test.result(); const { duration, retryCount, repeatCount } = test.diagnostic() || {}; const padding = this.getTestIndentation(test.task); let suffix = this.getDurationPrefix(test.task); if (retryCount != null && retryCount > 0) suffix += c.yellow(` (retry x${retryCount})`); if (repeatCount != null && repeatCount > 0) suffix += c.yellow(` (repeat x${repeatCount})`); if (testResult.state === "failed") { this.log(c.red(` ${padding}${taskFail} ${this.getTestName(test.task, c.dim(" > "))}`) + suffix); // print short errors, full errors will be at the end in summary testResult.errors.forEach((error) => { const message = this.formatShortError(error); if (message) this.log(c.red(` ${padding}${message}`)); }); } else if (duration && duration > this.ctx.config.slowTestThreshold) this.log(` ${padding}${c.yellow(c.dim(F_CHECK))} ${this.getTestName(test.task, c.dim(" > "))} ${suffix}`); else if (this.ctx.config.hideSkippedTests && testResult.state === "skipped") ; else if (testResult.state === "skipped" && testResult.note) this.log(` ${padding}${getStateSymbol(test.task)} ${this.getTestName(test.task, c.dim(" > "))}${c.dim(c.gray(` [${testResult.note}]`))}`); else if (this.renderSucceed || moduleState === "failed") this.log(` ${padding}${getStateSymbol(test.task)} ${this.getTestName(test.task, c.dim(" > "))}${suffix}`); } getModuleLog(testModule, counts) { let state = c.dim(`${counts.tests} test${counts.tests > 1 ? "s" : ""}`); if (counts.failed) state += c.dim(" | ") + c.red(`${counts.failed} failed`); if (counts.skipped) state += c.dim(" | ") + c.yellow(`${counts.skipped} skipped`); let suffix = c.dim("(") + state + c.dim(")") + this.getDurationPrefix(testModule.task); const diagnostic = testModule.diagnostic(); if (diagnostic.heap != null) suffix += c.magenta(` ${Math.floor(diagnostic.heap / 1024 / 1024)} MB heap used`); let title = getStateSymbol(testModule.task); if (testModule.meta().typecheck) title += ` ${c.bgBlue(c.bold(" TS "))}`; if (testModule.project.name) title += ` ${formatProjectName(testModule.project, "")}`; return ` ${title} ${testModule.task.name} ${suffix}`; } printTestSuite(_suite) { // Suite name is included in getTestName by default } getTestName(test, separator) { return getTestName(test, separator); } formatShortError(error) { return `${F_RIGHT} ${error.message}`; } getTestIndentation(_test) { return " "; } printAnnotations(test, console, padding = 0) { const annotations = test.annotations(); if (!annotations.length) return; const PADDING = " ".repeat(padding); annotations.forEach(({ location, type, message }) => { if (location) { const file = relative(test.project.config.root, location.file); this[console](`${PADDING}${c.blue(F_POINTER)} ${c.gray(`${file}:${location.line}:${location.column}`)} ${c.bold(type)}`); } else this[console](`${PADDING}${c.blue(F_POINTER)} ${c.bold(type)}`); this[console](`${PADDING} ${c.blue(F_DOWN_RIGHT)} ${message}`); }); } getDurationPrefix(task) { if (!task.result?.duration) return ""; const color = task.result.duration > this.ctx.config.slowTestThreshold ? c.yellow : c.green; return color(` ${Math.round(task.result.duration)}${c.dim("ms")}`); } onWatcherStart(files = this.ctx.state.getFiles(), errors = this.ctx.state.getUnhandledErrors()) { const failed = errors.length > 0 || hasFailed(files); if (failed) this.log(withLabel("red", "FAIL", "Tests failed. Watching for file changes...")); else if (this.ctx.isCancelling) this.log(withLabel("red", "CANCELLED", "Test run cancelled. Watching for file changes...")); else this.log(withLabel("green", "PASS", "Waiting for file changes...")); const hints = [c.dim("press ") + c.bold("h") + c.dim(" to show help")]; if (hasFailedSnapshot(files)) hints.unshift(c.dim("press ") + c.bold(c.yellow("u")) + c.dim(" to update snapshot")); else hints.push(c.dim("press ") + c.bold("q") + c.dim(" to quit")); this.log(BADGE_PADDING + hints.join(c.dim(", "))); } onWatcherRerun(files, trigger) { this.watchFilters = files; this.failedUnwatchedFiles = this.ctx.state.getTestModules().filter((testModule) => !files.includes(testModule.task.filepath) && testModule.state() === "failed"); // Update re-run count for each file files.forEach((filepath) => { let reruns = this._filesInWatchMode.get(filepath) ?? 0; this._filesInWatchMode.set(filepath, ++reruns); }); let banner = trigger ? c.dim(`${this.relative(trigger)} `) : ""; if (files.length === 1) { const rerun = this._filesInWatchMode.get(files[0]) ?? 1; banner += c.blue(`x${rerun} `); } this.ctx.logger.clearFullScreen(); this.log(withLabel("blue", "RERUN", banner)); if (this.ctx.configOverride.project) this.log(BADGE_PADDING + c.dim(" Project name: ") + c.blue(toArray(this.ctx.configOverride.project).join(", "))); if (this.ctx.filenamePattern) this.log(BADGE_PADDING + c.dim(" Filename pattern: ") + c.blue(this.ctx.filenamePattern.join(", "))); if (this.ctx.configOverride.testNamePattern) this.log(BADGE_PADDING + c.dim(" Test name pattern: ") + c.blue(String(this.ctx.configOverride.testNamePattern))); this.log(""); for (const testModule of this.failedUnwatchedFiles) this.printTestModule(testModule); this._timeStart = formatTimeString(/* @__PURE__ */ new Date()); this.start = performance$1.now(); } onUserConsoleLog(log, taskState) { if (!this.shouldLog(log, taskState)) return; const output = log.type === "stdout" ? this.ctx.logger.outputStream : this.ctx.logger.errorStream; const write = (msg) => output.write(msg); let headerText = "unknown test"; const task = log.taskId ? this.ctx.state.idMap.get(log.taskId) : void 0; if (task) headerText = getFullName(task, c.dim(" > ")); else if (log.taskId && log.taskId !== "__vitest__unknown_test__") headerText = log.taskId; write(c.gray(log.type + c.dim(` | ${headerText}\n`)) + log.content); if (log.origin) { // browser logs don't have an extra end of line at the end like Node.js does if (log.browser) write("\n"); const project = task ? this.ctx.getProjectByName(task.file.projectName || "") : this.ctx.getRootProject(); const stack = log.browser ? project.browser?.parseStacktrace(log.origin) || [] : parseStacktrace(log.origin); const highlight = task && stack.find((i) => i.file === task.file.filepath); for (const frame of stack) { const color = frame === highlight ? c.cyan : c.gray; const path = relative(project.config.root, frame.file); const positions = [frame.method, `${path}:${c.dim(`${frame.line}:${frame.column}`)}`].filter(Boolean).join(" "); write(color(` ${c.dim(F_POINTER)} ${positions}\n`)); } } write("\n"); } onTestRemoved(trigger) { this.log(c.yellow("Test removed...") + (trigger ? c.dim(` [ ${this.relative(trigger)} ]\n`) : "")); } shouldLog(log, taskState) { if (this.ctx.config.silent === true) return false; if (this.ctx.config.silent === "passed-only" && taskState !== "failed") return false; const shouldLog = this.ctx.config.onConsoleLog?.(log.content, log.type); if (shouldLog === false) return shouldLog; return true; } onServerRestart(reason) { this.log(c.bold(c.magenta(reason === "config" ? "\nRestarting due to config changes..." : "\nRestarting Vitest..."))); } reportSummary(files, errors) { this.printErrorsSummary(files, errors); if (this.ctx.config.mode === "benchmark") this.reportBenchmarkSummary(files); else this.reportTestSummary(files, errors); } reportTestSummary(files, errors) { this.log(); const affectedFiles = [...this.failedUnwatchedFiles.map((m) => m.task), ...files]; const tests = getTests(affectedFiles); const snapshotOutput = renderSnapshotSummary(this.ctx.config.root, this.ctx.snapshot.summary); for (const [index, snapshot] of snapshotOutput.entries()) { const title = index === 0 ? "Snapshots" : ""; this.log(`${padSummaryTitle(title)} ${snapshot}`); } if (snapshotOutput.length > 1) this.log(); this.log(padSummaryTitle("Test Files"), getStateString$1(affectedFiles)); this.log(padSummaryTitle("Tests"), getStateString$1(tests)); if (this.ctx.projects.some((c) => c.config.typecheck.enabled)) { const failed = tests.filter((t) => t.meta?.typecheck && t.result?.errors?.length); this.log(padSummaryTitle("Type Errors"), failed.length ? c.bold(c.red(`${failed.length} failed`)) : c.dim("no errors")); } if (errors.length) this.log(padSummaryTitle("Errors"), c.bold(c.red(`${errors.length} error${errors.length > 1 ? "s" : ""}`))); this.log(padSummaryTitle("Start at"), this._timeStart); const collectTime = sum(files, (file) => file.collectDuration); const testsTime = sum(files, (file) => file.result?.duration); const setupTime = sum(files, (file) => file.setupDuration); if (this.watchFilters) this.log(padSummaryTitle("Duration"), formatTime(collectTime + testsTime + setupTime)); else { const blobs = this.ctx.state.blobs; // Execution time is either sum of all runs of `--merge-reports` or the current run's time const executionTime = blobs?.executionTimes ? sum(blobs.executionTimes, (time) => time) : this.end - this.start; const environmentTime = sum(files, (file) => file.environmentLoad); const prepareTime = sum(files, (file) => file.prepareDuration); const transformTime = sum(this.ctx.projects, (project) => project.vitenode.getTotalDuration()); const typecheck = sum(this.ctx.projects, (project) => project.typechecker?.getResult().time); const timers = [ `transform ${formatTime(transformTime)}`, `setup ${formatTime(setupTime)}`, `collect ${formatTime(collectTime)}`, `tests ${formatTime(testsTime)}`, `environment ${formatTime(environmentTime)}`, `prepare ${formatTime(prepareTime)}`, typecheck && `typecheck ${formatTime(typecheck)}` ].filter(Boolean).join(", "); this.log(padSummaryTitle("Duration"), formatTime(executionTime) + c.dim(` (${timers})`)); if (blobs?.executionTimes) this.log(padSummaryTitle("Per blob") + blobs.executionTimes.map((time) => ` ${formatTime(time)}`).join("")); } this.log(); } printErrorsSummary(files, errors) { const suites = getSuites(files); const tests = getTests(files); const failedSuites = suites.filter((i) => i.result?.errors); const failedTests = tests.filter((i) => i.result?.state === "fail"); const failedTotal = countTestErrors(failedSuites) + countTestErrors(failedTests); let current = 1; const errorDivider = () => this.error(`${c.red(c.dim(divider(`[${current++}/${failedTotal}]`, void 0, 1)))}\n`); if (failedSuites.length) { this.error(`\n${errorBanner(`Failed Suites ${failedSuites.length}`)}\n`); this.printTaskErrors(failedSuites, errorDivider); } if (failedTests.length) { this.error(`\n${errorBanner(`Failed Tests ${failedTests.length}`)}\n`); this.printTaskErrors(failedTests, errorDivider); } if (errors.length) { this.ctx.logger.printUnhandledErrors(errors); this.error(); } } reportBenchmarkSummary(files) { const benches = getTests(files); const topBenches = benches.filter((i) => i.result?.benchmark?.rank === 1); this.log(`\n${withLabel("cyan", "BENCH", "Summary\n")}`); for (const bench of topBenches) { const group = bench.suite || bench.file; if (!group) continue; const groupName = getFullName(group, c.dim(" > ")); const project = this.ctx.projects.find((p) => p.name === bench.file.projectName); this.log(` ${formatProjectName(project)}${bench.name}${c.dim(` - ${groupName}`)}`); const siblings = group.tasks.filter((i) => i.meta.benchmark && i.result?.benchmark && i !== bench).sort((a, b) => a.result.benchmark.rank - b.result.benchmark.rank); for (const sibling of siblings) { const number = (sibling.result.benchmark.mean / bench.result.benchmark.mean).toFixed(2); this.log(c.green(` ${number}x `) + c.gray("faster than ") + sibling.name); } this.log(""); } } printTaskErrors(tasks, errorDivider) { const errorsQueue = []; for (const task of tasks) // Merge identical errors task.result?.errors?.forEach((error) => { let previous; if (error?.stack) previous = errorsQueue.find((i) => { if (i[0]?.stack !== error.stack) return false; const currentProjectName = task?.projectName || task.file?.projectName || ""; const projectName = i[1][0]?.projectName || i[1][0].file?.projectName || ""; const currentAnnotations = task.type === "test" && task.annotations; const itemAnnotations = i[1][0].type === "test" && i[1][0].annotations; return projectName === currentProjectName && deepEqual(currentAnnotations, itemAnnotations); }); if (previous) previous[1].push(task); else errorsQueue.push([error, [task]]); }); for (const [error, tasks] of errorsQueue) { for (const task of tasks) { const filepath = task?.filepath || ""; const projectName = task?.projectName || task.file?.projectName || ""; const project = this.ctx.projects.find((p) => p.name === projectName); let name = getFullName(task, c.dim(" > ")); if (filepath) name += c.dim(` [ ${this.relative(filepath)} ]`); this.ctx.logger.error(`${c.bgRed(c.bold(" FAIL "))} ${formatProjectName(project)}${name}`); } const screenshotPaths = tasks.map((t) => t.meta?.failScreenshotPath).filter((screenshot) => screenshot != null); this.ctx.logger.printError(error, { project: this.ctx.getProjectByName(tasks[0].file.projectName || ""), verbose: this.verbose, screenshotPaths, task: tasks[0] }); if (tasks[0].type === "test" && tasks[0].annotations.length) { const test = this.ctx.state.getReportedEntity(tasks[0]); this.printAnnotations(test, "error", 1); this.error(); } errorDivider(); } } } function deepEqual(a, b) { if (a === b) return true; if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) if (!keysB.includes(key) || !deepEqual(a[key], b[key])) return false; return true; } function sum(items, cb) { return items.reduce((total, next) => { return total + Math.max(cb(next) || 0, 0); }, 0); } class BasicReporter extends BaseReporter { constructor() { super(); this.isTTY = false; } onInit(ctx) { super.onInit(ctx); ctx.logger.deprecate(`'basic' reporter is deprecated and will be removed in Vitest v3.\nRemove 'basic' from 'reporters' option. To match 'basic' reporter 100%, use configuration:\n${JSON.stringify({ test: { reporters: [["default", { summary: false }]] } }, null, 2)}`); } reportSummary(files, errors) { // non-tty mode doesn't add a new line this.ctx.logger.log(); return super.reportSummary(files, errors); } } /// <reference types="../types/index.d.ts" /> // (c) 2020-present Andrea Giammarchi const {parse: $parse, stringify: $stringify} = JSON; const {keys} = Object; const Primitive = String; // it could be Number const primitive = 'string'; // it could be 'number' const ignore = {}; const object = 'object'; const noop = (_, value) => value; const primitives = value => ( value instanceof Primitive ? Primitive(value) : value ); const Primitives = (_, value) => ( typeof value === primitive ? new Primitive(value) : value ); const revive = (input, parsed, output, $) => { const lazy = []; for (let ke = keys(output), {length} = ke, y = 0; y < length; y++) { const k = ke[y]; const value = output[k]; if (value instanceof Primitive) { const tmp = input[value]; if (typeof tmp === object && !parsed.has(tmp)) { parsed.add(tmp); output[k] = ignore; lazy.push({k, a: [input, parsed, tmp, $]}); } else output[k] = $.call(output, k, tmp); } else if (output[k] !== ignore) output[k] = $.call(output, k, value); } for (let {length} = lazy, i = 0; i < length; i++) { const {k, a} = lazy[i]; output[k] = $.call(output, k, revive.apply(null, a)); } return output; }; const set = (known, input, value) => { const index = Primitive(input.push(value) - 1); known.set(value, index); return index; }; /** * Converts a specialized flatted string into a JS value. * @param {string} text * @param {(this: any, key: string, value: any) => any} [reviver] * @returns {any} */ const parse = (text, reviver) => { const input = $parse(text, Primitives).map(primitives); const value = input[0]; const $ = reviver || noop; const tmp = typeof value === object && value ? revive(input, new Set, value, $) : value; return $.call({'': tmp}, '', tmp); }; /** * Converts a JS value into a specialized flatted string. * @param {any} value * @param {((this: any, key: string, value: any) => any) | (string | number)[] | null | undefined} [replacer] * @param {string | number | undefined} [space] * @returns {string} */ const stringify = (value, replacer, space) => { const $ = replacer && typeof replacer === object ? (k, v) => (k === '' || -1 < replacer.indexOf(k) ? v : void 0) : (replacer || noop); const known = new Map; const input = []; const output = []; let i = +set(known, input, $.call({'': value}, '', value)); let firstRun = !i; while (i < input.length) { firstRun = true; output[i] = $stringify(input[i++], replace, space); } return '[' + output.join(',') + ']'; function replace(key, value) { if (firstRun) { firstRun = !firstRun; return value; } const after = $.call(this, key, value); switch (typeof after) { case object: if (after === null) return after; case primitive: return known.get(after) || set(known, input, after); } return after; } }; class BlobReporter { start = 0; ctx; options; constructor(options) { this.options = options; } onInit(ctx) { if (ctx.config.watch) throw new Error("Blob reporter is not supported in watch mode"); this.ctx = ctx; this.start = performance.now(); } async onFinished(files = [], errors = [], coverage) { const executionTime = performance.now() - this.start; let outputFile = this.options.outputFile ?? getOutputFile(this.ctx.config, "blob"); if (!outputFile) { const shard = this.ctx.config.shard; outputFile = shard ? `.vitest-reports/blob-${shard.index}-${shard.count}.json` : ".vitest-reports/blob.json"; } const modules = this.ctx.projects.map((project) => { return [project.name, [...project.vite.moduleGraph.idToModuleMap.entries()].map((mod) => { if (!mod[1].file) return null; return [ mod[0], mod[1].file, mod[1].url ]; }).filter((x) => x != null)]; }); const report = [ this.ctx.version, files, errors, modules, coverage, executionTime ]; const reportFile = resolve(this.ctx.config.root, outputFile); await writeBlob(report, reportFile); this.ctx.logger.log("blob report written to", reportFile); } } async function writeBlob(content, filename) { const report = stringify(content); const dir = dirname(filename); if (!existsSync(dir)) await mkdir(dir, { recursive: true }); await writeFile(filename, report, "utf-8"); } async function readBlobs(currentVersion, blobsDirectory, projectsArray) { // using process.cwd() because --merge-reports can only be used in CLI const resolvedDir = resolve(process.cwd(), blobsDirectory); const blobsFiles = await readdir(resolvedDir); const promises = blobsFiles.map(async (filename) => { const fullPath = resolve(resolvedDir, filename); const stats = await stat(fullPath); if (!stats.isFile()) throw new TypeError(`vitest.mergeReports() expects all paths in "${blobsDirectory}" to be files generated by the blob reporter, but "${filename}" is not a file`); const content = await readFile(fullPath, "utf-8"); const [version, files, errors, moduleKeys, coverage, executionTime] = parse(content); if (!version) throw new TypeError(`vitest.mergeReports() expects all paths in "${blobsDirectory}" to be files generated by the blob reporter, but "${filename}" is not a valid blob file`); return { version, files, errors, moduleKeys, coverage, file: filename, executionTime }; }); const blobs = await Promise.all(promises); if (!blobs.length) throw new Error(`vitest.mergeReports() requires at least one blob file in "${blobsDirectory}" directory, but none were found`); const versions = new Set(blobs.map((blob) => blob.version)); if (versions.size > 1) throw new Error(`vitest.mergeReports() requires all blob files to be generated by the same Vitest version, received\n\n${blobs.map((b) => `- "${b.file}" uses v${b.version}`).join("\n")}`); if (!versions.has(currentVersion)) throw new Error(`the blobs in "${blobsDirectory}" were generated by a different version of Vitest. Expected v${currentVersion}, but received v${blobs[0].version}`); // fake module graph - it is used to check if module is imported, but we don't use values inside const projects = Object.fromEntries(projectsArray.map((p) => [p.name, p])); blobs.forEach((blob) => { blob.moduleKeys.forEach(([projectName, moduleIds]) => { const project = projects[projectName]; if (!project) return; moduleIds.forEach(([moduleId, file, url]) => { const moduleNode = project.vite.moduleGraph.createFileOnlyEntry(file); moduleNode.url = url; moduleNode.id = moduleId; project.vite.moduleGraph.idToModuleMap.set(moduleId, moduleNode); }); }); }); const files = blobs.flatMap((blob) => blob.files).sort((f1, f2) => { const time1 = f1.result?.startTime || 0; const time2 = f2.result?.startTime || 0; return time1 - time2; }); const errors = blobs.flatMap((blob) => blob.errors); const coverages = blobs.map((blob) => blob.coverage); const executionTimes = blobs.map((blob) => blob.executionTime); return { files, errors, coverages, executionTimes }; } const DEFAULT_RENDER_INTERVAL_MS = 1e3; const ESC = "\x1B["; const CLEAR_LINE = `${ESC}K`; const MOVE_CURSOR_ONE_ROW_UP = `${ESC}1A`; const SYNC_START = `${ESC}?2026h`; const SYNC_END = `${ESC}?2026l`; /** * Renders content of `getWindow` at the bottom of the terminal and * forwards all other intercepted `stdout` and `stderr` logs above it. */ class WindowRenderer { options; streams; buffer = []; renderInterval = void 0; renderScheduled = false; windowHeight = 0; finished = false; cleanups = []; constructor(options) { this.options = { interval: DEFAULT_RENDER_INTERVAL_MS, ...options }; this.streams = { output: options.logger.outputStream.write.bind(options.logger.outputStream), error: options.logger.errorStream.write.bind(options.logger.errorStream) }; this.cleanups.push(this.interceptStream(process.stdout, "output"), this.interceptStream(process.stderr, "error")); // Write buffered content on unexpected exits, e.g. direct `process.exit()` calls this.options.logger.onTerminalCleanup(() => { this.flushBuffer(); this.stop(); }); this.start(); } start() { this.finished = false; this.renderInterval = setInterval(() => this.schedule(), this.options.interval).unref(); } stop() { this.cleanups.splice(0).map((fn) => fn()); clearInterval(this.renderInterval); } /** * Write all buffered output and stop buffering. * All intercepted writes are forwarded to actual write after this. */ finish() { this.finished = true; this.flushBuffer(); clearInterval(this.renderInterval); } /** * Queue new render update */ schedule() { if (!this.renderScheduled) { this.renderScheduled = true; this.flushBuffer(); setTimeout(() => { this.renderScheduled = false; }, 100).unref(); } } flushBuffer() { if (this.buffer.length === 0) return this.render(); let current; // Concatenate same types into a single render for (const next of this.buffer.splice(0)) { if (!current) { current = next; continue; } if (current.type !== next.type) { this.render(current.message, current.type); current = next; continue; } current.message += next.message; } if (current) this.render(current?.message, current?.type); } render(message, type = "output") { if (this.finished) { this.clearWindow(); return this.write(message || "", type); } const windowContent = this.options.getWindow(); const rowCount = getRenderedRowCount(windowContent, this.options.logger.getColumns()); let padding = this.windowHeight - rowCount; if (padding > 0 && message) padding -= getRenderedRowCount([message], this.options.logger.getColumns()); this.write(SYNC_START); this.clearWindow(); if (message) this.write(message, type); if (padding > 0) this.write("\n".repeat(padding)); this.write(windowContent.join("\n")); this.write(SYNC_END); this.windowHeight = rowCount + Math.max(0, padding); } clearWindow() { if (this.windowHeight === 0) return; this.write(CLEAR_LINE); for (let i = 1; i < this.windowHeight; i++) this.write(`${MOVE_CURSOR_ONE_ROW_UP}${CLEAR_LINE}`); this.windowHeight = 0; } interceptStream(stream, type) { const original = stream.write; // @ts-expect-error -- not sure how 2 overloads should be typed stream.write = (chunk, _, callback) => { if (chunk) if (this.finished) this.write(chunk.toString(), type); else this.buffer.push({ type, message: chunk.toString() }); callback?.(); }; return function restore() { stream.write = original; }; } write(message, type = "output") { this.streams[type](message); } } /** Calculate the actual row count needed to render `rows` into `stream` */ function getRenderedRowCount(rows, columns) { let count = 0; for (const row of rows) { const text = stripVTControlCharacters(row); count += Math.max(1, Math.ceil(text.length / columns)); } return count; } const DURATION_UPDATE_INTERVAL_MS = 100; const FINISHED_TEST_CLEANUP_TIME_MS = 1e3; /** * Reporter extension that renders summary and forwards all other logs above itself. * Intended to be used by other reporters, not as a standalone reporter. */ class SummaryReporter { ctx; options; renderer; modules = emptyCounters(); tests = emptyCounters(); maxParallelTests = 0; /** Currently running test modules, may include finished test modules too */ runningModules = /* @__PURE__ */ new Map(); /** ID of finished `this.runningModules` that are currently being shown */ finishedModules = /* @__PURE__ */ new Map(); startTime = ""; currentTime = 0; duration = 0; durationInterval = void 0; onInit(ctx, options = {}) { this.ctx = ctx; this.options = { verbose: false, ...options }; this.renderer = new WindowRenderer({ logger: ctx.logger, getWindow: () => this.createSummary() }); this.ctx.onClose(() => { clearInterval(this.durationInterval); this.renderer.stop(); }); } onTestRunStart(specifications) { this.runningModules.clear(); this.finishedModules.clear(); this.modules = emptyCounters(); this.tests = emptyCounters(); this.startTimers(); this.renderer.start(); this.modules.total = specifications.length; } onTestRunEnd() { this.runningModules.clear(); this.finishedModules.clear(); this.renderer.finish(); clearInterval(this.durationInterval); } onTestModuleQueued(module) { // When new test module starts, take the place of previously finished test module, if any if (this.finishedModules.size) { const finished = this.finishedModules.keys().next().value; this.removeTestModule(finished); } this.runningModules.set(module.id, initializeStats(module)); this.renderer.schedule(); } onTestModuleCollected(module) { let stats = this.runningModules.get(module.id); if (!stats) { stats = initializeStats(module); this.runningModules.set(module.id, stats); } const total = Array.from(module.children.allTests()).length; this.tests.total += total; stats.total = total; this.maxParallelTests = Math.max(this.maxParallelTests, this.runningModules.size); this.renderer.schedule(); } onHookStart(options) { const stats = this.getHookStats(options); if (!stats) return; const hook = { name: options.name, visible: false, startTime: performance.now(), onFinish: () => {} }; stats.hook?.onFinish?.(); stats.hook = hook; const timeout = setTimeout(() => { hook.visible = true; }, this.ctx.config.slowTestThreshold).unref(); hook.onFinish = () => clearTimeout(timeout); } onHookEnd(options) { const stats = this.getHookStats(options); if (stats?.hook?.name !== options.name) return; stats.hook.onFinish(); stats.hook.visible = false; } onTestCaseReady(test) { // Track slow running tests only on verbose mode if (!this.options.verbose) return; const stats = this.runningModules.get(test.module.id); if (!stats || stats.tests.has(test.id)) return; const slowTest = { name: test.name, visible: false, startTime: performance.now(), onFinish: () => {} }; const timeout = setTimeout(() => { slowTest.visible = true; }, this.ctx.config.slowTestThreshold).unref(); slowTest.onFinish = () => { slowTest.hook?.onFinish(); clearTimeout(timeout); }; stats.tests.set(test.id, slowTest); } onTestCaseResult(test) { const stats = this.runningModules.get(test.module.id); if (!stats) return; stats.tests.get(test.id)?.onFinish(); stats.tests.delete(test.id); stats.completed++; const result = test.result(); if (result?.state === "passed") this.tests.passed++; else if (result?.state === "failed") this.tests.failed++; else if (!result?.state || result?.state === "skipped") this.tests.skipped++; this.renderer.schedule(); } onTestModuleEnd(module) { const state = module.state(); this.modules.completed++; if (state === "passed") this.modules.passed++; else if (state === "failed") this.modules.failed++; else if (module.task.mode === "todo" && state === "skipped") this.modules.todo++; else if (state === "skipped") this.modules.skipped++; const left = this.modules.total - this.modules.completed; // Keep finished tests visible in summary for a while if there are more tests left. // When a new test starts in onTestModuleQueued it will take this ones place. // This reduces flickering by making summary more stable. if (left > this.maxParallelTests) this.finishedModules.set(module.id, setTimeout(() => { this.removeTestModule(module.id); }, FINISHED_TEST_CLEANUP_TIME_MS).unref()); else // Run is about to end as there are less tests left than whole run had parallel at max. // Remove finished test immediately. this.removeTestModule(module.id); this.renderer.schedule(); } getHookStats({ entity }) { // Track slow running hooks only on verbose mode if (!this.options.verbose) return; const module = entity.type === "module" ? entity : entity.module; const stats = this.runningModules.get(module.id); if (!stats) return; return entity.type === "test" ? stats.tests.get(entity.id) : stats; } createSummary() { const summary = [""]; for (const testFile of Array.from(this.runningModules.values()).sort(sortRunningModules)) { const typecheck = testFile.typecheck ? `${c.bgBlue(c.bold(" TS "))} ` : ""; summary.push(c.bold(c.yellow(` ${F_POINTER} `)) + formatProjectName({ name: testFile.projectName, color: testFile.projectColor }) + typecheck + testFile.filename + c.dim(!testFile.completed && !testFile.total ? " [queued]" : ` ${testFile.completed}/${testFile.total}`)); const slowTasks = [testFile.hook, ...Array.from(testFile.tests.values())].filter((t) => t != null && t.visible); for (const [index, task] of slowTasks.entries()) { const elapsed = this.currentTime - task.startTime; const icon = index === slowTasks.length - 1 ? F_TREE_NODE_END : F_TREE_NODE_MIDDLE; summary.push(c.bold(c.yellow(` ${icon} `)) + task.name + c.bold(c.yellow(` ${formatTime(Math.max(0, elapsed))}`))); if (task.hook?.visible) summary.push(c.bold(c.yellow(` ${F_TREE_NODE_END} `)) + task.hook.name); } } if (this.runningModules.size > 0) summary.push(""); summary.push(padSummaryTitle("Test Files") + getStateString(this.modules)); summary.push(padSummaryTitle("Tests") + getStateString(this.tests)); summary.push(padSummaryTitle("Start at") + this.startTime); summary.push(padSummaryTitle("Duration") + formatTime(this.duration)); summary.push(""); return summary; } startTimers() { const start = performance.now(); this.startTime = formatTimeString(/* @__PURE__ */ new Date()); this.durationInterval = setInterval(() => { this.currentTime = performance.now(); this.duration = this.currentTime - start; }, DURATION_UPDATE_INTERVAL_MS).unref(); } removeTestModule(id) { if (!id) return; const testFile = this.runningModules.get(id); testFile?.hook?.onFinish(); testFile?.tests?.forEach((test) => test.onFinish()); this.runningModules.delete(id); clearTimeout(this.finishedModules.get(id)); this.finishedModules.delete(id); } } function emptyCounters() { return { completed: 0, passed: 0, failed: 0, skipped: 0, todo: 0, total: 0 }; } function getStateString(entry) { return [ entry.failed ? c.bold(c.red(`${entry.failed} failed`)) : null, c.bold(c.green(`${entry.passed} passed`)), entry.skipped ? c.yellow(`${entry.skipped} skipped`) : null, entry.todo ? c.gray(`${entry.todo} todo`) : null ].filter(Boolean).join(c.dim(" | ")) + c.gray(` (${entry.total})`); } function sortRunningModules(a, b) { if ((a.projectName || "") > (b.projectName || "")) return 1; if ((a.projectName || "") < (b.projectName || "")) return -1; return a.filename.localeCompare(b.filename); } function initializeStats(module) { return { total: 0, completed: 0, filename: module.task.name, projectName: module.project.name, projectColor: module.project.color, tests: /* @__PURE__ */ new Map(), typecheck: !!module.task.meta.typecheck }; } class DefaultReporter extends BaseReporter { options; summary; constructor(options = {}) { super(options); this.options = { summary: true, ...options }; if (!this.isTTY) this.options.summary = false; if (this.options.summary) this.summary = new SummaryReporter(); } onTestRunStart(specifications) { if (this.isTTY) { if (this.renderSucceed === void 0) this.renderSucceed = !!this.renderSucceed; if (this.renderSucceed !== true) this.renderSucceed = specifications.length <= 1; } this.summary?.onTestRunStart(specifications); } onTestModuleQueued(file) { this.summary?.onTestModuleQueued(file); } onTestModuleCollected(module) { this.summary?.onTestModuleCollected(module); } onTestModuleEnd(module) { super.onTestModuleEnd(module); this.summary?.onTestModuleEnd(module); } onTestCaseReady(test) { this.summary?.onTestCaseReady(test); } onTestCaseResult(test) { super.onTestCaseResult(test); this.summary?.onTestCaseResult(test); } onHookStart(hook) { this.summary?.onHookStart(hook); } onHookEnd(hook) { this.summary?.onHookEnd(hook); } onInit(ctx) { super.onInit(ctx); this.summary?.onInit(ctx, { verbose: this.verbose }); } onTestRunEnd() { this.summary?.onTestRunEnd(); } } class DotReporter extends BaseReporter { renderer; tests = /* @__PURE__ */ new Map(); finishedTests = /* @__PURE__ */ new Set(); onInit(ctx) { super.onInit(ctx); if (this.isTTY) { this.renderer = new WindowRenderer({ logger: ctx.logger, getWindow: () => this.createSummary() }); this.ctx.onClose(() => this.renderer?.stop()); } } // Ignore default logging of base reporter printTestModule() {} onWatcherRerun(files, trigger) { this.tests.clear(); this.renderer?.start(); super.onWatcherRerun(files, trigger); } onFinished(files, errors) { if (this.isTTY) { const finalLog = formatTests(Array.from(this.tests.values())); this.ctx.logger.log(finalLog); } else this.ctx.logger.log(); this.tests.clear(); this.renderer?.finish(); super.onFinished(files, errors); } onTestModuleCollected(module) { for (const test of module.children.allTests()) // Dot reporter marks pending tests as running this.onTestCaseReady(test); } onTestCaseReady(test) { if (this.finishedTests.has(test.id)) return; this.tests.set(test.id, test.result().state || "run"); this.renderer?.schedule(); } onTestCaseResult(test) { const result = test.result().state; // On non-TTY the finished tests are printed immediately if (!this.isTTY && result !== "pending") this.ctx.logger.outputStream.write(formatTests([result])); super.onTestCaseResult(test); this.finishedTests.add(test.id); this.tests.set(test.id, result || "skipped"); this.renderer?.schedule(); } onTestModuleEnd(testModule) { super.onTestModuleEnd(testModule); if (!this.isTTY) return; const columns = this.ctx.logger.getColumns(); if (this.tests.size < columns) return; const finishedTests = Array.from(this.tests).filter((entry) => entry[1] !== "pending"); if (finishedTests.length < columns) return; // Remove finished tests from state and render them in static output const states = []; let count = 0; for (const [id, state] of finishedTests) { if (count++ >= columns) break; this.tests.delete(id); states.push(state); } this.ctx.logger.log(formatTests(states)); this.renderer?.schedule(); } createSummary() { return [formatTests(Array.from(this.tests.values())), ""]; } } // These are compared with reference equality in formatTests const pass = { char: "·", color: c.green }; const fail = { char: "x", color: c.red }; const pending = { char: "*", color: c.yellow }; const skip = { char: "-", color: (char) => c.dim(c.gray(char)) }; function getIcon(state) { switch (state) { case "passed": return pass; case "failed": return fail; case "skipped": return skip; default: return pending; } } /** * Format test states into string while keeping ANSI escapes at minimal. * Sibling icons with same color are merged into a single c.color() call. */ function formatTests(states) { let currentIcon = pending; let count = 0; let output = ""; for (const state of states) { const icon = getIcon(state); if (currentIcon === icon) { count++; continue; } output += currentIcon.color(currentIcon.char.repeat(count)); // Start tracking new group count = 1; currentIcon = icon; } output += currentIcon.color(currentIcon.char.repeat(count)); return output; } // use Logger with custom Console to capture entire error printing function capturePrintError(error, ctx, options) { let output = ""; const writable = new Writable({ write(chunk, _encoding, callback) { output += String(chunk); callback(); } }); const console = new Console(writable); const logger = { error: console.error.bind(console), highlight: ctx.logger.highlight.bind(ctx.logger) }; const result = printError(error, ctx, logger, { showCodeFrame: false, ...options }); return { nearest: result?.nearest, output }; } function printError(error, ctx, logger, options) { const project = options.project ?? ctx.coreWorkspaceProject ?? ctx.projects[0]; return printErrorInner(error, project, { logger, type: options.type, showCodeFrame: options.showCodeFrame, screenshotPaths: options.screenshotPaths, printProperties: options.verbose, parseErrorStacktrace(error) { // browser stack trace needs to be processed differently, // so there is a separate method for that if (options.task?.file.pool === "browser" && project.browser) return project.browser.parseErrorStacktrace(error, { ignoreStackEntries: options.fullStack ? [] : void 0 }); // node.js stack trace already has correct source map locations return parseErrorStacktrace(error, { frameFilter: project.config.onStackTrace, ignoreStackEntries: options.fullStack ? [] : void 0 }); } }); } function printErrorInner(error, project, options) { const { showCodeFrame = true, type, prin