refactor(reporters): base reporter readability improvements (#6889)

This commit is contained in:
Ari Perkkiö 2024-11-11 09:56:38 +02:00 committed by GitHub
parent 9b3c3de27b
commit 00ebea6414
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 248 additions and 338 deletions

View File

@ -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()

View File

@ -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<string, number>()
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`)}`
}
if (skipped.length) {
state += ` ${c.dim('|')} ${c.yellow(`${skipped.length} skipped`)}`
}
let suffix = c.dim(' (') + state + c.dim(')')
suffix += 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`,
)
state += c.dim(' | ') + c.red(`${failed.length} failed`)
}
let title = ` ${getStateSymbol(task)} `
if (skipped.length) {
state += c.dim(' | ') + c.yellow(`${skipped.length} skipped`)
}
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`)
}
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}`)
}
logger.log(padTitle('Test Files'), getStateString(affectedFiles))
logger.log(padTitle('Tests'), getStateString(tests))
if (snapshotOutput.length > 1) {
this.log()
}
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})`),
)
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})`))
}
logger.log()
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<T>(items: T[], cb: (_next: T) => number | undefined) {
return items.reduce((total, next) => {
return total + Math.max(cb(next) || 0, 0)
}, 0)
}

View File

@ -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()

View File

@ -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)}`
}

View File

@ -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')
})

View File

@ -88,13 +88,13 @@ test('merge reports', async () => {
beforeEach
test 1-2
first.test.ts (2 tests | 1 failed) <time>
first.test.ts (2 tests | 1 failed) <time>
× test 1-2 <time>
expected 1 to be 2 // Object.is equality
stdout | second.test.ts > test 2-1
test 2-1
second.test.ts (3 tests | 1 failed) <time>
second.test.ts (3 tests | 1 failed) <time>
× test 2-1 <time>
expected 1 to be 2 // Object.is equality