diff --git a/docs/guide/reporters.md b/docs/guide/reporters.md index 03050696eaff8..e960e2363dea9 100644 --- a/docs/guide/reporters.md +++ b/docs/guide/reporters.md @@ -96,33 +96,54 @@ This example will write separate JSON and XML reports as well as printing a verb ### Default Reporter -By default (i.e. if no reporter is specified), Vitest will display results for each test suite hierarchically as they run, and then collapse after a suite passes. When all tests have finished running, the final terminal output will display a summary of results and details of any failed tests. +By default (i.e. if no reporter is specified), Vitest will display summary of running tests and their status at the bottom. Once a suite passes, its status will be reported on top of the summary. + +You can disable the summary by configuring the reporter: + +:::code-group +```ts [vitest.config.ts] +export default defineConfig({ + test: { + reporters: [ + ['default', { summary: false }] + ] + }, +}) +``` +::: Example output for tests in progress: ```bash -✓ __tests__/file1.test.ts (2) 725ms -✓ __tests__/file2.test.ts (5) 746ms - ✓ second test file (2) 746ms - ✓ 1 + 1 should equal 2 - ✓ 2 - 1 should equal 1 + ✓ test/example-1.test.ts (5 tests | 1 skipped) 306ms + ✓ test/example-2.test.ts (5 tests | 1 skipped) 307ms + + ❯ test/example-3.test.ts 3/5 + ❯ test/example-4.test.ts 1/5 + + Test Files 2 passed (4) + Tests 10 passed | 3 skipped (65) + Start at 11:01:36 + Duration 2.00s ``` Final output after tests have finished: ```bash -✓ __tests__/file1.test.ts (2) 725ms -✓ __tests__/file2.test.ts (2) 746ms + ✓ test/example-1.test.ts (5 tests | 1 skipped) 306ms + ✓ test/example-2.test.ts (5 tests | 1 skipped) 307ms + ✓ test/example-3.test.ts (5 tests | 1 skipped) 307ms + ✓ test/example-4.test.ts (5 tests | 1 skipped) 307ms - Test Files 2 passed (2) - Tests 4 passed (4) + Test Files 4 passed (4) + Tests 16 passed | 4 skipped (20) Start at 12:34:32 Duration 1.26s (transform 35ms, setup 1ms, collect 90ms, tests 1.47s, environment 0ms, prepare 267ms) ``` ### Basic Reporter -The `basic` reporter displays the test files that have run and a summary of results after the entire suite has finished running. Individual tests are not included in the report unless they fail. +The `basic` reporter is equivalent to `default` reporter without `summary`. :::code-group ```bash [CLI] @@ -151,7 +172,7 @@ Example output using basic reporter: ### Verbose Reporter -Follows the same hierarchical structure as the `default` reporter, but does not collapse sub-trees for passed test suites. The final terminal output displays all tests that have run, including those that have passed. +Verbose reporter is same as `default` reporter, but it also displays each individual test after the suite has finished. Similar to `default` reporter, you can disable the summary by configuring the reporter. :::code-group ```bash [CLI] @@ -161,7 +182,9 @@ npx vitest --reporter=verbose ```ts [vitest.config.ts] export default defineConfig({ test: { - reporters: ['verbose'] + reporters: [ + ['verbose', { summary: false }] + ] }, }) ``` diff --git a/packages/vitest/package.json b/packages/vitest/package.json index b593db0ba70b4..20f65a1c7ff48 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -165,7 +165,7 @@ "std-env": "^3.7.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", + "tinypool": "https://pkg.pr.new/tinypool@67f3129", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "workspace:*", diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index f4b2d7f4596bd..3c870b25a6114 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -11,10 +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, withLabel } from './renderers/utils' +import { countTestErrors, divider, formatProjectName, formatTime, formatTimeString, getStateString, getStateSymbol, padSummaryTitle, renderSnapshotSummary, taskFail, withLabel } from './renderers/utils' const BADGE_PADDING = ' ' -const LAST_RUN_LOG_TIMEOUT = 1_500 export interface BaseOptions { isTTY?: boolean @@ -27,14 +26,12 @@ export abstract class BaseReporter implements Reporter { failedUnwatchedFiles: Task[] = [] isTTY: boolean ctx: Vitest = undefined! + renderSucceed = false protected verbose = false private _filesInWatchMode = new Map() private _timeStart = formatTimeString(new Date()) - private _lastRunTimeout = 0 - private _lastRunTimer: NodeJS.Timeout | undefined - private _lastRunCount = 0 constructor(options: BaseOptions = {}) { this.isTTY = options.isTTY ?? ((isNode || isDeno) && process.stdout?.isTTY && !isCI) @@ -65,9 +62,6 @@ export abstract class BaseReporter implements Reporter { } onTaskUpdate(packs: TaskResultPack[]) { - if (this.isTTY) { - return - } for (const pack of packs) { const task = this.ctx.state.idMap.get(pack[0]) @@ -117,6 +111,8 @@ export abstract class BaseReporter implements Reporter { this.log(` ${title} ${task.name} ${suffix}`) + const anyFailed = tests.some(test => test.result?.state === 'fail') + for (const test of tests) { const duration = test.result?.duration @@ -137,6 +133,10 @@ export abstract class BaseReporter implements Reporter { + ` ${c.yellow(Math.round(duration) + c.dim('ms'))}`, ) } + + else if (this.renderSucceed || anyFailed) { + this.log(` ${c.green(c.dim(F_CHECK))} ${getTestName(test, c.dim(' > '))}`) + } } } @@ -153,8 +153,6 @@ export abstract class BaseReporter implements Reporter { } onWatcherStart(files = this.ctx.state.getFiles(), errors = this.ctx.state.getUnhandledErrors()) { - this.resetLastRunLog() - const failed = errors.length > 0 || hasFailed(files) if (failed) { @@ -177,38 +175,9 @@ export abstract class BaseReporter implements Reporter { } this.log(BADGE_PADDING + hints.join(c.dim(', '))) - - if (this._lastRunCount) { - const LAST_RUN_TEXT = `rerun x${this._lastRunCount}` - const LAST_RUN_TEXTS = [ - c.blue(LAST_RUN_TEXT), - c.gray(LAST_RUN_TEXT), - c.dim(c.gray(LAST_RUN_TEXT)), - ] - this.ctx.logger.logUpdate(BADGE_PADDING + LAST_RUN_TEXTS[0]) - this._lastRunTimeout = 0 - this._lastRunTimer = setInterval(() => { - this._lastRunTimeout += 1 - if (this._lastRunTimeout >= LAST_RUN_TEXTS.length) { - this.resetLastRunLog() - } - else { - this.ctx.logger.logUpdate( - BADGE_PADDING + LAST_RUN_TEXTS[this._lastRunTimeout], - ) - } - }, LAST_RUN_LOG_TIMEOUT / LAST_RUN_TEXTS.length) - } - } - - private resetLastRunLog() { - clearInterval(this._lastRunTimer) - this._lastRunTimer = undefined - this.ctx.logger.logUpdate.clear() } onWatcherRerun(files: string[], trigger?: string) { - this.resetLastRunLog() this.watchFilters = files this.failedUnwatchedFiles = this.ctx.state.getFiles().filter(file => !files.includes(file.filepath) && hasFailed(file), @@ -222,11 +191,7 @@ export abstract class BaseReporter implements Reporter { 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._lastRunCount = 0 - } - else if (files.length === 1) { + if (files.length === 1) { const rerun = this._filesInWatchMode.get(files[0]) ?? 1 banner += c.blue(`x${rerun} `) } @@ -248,10 +213,8 @@ export abstract class BaseReporter implements Reporter { this.log('') - if (!this.isTTY) { - for (const task of this.failedUnwatchedFiles) { - this.printTask(task) - } + for (const task of this.failedUnwatchedFiles) { + this.printTask(task) } this._timeStart = formatTimeString(new Date()) @@ -351,6 +314,8 @@ export abstract class BaseReporter implements Reporter { } reportTestSummary(files: File[], errors: unknown[]) { + this.log() + const affectedFiles = [ ...this.failedUnwatchedFiles, ...files, @@ -364,21 +329,21 @@ export abstract class BaseReporter implements Reporter { for (const [index, snapshot] of snapshotOutput.entries()) { const title = index === 0 ? 'Snapshots' : '' - this.log(`${padTitle(title)} ${snapshot}`) + this.log(`${padSummaryTitle(title)} ${snapshot}`) } if (snapshotOutput.length > 1) { this.log() } - this.log(padTitle('Test Files'), getStateString(affectedFiles)) - this.log(padTitle('Tests'), getStateString(tests)) + this.log(padSummaryTitle('Test Files'), getStateString(affectedFiles)) + this.log(padSummaryTitle('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) this.log( - padTitle('Type Errors'), + padSummaryTitle('Type Errors'), failed.length ? c.bold(c.red(`${failed.length} failed`)) : c.dim('no errors'), @@ -387,19 +352,19 @@ export abstract class BaseReporter implements Reporter { if (errors.length) { this.log( - padTitle('Errors'), + padSummaryTitle('Errors'), c.bold(c.red(`${errors.length} error${errors.length > 1 ? 's' : ''}`)), ) } - this.log(padTitle('Start at'), this._timeStart) + 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(padTitle('Duration'), time(collectTime + testsTime + setupTime)) + this.log(padSummaryTitle('Duration'), formatTime(collectTime + testsTime + setupTime)) } else { const executionTime = this.end - this.start @@ -409,16 +374,16 @@ export abstract class BaseReporter implements Reporter { 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)}`, + `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(padTitle('Duration'), time(executionTime) + c.dim(` (${timers})`)) + this.log(padSummaryTitle('Duration'), formatTime(executionTime) + c.dim(` (${timers})`)) } this.log() @@ -544,17 +509,6 @@ function errorBanner(message: string) { return c.red(divider(c.bold(c.inverse(` ${message} `)))) } -function padTitle(str: string) { - return c.dim(`${str.padStart(11)} `) -} - -function time(time: number) { - if (time > 1000) { - return `${(time / 1000).toFixed(2)}s` - } - 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) diff --git a/packages/vitest/src/node/reporters/default.ts b/packages/vitest/src/node/reporters/default.ts index 639f1ba3a8a51..f147bdeafcf83 100644 --- a/packages/vitest/src/node/reporters/default.ts +++ b/packages/vitest/src/node/reporters/default.ts @@ -1,99 +1,64 @@ -import type { UserConsoleLog } from '../../types/general' -import type { ListRendererOptions } from './renderers/listRenderer' -import c from 'tinyrainbow' +import type { File, TaskResultPack } from '@vitest/runner' +import type { Vitest } from '../core' +import type { BaseOptions } from './base' import { BaseReporter } from './base' -import { createListRenderer } from './renderers/listRenderer' +import { SummaryReporter } from './summary' + +export interface DefaultReporterOptions extends BaseOptions { + summary?: boolean +} export class DefaultReporter extends BaseReporter { - renderer?: ReturnType - rendererOptions: ListRendererOptions = {} as any - private renderSucceedDefault?: boolean + private options: DefaultReporterOptions + private summary?: SummaryReporter - onPathsCollected(paths: string[] = []) { - if (this.isTTY) { - if (this.renderSucceedDefault === undefined) { - this.renderSucceedDefault = !!this.rendererOptions.renderSucceed - } + constructor(options: DefaultReporterOptions = {}) { + super(options) + this.options = { + summary: true, + ...options, + } - if (this.renderSucceedDefault !== true) { - this.rendererOptions.renderSucceed = paths.length <= 1 - } + if (!this.isTTY) { + this.options.summary = false + } + + if (this.options.summary) { + this.summary = new SummaryReporter() } } - async onTestRemoved(trigger?: string) { - this.stopListRender() - this.ctx.logger.clearScreen( - c.yellow('Test removed...') - + (trigger ? c.dim(` [ ${this.relative(trigger)} ]\n`) : ''), - true, - ) - const files = this.ctx.state.getFiles(this.watchFilters) - createListRenderer(files, this.rendererOptions).stop() - this.ctx.logger.log() - super.reportSummary(files, this.ctx.state.getUnhandledErrors()) - super.onWatcherStart() + onInit(ctx: Vitest) { + super.onInit(ctx) + this.summary?.onInit(ctx) } - onCollected() { + onPathsCollected(paths: string[] = []) { if (this.isTTY) { - this.rendererOptions.logger = this.ctx.logger - this.rendererOptions.showHeap = this.ctx.config.logHeapUsage - this.rendererOptions.slowTestThreshold - = this.ctx.config.slowTestThreshold - 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() + if (this.renderSucceed === undefined) { + this.renderSucceed = !!this.renderSucceed } - else { - this.renderer.update(files) + + if (this.renderSucceed !== true) { + this.renderSucceed = paths.length <= 1 } } - } - onFinished( - files = this.ctx.state.getFiles(), - errors = this.ctx.state.getUnhandledErrors(), - ) { - // print failed tests without their errors to keep track of previously failed tests - // this can happen if there are multiple test errors, and user changed a file - // that triggered a rerun of unrelated tests - in that case they want to see - // the error for the test they are currently working on, but still keep track of - // the other failed tests - this.renderer?.update([ - ...this.failedUnwatchedFiles, - ...files, - ]) - - this.stopListRender() - this.ctx.logger.log() - super.onFinished(files, errors) + this.summary?.onPathsCollected(paths) } - async onWatcherStart( - files = this.ctx.state.getFiles(), - errors = this.ctx.state.getUnhandledErrors(), - ) { - this.stopListRender() - await super.onWatcherStart(files, errors) + onTaskUpdate(packs: TaskResultPack[]) { + this.summary?.onTaskUpdate(packs) + super.onTaskUpdate(packs) } - stopListRender() { - this.renderer?.stop() - this.renderer = undefined + onWatcherRerun(files: string[], trigger?: string) { + this.summary?.onWatcherRerun() + super.onWatcherRerun(files, trigger) } - async onWatcherRerun(files: string[], trigger?: string) { - this.stopListRender() - await super.onWatcherRerun(files, trigger) - } - - onUserConsoleLog(log: UserConsoleLog) { - if (!this.shouldLog(log)) { - return - } - this.renderer?.clear() - super.onUserConsoleLog(log) + onFinished(files?: File[], errors?: unknown[]) { + this.summary?.onFinished() + super.onFinished(files, errors) } } diff --git a/packages/vitest/src/node/reporters/dot.ts b/packages/vitest/src/node/reporters/dot.ts index 64b5cc95900b6..44254dd6e1524 100644 --- a/packages/vitest/src/node/reporters/dot.ts +++ b/packages/vitest/src/node/reporters/dot.ts @@ -5,6 +5,8 @@ import { createDotRenderer } from './renderers/dotRenderer' export class DotReporter extends BaseReporter { renderer?: ReturnType + onTaskUpdate() {} + onCollected() { if (this.isTTY) { const files = this.ctx.state.getFiles(this.watchFilters) diff --git a/packages/vitest/src/node/reporters/index.ts b/packages/vitest/src/node/reporters/index.ts index dbeac3434e1c4..9b9f3105160fb 100644 --- a/packages/vitest/src/node/reporters/index.ts +++ b/packages/vitest/src/node/reporters/index.ts @@ -1,6 +1,7 @@ import type { Reporter } from '../types/reporter' import type { BaseOptions, BaseReporter } from './base' import type { BlobOptions } from './blob' +import type { DefaultReporterOptions } from './default' import type { HTMLOptions } from './html' import type { ModuleDiagnostic as _FileDiagnostic } from './reported-tasks' import { BasicReporter } from './basic' @@ -65,9 +66,9 @@ export const ReportersMap = { export type BuiltinReporters = keyof typeof ReportersMap export interface BuiltinReporterOptions { - 'default': BaseOptions + 'default': DefaultReporterOptions 'basic': BaseOptions - 'verbose': never + 'verbose': DefaultReporterOptions 'dot': BaseOptions 'json': JsonOptions 'blob': BlobOptions diff --git a/packages/vitest/src/node/reporters/renderers/listRenderer.ts b/packages/vitest/src/node/reporters/renderers/listRenderer.ts deleted file mode 100644 index 0e62982caee3c..0000000000000 --- a/packages/vitest/src/node/reporters/renderers/listRenderer.ts +++ /dev/null @@ -1,310 +0,0 @@ -import type { SuiteHooks, Task } from '@vitest/runner' -import type { Benchmark, BenchmarkResult } from '../../../runtime/types/benchmark' -import type { Logger } from '../../logger' -import type { VitestRunMode } from '../../types/config' -import { stripVTControlCharacters } from 'node:util' -import { getTests } from '@vitest/runner/utils' -import { notNullish } from '@vitest/utils' -import cliTruncate from 'cli-truncate' -import c from 'tinyrainbow' -import { F_RIGHT } from './figures' -import { - formatProjectName, - getCols, - getHookStateSymbol, - getStateSymbol, -} from './utils' - -export interface ListRendererOptions { - renderSucceed?: boolean - logger: Logger - showHeap: boolean - slowTestThreshold: number - mode: VitestRunMode -} - -const outputMap = new WeakMap() - -function formatFilepath(path: string) { - const lastSlash = Math.max(path.lastIndexOf('/') + 1, 0) - const basename = path.slice(lastSlash) - let firstDot = basename.indexOf('.') - if (firstDot < 0) { - firstDot = basename.length - } - firstDot += lastSlash - - return ( - c.dim(path.slice(0, lastSlash)) - + path.slice(lastSlash, firstDot) - + c.dim(path.slice(firstDot)) - ) -} - -function formatNumber(number: number) { - const res = String(number.toFixed(number < 100 ? 4 : 2)).split('.') - return ( - res[0].replace(/(?=(?:\d{3})+$)\B/g, ',') + (res[1] ? `.${res[1]}` : '') - ) -} - -function renderHookState( - task: Task, - hookName: keyof SuiteHooks, - level = 0, -): string { - const state = task.result?.hooks?.[hookName] - if (state && state === 'run') { - return `${' '.repeat(level)} ${getHookStateSymbol(task, hookName)} ${c.dim( - `[ ${hookName} ]`, - )}` - } - - return '' -} - -function renderBenchmarkItems(result: BenchmarkResult) { - return [ - result.name, - formatNumber(result.hz || 0), - formatNumber(result.p99 || 0), - `±${result.rme.toFixed(2)}%`, - result.samples.length.toString(), - ] -} - -function renderBenchmark(task: Benchmark, tasks: Task[]): string { - const result = task.result?.benchmark - if (!result) { - return task.name - } - - const benches = tasks - .map(i => (i.meta?.benchmark ? i.result?.benchmark : undefined)) - .filter(notNullish) - - const allItems = benches.map(renderBenchmarkItems) - const items = renderBenchmarkItems(result) - const padded = items.map((i, idx) => { - const width = Math.max(...allItems.map(i => i[idx].length)) - return idx ? i.padStart(width, ' ') : i.padEnd(width, ' ') // name - }) - - return [ - padded[0], // name - c.dim(' '), - c.blue(padded[1]), - c.dim(' ops/sec '), - c.cyan(padded[3]), - c.dim(` (${padded[4]} samples)`), - result.rank === 1 - ? c.bold(c.green(' fastest')) - : result.rank === benches.length && benches.length > 2 - ? c.bold(c.gray(' slowest')) - : '', - ].join('') -} - -function renderTree( - tasks: Task[], - options: ListRendererOptions, - level = 0, - maxRows?: number, -): string { - const output: string[] = [] - let currentRowCount = 0 - - // Go through tasks in reverse order since maxRows is used to bail out early when limit is reached - for (const task of [...tasks].reverse()) { - const taskOutput = [] - - let suffix = '' - let prefix = ` ${getStateSymbol(task)} ` - - if (level === 0 && task.type === 'suite' && 'projectName' in task) { - prefix += formatProjectName(task.projectName) - } - - if (level === 0 && task.type === 'suite' && task.meta.typecheck) { - prefix += c.bgBlue(c.bold(' TS ')) - prefix += ' ' - } - - if ( - task.type === 'test' - && task.result?.retryCount - && task.result.retryCount > 0 - ) { - suffix += c.yellow(` (retry x${task.result.retryCount})`) - } - - if (task.type === 'suite') { - const tests = getTests(task) - suffix += c.dim(` (${tests.length})`) - } - - if (task.mode === 'skip' || task.mode === 'todo') { - suffix += ` ${c.dim(c.gray('[skipped]'))}` - } - - if ( - task.type === 'test' - && task.result?.repeatCount - && task.result.repeatCount > 0 - ) { - suffix += c.yellow(` (repeat x${task.result.repeatCount})`) - } - - if (task.result?.duration != null) { - if (task.result.duration > options.slowTestThreshold) { - suffix += c.yellow( - ` ${Math.round(task.result.duration)}${c.dim('ms')}`, - ) - } - } - - if (options.showHeap && task.result?.heap != null) { - suffix += c.magenta( - ` ${Math.floor(task.result.heap / 1024 / 1024)} MB heap used`, - ) - } - - let name = task.name - if (level === 0) { - name = formatFilepath(name) - } - - const padding = ' '.repeat(level) - const body = task.meta?.benchmark - ? renderBenchmark(task as Benchmark, tasks) - : name - - taskOutput.push(padding + prefix + body + suffix) - - if (task.result?.state !== 'pass' && outputMap.get(task) != null) { - let data: string | undefined = outputMap.get(task) - if (typeof data === 'string') { - data = stripVTControlCharacters(data.trim().split('\n').filter(Boolean).pop()!) - if (data === '') { - data = undefined - } - } - - if (data != null) { - const out = `${' '.repeat(level)}${F_RIGHT} ${data}` - taskOutput.push(` ${c.gray(cliTruncate(out, getCols(-3)))}`) - } - } - - taskOutput.push(renderHookState(task, 'beforeAll', level + 1)) - taskOutput.push(renderHookState(task, 'beforeEach', level + 1)) - if (task.type === 'suite' && task.tasks.length > 0) { - if ( - task.result?.state === 'fail' - || task.result?.state === 'run' - || options.renderSucceed - ) { - if (options.logger.ctx.config.hideSkippedTests) { - const filteredTasks = task.tasks.filter( - t => t.mode !== 'skip' && t.mode !== 'todo', - ) - taskOutput.push( - renderTree(filteredTasks, options, level + 1, maxRows), - ) - } - else { - taskOutput.push(renderTree(task.tasks, options, level + 1, maxRows)) - } - } - } - taskOutput.push(renderHookState(task, 'afterAll', level + 1)) - taskOutput.push(renderHookState(task, 'afterEach', level + 1)) - - const rows = taskOutput.filter(Boolean) - output.push(rows.join('\n')) - currentRowCount += rows.length - - if (maxRows && currentRowCount >= maxRows) { - break - } - } - - // TODO: moving windows - return output.reverse().join('\n') -} - -export function createListRenderer( - _tasks: Task[], - options: ListRendererOptions, -) { - let tasks = _tasks - let timer: any - - const log = options.logger.logUpdate - - function update() { - if (options.logger.ctx.config.hideSkippedTests) { - const filteredTasks = tasks.filter( - t => t.mode !== 'skip' && t.mode !== 'todo', - ) - log( - renderTree( - filteredTasks, - options, - 0, - // log-update already limits the amount of printed rows to fit the current terminal - // but we can optimize performance by doing it ourselves - process.stdout.rows, - ), - ) - } - else { - log( - renderTree( - tasks, - options, - 0, - // log-update already limits the amount of printed rows to fit the current terminal - // but we can optimize performance by doing it ourselves - process.stdout.rows, - ), - ) - } - } - - return { - start() { - if (timer) { - return this - } - timer = setInterval(update, 16) - return this - }, - update(_tasks: Task[]) { - tasks = _tasks - return this - }, - stop() { - if (timer) { - clearInterval(timer) - timer = undefined - } - log.clear() - if (options.logger.ctx.config.hideSkippedTests) { - const filteredTasks = tasks.filter( - t => t.mode !== 'skip' && t.mode !== 'todo', - ) - // Note that at this point the renderTree should output all tasks - options.logger.log(renderTree(filteredTasks, options)) - } - else { - // Note that at this point the renderTree should output all tasks - options.logger.log(renderTree(tasks, options)) - } - return this - }, - clear() { - log.clear() - }, - } -} diff --git a/packages/vitest/src/node/reporters/renderers/utils.ts b/packages/vitest/src/node/reporters/renderers/utils.ts index 9be1bff569747..2f3b4b9a6b6d6 100644 --- a/packages/vitest/src/node/reporters/renderers/utils.ts +++ b/packages/vitest/src/node/reporters/renderers/utils.ts @@ -1,4 +1,4 @@ -import type { SuiteHooks, Task } from '@vitest/runner' +import type { Task } from '@vitest/runner' import type { SnapshotSummary } from '@vitest/snapshot' import { stripVTControlCharacters } from 'node:util' import { slash } from '@vitest/utils' @@ -186,25 +186,6 @@ export function getStateSymbol(task: Task) { return ' ' } -export function getHookStateSymbol(task: Task, hookName: keyof SuiteHooks) { - const state = task.result?.hooks?.[hookName] - - // pending - if (state && state === 'run') { - let spinnerMap = hookSpinnerMap.get(task) - if (!spinnerMap) { - spinnerMap = new Map string>() - hookSpinnerMap.set(task, spinnerMap) - } - let spinner = spinnerMap.get(hookName) - if (!spinner) { - spinner = elegantSpinner() - spinnerMap.set(hookName, spinner) - } - return c.yellow(spinner()) - } -} - export const spinnerFrames = process.platform === 'win32' ? ['-', '\\', '|', '/'] @@ -247,6 +228,13 @@ export function formatTimeString(date: Date) { return date.toTimeString().split(' ')[0] } +export function formatTime(time: number) { + if (time > 1000) { + return `${(time / 1000).toFixed(2)}s` + } + return `${Math.round(time)}ms` +} + export function formatProjectName(name: string | undefined, suffix = ' ') { if (!name) { return '' @@ -260,6 +248,10 @@ export function formatProjectName(name: string | undefined, suffix = ' ') { 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)}` +export function withLabel(color: 'red' | 'green' | 'blue' | 'cyan' | 'yellow', label: string, message?: string) { + return `${c.bold(c.inverse(c[color](` ${label} `)))} ${message ? c[color](message) : ''}` +} + +export function padSummaryTitle(str: string) { + return c.dim(`${str.padStart(11)} `) } diff --git a/packages/vitest/src/node/reporters/renderers/windowedRenderer.ts b/packages/vitest/src/node/reporters/renderers/windowedRenderer.ts new file mode 100644 index 0000000000000..017f9a7b14d18 --- /dev/null +++ b/packages/vitest/src/node/reporters/renderers/windowedRenderer.ts @@ -0,0 +1,178 @@ +import type { Writable } from 'node:stream' +import type { Vitest } from '../../core' +import { stripVTControlCharacters } from 'node:util' + +const DEFAULT_RENDER_INTERVAL = 16 + +const ESC = '\x1B[' +const CLEAR_LINE = `${ESC}K` +const MOVE_CURSOR_ONE_ROW_UP = `${ESC}1A` +const HIDE_CURSOR = `${ESC}?25l` +const SHOW_CURSOR = `${ESC}?25h` +const SYNC_START = `${ESC}?2026h` +const SYNC_END = `${ESC}?2026l` + +interface Options { + logger: Vitest['logger'] + interval?: number + getWindow: () => string[] +} + +type StreamType = 'output' | 'error' + +/** + * Renders content of `getWindow` at the bottom of the terminal and + * forwards all other intercepted `stdout` and `stderr` logs above it. + */ +export class WindowRenderer { + private options: Required + private streams!: Record + private buffer: { type: StreamType; message: string }[] = [] + private renderInterval: NodeJS.Timeout | undefined = undefined + + private windowHeight = 0 + private finished = false + + constructor(options: Options) { + this.options = { + interval: DEFAULT_RENDER_INTERVAL, + ...options, + } + + this.streams = { + output: options.logger.outputStream.write.bind(options.logger.outputStream), + error: options.logger.errorStream.write.bind(options.logger.errorStream), + } + + this.interceptStream(process.stdout, 'output') + this.interceptStream(process.stderr, 'error') + + this.start() + } + + start() { + this.finished = false + this.renderInterval = setInterval(() => this.flushBuffer(), this.options.interval) + } + + stop() { + this.write(SHOW_CURSOR, 'output') + 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) + } + + private 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) + } + } + + private render(message?: string, type: StreamType = 'output') { + if (this.finished) { + this.clearWindow() + return this.write(message || '', type) + } + + const windowContent = this.options.getWindow() + const rowCount = getRenderedRowCount(windowContent, this.options.logger.outputStream) + let padding = this.windowHeight - rowCount + + if (padding > 0 && message) { + padding -= getRenderedRowCount([message], this.options.logger.outputStream) + } + + 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.write(HIDE_CURSOR) + + this.windowHeight = rowCount + Math.max(0, padding) + } + + private 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 + } + + private interceptStream(stream: NodeJS.WriteStream, type: StreamType) { + // @ts-expect-error -- not sure how 2 overloads should be typed + stream.write = (chunk, _, callback) => { + if (chunk) { + if (this.finished) { + this.write(chunk.toString(), type) + } + else { + this.buffer.push({ type, message: chunk.toString() }) + } + } + callback?.() + } + } + + private write(message: string, type: 'output' | 'error' = 'output') { + (this.streams[type] as Writable['write'])(message) + } +} + +/** Calculate the actual row count needed to render `rows` into `stream` */ +function getRenderedRowCount(rows: string[], stream: Options['logger']['outputStream']) { + let count = 0 + const columns = 'columns' in stream ? stream.columns : 80 + + for (const row of rows) { + const text = stripVTControlCharacters(row) + count += Math.max(1, Math.ceil(text.length / columns)) + } + + return count +} diff --git a/packages/vitest/src/node/reporters/summary.ts b/packages/vitest/src/node/reporters/summary.ts new file mode 100644 index 0000000000000..a2f7d5968c157 --- /dev/null +++ b/packages/vitest/src/node/reporters/summary.ts @@ -0,0 +1,281 @@ +import type { Custom, File, TaskResultPack, Test } from '@vitest/runner' +import type { Vitest } from '../core' +import type { Reporter } from '../types/reporter' +import { getTests } from '@vitest/runner/utils' +import c from 'tinyrainbow' +import { F_POINTER } from './renderers/figures' +import { formatProjectName, formatTime, formatTimeString, padSummaryTitle } from './renderers/utils' +import { WindowRenderer } from './renderers/windowedRenderer' + +const DURATION_UPDATE_INTERVAL_MS = 500 +const FINISHED_TEST_CLEANUP_TIME_MS = 1_000 + +interface Counter { + total: number + completed: number + passed: number + failed: number + skipped: number + todo: number +} + +interface RunningTest extends Pick { + filename: File['name'] + projectName: File['projectName'] +} + +/** + * Reporter extension that renders summary and forwards all other logs above itself. + * Intended to be used by other reporters, not as a standalone reporter. + */ +export class SummaryReporter implements Reporter { + private ctx!: Vitest + private renderer!: WindowRenderer + + private suites = emptyCounters() + private tests = emptyCounters() + private maxParallelTests = 0 + + /** Currently running tests, may include finished tests too */ + private runningTests = new Map() + + /** ID of finished `this.runningTests` that are currently being shown */ + private finishedTests = new Map() + + /** IDs of all finished tests */ + private allFinishedTests = new Set() + + private startTime = '' + private duration = 0 + private durationInterval: NodeJS.Timeout | undefined = undefined + + onInit(ctx: Vitest) { + this.ctx = ctx + + this.renderer = new WindowRenderer({ + logger: ctx.logger, + getWindow: () => this.createSummary(), + }) + + this.startTimers() + + this.ctx.onClose(() => { + clearInterval(this.durationInterval) + this.renderer.stop() + }) + } + + onPathsCollected(paths?: string[]) { + this.suites.total = (paths || []).length + } + + onTaskUpdate(packs: TaskResultPack[]) { + for (const pack of packs) { + const task = this.ctx.state.idMap.get(pack[0]) + + if (task && 'filepath' in task && task.result?.state && task?.type === 'suite') { + if (task?.result?.state === 'run') { + this.onTestFilePrepare(task) + } + else { + // Skipped tests are not reported, do it manually + for (const test of getTests(task)) { + if (!test.result || test.result?.state === 'skip') { + this.onTestFinished(test) + } + } + } + } + + if (task?.type === 'test' || task?.type === 'custom') { + if (task.result?.state !== 'run') { + this.onTestFinished(task) + } + } + } + } + + onWatcherRerun() { + this.runningTests.clear() + this.finishedTests.clear() + this.allFinishedTests.clear() + this.suites = emptyCounters() + this.tests = emptyCounters() + + this.startTimers() + this.renderer.start() + } + + onFinished() { + this.runningTests.clear() + this.finishedTests.clear() + this.allFinishedTests.clear() + this.renderer.finish() + clearInterval(this.durationInterval) + } + + private onTestFilePrepare(file: File) { + if (this.allFinishedTests.has(file.id) || this.runningTests.has(file.id)) { + return + } + + const total = getTests(file).length + this.tests.total += total + + // When new test starts, take the place of previously finished test, if any + if (this.finishedTests.size) { + const finished = this.finishedTests.entries().next().value + + if (finished) { + clearTimeout(finished[1]) + this.finishedTests.delete(finished[0]) + this.runningTests.delete(finished[0]) + } + } + + this.runningTests.set(file.id, { + total, + completed: 0, + filename: file.name, + projectName: file.projectName, + }) + + this.maxParallelTests = Math.max(this.maxParallelTests, this.runningTests.size) + } + + private onTestFinished(test: Test | Custom) { + const file = test.file + let stats = this.runningTests.get(file.id) + + if (!stats) { + // It's possible that that test finished before it's preparation was even reported + this.onTestFilePrepare(test.file) + stats = this.runningTests.get(file.id)! + + // It's also possible that this update came after whole test file was reported as finished + if (!stats) { + return + } + } + + stats.completed++ + const result = test.result + + if (result?.state === 'pass') { + this.tests.passed++ + } + else if (result?.state === 'fail') { + this.tests.failed++ + } + else if (!result?.state || result?.state === 'skip' || result?.state === 'todo') { + this.tests.skipped++ + } + + if (stats.completed >= stats.total) { + this.onTestFileFinished(file) + } + } + + private onTestFileFinished(file: File) { + if (this.allFinishedTests.has(file.id)) { + return + } + + this.allFinishedTests.add(file.id) + this.suites.completed++ + + if (file.result?.state === 'pass') { + this.suites.passed++ + } + else if (file.result?.state === 'fail') { + this.suites.failed++ + } + else if (file.result?.state === 'skip') { + this.suites.skipped++ + } + else if (file.result?.state === 'todo') { + this.suites.todo++ + } + + const left = this.suites.total - this.suites.completed + + // Keep finished tests visibe in summary for a while if there are more tests left. + // When a new test starts in onTestFilePrepare it will take this ones place. + // This reduces flickering by making summary more stable. + if (left > this.maxParallelTests) { + this.finishedTests.set(file.id, setTimeout(() => { + this.finishedTests.delete(file.id) + this.runningTests.delete(file.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 immediatelly. + this.runningTests.delete(file.id) + } + } + + private createSummary() { + const summary = [''] + + for (const test of Array.from(this.runningTests.values()).sort(sortRunningTests)) { + summary.push( + c.bold(c.yellow(` ${F_POINTER} `)) + + formatProjectName(test.projectName) + + test.filename + + c.dim(` ${test.completed}/${test.total}`), + ) + } + + if (this.runningTests.size > 0) { + summary.push('') + } + + summary.push(padSummaryTitle('Test Files') + getStateString(this.suites)) + 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 + } + + private startTimers() { + const start = performance.now() + this.startTime = formatTimeString(new Date()) + + this.durationInterval = setInterval(() => { + this.duration = performance.now() - start + }, DURATION_UPDATE_INTERVAL_MS) + } +} + +function emptyCounters(): Counter { + return { completed: 0, passed: 0, failed: 0, skipped: 0, todo: 0, total: 0 } +} + +function getStateString(entry: Counter) { + 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 sortRunningTests(a: RunningTest, b: RunningTest) { + if ((a.projectName || '') > (b.projectName || '')) { + return 1 + } + + if ((a.projectName || '') < (b.projectName || '')) { + return -1 + } + + return a.filename.localeCompare(b.filename) +} diff --git a/packages/vitest/src/node/reporters/verbose.ts b/packages/vitest/src/node/reporters/verbose.ts index dac91a52a72a2..f74fd61910d89 100644 --- a/packages/vitest/src/node/reporters/verbose.ts +++ b/packages/vitest/src/node/reporters/verbose.ts @@ -7,15 +7,11 @@ import { formatProjectName, getStateSymbol } from './renderers/utils' export class VerboseReporter extends DefaultReporter { protected verbose = true - - constructor() { - super() - this.rendererOptions.renderSucceed = true - } + renderSucceed = true onTaskUpdate(packs: TaskResultPack[]) { if (this.isTTY) { - return + return super.onTaskUpdate(packs) } for (const pack of packs) { const task = this.ctx.state.idMap.get(pack[0]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79eda0fa04535..54bd3bbd749ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -890,8 +890,8 @@ importers: specifier: ^0.3.1 version: 0.3.1 tinypool: - specifier: ^1.0.1 - version: 1.0.1 + specifier: https://pkg.pr.new/tinypool@67f3129 + version: https://pkg.pr.new/tinypool@67f3129 tinyrainbow: specifier: ^1.2.0 version: 1.2.0 @@ -8657,8 +8657,9 @@ packages: picocolors: optional: true - tinypool@1.0.1: - resolution: {integrity: sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==} + tinypool@https://pkg.pr.new/tinypool@67f3129: + resolution: {tarball: https://pkg.pr.new/tinypool@67f3129} + version: 1.0.1 engines: {node: ^18.0.0 || >=20.0.0} tinyrainbow@1.2.0: @@ -18031,7 +18032,7 @@ snapshots: optionalDependencies: picocolors: 1.1.1 - tinypool@1.0.1: {} + tinypool@https://pkg.pr.new/tinypool@67f3129: {} tinyrainbow@1.2.0: {} diff --git a/test/reporters/tests/console.test.ts b/test/reporters/tests/console.test.ts index 1b7179cc2627f..876bc948d019f 100644 --- a/test/reporters/tests/console.test.ts +++ b/test/reporters/tests/console.test.ts @@ -6,12 +6,7 @@ import { runVitest } from '../../test-utils' class LogReporter extends DefaultReporter { isTTY = true - renderer = { - start() {}, - update() {}, - stop() {}, - clear() {}, - } + onTaskUpdate() {} } test('should print logs correctly', async () => { diff --git a/test/reporters/tests/default.test.ts b/test/reporters/tests/default.test.ts index 0ae8fa3389951..cb641423e0fee 100644 --- a/test/reporters/tests/default.test.ts +++ b/test/reporters/tests/default.test.ts @@ -9,9 +9,9 @@ describe('default reporter', async () => { reporters: 'none', }) - expect(stdout).contain('✓ b2 test') + expect(stdout).contain('✓ b2 passed > b2 test') expect(stdout).not.contain('✓ nested b1 test') - expect(stdout).contain('× b failed test') + expect(stdout).contain('× b1 failed > b failed test') }) test('show full test suite when only one file', async () => { @@ -21,9 +21,9 @@ describe('default reporter', async () => { reporters: 'none', }) - expect(stdout).contain('✓ a1 test') - expect(stdout).contain('✓ nested a3 test') - expect(stdout).contain('× a failed test') + expect(stdout).contain('✓ a passed > a1 test') + expect(stdout).contain('✓ a passed > nested a > nested a3 test') + expect(stdout).contain('× a failed > a failed test') expect(stdout).contain('nested a failed 1 test') }) @@ -43,8 +43,9 @@ describe('default reporter', async () => { vitest.write('\n') await vitest.waitForStdout('Filename pattern: a') await vitest.waitForStdout('Waiting for file changes...') - expect(vitest.stdout).contain('✓ a1 test') - expect(vitest.stdout).contain('✓ nested a3 test') + + expect(vitest.stdout).contain('✓ a passed > a1 test') + expect(vitest.stdout).contain('✓ a passed > nested a > nested a3 test') // rerun and two files vitest.write('p') diff --git a/test/reporters/tests/merge-reports.test.ts b/test/reporters/tests/merge-reports.test.ts index 1cf3493142fe7..c868b4955dd83 100644 --- a/test/reporters/tests/merge-reports.test.ts +++ b/test/reporters/tests/merge-reports.test.ts @@ -89,6 +89,7 @@ test('merge reports', async () => { test 1-2 ❯ first.test.ts (2 tests | 1 failed)