From 15450bba8d1dc0b5f6b32d368612fee17d050b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Sun, 10 Nov 2024 11:30:54 +0200 Subject: [PATCH] refactor(reporters): base reporter readability improvements --- packages/vitest/src/node/logger.ts | 77 +-- packages/vitest/src/node/reporters/base.ts | 487 ++++++++---------- packages/vitest/src/node/reporters/default.ts | 2 +- .../src/node/reporters/renderers/utils.ts | 6 + test/config/test/console-color.test.ts | 6 +- test/reporters/tests/merge-reports.test.ts | 4 +- 6 files changed, 246 insertions(+), 336 deletions(-) diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index 4d882b0c4b52..7ac51abb63bb 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -12,7 +12,7 @@ import { createLogUpdate } from 'log-update' import c from 'tinyrainbow' import { highlightCode } from '../utils/colors' import { printError } from './error' -import { divider } from './reporters/renderers/utils' +import { divider, withLabel } from './reporters/renderers/utils' import { RandomSequencer } from './sequencers/RandomSequencer' export interface ErrorOptions { @@ -25,6 +25,8 @@ export interface ErrorOptions { showCodeFrame?: boolean } +const PAD = ' ' + const ESC = '\x1B[' const ERASE_DOWN = `${ESC}J` const ERASE_SCROLLBACK = `${ESC}3J` @@ -64,13 +66,18 @@ export class Logger { this.console.warn(...args) } - clearFullScreen(message: string) { + clearFullScreen(message = '') { if (!this.ctx.config.clearScreen) { this.console.log(message) return } - this.console.log(`${CLEAR_SCREEN}${ERASE_SCROLLBACK}${message}`) + if (message) { + this.console.log(`${CLEAR_SCREEN}${ERASE_SCROLLBACK}${message}`) + } + else { + (this.outputStream as Writable).write(`${CLEAR_SCREEN}${ERASE_SCROLLBACK}`) + } } clearScreen(message: string, force = false) { @@ -201,23 +208,13 @@ export class Logger { printBanner() { this.log() - const versionTest = this.ctx.config.watch - ? c.blue(`v${this.ctx.version}`) - : c.cyan(`v${this.ctx.version}`) - const mode = this.ctx.config.watch ? c.blue(' DEV ') : c.cyan(' RUN ') + const color = this.ctx.config.watch ? 'blue' : 'cyan' + const mode = this.ctx.config.watch ? 'DEV' : 'RUN' - this.log( - `${c.inverse(c.bold(mode))} ${versionTest} ${c.gray( - this.ctx.config.root, - )}`, - ) + this.log(withLabel(color, mode, `v${this.ctx.version} `) + c.gray(this.ctx.config.root)) if (this.ctx.config.sequence.sequencer === RandomSequencer) { - this.log( - c.gray( - ` Running tests with seed "${this.ctx.config.sequence.seed}"`, - ), - ) + this.log(PAD + c.gray(`Running tests with seed "${this.ctx.config.sequence.seed}"`)) } this.ctx.projects.forEach((project) => { @@ -231,52 +228,32 @@ export class Logger { const origin = resolvedUrls?.local[0] ?? resolvedUrls?.network[0] const provider = project.browser.provider.name const providerString = provider === 'preview' ? '' : ` by ${provider}` - this.log( - c.dim( - c.green( - ` ${output} Browser runner started${providerString} at ${new URL('/', origin)}`, - ), - ), - ) + + this.log(PAD + c.dim(c.green(`${output} Browser runner started${providerString} at ${new URL('/', origin)}`))) }) if (this.ctx.config.ui) { - this.log( - c.dim( - c.green( - ` UI started at http://${ - this.ctx.config.api?.host || 'localhost' - }:${c.bold(`${this.ctx.server.config.server.port}`)}${ - this.ctx.config.uiBase - }`, - ), - ), - ) + const host = this.ctx.config.api?.host || 'localhost' + const port = this.ctx.server.config.server.port + const base = this.ctx.config.uiBase + + this.log(PAD + c.dim(c.green(`UI started at http://${host}:${c.bold(port)}${base}`))) } else if (this.ctx.config.api?.port) { const resolvedUrls = this.ctx.server.resolvedUrls // workaround for https://github.com/vitejs/vite/issues/15438, it was fixed in vite 5.1 - const fallbackUrl = `http://${this.ctx.config.api.host || 'localhost'}:${ - this.ctx.config.api.port - }` - const origin - = resolvedUrls?.local[0] ?? resolvedUrls?.network[0] ?? fallbackUrl - this.log(c.dim(c.green(` API started at ${new URL('/', origin)}`))) + const fallbackUrl = `http://${this.ctx.config.api.host || 'localhost'}:${this.ctx.config.api.port}` + const origin = resolvedUrls?.local[0] ?? resolvedUrls?.network[0] ?? fallbackUrl + + this.log(PAD + c.dim(c.green(`API started at ${new URL('/', origin)}`))) } if (this.ctx.coverageProvider) { - this.log( - c.dim(' Coverage enabled with ') - + c.yellow(this.ctx.coverageProvider.name), - ) + this.log(PAD + c.dim('Coverage enabled with ') + c.yellow(this.ctx.coverageProvider.name)) } if (this.ctx.config.standalone) { - this.log( - c.yellow( - `\nVitest is running in standalone mode. Edit a test file to rerun tests.`, - ), - ) + this.log(c.yellow(`\nVitest is running in standalone mode. Edit a test file to rerun tests.`)) } else { this.log() diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index 01e42b5958af..f4b2d7f4596b 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -11,33 +11,9 @@ import c from 'tinyrainbow' import { isCI, isDeno, isNode } from '../../utils/env' import { hasFailedSnapshot } from '../../utils/tasks' import { F_CHECK, F_POINTER, F_RIGHT } from './renderers/figures' -import { - countTestErrors, - divider, - formatProjectName, - formatTimeString, - getStateString, - getStateSymbol, - renderSnapshotSummary, - taskFail, -} from './renderers/utils' +import { countTestErrors, divider, formatProjectName, formatTimeString, getStateString, getStateSymbol, renderSnapshotSummary, taskFail, withLabel } from './renderers/utils' const BADGE_PADDING = ' ' -const HELP_HINT = `${c.dim('press ')}${c.bold('h')}${c.dim(' to show help')}` -const HELP_UPDATE_SNAP - = c.dim('press ') + c.bold(c.yellow('u')) + c.dim(' to update snapshot') -const HELP_QUITE = `${c.dim('press ')}${c.bold('q')}${c.dim(' to quit')}` - -const WAIT_FOR_CHANGE_PASS = `\n${c.bold( - c.inverse(c.green(' PASS ')), -)}${c.green(' Waiting for file changes...')}` -const WAIT_FOR_CHANGE_FAIL = `\n${c.bold(c.inverse(c.red(' FAIL ')))}${c.red( - ' Tests failed. Watching for file changes...', -)}` -const WAIT_FOR_CHANGE_CANCELLED = `\n${c.bold( - c.inverse(c.red(' CANCELLED ')), -)}${c.red(' Test run cancelled. Watching for file changes...')}` - const LAST_RUN_LOG_TIMEOUT = 1_500 export interface BaseOptions { @@ -55,35 +31,36 @@ export abstract class BaseReporter implements Reporter { protected verbose = false private _filesInWatchMode = new Map() + private _timeStart = formatTimeString(new Date()) private _lastRunTimeout = 0 private _lastRunTimer: NodeJS.Timeout | undefined private _lastRunCount = 0 - private _timeStart = new Date() constructor(options: BaseOptions = {}) { this.isTTY = options.isTTY ?? ((isNode || isDeno) && process.stdout?.isTTY && !isCI) } - get mode() { - return this.ctx.config.mode - } - onInit(ctx: Vitest) { this.ctx = ctx - ctx.logger.printBanner() + + this.ctx.logger.printBanner() this.start = performance.now() } + log(...messages: any) { + this.ctx.logger.log(...messages) + } + + error(...messages: any) { + this.ctx.logger.error(...messages) + } + relative(path: string) { return relative(this.ctx.config.root, path) } - onFinished( - files = this.ctx.state.getFiles(), - errors = this.ctx.state.getUnhandledErrors(), - ) { + onFinished(files = this.ctx.state.getFiles(), errors = this.ctx.state.getUnhandledErrors()) { this.end = performance.now() - this.reportSummary(files, errors) } @@ -93,6 +70,7 @@ export abstract class BaseReporter implements Reporter { } for (const pack of packs) { const task = this.ctx.state.idMap.get(pack[0]) + if (task) { this.printTask(task) } @@ -106,55 +84,57 @@ export abstract class BaseReporter implements Reporter { || task.result?.state === 'run') { return } - const logger = this.ctx.logger const tests = getTests(task) const failed = tests.filter(t => t.result?.state === 'fail') - const skipped = tests.filter( - t => t.mode === 'skip' || t.mode === 'todo', - ) + const skipped = tests.filter(t => t.mode === 'skip' || t.mode === 'todo') + let state = c.dim(`${tests.length} test${tests.length > 1 ? 's' : ''}`) + if (failed.length) { - state += ` ${c.dim('|')} ${c.red(`${failed.length} failed`)}` + state += c.dim(' | ') + c.red(`${failed.length} failed`) } + if (skipped.length) { - state += ` ${c.dim('|')} ${c.yellow(`${skipped.length} skipped`)}` + state += c.dim(' | ') + c.yellow(`${skipped.length} skipped`) } - let suffix = c.dim(' (') + state + c.dim(')') - suffix += this.getDurationPrefix(task) + + let suffix = c.dim('(') + state + c.dim(')') + this.getDurationPrefix(task) + if (this.ctx.config.logHeapUsage && task.result.heap != null) { - suffix += c.magenta( - ` ${Math.floor(task.result.heap / 1024 / 1024)} MB heap used`, - ) + suffix += c.magenta(` ${Math.floor(task.result.heap / 1024 / 1024)} MB heap used`) } - let title = ` ${getStateSymbol(task)} ` + let title = getStateSymbol(task) + if (task.meta.typecheck) { - title += `${c.bgBlue(c.bold(' TS '))} ` + title += ` ${c.bgBlue(c.bold(' TS '))}` } + if (task.projectName) { - title += formatProjectName(task.projectName) + title += ` ${formatProjectName(task.projectName, '')}` } - title += `${task.name} ${suffix}` - logger.log(title) + + this.log(` ${title} ${task.name} ${suffix}`) for (const test of tests) { const duration = test.result?.duration + if (test.result?.state === 'fail') { const suffix = this.getDurationPrefix(test) - logger.log(c.red(` ${taskFail} ${getTestName(test, c.dim(' > '))}${suffix}`)) + this.log(c.red(` ${taskFail} ${getTestName(test, c.dim(' > '))}${suffix}`)) test.result?.errors?.forEach((e) => { // print short errors, full errors will be at the end in summary - logger.log(c.red(` ${F_RIGHT} ${(e as any)?.message}`)) + this.log(c.red(` ${F_RIGHT} ${e?.message}`)) }) } + // also print slow tests else if (duration && duration > this.ctx.config.slowTestThreshold) { - logger.log( - ` ${c.yellow(c.dim(F_CHECK))} ${getTestName(test, c.dim(' > '))}${c.yellow( - ` ${Math.round(duration)}${c.dim('ms')}`, - )}`, + this.log( + ` ${c.yellow(c.dim(F_CHECK))} ${getTestName(test, c.dim(' > '))}` + + ` ${c.yellow(Math.round(duration) + c.dim('ms'))}`, ) } } @@ -164,42 +144,39 @@ export abstract class BaseReporter implements Reporter { if (!task.result?.duration) { return '' } + const color = task.result.duration > this.ctx.config.slowTestThreshold ? c.yellow : c.gray + return color(` ${Math.round(task.result.duration)}${c.dim('ms')}`) } - onWatcherStart( - files = this.ctx.state.getFiles(), - errors = this.ctx.state.getUnhandledErrors(), - ) { + onWatcherStart(files = this.ctx.state.getFiles(), errors = this.ctx.state.getUnhandledErrors()) { this.resetLastRunLog() const failed = errors.length > 0 || hasFailed(files) - const failedSnap = hasFailedSnapshot(files) - const cancelled = this.ctx.isCancelling if (failed) { - this.ctx.logger.log(WAIT_FOR_CHANGE_FAIL) + this.log(withLabel('red', 'FAIL', 'Tests failed. Watching for file changes...')) } - else if (cancelled) { - this.ctx.logger.log(WAIT_FOR_CHANGE_CANCELLED) + else if (this.ctx.isCancelling) { + this.log(withLabel('red', 'CANCELLED', 'Test run cancelled. Watching for file changes...')) } else { - this.ctx.logger.log(WAIT_FOR_CHANGE_PASS) + this.log(withLabel('green', 'PASS', 'Waiting for file changes...')) } - const hints: string[] = [] - hints.push(HELP_HINT) - if (failedSnap) { - hints.unshift(HELP_UPDATE_SNAP) + 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(HELP_QUITE) + hints.push(c.dim('press ') + c.bold('q') + c.dim(' to quit')) } - this.ctx.logger.log(BADGE_PADDING + hints.join(c.dim(', '))) + this.log(BADGE_PADDING + hints.join(c.dim(', '))) if (this._lastRunCount) { const LAST_RUN_TEXT = `rerun x${this._lastRunCount}` @@ -233,57 +210,51 @@ export abstract class BaseReporter implements Reporter { onWatcherRerun(files: string[], trigger?: string) { this.resetLastRunLog() this.watchFilters = files - this.failedUnwatchedFiles = this.ctx.state.getFiles().filter((file) => { - return !files.includes(file.filepath) && hasFailed(file) - }) + this.failedUnwatchedFiles = this.ctx.state.getFiles().filter(file => + !files.includes(file.filepath) && hasFailed(file), + ) + // Update re-run count for each file files.forEach((filepath) => { let reruns = this._filesInWatchMode.get(filepath) ?? 0 this._filesInWatchMode.set(filepath, ++reruns) }) - const BADGE = c.inverse(c.bold(c.blue(' RERUN '))) - const TRIGGER = trigger ? c.dim(` ${this.relative(trigger)}`) : '' - const FILENAME_PATTERN = this.ctx.filenamePattern - ? `${BADGE_PADDING} ${c.dim('Filename pattern: ')}${c.blue( - this.ctx.filenamePattern, - )}\n` - : '' - const TESTNAME_PATTERN = this.ctx.configOverride.testNamePattern - ? `${BADGE_PADDING} ${c.dim('Test name pattern: ')}${c.blue( - String(this.ctx.configOverride.testNamePattern), - )}\n` - : '' - const PROJECT_FILTER = this.ctx.configOverride.project - ? `${BADGE_PADDING} ${c.dim('Project name: ')}${c.blue( - toArray(this.ctx.configOverride.project).join(', '), - )}\n` - : '' + let banner = trigger ? c.dim(`${this.relative(trigger)} `) : '' if (files.length > 1 || !files.length) { // we need to figure out how to handle rerun all from stdin - this.ctx.logger.clearFullScreen( - `\n${BADGE}${TRIGGER}\n${PROJECT_FILTER}${FILENAME_PATTERN}${TESTNAME_PATTERN}`, - ) this._lastRunCount = 0 } else if (files.length === 1) { const rerun = this._filesInWatchMode.get(files[0]) ?? 1 - this._lastRunCount = rerun - this.ctx.logger.clearFullScreen( - `\n${BADGE}${TRIGGER} ${c.blue( - `x${rerun}`, - )}\n${PROJECT_FILTER}${FILENAME_PATTERN}${TESTNAME_PATTERN}`, - ) + 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)) + } + + if (this.ctx.configOverride.testNamePattern) { + this.log(BADGE_PADDING + c.dim(' Test name pattern: ') + c.blue(String(this.ctx.configOverride.testNamePattern))) } + this.log('') + if (!this.isTTY) { for (const task of this.failedUnwatchedFiles) { this.printTask(task) } } - this._timeStart = new Date() + this._timeStart = formatTimeString(new Date()) this.start = performance.now() } @@ -291,27 +262,25 @@ export abstract class BaseReporter implements Reporter { if (!this.shouldLog(log)) { return } - const task = log.taskId ? this.ctx.state.idMap.get(log.taskId) : undefined - const header = c.gray( - log.type - + c.dim( - ` | ${ - task - ? getFullName(task, c.dim(' > ')) - : log.taskId !== '__vitest__unknown_test__' - ? log.taskId - : 'unknown test' - }`, - ), - ) const output = log.type === 'stdout' ? this.ctx.logger.outputStream : this.ctx.logger.errorStream + const write = (msg: string) => (output as any).write(msg) - write(`${header}\n${log.content}`) + let headerText = 'unknown test' + const task = log.taskId ? this.ctx.state.idMap.get(log.taskId) : undefined + + 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 @@ -327,29 +296,30 @@ export abstract class BaseReporter implements Reporter { ? (project.browser?.parseStacktrace(log.origin) || []) : parseStacktrace(log.origin) - const highlight = task - ? stack.find(i => i.file === task.file.filepath) - : null + 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) - write( - color( - ` ${c.dim(F_POINTER)} ${[ - frame.method, - `${path}:${c.dim(`${frame.line}:${frame.column}`)}`, - ] - .filter(Boolean) - .join(' ')}\n`, - ), - ) + 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?: string) { + this.log(c.yellow('Test removed...') + (trigger ? c.dim(` [ ${this.relative(trigger)} ]\n`) : '')) + } + shouldLog(log: UserConsoleLog) { if (this.ctx.config.silent) { return false @@ -362,20 +332,17 @@ export abstract class BaseReporter implements Reporter { } onServerRestart(reason?: string) { - this.ctx.logger.log( - c.bold( - c.magenta( - reason === 'config' - ? '\nRestarting due to config changes...' - : '\nRestarting Vitest...', - ), - ), - ) + this.log(c.bold(c.magenta( + reason === 'config' + ? '\nRestarting due to config changes...' + : '\nRestarting Vitest...', + ))) } reportSummary(files: File[], errors: unknown[]) { this.printErrorsSummary(files, errors) - if (this.mode === 'benchmark') { + + if (this.ctx.config.mode === 'benchmark') { this.reportBenchmarkSummary(files) } else { @@ -389,240 +356,194 @@ export abstract class BaseReporter implements Reporter { ...files, ] const tests = getTests(affectedFiles) - const logger = this.ctx.logger - - const executionTime = this.end - this.start - const collectTime = files.reduce( - (acc, test) => acc + Math.max(0, test.collectDuration || 0), - 0, - ) - const setupTime = files.reduce( - (acc, test) => acc + Math.max(0, test.setupDuration || 0), - 0, - ) - const testsTime = files.reduce( - (acc, test) => acc + Math.max(0, test.result?.duration || 0), - 0, - ) - const transformTime = this.ctx.projects - .flatMap(w => w.vitenode.getTotalDuration()) - .reduce((a, b) => a + b, 0) - const environmentTime = files.reduce( - (acc, file) => acc + Math.max(0, file.environmentLoad || 0), - 0, - ) - const prepareTime = files.reduce( - (acc, file) => acc + Math.max(0, file.prepareDuration || 0), - 0, - ) - const threadTime = collectTime + testsTime + setupTime - - // show top 10 costly transform module - // console.log(Array.from(this.ctx.vitenode.fetchCache.entries()).filter(i => i[1].duration) - // .sort((a, b) => b[1].duration! - a[1].duration!) - // .map(i => `${time(i[1].duration!)} ${i[0]}`) - // .slice(0, 10) - // .join('\n'), - // ) const snapshotOutput = renderSnapshotSummary( this.ctx.config.root, this.ctx.snapshot.summary, ) - if (snapshotOutput.length) { - logger.log( - snapshotOutput - .map((t, i) => - i === 0 ? `${padTitle('Snapshots')} ${t}` : `${padTitle('')} ${t}`, - ) - .join('\n'), - ) - if (snapshotOutput.length > 1) { - logger.log() - } + + for (const [index, snapshot] of snapshotOutput.entries()) { + const title = index === 0 ? 'Snapshots' : '' + this.log(`${padTitle(title)} ${snapshot}`) + } + + if (snapshotOutput.length > 1) { + this.log() } - logger.log(padTitle('Test Files'), getStateString(affectedFiles)) - logger.log(padTitle('Tests'), getStateString(tests)) + this.log(padTitle('Test Files'), getStateString(affectedFiles)) + this.log(padTitle('Tests'), getStateString(tests)) + if (this.ctx.projects.some(c => c.config.typecheck.enabled)) { - const failed = tests.filter( - t => t.meta?.typecheck && t.result?.errors?.length, - ) - logger.log( + const failed = tests.filter(t => t.meta?.typecheck && t.result?.errors?.length) + + this.log( padTitle('Type Errors'), failed.length ? c.bold(c.red(`${failed.length} failed`)) : c.dim('no errors'), ) } + if (errors.length) { - logger.log( + this.log( padTitle('Errors'), c.bold(c.red(`${errors.length} error${errors.length > 1 ? 's' : ''}`)), ) } - logger.log(padTitle('Start at'), formatTimeString(this._timeStart)) + + this.log(padTitle('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) { - logger.log(padTitle('Duration'), time(threadTime)) + this.log(padTitle('Duration'), time(collectTime + testsTime + setupTime)) } else { - let timers = `transform ${time(transformTime)}, setup ${time( - setupTime, - )}, collect ${time(collectTime)}, tests ${time( - testsTime, - )}, environment ${time(environmentTime)}, prepare ${time(prepareTime)}` - const typecheck = this.ctx.projects.reduce( - (acc, c) => acc + (c.typechecker?.getResult().time || 0), - 0, - ) - if (typecheck) { - timers += `, typecheck ${time(typecheck)}` - } - logger.log( - padTitle('Duration'), - time(executionTime) + c.dim(` (${timers})`), - ) - } - - logger.log() + const executionTime = 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 ${time(transformTime)}`, + `setup ${time(setupTime)}`, + `collect ${time(collectTime)}`, + `tests ${time(testsTime)}`, + `environment ${time(environmentTime)}`, + `prepare ${time(prepareTime)}`, + typecheck && `typecheck ${time(typecheck)}`, + ].filter(Boolean).join(', ') + + this.log(padTitle('Duration'), time(executionTime) + c.dim(` (${timers})`)) + } + + this.log() } private printErrorsSummary(files: File[], errors: unknown[]) { - const logger = this.ctx.logger 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) + const failedTotal = countTestErrors(failedSuites) + countTestErrors(failedTests) let current = 1 - - const errorDivider = () => - logger.error( - `${c.red( - c.dim(divider(`[${current++}/${failedTotal}]`, undefined, 1)), - )}\n`, - ) + const errorDivider = () => this.error(`${c.red(c.dim(divider(`[${current++}/${failedTotal}]`, undefined, 1)))}\n`) if (failedSuites.length) { - logger.error( - c.red( - divider(c.bold(c.inverse(` Failed Suites ${failedSuites.length} `))), - ), - ) - logger.error() + this.error(`${errorBanner(`Failed Suites ${failedSuites.length}`)}\n`) this.printTaskErrors(failedSuites, errorDivider) } if (failedTests.length) { - logger.error( - c.red( - divider(c.bold(c.inverse(` Failed Tests ${failedTests.length} `))), - ), - ) - logger.error() - + this.error(`${errorBanner(`Failed Tests ${failedTests.length}`)}\n`) this.printTaskErrors(failedTests, errorDivider) } + if (errors.length) { - logger.printUnhandledErrors(errors) - logger.error() + this.ctx.logger.printUnhandledErrors(errors) + this.error() } - return tests } reportBenchmarkSummary(files: File[]) { - const logger = this.ctx.logger const benches = getTests(files) - const topBenches = benches.filter(i => i.result?.benchmark?.rank === 1) - logger.log( - `\n${c.cyan(c.inverse(c.bold(' BENCH ')))} ${c.cyan('Summary')}\n`, - ) + this.log(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(' > ')) - logger.log(` ${bench.name}${c.dim(` - ${groupName}`)}`) + this.log(` ${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) - if (siblings.length === 0) { - logger.log('') - continue - } + for (const sibling of siblings) { - const number = `${( - sibling.result!.benchmark!.mean / bench.result!.benchmark!.mean - ).toFixed(2)}x` - logger.log( - ` ${c.green(number)} ${c.gray('faster than')} ${sibling.name}`, - ) + const number = (sibling.result!.benchmark!.mean / bench.result!.benchmark!.mean).toFixed(2) + this.log(c.green(` ${number}x `) + c.gray('faster than ') + sibling.name) } - logger.log('') + + this.log('') } } private printTaskErrors(tasks: Task[], errorDivider: () => void) { const errorsQueue: [error: ErrorWithDiff | undefined, tests: Task[]][] = [] + for (const task of tasks) { - // merge identical errors + // Merge identical errors task.result?.errors?.forEach((error) => { - const errorItem - = error?.stackStr - && errorsQueue.find((i) => { - const hasStr = i[0]?.stackStr === error.stackStr - if (!hasStr) { + let previous + + if (error?.stackStr) { + previous = errorsQueue.find((i) => { + if (i[0]?.stackStr !== error.stackStr) { return false } - const currentProjectName - = (task as File)?.projectName || task.file?.projectName || '' - const projectName - = (i[1][0] as File)?.projectName || i[1][0].file?.projectName || '' + + const currentProjectName = (task as File)?.projectName || task.file?.projectName || '' + const projectName = (i[1][0] as File)?.projectName || i[1][0].file?.projectName || '' + return projectName === currentProjectName }) - if (errorItem) { - errorItem[1].push(task) + } + + 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 as File)?.filepath || '' - const projectName - = (task as File)?.projectName || task.file?.projectName || '' + const projectName = (task as File)?.projectName || task.file?.projectName || '' + let name = getFullName(task, c.dim(' > ')) + if (filepath) { - name = `${name} ${c.dim(`[ ${this.relative(filepath)} ]`)}` + name += c.dim(` [ ${this.relative(filepath)} ]`) } this.ctx.logger.error( - `${c.red(c.bold(c.inverse(' FAIL ')))} ${formatProjectName( - projectName, - )}${name}`, + `${c.red(c.bold(c.inverse(' FAIL ')))}${formatProjectName(projectName)} ${name}`, ) } - const screenshots = tasks.filter(t => t.meta?.failScreenshotPath).map(t => t.meta?.failScreenshotPath as string) - const project = this.ctx.getProjectByTaskId(tasks[0].id) + + const screenshotPaths = tasks.map(t => t.meta?.failScreenshotPath).filter(screenshot => screenshot != null) + this.ctx.logger.printError(error, { - project, + project: this.ctx.getProjectByTaskId(tasks[0].id), verbose: this.verbose, - screenshotPaths: screenshots, + screenshotPaths, task: tasks[0], }) + errorDivider() } } } +function errorBanner(message: string) { + return c.red(divider(c.bold(c.inverse(` ${message} `)))) +} + function padTitle(str: string) { return c.dim(`${str.padStart(11)} `) } @@ -633,3 +554,9 @@ function time(time: number) { } return `${Math.round(time)}ms` } + +function sum(items: T[], cb: (_next: T) => number | undefined) { + return items.reduce((total, next) => { + return total + Math.max(cb(next) || 0, 0) + }, 0) +} diff --git a/packages/vitest/src/node/reporters/default.ts b/packages/vitest/src/node/reporters/default.ts index 0c16761ab3b6..639f1ba3a8a5 100644 --- a/packages/vitest/src/node/reporters/default.ts +++ b/packages/vitest/src/node/reporters/default.ts @@ -41,7 +41,7 @@ export class DefaultReporter extends BaseReporter { this.rendererOptions.showHeap = this.ctx.config.logHeapUsage this.rendererOptions.slowTestThreshold = this.ctx.config.slowTestThreshold - this.rendererOptions.mode = this.mode + this.rendererOptions.mode = this.ctx.config.mode const files = this.ctx.state.getFiles(this.watchFilters) if (!this.renderer) { this.renderer = createListRenderer(files, this.rendererOptions).start() diff --git a/packages/vitest/src/node/reporters/renderers/utils.ts b/packages/vitest/src/node/reporters/renderers/utils.ts index a895bc9a284c..9be1bff56974 100644 --- a/packages/vitest/src/node/reporters/renderers/utils.ts +++ b/packages/vitest/src/node/reporters/renderers/utils.ts @@ -254,6 +254,12 @@ export function formatProjectName(name: string | undefined, suffix = ' ') { const index = name .split('') .reduce((acc, v, idx) => acc + v.charCodeAt(0) + idx, 0) + const colors = [c.blue, c.yellow, c.cyan, c.green, c.magenta] + return colors[index % colors.length](`|${name}|`) + suffix } + +export function withLabel(color: 'red' | 'green' | 'blue' | 'cyan', label: string, message: string) { + return `${c.bold(c.inverse(c[color](` ${label} `)))} ${c[color](message)}` +} diff --git a/test/config/test/console-color.test.ts b/test/config/test/console-color.test.ts index 3ecd19cc2c8a..1678a55cacfe 100644 --- a/test/config/test/console-color.test.ts +++ b/test/config/test/console-color.test.ts @@ -1,7 +1,7 @@ import { x } from 'tinyexec' import { expect, test } from 'vitest' -// use "x" directly since "runVitestCli" strips color +// use "tinyexec" directly since "runVitestCli" strips color test('with color', async () => { const proc = await x('vitest', ['run', '--root=./fixtures/console-color'], { @@ -14,7 +14,7 @@ test('with color', async () => { }, }, }) - expect(proc.stdout).toContain('\n\x1B[33mtrue\x1B[39m\n') + expect(proc.stdout).toContain('\x1B[33mtrue\x1B[39m\n') }) test('without color', async () => { @@ -28,5 +28,5 @@ test('without color', async () => { }, }, }) - expect(proc.stdout).toContain('\ntrue\n') + expect(proc.stdout).toContain('true\n') }) diff --git a/test/reporters/tests/merge-reports.test.ts b/test/reporters/tests/merge-reports.test.ts index 1ca7cc3cadf2..1cf3493142fe 100644 --- a/test/reporters/tests/merge-reports.test.ts +++ b/test/reporters/tests/merge-reports.test.ts @@ -88,13 +88,13 @@ test('merge reports', async () => { beforeEach test 1-2 - ❯ first.test.ts (2 tests | 1 failed)