UNPKG

vitest

Version:

Next generation testing framework powered by Vite

1,355 lines (1,332 loc) 129 kB
import { existsSync, readFileSync, promises } from 'node:fs'; import { mkdir, writeFile, readdir, stat, readFile } from 'node:fs/promises'; import { resolve as resolve$1, dirname, isAbsolute, relative, basename, join, normalize } from 'pathe'; import { performance as performance$1 } from 'node:perf_hooks'; import { getTests, getTestName, hasFailed, getSuites, generateHash, calculateSuiteHash, someTasksAreOnly, interpretTaskModes, getTasks, getFullName } from '@vitest/runner/utils'; import { slash, toArray, isPrimitive } from '@vitest/utils/helpers'; import { parseStacktrace, defaultStackIgnorePatterns, parseErrorStacktrace } from '@vitest/utils/source-map'; import c from 'tinyrainbow'; import { i as isTTY } from './env.D4Lgay0q.js'; import { stripVTControlCharacters } from 'node:util'; import { Console } from 'node:console'; import { Writable } from 'node:stream'; import { inspect } from '@vitest/utils/display'; import nodeos__default, { hostname } from 'node:os'; import { x } from 'tinyexec'; import { distDir } from '../path.js'; import { parseAstAsync } from 'vite'; import { positionToOffset, lineSplitRE } from '@vitest/utils/offset'; import { createRequire } from 'node:module'; /// <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$1 = {}; 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$1; lazy.push({k, a: [input, parsed, tmp, $]}); } else output[k] = $.call(output, k, tmp); } else if (output[k] !== ignore$1) 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$1 = (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; } }; function getOutputFile(config, reporter) { if (!config?.outputFile) return; if (typeof config.outputFile === "string") return config.outputFile; return config.outputFile[reporter]; } function createDefinesScript(define) { if (!define) return ""; if (serializeDefine(define) === "{}") return ""; return ` const defines = ${serializeDefine(define)} Object.keys(defines).forEach((key) => { const segments = key.split('.') let target = globalThis for (let i = 0; i < segments.length; i++) { const segment = segments[i] if (i === segments.length - 1) { target[segment] = defines[key] } else { target = target[segment] || (target[segment] = {}) } } }) `; } /** * Like `JSON.stringify` but keeps raw string values as a literal * in the generated code. For example: `"window"` would refer to * the global `window` object directly. */ function serializeDefine(define) { const userDefine = {}; for (const key in define) { // vitest sets this to avoid vite:client-inject plugin if (key === "process.env.NODE_ENV" && define[key] === "process.env.NODE_ENV") continue; // import.meta.env.* is handled in `importAnalysis` plugin if (!key.startsWith("import.meta.env.")) userDefine[key] = define[key]; } let res = `{`; const keys = Object.keys(userDefine).sort(); for (let i = 0; i < keys.length; i++) { const key = keys[i]; const val = userDefine[key]; res += `${JSON.stringify(key)}: ${handleDefineValue(val)}`; if (i !== keys.length - 1) res += `, `; } return `${res}}`; } function handleDefineValue(value) { if (typeof value === "undefined") return "undefined"; if (typeof value === "string") return value; return JSON.stringify(value); } class BlobReporter { start = 0; ctx; options; coverage; 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(); this.coverage = void 0; } onCoverage(coverage) { this.coverage = coverage; } async onTestRunEnd(testModules, unhandledErrors) { const executionTime = performance.now() - this.start; const files = testModules.map((testModule) => testModule.task); const errors = [...unhandledErrors]; const coverage = this.coverage; 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$1(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$1(process.cwd(), blobsDirectory); const promises = (await readdir(resolvedDir)).map(async (filename) => { const fullPath = resolve$1(resolvedDir, filename); if (!(await stat(fullPath)).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 [version, files, errors, moduleKeys, coverage, executionTime] = parse$1(await readFile(fullPath, "utf-8")); 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; moduleNode.transformResult = { code: " ", map: null }; project.vite.moduleGraph.idToModuleMap.set(moduleId, moduleNode); }); }); }); return { files: blobs.flatMap((blob) => blob.files).sort((f1, f2) => { return (f1.result?.startTime || 0) - (f2.result?.startTime || 0); }), errors: blobs.flatMap((blob) => blob.errors), coverages: blobs.map((blob) => blob.coverage), executionTimes: blobs.map((blob) => blob.executionTime) }; } function hasFailedSnapshot(suite) { return getTests(suite).some((s) => { return s.result?.errors?.some((e) => typeof e?.message === "string" && e.message.match(/Snapshot .* mismatched/)); }); } function convertTasksToEvents(file, onTask) { const packs = []; const events = []; function visit(suite) { onTask?.(suite); packs.push([ suite.id, suite.result, suite.meta ]); events.push([ suite.id, "suite-prepare", void 0 ]); suite.tasks.forEach((task) => { if (task.type === "suite") visit(task); else { onTask?.(task); if (suite.mode !== "skip" && suite.mode !== "todo") { packs.push([ task.id, task.result, task.meta ]); events.push([ task.id, "test-prepare", void 0 ]); task.annotations.forEach((annotation) => { events.push([ task.id, "test-annotation", { annotation } ]); }); task.artifacts.forEach((artifact) => { events.push([ task.id, "test-artifact", { artifact } ]); }); events.push([ task.id, "test-finished", void 0 ]); } } }); events.push([ suite.id, "suite-finished", void 0 ]); } visit(file); return { packs, events }; } 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 separator = c.dim(" > "); 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.reduce((acc, i) => i.result?.state === "pass" ? acc + 1 : acc, 0); const failed = tasks.reduce((acc, i) => i.result?.state === "fail" ? acc + 1 : acc, 0); const skipped = tasks.reduce((acc, i) => i.mode === "skip" ? acc + 1 : acc, 0); const todo = tasks.reduce((acc, i) => i.mode === "todo" ? acc + 1 : acc, 0); return [ failed ? c.bold(c.red(`${failed} failed`)) : null, passed ? c.bold(c.green(`${passed} passed`)) : null, skipped ? c.yellow(`${skipped} skipped`) : null, todo ? c.gray(`${todo} 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) background = labelDefaultColors[project.name.split("").reduce((acc, v, idx) => acc + v.charCodeAt(0) + idx, 0) % 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, separator: separator, 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(); } log(...messages) { this.ctx.logger.log(...messages); } error(...messages) { this.ctx.logger.error(...messages); } relative(path) { return relative(this.ctx.config.root, path); } onTestRunStart(_specifications) { this.start = performance$1.now(); this._timeStart = formatTimeString(/* @__PURE__ */ new Date()); } onTestRunEnd(testModules, unhandledErrors, _reason) { const files = testModules.map((testModule) => testModule.task); const errors = [...unhandledErrors]; 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 = 0 } = test.diagnostic() || {}; const padding = this.getTestIndentation(test.task); const suffix = this.getTestCaseSuffix(test); if (testResult.state === "failed") this.log(c.red(` ${padding}${taskFail} ${this.getTestName(test.task, separator)}`) + suffix); else if (duration > this.ctx.config.slowTestThreshold) this.log(` ${padding}${c.yellow(c.dim(F_CHECK))} ${this.getTestName(test.task, separator)} ${suffix}`); else if (this.ctx.config.hideSkippedTests && testResult.state === "skipped") ; else if (this.renderSucceed || moduleState === "failed") this.log(` ${padding}${this.getStateSymbol(test)} ${this.getTestName(test.task, separator)}${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`); return ` ${this.getEntityPrefix(testModule)} ${testModule.task.name} ${suffix}`; } printTestSuite(testSuite) { if (!this.renderSucceed) return; const indentation = " ".repeat(getIndentation(testSuite.task)); const tests = Array.from(testSuite.children.allTests()); const state = this.getStateSymbol(testSuite); this.log(` ${indentation}${state} ${testSuite.name} ${c.dim(`(${tests.length})`)}`); } getTestName(test, _separator) { return test.name; } getFullName(test, separator) { if (test === test.file) return test.name; let name = test.file.name; if (test.location) name += c.dim(`:${test.location.line}:${test.location.column}`); name += separator; name += getTestName(test, separator); return name; } getTestIndentation(test) { return " ".repeat(getIndentation(test)); } printAnnotations(test, console, padding = 0) { const annotations = test.annotations(); if (!annotations.length) return; const PADDING = " ".repeat(padding); const groupedAnnotations = {}; annotations.forEach((annotation) => { const { location, type } = annotation; let group; if (location) { const file = relative(test.project.config.root, location.file); group = `${c.gray(`${file}:${location.line}:${location.column}`)} ${c.bold(type)}`; } else group = c.bold(type); groupedAnnotations[group] ??= []; groupedAnnotations[group].push(annotation); }); for (const group in groupedAnnotations) { this[console](`${PADDING}${c.blue(F_POINTER)} ${group}`); groupedAnnotations[group].forEach(({ message }) => { this[console](`${PADDING} ${c.blue(F_DOWN_RIGHT)} ${message}`); }); } } getEntityPrefix(entity) { let title = this.getStateSymbol(entity); if (entity.project.name) title += ` ${formatProjectName(entity.project, "")}`; if (entity.meta().typecheck) title += ` ${c.bgBlue(c.bold(" TS "))}`; return title; } getTestCaseSuffix(testCase) { const { heap, retryCount, repeatCount } = testCase.diagnostic() || {}; const testResult = testCase.result(); let suffix = this.getDurationPrefix(testCase.task); if (retryCount != null && retryCount > 0) suffix += c.yellow(` (retry x${retryCount})`); if (repeatCount != null && repeatCount > 0) suffix += c.yellow(` (repeat x${repeatCount})`); if (heap != null) suffix += c.magenta(` ${Math.floor(heap / 1024 / 1024)} MB heap used`); if (testResult.state === "skipped" && testResult.note) suffix += c.dim(c.gray(` [${testResult.note}]`)); return suffix; } getStateSymbol(test) { return getStateSymbol(test.task); } getDurationPrefix(task) { const duration = task.result?.duration && Math.round(task.result?.duration); if (duration == null) return ""; return (duration > this.ctx.config.slowTestThreshold ? c.yellow : c.green)(` ${duration}${c.dim("ms")}`); } onWatcherStart(files = this.ctx.state.getFiles(), errors = this.ctx.state.getUnhandledErrors()) { if (errors.length > 0 || hasFailed(files)) 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); } 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 = this.getFullName(task, separator); 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; if (this.ctx.config.onConsoleLog) { const task = log.taskId ? this.ctx.state.idMap.get(log.taskId) : void 0; const entity = task && this.ctx.state.getReportedEntity(task); if (this.ctx.config.onConsoleLog(log.content, log.type, entity) === false) return false; } 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 = this.ctx.state.transformTime; 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); // TODO: error divider should take into account merged errors for counting 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 topBenches = getTests(files).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 = this.getFullName(group, separator); 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 || i[0]?.diff !== error.diff) 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 = this.getFullName(task, separator); 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); } function getIndentation(suite, level = 1) { if (suite.suite && !("filepath" in suite.suite)) return getIndentation(suite.suite, level + 1); return level; } 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; started = false; 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(); }); } start() { this.started = true; 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.started) 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++; // 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 (this.modules.total - this.modules.completed > 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, ...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; } super.onTestRunStart(specifications); this.summary?.onTestRunStart(specifications); } onTestRunEnd(testModules, unhandledErrors, reason) { super.onTestRunEnd(testModules, unhandledErrors, reason); this.summary?.onTestRunEnd(); } 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 }); } } 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); } onTestRunEnd(testModules, unhandledErrors, reaso