diff --git a/packages/vitest/LICENSE.md b/packages/vitest/LICENSE.md index dd4d67915b7a..375bf139ecfe 100644 --- a/packages/vitest/LICENSE.md +++ b/packages/vitest/LICENSE.md @@ -1245,6 +1245,30 @@ Repository: git://github.com/feross/run-parallel.git --------------------------------------- +## signal-exit +License: ISC +By: Ben Coe +Repository: https://github.com/tapjs/signal-exit.git + +> The ISC License +> +> Copyright (c) 2015, Contributors +> +> Permission to use, copy, modify, and/or distribute this software +> for any purpose with or without fee is hereby granted, provided +> that the above copyright notice and this permission notice +> appear in all copies. +> +> THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +> WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +> OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE +> LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES +> OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +> WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +> ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--------------------------------------- + ## sisteransi License: MIT By: Terkel Gjervig diff --git a/packages/vitest/src/node/reporters/dot.ts b/packages/vitest/src/node/reporters/dot.ts index 44254dd6e152..ee0206221ca9 100644 --- a/packages/vitest/src/node/reporters/dot.ts +++ b/packages/vitest/src/node/reporters/dot.ts @@ -1,53 +1,182 @@ -import type { UserConsoleLog } from '../../types/general' +import type { Custom, File, TaskResultPack, TaskState, Test } from '@vitest/runner' +import type { Vitest } from '../core' +import { getTests } from '@vitest/runner/utils' +import c from 'tinyrainbow' import { BaseReporter } from './base' -import { createDotRenderer } from './renderers/dotRenderer' +import { WindowRenderer } from './renderers/windowedRenderer' +import { TaskParser } from './task-parser' + +interface Icon { + char: string + color: (char: string) => string +} export class DotReporter extends BaseReporter { - renderer?: ReturnType + private summary?: DotSummary - onTaskUpdate() {} + onInit(ctx: Vitest) { + super.onInit(ctx) - onCollected() { if (this.isTTY) { - const files = this.ctx.state.getFiles(this.watchFilters) - if (!this.renderer) { - this.renderer = createDotRenderer(files, { - logger: this.ctx.logger, - }).start() - } - else { - this.renderer.update(files) - } + this.summary = new DotSummary() + this.summary.onInit(ctx) + } + } + + onTaskUpdate(packs: TaskResultPack[]) { + this.summary?.onTaskUpdate(packs) + + if (!this.isTTY) { + super.onTaskUpdate(packs) } } - async onFinished( - files = this.ctx.state.getFiles(), - errors = this.ctx.state.getUnhandledErrors(), - ) { - await this.stopListRender() - this.ctx.logger.log() + onWatcherRerun(files: string[], trigger?: string) { + this.summary?.onWatcherRerun() + super.onWatcherRerun(files, trigger) + } + + onFinished(files?: File[], errors?: unknown[]) { + this.summary?.onFinished() super.onFinished(files, errors) } +} + +class DotSummary extends TaskParser { + private renderer!: WindowRenderer + private tests = new Map() + private finishedTests = new Set() + + onInit(ctx: Vitest): void { + this.ctx = ctx + + this.renderer = new WindowRenderer({ + logger: ctx.logger, + getWindow: () => this.createSummary(), + }) + + this.ctx.onClose(() => this.renderer.stop()) + } - async onWatcherStart() { - await this.stopListRender() - super.onWatcherStart() + onWatcherRerun() { + this.tests.clear() + this.renderer.start() } - async stopListRender() { - this.renderer?.stop() - this.renderer = undefined - await new Promise(resolve => setTimeout(resolve, 10)) + onFinished() { + const finalLog = formatTests(Array.from(this.tests.values())) + this.ctx.logger.log(finalLog) + + this.tests.clear() + this.renderer.finish() } - async onWatcherRerun(files: string[], trigger?: string) { - await this.stopListRender() - super.onWatcherRerun(files, trigger) + onTestFilePrepare(file: File): void { + for (const test of getTests(file)) { + // Dot reporter marks pending tests as running + this.onTestStart(test) + } + } + + onTestStart(test: Test | Custom) { + if (this.finishedTests.has(test.id)) { + return + } + + this.tests.set(test.id, test.mode || 'run') } - onUserConsoleLog(log: UserConsoleLog) { - this.renderer?.clear() - super.onUserConsoleLog(log) + onTestFinished(test: Test | Custom) { + if (this.finishedTests.has(test.id)) { + return + } + + this.finishedTests.add(test.id) + this.tests.set(test.id, test.result?.state || 'skip') } + + onTestFileFinished() { + const columns = this.renderer.getColumns() + + if (this.tests.size < columns) { + return + } + + const finishedTests = Array.from(this.tests).filter(entry => entry[1] !== 'run') + + if (finishedTests.length < columns) { + return + } + + // Remove finished tests from state and render them in static output + const states: TaskState[] = [] + let count = 0 + + for (const [id, state] of finishedTests) { + if (count++ >= columns) { + break + } + + this.tests.delete(id) + states.push(state) + } + + this.ctx.logger.log(formatTests(states)) + } + + private createSummary() { + return [ + formatTests(Array.from(this.tests.values())), + '', + ] + } +} + +// These are compared with reference equality in formatTests +const pass: Icon = { char: '·', color: c.green } +const fail: Icon = { char: 'x', color: c.red } +const pending: Icon = { char: '*', color: c.yellow } +const skip: Icon = { char: '-', color: (char: string) => c.dim(c.gray(char)) } + +function getIcon(state: TaskState): Icon { + switch (state) { + case 'pass': + return pass + case 'fail': + return fail + case 'skip': + case 'todo': + return skip + default: + return pending + } +} + +/** + * Format test states into string while keeping ANSI escapes at minimal. + * Sibling icons with same color are merged into a single c.color() call. + */ +function formatTests(states: TaskState[]): string { + let currentIcon = pending + let count = 0 + let output = '' + + for (const state of states) { + const icon = getIcon(state) + + if (currentIcon === icon) { + count++ + continue + } + + output += currentIcon.color(currentIcon.char.repeat(count)) + + // Start tracking new group + count = 1 + currentIcon = icon + } + + output += currentIcon.color(currentIcon.char.repeat(count)) + + return output } diff --git a/packages/vitest/src/node/reporters/renderers/dotRenderer.ts b/packages/vitest/src/node/reporters/renderers/dotRenderer.ts deleted file mode 100644 index 7d1982172bed..000000000000 --- a/packages/vitest/src/node/reporters/renderers/dotRenderer.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { Task } from '@vitest/runner' -import type { Logger } from '../../logger' -import { getTests } from '@vitest/runner/utils' -import c from 'tinyrainbow' - -export interface DotRendererOptions { - logger: Logger -} - -interface Icon { - char: string - color: (char: string) => string -} - -const check: Icon = { char: '·', color: c.green } -const cross: Icon = { char: 'x', color: c.red } -const pending: Icon = { char: '*', color: c.yellow } -const skip: Icon = { char: '-', color: (char: string) => c.dim(c.gray(char)) } - -function getIcon(task: Task) { - if (task.mode === 'skip' || task.mode === 'todo') { - return skip - } - switch (task.result?.state) { - case 'pass': - return check - case 'fail': - return cross - default: - return pending - } -} - -function render(tasks: Task[], width: number): string { - const all = getTests(tasks) - let currentIcon = pending - let currentTasks = 0 - let previousLineWidth = 0 - let output = '' - - // The log-update uses various ANSI helper utilities, e.g. ansi-warp, ansi-slice, - // when printing. Passing it hundreds of single characters containing ANSI codes reduces - // performances. We can optimize it by reducing amount of ANSI codes, e.g. by coloring - // multiple tasks at once instead of each task separately. - const addOutput = () => { - const { char, color } = currentIcon - const availableWidth = width - previousLineWidth - if (availableWidth > currentTasks) { - output += color(char.repeat(currentTasks)) - previousLineWidth += currentTasks - } - else { - // We need to split the line otherwise it will mess up log-update's height calculation - // and spam the scrollback buffer with dots. - - // Fill the current line first - let buf = `${char.repeat(availableWidth)}\n` - const remaining = currentTasks - availableWidth - - // Then fill as many full rows as possible - const fullRows = Math.floor(remaining / width) - buf += `${char.repeat(width)}\n`.repeat(fullRows) - - // Add remaining dots which don't make a full row - const partialRow = remaining % width - if (partialRow > 0) { - buf += char.repeat(partialRow) - previousLineWidth = partialRow - } - else { - previousLineWidth = 0 - } - - output += color(buf) - } - } - for (const task of all) { - const icon = getIcon(task) - if (icon === currentIcon) { - currentTasks++ - continue - } - // Task mode/state has changed, add previous group to output - addOutput() - - // Start tracking new group - currentTasks = 1 - currentIcon = icon - } - addOutput() - return output -} - -export function createDotRenderer(_tasks: Task[], options: DotRendererOptions) { - let tasks = _tasks - let timer: any - - const { logUpdate: log, outputStream } = options.logger - const columns = 'columns' in outputStream ? outputStream.columns : 80 - - function update() { - log(render(tasks, columns)) - } - - return { - start() { - if (timer) { - return this - } - timer = setInterval(update, 16) - return this - }, - update(_tasks: Task[]) { - tasks = _tasks - return this - }, - async stop() { - if (timer) { - clearInterval(timer) - timer = undefined - } - log.clear() - options.logger.log(render(tasks, columns)) - return this - }, - clear() { - log.clear() - }, - } -} diff --git a/packages/vitest/src/node/reporters/renderers/windowedRenderer.ts b/packages/vitest/src/node/reporters/renderers/windowedRenderer.ts index 649b08a60bb2..5f836979d51d 100644 --- a/packages/vitest/src/node/reporters/renderers/windowedRenderer.ts +++ b/packages/vitest/src/node/reporters/renderers/windowedRenderer.ts @@ -77,6 +77,10 @@ export class WindowRenderer { clearInterval(this.renderInterval) } + getColumns() { + return 'columns' in this.options.logger.outputStream ? this.options.logger.outputStream.columns : 80 + } + private flushBuffer() { if (this.buffer.length === 0) { return this.render() @@ -112,11 +116,11 @@ export class WindowRenderer { } const windowContent = this.options.getWindow() - const rowCount = getRenderedRowCount(windowContent, this.options.logger.outputStream) + const rowCount = getRenderedRowCount(windowContent, this.getColumns()) let padding = this.windowHeight - rowCount if (padding > 0 && message) { - padding -= getRenderedRowCount([message], this.options.logger.outputStream) + padding -= getRenderedRowCount([message], this.getColumns()) } this.write(SYNC_START) @@ -203,9 +207,8 @@ export class WindowRenderer { } /** Calculate the actual row count needed to render `rows` into `stream` */ -function getRenderedRowCount(rows: string[], stream: Options['logger']['outputStream']) { +function getRenderedRowCount(rows: string[], columns: number) { let count = 0 - const columns = 'columns' in stream ? stream.columns : 80 for (const row of rows) { const text = stripVTControlCharacters(row) diff --git a/test/reporters/tests/dot.test.ts b/test/reporters/tests/dot.test.ts new file mode 100644 index 000000000000..33631a9f8671 --- /dev/null +++ b/test/reporters/tests/dot.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'vitest' +import { runVitest } from '../../test-utils' + +describe('{ isTTY: true }', () => { + const isTTY = true + + test('renders successful tests', async () => { + const { stdout, stderr } = await runVitest({ + include: ['./fixtures/ok.test.ts'], + reporters: [['dot', { isTTY }]], + typecheck: undefined, + }) + + expect(stdout).toContain('\n·\n') + expect(stdout).toContain('Test Files 1 passed (1)') + + expect(stderr).toBe('') + }) + + test('renders failing tests', async () => { + const { stdout, stderr } = await runVitest({ + include: ['./fixtures/some-failing.test.ts'], + reporters: [['dot', { isTTY }]], + typecheck: undefined, + }) + + expect(stdout).toContain('\n·x\n') + expect(stdout).toContain('Test Files 1 failed (1)') + expect(stdout).toContain('Tests 1 failed | 1 passed') + + expect(stderr).toContain('AssertionError: expected 6 to be 7 // Object.is equality') + }) + + test('renders skipped tests', async () => { + const { stdout, stderr } = await runVitest({ + include: ['./fixtures/all-skipped.test.ts'], + reporters: [['dot', { isTTY }]], + typecheck: undefined, + }) + + expect(stdout).toContain('\n--\n') + expect(stdout).toContain('Test Files 1 skipped (1)') + expect(stdout).toContain('Tests 1 skipped | 1 todo') + + expect(stderr).toContain('') + }) +}) + +describe('{ isTTY: false }', () => { + const isTTY = false + + test('renders successful tests', async () => { + const { stdout, stderr } = await runVitest({ + include: ['./fixtures/ok.test.ts'], + reporters: [['dot', { isTTY }]], + typecheck: undefined, + }) + + expect(stdout).toContain('✓ fixtures/ok.test.ts') + expect(stdout).toContain('Test Files 1 passed (1)') + + expect(stderr).toBe('') + }) + + test('renders failing tests', async () => { + const { stdout, stderr } = await runVitest({ + include: ['./fixtures/some-failing.test.ts'], + reporters: [['dot', { isTTY }]], + typecheck: undefined, + }) + + expect(stdout).toContain('❯ fixtures/some-failing.test.ts (2 tests | 1 failed)') + expect(stdout).toContain('✓ 2 + 3 = 5') + expect(stdout).toContain('× 3 + 3 = 7') + + expect(stdout).toContain('Test Files 1 failed (1)') + expect(stdout).toContain('Tests 1 failed | 1 passed') + + expect(stderr).toContain('AssertionError: expected 6 to be 7 // Object.is equality') + }) + + test('renders skipped tests', async () => { + const { stdout, stderr } = await runVitest({ + include: ['./fixtures/all-skipped.test.ts'], + reporters: [['dot', { isTTY }]], + typecheck: undefined, + }) + + expect(stdout).toContain('↓ fixtures/all-skipped.test.ts (2 tests | 2 skipped)') + expect(stdout).toContain('Test Files 1 skipped (1)') + expect(stdout).toContain('Tests 1 skipped | 1 todo') + + expect(stderr).toContain('') + }) +})