Skip to content

Commit

Permalink
fix(reporters): render tasks in tree when in TTY (#7503)
Browse files Browse the repository at this point in the history
  • Loading branch information
AriPerkkio authored Feb 18, 2025
1 parent dd6d685 commit 027ce9b
Show file tree
Hide file tree
Showing 6 changed files with 278 additions and 45 deletions.
100 changes: 64 additions & 36 deletions packages/vitest/src/node/reporters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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 ''
Expand Down
31 changes: 30 additions & 1 deletion packages/vitest/src/node/reporters/verbose.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
}
35 changes: 35 additions & 0 deletions test/reporters/fixtures/verbose/example-1.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
9 changes: 9 additions & 0 deletions test/reporters/fixtures/verbose/example-2.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {});
});
52 changes: 49 additions & 3 deletions test/reporters/tests/default.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { TestSpecification } from 'vitest/node'
import { describe, expect, test } from 'vitest'
import { runVitest } from '../../test-utils'

Expand All @@ -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 () => {
Expand Down
96 changes: 91 additions & 5 deletions test/reporters/tests/verbose.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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()
}

0 comments on commit 027ce9b

Please sign in to comment.