From 027ce9bbf73c560006c372d68bcde867c80f9c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Tue, 18 Feb 2025 09:35:03 +0200 Subject: [PATCH] fix(reporters): render tasks in tree when in TTY (#7503) --- packages/vitest/src/node/reporters/base.ts | 100 +++++++++++------- packages/vitest/src/node/reporters/verbose.ts | 31 +++++- .../fixtures/verbose/example-1.test.ts | 35 ++++++ .../fixtures/verbose/example-2.test.ts | 9 ++ test/reporters/tests/default.test.ts | 52 ++++++++- test/reporters/tests/verbose.test.ts | 96 ++++++++++++++++- 6 files changed, 278 insertions(+), 45 deletions(-) create mode 100644 test/reporters/fixtures/verbose/example-1.test.ts create mode 100644 test/reporters/fixtures/verbose/example-2.test.ts diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index f293a73be8ce..4d6ed941e92f 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -88,11 +88,12 @@ export abstract class BaseReporter implements Reporter { return } - const tests = getTests(task) - const failed = tests.filter(t => t.result?.state === 'fail') - const skipped = tests.filter(t => t.mode === 'skip' || t.mode === 'todo') + const suites = getSuites(task) + const allTests = getTests(task) + const failed = allTests.filter(t => t.result?.state === 'fail') + const skipped = allTests.filter(t => t.mode === 'skip' || t.mode === 'todo') - let state = c.dim(`${tests.length} test${tests.length > 1 ? 's' : ''}`) + let state = c.dim(`${allTests.length} test${allTests.length > 1 ? 's' : ''}`) if (failed.length) { state += c.dim(' | ') + c.red(`${failed.length} failed`) @@ -120,52 +121,79 @@ export abstract class BaseReporter implements Reporter { this.log(` ${title} ${task.name} ${suffix}`) - const anyFailed = tests.some(test => test.result?.state === 'fail') + for (const suite of suites) { + const tests = suite.tasks.filter(task => task.type === 'test') - for (const test of tests) { - const { duration, retryCount, repeatCount } = test.result || {} - let suffix = '' - - if (retryCount != null && retryCount > 0) { - suffix += c.yellow(` (retry x${retryCount})`) + if (!('filepath' in suite)) { + this.printSuite(suite) } - if (repeatCount != null && repeatCount > 0) { - suffix += c.yellow(` (repeat x${repeatCount})`) - } + for (const test of tests) { + const { duration, retryCount, repeatCount } = test.result || {} + const padding = this.getTestIndentation(test) + let suffix = '' - if (test.result?.state === 'fail') { - this.log(c.red(` ${taskFail} ${getTestName(test, c.dim(' > '))}${this.getDurationPrefix(test)}`) + suffix) + if (retryCount != null && retryCount > 0) { + suffix += c.yellow(` (retry x${retryCount})`) + } + + if (repeatCount != null && repeatCount > 0) { + suffix += c.yellow(` (repeat x${repeatCount})`) + } + + if (test.result?.state === 'fail') { + this.log(c.red(` ${padding}${taskFail} ${this.getTestName(test, c.dim(' > '))}${this.getDurationPrefix(test)}`) + suffix) - test.result?.errors?.forEach((e) => { // print short errors, full errors will be at the end in summary - this.log(c.red(` ${F_RIGHT} ${e?.message}`)) - }) - } + test.result?.errors?.forEach((error) => { + const message = this.formatShortError(error) - // also print slow tests - else if (duration && duration > this.ctx.config.slowTestThreshold) { - this.log( - ` ${c.yellow(c.dim(F_CHECK))} ${getTestName(test, c.dim(' > '))}` - + ` ${c.yellow(Math.round(duration) + c.dim('ms'))}${suffix}`, - ) - } + if (message) { + this.log(c.red(` ${padding}${message}`)) + } + }) + } - else if (this.ctx.config.hideSkippedTests && (test.mode === 'skip' || test.result?.state === 'skip')) { - // Skipped tests are hidden when --hideSkippedTests - } + // also print slow tests + else if (duration && duration > this.ctx.config.slowTestThreshold) { + this.log( + ` ${padding}${c.yellow(c.dim(F_CHECK))} ${this.getTestName(test, c.dim(' > '))}` + + ` ${c.yellow(Math.round(duration) + c.dim('ms'))}${suffix}`, + ) + } - // 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.ctx.config.hideSkippedTests && (test.mode === 'skip' || test.result?.state === 'skip')) { + // Skipped tests are hidden when --hideSkippedTests + } + + // also print skipped tests that have notes + else if (test.result?.state === 'skip' && test.result.note) { + this.log(` ${padding}${getStateSymbol(test)} ${this.getTestName(test)}${c.dim(c.gray(` [${test.result.note}]`))}`) + } - else if (this.renderSucceed || anyFailed) { - this.log(` ${getStateSymbol(test)} ${getTestName(test, c.dim(' > '))}${suffix}`) + else if (this.renderSucceed || failed.length > 0) { + this.log(` ${padding}${getStateSymbol(test)} ${this.getTestName(test, c.dim(' > '))}${suffix}`) + } } } } + protected printSuite(_task: Task): void { + // Suite name is included in getTestName by default + } + + protected getTestName(test: Task, separator?: string): string { + return getTestName(test, separator) + } + + protected formatShortError(error: ErrorWithDiff): string { + return `${F_RIGHT} ${error.message}` + } + + protected getTestIndentation(_test: Task) { + return ' ' + } + private getDurationPrefix(task: Task) { if (!task.result?.duration) { return '' diff --git a/packages/vitest/src/node/reporters/verbose.ts b/packages/vitest/src/node/reporters/verbose.ts index b4449685eb98..4577cabb358d 100644 --- a/packages/vitest/src/node/reporters/verbose.ts +++ b/packages/vitest/src/node/reporters/verbose.ts @@ -1,5 +1,5 @@ import type { Task } from '@vitest/runner' -import { getFullName } from '@vitest/runner/utils' +import { getFullName, getTests } from '@vitest/runner/utils' import c from 'tinyrainbow' import { DefaultReporter } from './default' import { F_RIGHT } from './renderers/figures' @@ -45,4 +45,33 @@ export class VerboseReporter extends DefaultReporter { task.result.errors?.forEach(error => this.log(c.red(` ${F_RIGHT} ${error?.message}`))) } } + + protected printSuite(task: Task): void { + const indentation = ' '.repeat(getIndentation(task)) + const tests = getTests(task) + const state = getStateSymbol(task) + + this.log(` ${indentation}${state} ${task.name} ${c.dim(`(${tests.length})`)}`) + } + + protected getTestName(test: Task): string { + return test.name + } + + protected getTestIndentation(test: Task): string { + return ' '.repeat(getIndentation(test)) + } + + protected formatShortError(): string { + // Short errors are not shown in tree-view + return '' + } +} + +function getIndentation(suite: Task, level = 1): number { + if (suite.suite && !('filepath' in suite.suite)) { + return getIndentation(suite.suite, level + 1) + } + + return level } diff --git a/test/reporters/fixtures/verbose/example-1.test.ts b/test/reporters/fixtures/verbose/example-1.test.ts new file mode 100644 index 000000000000..524c57180796 --- /dev/null +++ b/test/reporters/fixtures/verbose/example-1.test.ts @@ -0,0 +1,35 @@ +import { test, describe, expect } from "vitest"; + +test("test pass in root", () => {}); + +test.skip("test skip in root", () => {}); + +describe("suite in root", () => { + test("test pass in 1. suite #1", () => {}); + + test("test pass in 1. suite #2", () => {}); + + describe("suite in suite", () => { + test("test pass in nested suite #1", () => {}); + + test("test pass in nested suite #2", () => {}); + + describe("suite in nested suite", () => { + test("test failure in 2x nested suite", () => { + expect("should fail").toBe("as expected"); + }); + }); + }); +}); + +describe.skip("suite skip in root", () => { + test("test 1.3", () => {}); + + describe("suite in suite", () => { + test("test in nested suite", () => {}); + + test("test failure in nested suite of skipped suite", () => { + expect("should fail").toBe("but should not run"); + }); + }); +}); diff --git a/test/reporters/fixtures/verbose/example-2.test.ts b/test/reporters/fixtures/verbose/example-2.test.ts new file mode 100644 index 000000000000..86de60a139eb --- /dev/null +++ b/test/reporters/fixtures/verbose/example-2.test.ts @@ -0,0 +1,9 @@ +import { test, describe } from "vitest"; + +test("test 0.1", () => {}); + +test.skip("test 0.2", () => {}); + +describe("suite 1.1", () => { + test("test 1.1", () => {}); +}); diff --git a/test/reporters/tests/default.test.ts b/test/reporters/tests/default.test.ts index 52eb3e23fdbb..4075d1620764 100644 --- a/test/reporters/tests/default.test.ts +++ b/test/reporters/tests/default.test.ts @@ -1,3 +1,4 @@ +import type { TestSpecification } from 'vitest/node' import { describe, expect, test } from 'vitest' import { runVitest } from '../../test-utils' @@ -7,11 +8,56 @@ describe('default reporter', async () => { include: ['b1.test.ts', 'b2.test.ts'], root: 'fixtures/default', reporters: 'none', + fileParallelism: false, + sequence: { + sequencer: class StableTestFileOrderSorter { + sort(files: TestSpecification[]) { + return files.sort((a, b) => a.moduleId.localeCompare(b.moduleId)) + } + + shard(files: TestSpecification[]) { + return files + } + }, + }, }) - expect(stdout).contain('✓ b2 passed > b2 test') - expect(stdout).not.contain('✓ nested b1 test') - expect(stdout).contain('× b1 failed > b failed test') + const rows = stdout.replace(/\d+ms/g, '[...]ms').split('\n') + rows.splice(0, rows.findIndex(row => row.includes('b1.test.ts'))) + rows.splice(rows.findIndex(row => row.includes('Test Files'))) + + expect(rows.join('\n').trim()).toMatchInlineSnapshot(` + "❯ b1.test.ts (13 tests | 1 failed) [...]ms + ✓ b1 passed > b1 test + ✓ b1 passed > b2 test + ✓ b1 passed > b3 test + ✓ b1 passed > nested b > nested b1 test + ✓ b1 passed > nested b > nested b2 test + ✓ b1 passed > nested b > nested b3 test + ✓ b1 failed > b1 test + ✓ b1 failed > b2 test + ✓ b1 failed > b3 test + × b1 failed > b failed test [...]ms + → expected 1 to be 2 // Object.is equality + ✓ b1 failed > nested b > nested b1 test + ✓ b1 failed > nested b > nested b2 test + ✓ b1 failed > nested b > nested b3 test + ❯ b2.test.ts (13 tests | 1 failed) [...]ms + ✓ b2 passed > b1 test + ✓ b2 passed > b2 test + ✓ b2 passed > b3 test + ✓ b2 passed > nested b > nested b1 test + ✓ b2 passed > nested b > nested b2 test + ✓ b2 passed > nested b > nested b3 test + ✓ b2 failed > b1 test + ✓ b2 failed > b2 test + ✓ b2 failed > b3 test + × b2 failed > b failed test [...]ms + → expected 1 to be 2 // Object.is equality + ✓ b2 failed > nested b > nested b1 test + ✓ b2 failed > nested b > nested b2 test + ✓ b2 failed > nested b > nested b3 test" + `) }) test('show full test suite when only one file', async () => { diff --git a/test/reporters/tests/verbose.test.ts b/test/reporters/tests/verbose.test.ts index c3258a4b6e38..4ed6766b691b 100644 --- a/test/reporters/tests/verbose.test.ts +++ b/test/reporters/tests/verbose.test.ts @@ -1,18 +1,18 @@ +import type { TestSpecification } from 'vitest/node' import { expect, test } from 'vitest' import { runVitest } from '../../test-utils' test('duration', async () => { - const result = await runVitest({ + const { stdout } = await runVitest({ root: 'fixtures/duration', reporters: 'verbose', env: { CI: '1' }, }) - const output = result.stdout.replace(/\d+ms/g, '[...]ms') - expect(output).toContain(` + expect(trimReporterOutput(stdout)).toContain(` ✓ basic.test.ts > fast - ✓ basic.test.ts > slow [...]ms -`) + ✓ basic.test.ts > slow [...]ms`, + ) }) test('prints error properties', async () => { @@ -72,3 +72,89 @@ test('prints repeat count', async () => { expect(stdout).toContain('1 passed') expect(stdout).toContain('✓ repeat couple of times (repeat x3)') }) + +test('renders tree when in TTY', async () => { + const { stdout } = await runVitest({ + include: ['fixtures/verbose/*.test.ts'], + reporters: [['verbose', { isTTY: true, summary: false }]], + config: false, + fileParallelism: false, + sequence: { + sequencer: class StableTestFileOrderSorter { + sort(files: TestSpecification[]) { + return files.sort((a, b) => a.moduleId.localeCompare(b.moduleId)) + } + + shard(files: TestSpecification[]) { + return files + } + }, + }, + }) + + expect(trimReporterOutput(stdout)).toMatchInlineSnapshot(` + "❯ fixtures/verbose/example-1.test.ts (10 tests | 1 failed | 4 skipped) [...]ms + ✓ test pass in root + ↓ test skip in root + ❯ suite in root (5) + ✓ test pass in 1. suite #1 + ✓ test pass in 1. suite #2 + ❯ suite in suite (3) + ✓ test pass in nested suite #1 + ✓ test pass in nested suite #2 + ❯ suite in nested suite (1) + × test failure in 2x nested suite [...]ms + ↓ suite skip in root (3) + ↓ test 1.3 + ↓ suite in suite (2) + ↓ test in nested suite + ↓ test failure in nested suite of skipped suite + ✓ fixtures/verbose/example-2.test.ts (3 tests | 1 skipped) [...]ms + ✓ test 0.1 + ↓ test 0.2 + ✓ suite 1.1 (1) + ✓ test 1.1" + `) +}) + +test('does not render tree when in non-TTY', async () => { + const { stdout } = await runVitest({ + include: ['fixtures/verbose/*.test.ts'], + reporters: [['verbose', { isTTY: false, summary: false }]], + config: false, + fileParallelism: false, + sequence: { + sequencer: class StableTestFileOrderSorter { + sort(files: TestSpecification[]) { + return files.sort((a, b) => a.moduleId.localeCompare(b.moduleId)) + } + + shard(files: TestSpecification[]) { + return files + } + }, + }, + }) + + expect(trimReporterOutput(stdout)).toMatchInlineSnapshot(` + "✓ fixtures/verbose/example-1.test.ts > test pass in root + ✓ fixtures/verbose/example-1.test.ts > suite in root > test pass in 1. suite #1 + ✓ fixtures/verbose/example-1.test.ts > suite in root > test pass in 1. suite #2 + ✓ fixtures/verbose/example-1.test.ts > suite in root > suite in suite > test pass in nested suite #1 + ✓ fixtures/verbose/example-1.test.ts > suite in root > suite in suite > test pass in nested suite #2 + × fixtures/verbose/example-1.test.ts > suite in root > suite in suite > suite in nested suite > test failure in 2x nested suite + → expected 'should fail' to be 'as expected' // Object.is equality + ✓ fixtures/verbose/example-2.test.ts > test 0.1 + ✓ fixtures/verbose/example-2.test.ts > suite 1.1 > test 1.1" + `) +}) + +function trimReporterOutput(report: string) { + const rows = report.replace(/\d+ms/g, '[...]ms').split('\n') + + // Trim start and end, capture just rendered tree + rows.splice(0, rows.findIndex(row => row.includes('fixtures/verbose/example-'))) + rows.splice(rows.findIndex(row => row.includes('Test Files'))) + + return rows.join('\n').trim() +}