From 511b73c7e816429e1e5b798af4db98b0ef2b0b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Mon, 18 Nov 2024 16:05:07 +0200 Subject: [PATCH] feat(reporters): `summary` option for `verbose` and `default` reporters (#6893) --- docs/guide/reporters.md | 67 ++- packages/vitest/package.json | 3 +- packages/vitest/src/node/reporters/base.ts | 107 ++--- packages/vitest/src/node/reporters/basic.ts | 12 + packages/vitest/src/node/reporters/default.ts | 117 ++---- packages/vitest/src/node/reporters/dot.ts | 2 + packages/vitest/src/node/reporters/index.ts | 5 +- .../src/node/reporters/renderers/figures.ts | 2 + .../node/reporters/renderers/listRenderer.ts | 311 -------------- .../src/node/reporters/renderers/utils.ts | 36 +- .../reporters/renderers/windowedRenderer.ts | 191 +++++++++ packages/vitest/src/node/reporters/summary.ts | 397 ++++++++++++++++++ .../vitest/src/node/reporters/task-parser.ts | 87 ++++ packages/vitest/src/node/reporters/verbose.ts | 8 +- pnpm-lock.yaml | 39 +- .../task-parser-tests/example-1.test.ts | 40 ++ .../task-parser-tests/example-2.test.ts | 40 ++ test/reporters/package.json | 1 + test/reporters/tests/console.test.ts | 7 +- test/reporters/tests/default.test.ts | 15 +- test/reporters/tests/merge-reports.test.ts | 3 + test/reporters/tests/task-parser.test.ts | 157 +++++++ 22 files changed, 1124 insertions(+), 523 deletions(-) delete mode 100644 packages/vitest/src/node/reporters/renderers/listRenderer.ts create mode 100644 packages/vitest/src/node/reporters/renderers/windowedRenderer.ts create mode 100644 packages/vitest/src/node/reporters/summary.ts create mode 100644 packages/vitest/src/node/reporters/task-parser.ts create mode 100644 test/reporters/fixtures/task-parser-tests/example-1.test.ts create mode 100644 test/reporters/fixtures/task-parser-tests/example-2.test.ts create mode 100644 test/reporters/tests/task-parser.test.ts diff --git a/docs/guide/reporters.md b/docs/guide/reporters.md index 4d3785fd1e73..7114f3a5c749 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. It also displays currently running tests that are taking longer than [`slowTestThreshold`](/config/#slowtestthreshold). Similar to `default` reporter, you can disable the summary by configuring the reporter. :::code-group ```bash [CLI] @@ -161,12 +182,32 @@ npx vitest --reporter=verbose ```ts [vitest.config.ts] export default defineConfig({ test: { - reporters: ['verbose'] + reporters: [ + ['verbose', { summary: false }] + ] }, }) ``` ::: +Example output for tests in progress with default `slowTestThreshold: 300`: + +```bash + ✓ __tests__/example-1.test.ts (2) 725ms + ✓ first test file (2) 725ms + ✓ 2 + 2 should equal 4 + ✓ 4 - 2 should equal 2 + + ❯ test/example-2.test.ts 3/5 + ↳ should run longer than three seconds 1.57s + ❯ test/example-3.test.ts 1/5 + + Test Files 2 passed (4) + Tests 10 passed | 3 skipped (65) + Start at 11:01:36 + Duration 2.00s +``` + Example of final terminal output for a passing test suite: ```bash diff --git a/packages/vitest/package.json b/packages/vitest/package.json index ab7ea0cc7954..5dc1cd5958db 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -162,10 +162,11 @@ "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", + "restore-cursor": "^5.1.0", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", + "tinypool": "^1.0.2", "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 44e7ccc3ed44..6895adafa176 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,15 @@ export abstract class BaseReporter implements Reporter { + ` ${c.yellow(Math.round(duration) + c.dim('ms'))}`, ) } + + // also print skipped tests that have notes + else if (test.result?.state === 'skip' && test.result.note) { + this.log(` ${getStateSymbol(test)} ${getTestName(test)}${c.dim(c.gray(` [${test.result.note}]`))}`) + } + + else if (this.renderSucceed || anyFailed) { + this.log(` ${c.green(c.dim(F_CHECK))} ${getTestName(test, c.dim(' > '))}`) + } } } @@ -153,8 +158,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 +180,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 +196,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 +218,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 +319,8 @@ export abstract class BaseReporter implements Reporter { } reportTestSummary(files: File[], errors: unknown[]) { + this.log() + const affectedFiles = [ ...this.failedUnwatchedFiles, ...files, @@ -364,21 +334,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 +357,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 +379,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 +514,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/basic.ts b/packages/vitest/src/node/reporters/basic.ts index f93b2325798c..6e018fa636fb 100644 --- a/packages/vitest/src/node/reporters/basic.ts +++ b/packages/vitest/src/node/reporters/basic.ts @@ -1,4 +1,6 @@ import type { File } from '@vitest/runner' +import type { Vitest } from '../core' +import c from 'tinyrainbow' import { BaseReporter } from './base' export class BasicReporter extends BaseReporter { @@ -7,6 +9,16 @@ export class BasicReporter extends BaseReporter { this.isTTY = false } + onInit(ctx: Vitest) { + super.onInit(ctx) + + ctx.logger.log(c.inverse(c.bold(c.yellow(' DEPRECATED '))), c.yellow( + `'basic' reporter is deprecated and will be removed in Vitest v3.\n` + + `Remove 'basic' from 'reporters' option. To match 'basic' reporter 100%, use configuration:\n${ + JSON.stringify({ test: { reporters: [['default', { summary: false }]] } }, null, 2)}`, + )) + } + reportSummary(files: File[], errors: unknown[]) { // non-tty mode doesn't add a new line this.ctx.logger.log() diff --git a/packages/vitest/src/node/reporters/default.ts b/packages/vitest/src/node/reporters/default.ts index 639f1ba3a8a5..5ac02191fcab 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, { verbose: this.verbose }) } - 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 64b5cc95900b..44254dd6e152 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 f1c1c7449df1..dcb7e99a9297 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/figures.ts b/packages/vitest/src/node/reporters/renderers/figures.ts index b5dc259d3bc1..99543e078861 100644 --- a/packages/vitest/src/node/reporters/renderers/figures.ts +++ b/packages/vitest/src/node/reporters/renderers/figures.ts @@ -8,3 +8,5 @@ export const F_CHECK = '✓' export const F_CROSS = '×' export const F_LONG_DASH = '⎯' export const F_RIGHT_TRI = '▶' +export const F_TREE_NODE_MIDDLE = '├──' +export const F_TREE_NODE_END = '└──' 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 aff7f042935f..000000000000 --- a/packages/vitest/src/node/reporters/renderers/listRenderer.ts +++ /dev/null @@ -1,311 +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') { - const note = task.result?.note || 'skipped' - suffix += ` ${c.dim(c.gray(`[${note}]`))}` - } - - 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 9be1bff56974..2f3b4b9a6b6d 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 000000000000..3c7a9dcd2371 --- /dev/null +++ b/packages/vitest/src/node/reporters/renderers/windowedRenderer.ts @@ -0,0 +1,191 @@ +import type { Writable } from 'node:stream' +import type { Vitest } from '../../core' +import { stripVTControlCharacters } from 'node:util' +import restoreCursor from 'restore-cursor' + +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 + private cleanups: (() => void)[] = [] + + 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.cleanups.push( + this.interceptStream(process.stdout, 'output'), + this.interceptStream(process.stderr, 'error'), + ) + + restoreCursor() + this.write(HIDE_CURSOR, 'output') + + this.start() + } + + start() { + this.finished = false + this.renderInterval = setInterval(() => this.flushBuffer(), this.options.interval) + } + + stop() { + this.write(SHOW_CURSOR, 'output') + this.cleanups.splice(0).map(fn => fn()) + clearInterval(this.renderInterval) + } + + /** + * Write all buffered output and stop buffering. + * All intercepted writes are forwarded to actual write after this. + */ + finish() { + this.finished = true + this.flushBuffer() + clearInterval(this.renderInterval) + } + + 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.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) { + const original = stream.write + + // @ts-expect-error -- not sure how 2 overloads should be typed + stream.write = (chunk, _, callback) => { + if (chunk) { + if (this.finished) { + this.write(chunk.toString(), type) + } + else { + this.buffer.push({ type, message: chunk.toString() }) + } + } + callback?.() + } + + return function restore() { + stream.write = original + } + } + + 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 000000000000..25030392d4a7 --- /dev/null +++ b/packages/vitest/src/node/reporters/summary.ts @@ -0,0 +1,397 @@ +import type { Custom, File, Test } from '@vitest/runner' +import type { Vitest } from '../core' +import type { Reporter } from '../types/reporter' +import type { HookOptions } from './task-parser' +import { getTests } from '@vitest/runner/utils' +import c from 'tinyrainbow' +import { F_POINTER, F_TREE_NODE_END, F_TREE_NODE_MIDDLE } from './renderers/figures' +import { formatProjectName, formatTime, formatTimeString, padSummaryTitle } from './renderers/utils' +import { WindowRenderer } from './renderers/windowedRenderer' +import { TaskParser } from './task-parser' + +const DURATION_UPDATE_INTERVAL_MS = 100 +const FINISHED_TEST_CLEANUP_TIME_MS = 1_000 + +interface Options { + verbose?: boolean +} + +interface Counter { + total: number + completed: number + passed: number + failed: number + skipped: number + todo: number +} + +interface SlowTask { + name: string + visible: boolean + startTime: number + onFinish: () => void + hook?: Omit +} + +interface RunningTest extends Pick { + filename: File['name'] + projectName: File['projectName'] + hook?: Omit + tests: Map +} + +/** + * 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 extends TaskParser implements Reporter { + private options!: Options + 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 currentTime = 0 + private duration = 0 + private durationInterval: NodeJS.Timeout | undefined = undefined + + onInit(ctx: Vitest, options: Options = {}) { + this.ctx = ctx + + this.options = { + verbose: false, + ...options, + } + + 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 + } + + 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) + } + + 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.keys().next().value + this.removeTestFile(finished) + } + + this.runningTests.set(file.id, { + total, + completed: 0, + filename: file.name, + projectName: file.projectName, + tests: new Map(), + }) + + this.maxParallelTests = Math.max(this.maxParallelTests, this.runningTests.size) + } + + onHookStart(options: HookOptions) { + const stats = this.getHookStats(options) + + if (!stats) { + return + } + + const hook = { + name: options.name, + visible: false, + startTime: performance.now(), + onFinish: () => {}, + } + stats.hook?.onFinish?.() + stats.hook = hook + + const timeout = setTimeout(() => { + hook.visible = true + }, this.ctx.config.slowTestThreshold).unref() + + hook.onFinish = () => clearTimeout(timeout) + } + + onHookEnd(options: HookOptions) { + const stats = this.getHookStats(options) + + if (stats?.hook?.name !== options.name) { + return + } + + stats.hook.onFinish() + stats.hook.visible = false + } + + onTestStart(test: Test | Custom) { + // Track slow running tests only on verbose mode + if (!this.options.verbose) { + return + } + + const stats = this.getTestStats(test) + + if (!stats || stats.tests.has(test.id)) { + return + } + + const slowTest: SlowTask = { + name: test.name, + visible: false, + startTime: performance.now(), + onFinish: () => {}, + } + + const timeout = setTimeout(() => { + slowTest.visible = true + }, this.ctx.config.slowTestThreshold).unref() + + slowTest.onFinish = () => { + slowTest.hook?.onFinish() + clearTimeout(timeout) + } + + stats.tests.set(test.id, slowTest) + } + + onTestFinished(test: Test | Custom) { + const stats = this.getTestStats(test) + + if (!stats) { + return + } + + stats.tests.get(test.id)?.onFinish() + stats.tests.delete(test.id) + + 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++ + } + } + + 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.removeTestFile(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.removeTestFile(file.id) + } + } + + private getTestStats(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 + } + } + + return stats + } + + private getHookStats({ file, id, type }: HookOptions) { + // Track slow running hooks only on verbose mode + if (!this.options.verbose) { + return + } + + const stats = this.runningTests.get(file.id) + + if (!stats) { + return + } + + return type === 'suite' ? stats : stats?.tests.get(id) + } + + private createSummary() { + const summary = [''] + + for (const testFile of Array.from(this.runningTests.values()).sort(sortRunningTests)) { + summary.push( + c.bold(c.yellow(` ${F_POINTER} `)) + + formatProjectName(testFile.projectName) + + testFile.filename + + c.dim(` ${testFile.completed}/${testFile.total}`), + ) + + const slowTasks = [ + testFile.hook, + ...Array.from(testFile.tests.values()), + ].filter((t): t is SlowTask => t != null && t.visible) + + for (const [index, task] of slowTasks.entries()) { + const elapsed = this.currentTime - task.startTime + const icon = index === slowTasks.length - 1 ? F_TREE_NODE_END : F_TREE_NODE_MIDDLE + + summary.push( + c.bold(c.yellow(` ${icon} `)) + + task.name + + c.bold(c.yellow(` ${formatTime(Math.max(0, elapsed))}`)), + ) + + if (task.hook?.visible) { + summary.push(c.bold(c.yellow(` ${F_TREE_NODE_END} `)) + task.hook.name) + } + } + } + + if (this.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.currentTime = performance.now() + this.duration = this.currentTime - start + }, DURATION_UPDATE_INTERVAL_MS).unref() + } + + private removeTestFile(id?: File['id']) { + if (!id) { + return + } + + const testFile = this.runningTests.get(id) + testFile?.hook?.onFinish() + testFile?.tests?.forEach(test => test.onFinish()) + + this.runningTests.delete(id) + + clearTimeout(this.finishedTests.get(id)) + this.finishedTests.delete(id) + } +} + +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/task-parser.ts b/packages/vitest/src/node/reporters/task-parser.ts new file mode 100644 index 000000000000..3859594dc44a --- /dev/null +++ b/packages/vitest/src/node/reporters/task-parser.ts @@ -0,0 +1,87 @@ +import type { Custom, File, Task, TaskResultPack, Test } from '@vitest/runner' +import type { Vitest } from '../core' +import { getTests } from '@vitest/runner/utils' + +export interface HookOptions { + name: string + file: File + id: File['id'] | Test['id'] + type: Task['type'] +} + +export class TaskParser { + ctx!: Vitest + + onInit(ctx: Vitest) { + this.ctx = ctx + } + + onHookStart(_options: HookOptions) {} + onHookEnd(_options: HookOptions) {} + + onTestStart(_test: Test | Custom) {} + onTestFinished(_test: Test | Custom) {} + + onTestFilePrepare(_file: File) {} + onTestFileFinished(_file: File) {} + + onTaskUpdate(packs: TaskResultPack[]) { + const startingTestFiles: File[] = [] + const finishedTestFiles: File[] = [] + + const startingTests: (Test | Custom)[] = [] + const finishedTests: (Test | Custom)[] = [] + + const startingHooks: HookOptions[] = [] + const endingHooks: HookOptions[] = [] + + for (const pack of packs) { + const task = this.ctx.state.idMap.get(pack[0]) + + if (task?.type === 'suite' && 'filepath' in task && task.result?.state) { + if (task?.result?.state === 'run') { + startingTestFiles.push(task) + } + else { + // Skipped tests are not reported, do it manually + for (const test of getTests(task)) { + if (!test.result || test.result?.state === 'skip') { + finishedTests.push(test) + } + } + + finishedTestFiles.push(task.file) + } + } + + if (task?.type === 'test' || task?.type === 'custom') { + if (task.result?.state === 'run') { + startingTests.push(task) + } + else if (task.result?.hooks?.afterEach !== 'run') { + finishedTests.push(task) + } + } + + if (task?.result?.hooks) { + for (const [hook, state] of Object.entries(task.result.hooks)) { + if (state === 'run') { + startingHooks.push({ name: hook, file: task.file, id: task.id, type: task.type }) + } + else { + endingHooks.push({ name: hook, file: task.file, id: task.id, type: task.type }) + } + } + } + } + + endingHooks.forEach(hook => this.onHookEnd(hook)) + finishedTests.forEach(test => this.onTestFinished(test)) + finishedTestFiles.forEach(file => this.onTestFileFinished(file)) + + startingTestFiles.forEach(file => this.onTestFilePrepare(file)) + startingTests.forEach(test => this.onTestStart(test)) + startingHooks.forEach(hook => this.onHookStart(hook), + ) + } +} diff --git a/packages/vitest/src/node/reporters/verbose.ts b/packages/vitest/src/node/reporters/verbose.ts index bb3bbceb5e83..dff3fbf168b1 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 5cbc1225559a..15ee5120ba50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -880,6 +880,9 @@ importers: pathe: specifier: ^1.1.2 version: 1.1.2 + restore-cursor: + specifier: ^5.1.0 + version: 5.1.0 std-env: specifier: ^3.8.0 version: 3.8.0 @@ -890,8 +893,8 @@ importers: specifier: ^0.3.1 version: 0.3.1 tinypool: - specifier: ^1.0.1 - version: 1.0.1 + specifier: ^1.0.2 + version: 1.0.2 tinyrainbow: specifier: ^1.2.0 version: 1.2.0 @@ -1303,6 +1306,9 @@ importers: test/reporters: devDependencies: + '@vitest/runner': + specifier: workspace:* + version: link:../../packages/runner flatted: specifier: ^3.2.9 version: 3.2.9 @@ -7144,6 +7150,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -7438,6 +7448,10 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + oniguruma-to-js@0.4.3: resolution: {integrity: sha512-X0jWUcAlxORhOqqBREgPMgnshB7ZGYszBNspP+tS9hPD3l13CdaXcHbgImoHUHlrvGx/7AvFEkTRhAGYh+jzjQ==} @@ -7990,6 +8004,10 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + ret@0.2.2: resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==} engines: {node: '>=4'} @@ -8661,8 +8679,8 @@ packages: picocolors: optional: true - tinypool@1.0.1: - resolution: {integrity: sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==} + tinypool@1.0.2: + resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} engines: {node: ^18.0.0 || >=20.0.0} tinyrainbow@1.2.0: @@ -16364,6 +16382,8 @@ snapshots: mimic-fn@4.0.0: {} + mimic-function@5.0.1: {} + mimic-response@3.1.0: {} mimic-response@4.0.0: {} @@ -16624,6 +16644,10 @@ snapshots: dependencies: mimic-fn: 4.0.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + oniguruma-to-js@0.4.3: dependencies: regex: 4.3.3 @@ -17243,6 +17267,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + ret@0.2.2: {} reusify@1.0.4: {} @@ -18037,7 +18066,7 @@ snapshots: optionalDependencies: picocolors: 1.1.1 - tinypool@1.0.1: {} + tinypool@1.0.2: {} tinyrainbow@1.2.0: {} diff --git a/test/reporters/fixtures/task-parser-tests/example-1.test.ts b/test/reporters/fixtures/task-parser-tests/example-1.test.ts new file mode 100644 index 000000000000..1f77707a7eae --- /dev/null +++ b/test/reporters/fixtures/task-parser-tests/example-1.test.ts @@ -0,0 +1,40 @@ +import { beforeAll, beforeEach, afterEach, afterAll, test, describe } from "vitest"; +import { setTimeout } from "node:timers/promises"; + +beforeAll(async () => { + await setTimeout(100); +}); + +afterAll(async () => { + await setTimeout(100); +}); + +describe("some suite", async () => { + beforeEach(async () => { + await setTimeout(100); + }); + + test("some test", async () => { + await setTimeout(100); + }); + + afterEach(async () => { + await setTimeout(100); + }); +}); + +test("Fast test 1", () => { + // +}); + +test.skip("Skipped test 1", () => { + // +}); + +test.concurrent("parallel slow tests 1.1", async () => { + await setTimeout(100); +}); + +test.concurrent("parallel slow tests 1.2", async () => { + await setTimeout(100); +}); diff --git a/test/reporters/fixtures/task-parser-tests/example-2.test.ts b/test/reporters/fixtures/task-parser-tests/example-2.test.ts new file mode 100644 index 000000000000..55ba3fe883e5 --- /dev/null +++ b/test/reporters/fixtures/task-parser-tests/example-2.test.ts @@ -0,0 +1,40 @@ +import { beforeAll, beforeEach, afterEach, afterAll, test, describe } from "vitest"; +import { setTimeout } from "node:timers/promises"; + +beforeAll(async () => { + await setTimeout(100); +}); + +afterAll(async () => { + await setTimeout(100); +}); + +describe("some suite", async () => { + beforeEach(async () => { + await setTimeout(100); + }); + + test("some test", async () => { + await setTimeout(100); + }); + + afterEach(async () => { + await setTimeout(100); + }); +}); + +test("Fast test 1", () => { + // +}); + +test.skip("Skipped test 1", () => { + // +}); + +test.concurrent("parallel slow tests 2.1", async () => { + await setTimeout(100); +}); + +test.concurrent("parallel slow tests 2.2", async () => { + await setTimeout(100); +}); diff --git a/test/reporters/package.json b/test/reporters/package.json index ed03c0a4fa2d..901834b98d02 100644 --- a/test/reporters/package.json +++ b/test/reporters/package.json @@ -6,6 +6,7 @@ "test": "vitest" }, "devDependencies": { + "@vitest/runner": "workspace:*", "flatted": "^3.2.9", "pkg-reporter": "./reportPkg/", "vitest": "workspace:*", diff --git a/test/reporters/tests/console.test.ts b/test/reporters/tests/console.test.ts index 1b7179cc2627..876bc948d019 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 0ae8fa338995..cb641423e0fe 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 1cf3493142fe..c868b4955dd8 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)