Skip to content

Commit 027ce9b

Browse files
authoredFeb 18, 2025··
fix(reporters): render tasks in tree when in TTY (#7503)
1 parent dd6d685 commit 027ce9b

File tree

6 files changed

+278
-45
lines changed

6 files changed

+278
-45
lines changed
 

‎packages/vitest/src/node/reporters/base.ts

+64-36
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,12 @@ export abstract class BaseReporter implements Reporter {
8888
return
8989
}
9090

91-
const tests = getTests(task)
92-
const failed = tests.filter(t => t.result?.state === 'fail')
93-
const skipped = tests.filter(t => t.mode === 'skip' || t.mode === 'todo')
91+
const suites = getSuites(task)
92+
const allTests = getTests(task)
93+
const failed = allTests.filter(t => t.result?.state === 'fail')
94+
const skipped = allTests.filter(t => t.mode === 'skip' || t.mode === 'todo')
9495

95-
let state = c.dim(`${tests.length} test${tests.length > 1 ? 's' : ''}`)
96+
let state = c.dim(`${allTests.length} test${allTests.length > 1 ? 's' : ''}`)
9697

9798
if (failed.length) {
9899
state += c.dim(' | ') + c.red(`${failed.length} failed`)
@@ -120,52 +121,79 @@ export abstract class BaseReporter implements Reporter {
120121

121122
this.log(` ${title} ${task.name} ${suffix}`)
122123

123-
const anyFailed = tests.some(test => test.result?.state === 'fail')
124+
for (const suite of suites) {
125+
const tests = suite.tasks.filter(task => task.type === 'test')
124126

125-
for (const test of tests) {
126-
const { duration, retryCount, repeatCount } = test.result || {}
127-
let suffix = ''
128-
129-
if (retryCount != null && retryCount > 0) {
130-
suffix += c.yellow(` (retry x${retryCount})`)
127+
if (!('filepath' in suite)) {
128+
this.printSuite(suite)
131129
}
132130

133-
if (repeatCount != null && repeatCount > 0) {
134-
suffix += c.yellow(` (repeat x${repeatCount})`)
135-
}
131+
for (const test of tests) {
132+
const { duration, retryCount, repeatCount } = test.result || {}
133+
const padding = this.getTestIndentation(test)
134+
let suffix = ''
136135

137-
if (test.result?.state === 'fail') {
138-
this.log(c.red(` ${taskFail} ${getTestName(test, c.dim(' > '))}${this.getDurationPrefix(test)}`) + suffix)
136+
if (retryCount != null && retryCount > 0) {
137+
suffix += c.yellow(` (retry x${retryCount})`)
138+
}
139+
140+
if (repeatCount != null && repeatCount > 0) {
141+
suffix += c.yellow(` (repeat x${repeatCount})`)
142+
}
143+
144+
if (test.result?.state === 'fail') {
145+
this.log(c.red(` ${padding}${taskFail} ${this.getTestName(test, c.dim(' > '))}${this.getDurationPrefix(test)}`) + suffix)
139146

140-
test.result?.errors?.forEach((e) => {
141147
// print short errors, full errors will be at the end in summary
142-
this.log(c.red(` ${F_RIGHT} ${e?.message}`))
143-
})
144-
}
148+
test.result?.errors?.forEach((error) => {
149+
const message = this.formatShortError(error)
145150

146-
// also print slow tests
147-
else if (duration && duration > this.ctx.config.slowTestThreshold) {
148-
this.log(
149-
` ${c.yellow(c.dim(F_CHECK))} ${getTestName(test, c.dim(' > '))}`
150-
+ ` ${c.yellow(Math.round(duration) + c.dim('ms'))}${suffix}`,
151-
)
152-
}
151+
if (message) {
152+
this.log(c.red(` ${padding}${message}`))
153+
}
154+
})
155+
}
153156

154-
else if (this.ctx.config.hideSkippedTests && (test.mode === 'skip' || test.result?.state === 'skip')) {
155-
// Skipped tests are hidden when --hideSkippedTests
156-
}
157+
// also print slow tests
158+
else if (duration && duration > this.ctx.config.slowTestThreshold) {
159+
this.log(
160+
` ${padding}${c.yellow(c.dim(F_CHECK))} ${this.getTestName(test, c.dim(' > '))}`
161+
+ ` ${c.yellow(Math.round(duration) + c.dim('ms'))}${suffix}`,
162+
)
163+
}
157164

158-
// also print skipped tests that have notes
159-
else if (test.result?.state === 'skip' && test.result.note) {
160-
this.log(` ${getStateSymbol(test)} ${getTestName(test)}${c.dim(c.gray(` [${test.result.note}]`))}`)
161-
}
165+
else if (this.ctx.config.hideSkippedTests && (test.mode === 'skip' || test.result?.state === 'skip')) {
166+
// Skipped tests are hidden when --hideSkippedTests
167+
}
168+
169+
// also print skipped tests that have notes
170+
else if (test.result?.state === 'skip' && test.result.note) {
171+
this.log(` ${padding}${getStateSymbol(test)} ${this.getTestName(test)}${c.dim(c.gray(` [${test.result.note}]`))}`)
172+
}
162173

163-
else if (this.renderSucceed || anyFailed) {
164-
this.log(` ${getStateSymbol(test)} ${getTestName(test, c.dim(' > '))}${suffix}`)
174+
else if (this.renderSucceed || failed.length > 0) {
175+
this.log(` ${padding}${getStateSymbol(test)} ${this.getTestName(test, c.dim(' > '))}${suffix}`)
176+
}
165177
}
166178
}
167179
}
168180

181+
protected printSuite(_task: Task): void {
182+
// Suite name is included in getTestName by default
183+
}
184+
185+
protected getTestName(test: Task, separator?: string): string {
186+
return getTestName(test, separator)
187+
}
188+
189+
protected formatShortError(error: ErrorWithDiff): string {
190+
return `${F_RIGHT} ${error.message}`
191+
}
192+
193+
protected getTestIndentation(_test: Task) {
194+
return ' '
195+
}
196+
169197
private getDurationPrefix(task: Task) {
170198
if (!task.result?.duration) {
171199
return ''

‎packages/vitest/src/node/reporters/verbose.ts

+30-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Task } from '@vitest/runner'
2-
import { getFullName } from '@vitest/runner/utils'
2+
import { getFullName, getTests } from '@vitest/runner/utils'
33
import c from 'tinyrainbow'
44
import { DefaultReporter } from './default'
55
import { F_RIGHT } from './renderers/figures'
@@ -45,4 +45,33 @@ export class VerboseReporter extends DefaultReporter {
4545
task.result.errors?.forEach(error => this.log(c.red(` ${F_RIGHT} ${error?.message}`)))
4646
}
4747
}
48+
49+
protected printSuite(task: Task): void {
50+
const indentation = ' '.repeat(getIndentation(task))
51+
const tests = getTests(task)
52+
const state = getStateSymbol(task)
53+
54+
this.log(` ${indentation}${state} ${task.name} ${c.dim(`(${tests.length})`)}`)
55+
}
56+
57+
protected getTestName(test: Task): string {
58+
return test.name
59+
}
60+
61+
protected getTestIndentation(test: Task): string {
62+
return ' '.repeat(getIndentation(test))
63+
}
64+
65+
protected formatShortError(): string {
66+
// Short errors are not shown in tree-view
67+
return ''
68+
}
69+
}
70+
71+
function getIndentation(suite: Task, level = 1): number {
72+
if (suite.suite && !('filepath' in suite.suite)) {
73+
return getIndentation(suite.suite, level + 1)
74+
}
75+
76+
return level
4877
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { test, describe, expect } from "vitest";
2+
3+
test("test pass in root", () => {});
4+
5+
test.skip("test skip in root", () => {});
6+
7+
describe("suite in root", () => {
8+
test("test pass in 1. suite #1", () => {});
9+
10+
test("test pass in 1. suite #2", () => {});
11+
12+
describe("suite in suite", () => {
13+
test("test pass in nested suite #1", () => {});
14+
15+
test("test pass in nested suite #2", () => {});
16+
17+
describe("suite in nested suite", () => {
18+
test("test failure in 2x nested suite", () => {
19+
expect("should fail").toBe("as expected");
20+
});
21+
});
22+
});
23+
});
24+
25+
describe.skip("suite skip in root", () => {
26+
test("test 1.3", () => {});
27+
28+
describe("suite in suite", () => {
29+
test("test in nested suite", () => {});
30+
31+
test("test failure in nested suite of skipped suite", () => {
32+
expect("should fail").toBe("but should not run");
33+
});
34+
});
35+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { test, describe } from "vitest";
2+
3+
test("test 0.1", () => {});
4+
5+
test.skip("test 0.2", () => {});
6+
7+
describe("suite 1.1", () => {
8+
test("test 1.1", () => {});
9+
});

‎test/reporters/tests/default.test.ts

+49-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { TestSpecification } from 'vitest/node'
12
import { describe, expect, test } from 'vitest'
23
import { runVitest } from '../../test-utils'
34

@@ -7,11 +8,56 @@ describe('default reporter', async () => {
78
include: ['b1.test.ts', 'b2.test.ts'],
89
root: 'fixtures/default',
910
reporters: 'none',
11+
fileParallelism: false,
12+
sequence: {
13+
sequencer: class StableTestFileOrderSorter {
14+
sort(files: TestSpecification[]) {
15+
return files.sort((a, b) => a.moduleId.localeCompare(b.moduleId))
16+
}
17+
18+
shard(files: TestSpecification[]) {
19+
return files
20+
}
21+
},
22+
},
1023
})
1124

12-
expect(stdout).contain('✓ b2 passed > b2 test')
13-
expect(stdout).not.contain('✓ nested b1 test')
14-
expect(stdout).contain('× b1 failed > b failed test')
25+
const rows = stdout.replace(/\d+ms/g, '[...]ms').split('\n')
26+
rows.splice(0, rows.findIndex(row => row.includes('b1.test.ts')))
27+
rows.splice(rows.findIndex(row => row.includes('Test Files')))
28+
29+
expect(rows.join('\n').trim()).toMatchInlineSnapshot(`
30+
"❯ b1.test.ts (13 tests | 1 failed) [...]ms
31+
✓ b1 passed > b1 test
32+
✓ b1 passed > b2 test
33+
✓ b1 passed > b3 test
34+
✓ b1 passed > nested b > nested b1 test
35+
✓ b1 passed > nested b > nested b2 test
36+
✓ b1 passed > nested b > nested b3 test
37+
✓ b1 failed > b1 test
38+
✓ b1 failed > b2 test
39+
✓ b1 failed > b3 test
40+
× b1 failed > b failed test [...]ms
41+
→ expected 1 to be 2 // Object.is equality
42+
✓ b1 failed > nested b > nested b1 test
43+
✓ b1 failed > nested b > nested b2 test
44+
✓ b1 failed > nested b > nested b3 test
45+
❯ b2.test.ts (13 tests | 1 failed) [...]ms
46+
✓ b2 passed > b1 test
47+
✓ b2 passed > b2 test
48+
✓ b2 passed > b3 test
49+
✓ b2 passed > nested b > nested b1 test
50+
✓ b2 passed > nested b > nested b2 test
51+
✓ b2 passed > nested b > nested b3 test
52+
✓ b2 failed > b1 test
53+
✓ b2 failed > b2 test
54+
✓ b2 failed > b3 test
55+
× b2 failed > b failed test [...]ms
56+
→ expected 1 to be 2 // Object.is equality
57+
✓ b2 failed > nested b > nested b1 test
58+
✓ b2 failed > nested b > nested b2 test
59+
✓ b2 failed > nested b > nested b3 test"
60+
`)
1561
})
1662

1763
test('show full test suite when only one file', async () => {

‎test/reporters/tests/verbose.test.ts

+91-5
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1+
import type { TestSpecification } from 'vitest/node'
12
import { expect, test } from 'vitest'
23
import { runVitest } from '../../test-utils'
34

45
test('duration', async () => {
5-
const result = await runVitest({
6+
const { stdout } = await runVitest({
67
root: 'fixtures/duration',
78
reporters: 'verbose',
89
env: { CI: '1' },
910
})
1011

11-
const output = result.stdout.replace(/\d+ms/g, '[...]ms')
12-
expect(output).toContain(`
12+
expect(trimReporterOutput(stdout)).toContain(`
1313
✓ basic.test.ts > fast
14-
✓ basic.test.ts > slow [...]ms
15-
`)
14+
✓ basic.test.ts > slow [...]ms`,
15+
)
1616
})
1717

1818
test('prints error properties', async () => {
@@ -72,3 +72,89 @@ test('prints repeat count', async () => {
7272
expect(stdout).toContain('1 passed')
7373
expect(stdout).toContain('✓ repeat couple of times (repeat x3)')
7474
})
75+
76+
test('renders tree when in TTY', async () => {
77+
const { stdout } = await runVitest({
78+
include: ['fixtures/verbose/*.test.ts'],
79+
reporters: [['verbose', { isTTY: true, summary: false }]],
80+
config: false,
81+
fileParallelism: false,
82+
sequence: {
83+
sequencer: class StableTestFileOrderSorter {
84+
sort(files: TestSpecification[]) {
85+
return files.sort((a, b) => a.moduleId.localeCompare(b.moduleId))
86+
}
87+
88+
shard(files: TestSpecification[]) {
89+
return files
90+
}
91+
},
92+
},
93+
})
94+
95+
expect(trimReporterOutput(stdout)).toMatchInlineSnapshot(`
96+
"❯ fixtures/verbose/example-1.test.ts (10 tests | 1 failed | 4 skipped) [...]ms
97+
✓ test pass in root
98+
↓ test skip in root
99+
❯ suite in root (5)
100+
✓ test pass in 1. suite #1
101+
✓ test pass in 1. suite #2
102+
❯ suite in suite (3)
103+
✓ test pass in nested suite #1
104+
✓ test pass in nested suite #2
105+
❯ suite in nested suite (1)
106+
× test failure in 2x nested suite [...]ms
107+
↓ suite skip in root (3)
108+
↓ test 1.3
109+
↓ suite in suite (2)
110+
↓ test in nested suite
111+
↓ test failure in nested suite of skipped suite
112+
✓ fixtures/verbose/example-2.test.ts (3 tests | 1 skipped) [...]ms
113+
✓ test 0.1
114+
↓ test 0.2
115+
✓ suite 1.1 (1)
116+
✓ test 1.1"
117+
`)
118+
})
119+
120+
test('does not render tree when in non-TTY', async () => {
121+
const { stdout } = await runVitest({
122+
include: ['fixtures/verbose/*.test.ts'],
123+
reporters: [['verbose', { isTTY: false, summary: false }]],
124+
config: false,
125+
fileParallelism: false,
126+
sequence: {
127+
sequencer: class StableTestFileOrderSorter {
128+
sort(files: TestSpecification[]) {
129+
return files.sort((a, b) => a.moduleId.localeCompare(b.moduleId))
130+
}
131+
132+
shard(files: TestSpecification[]) {
133+
return files
134+
}
135+
},
136+
},
137+
})
138+
139+
expect(trimReporterOutput(stdout)).toMatchInlineSnapshot(`
140+
"✓ fixtures/verbose/example-1.test.ts > test pass in root
141+
✓ fixtures/verbose/example-1.test.ts > suite in root > test pass in 1. suite #1
142+
✓ fixtures/verbose/example-1.test.ts > suite in root > test pass in 1. suite #2
143+
✓ fixtures/verbose/example-1.test.ts > suite in root > suite in suite > test pass in nested suite #1
144+
✓ fixtures/verbose/example-1.test.ts > suite in root > suite in suite > test pass in nested suite #2
145+
× fixtures/verbose/example-1.test.ts > suite in root > suite in suite > suite in nested suite > test failure in 2x nested suite
146+
→ expected 'should fail' to be 'as expected' // Object.is equality
147+
✓ fixtures/verbose/example-2.test.ts > test 0.1
148+
✓ fixtures/verbose/example-2.test.ts > suite 1.1 > test 1.1"
149+
`)
150+
})
151+
152+
function trimReporterOutput(report: string) {
153+
const rows = report.replace(/\d+ms/g, '[...]ms').split('\n')
154+
155+
// Trim start and end, capture just rendered tree
156+
rows.splice(0, rows.findIndex(row => row.includes('fixtures/verbose/example-')))
157+
rows.splice(rows.findIndex(row => row.includes('Test Files')))
158+
159+
return rows.join('\n').trim()
160+
}

0 commit comments

Comments
 (0)
Please sign in to comment.