Skip to content

Commit 6d23f4b

Browse files
authored
fix(benchmark): rewrite reporter without log-update (#7019)
1 parent b700d26 commit 6d23f4b

29 files changed

+452
-923
lines changed

packages/vitest/LICENSE.md

Lines changed: 0 additions & 314 deletions
Large diffs are not rendered by default.

packages/vitest/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,15 +189,13 @@
189189
"birpc": "0.2.19",
190190
"cac": "^6.7.14",
191191
"chai-subset": "^1.6.0",
192-
"cli-truncate": "^4.0.0",
193192
"fast-glob": "3.3.2",
194193
"find-up": "^6.3.0",
195194
"flatted": "^3.3.2",
196195
"get-tsconfig": "^4.8.1",
197196
"happy-dom": "^15.11.7",
198197
"jsdom": "^25.0.1",
199198
"local-pkg": "^0.5.1",
200-
"log-update": "^5.0.1",
201199
"micromatch": "^4.0.8",
202200
"pretty-format": "^29.7.0",
203201
"prompts": "^2.4.2",

packages/vitest/src/node/core.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1113,7 +1113,6 @@ export class Vitest {
11131113
this.logger.error('error during close', r.reason)
11141114
}
11151115
})
1116-
this.logger.logUpdate.done() // restore terminal cursor
11171116
})
11181117
})()
11191118
}

packages/vitest/src/node/error.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { existsSync, readFileSync } from 'node:fs'
77
import { Writable } from 'node:stream'
88
import { stripVTControlCharacters } from 'node:util'
99
import { inspect, isPrimitive } from '@vitest/utils'
10-
import cliTruncate from 'cli-truncate'
1110
import { normalize, relative } from 'pathe'
1211
import c from 'tinyrainbow'
1312
import { TypeCheckError } from '../typecheck/typechecker'
@@ -17,7 +16,7 @@ import {
1716
} from '../utils/source-map'
1817
import { Logger } from './logger'
1918
import { F_POINTER } from './reporters/renderers/figures'
20-
import { divider } from './reporters/renderers/utils'
19+
import { divider, truncateString } from './reporters/renderers/utils'
2120

2221
interface PrintErrorOptions {
2322
type?: string
@@ -413,7 +412,7 @@ export function generateCodeFrame(
413412

414413
res.push(
415414
lineNo(j + 1)
416-
+ cliTruncate(lines[j].replace(/\t/g, ' '), columns - 5 - indent),
415+
+ truncateString(lines[j].replace(/\t/g, ' '), columns - 5 - indent),
417416
)
418417

419418
if (j === i) {

packages/vitest/src/node/logger.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import type { TestProject } from './project'
88
import { Console } from 'node:console'
99
import { toArray } from '@vitest/utils'
1010
import { parseErrorStacktrace } from '@vitest/utils/source-map'
11-
import { createLogUpdate } from 'log-update'
1211
import c from 'tinyrainbow'
1312
import { highlightCode } from '../utils/colors'
1413
import { printError } from './error'
@@ -25,19 +24,22 @@ export interface ErrorOptions {
2524
showCodeFrame?: boolean
2625
}
2726

27+
type Listener = () => void
28+
2829
const PAD = ' '
2930

3031
const ESC = '\x1B['
3132
const ERASE_DOWN = `${ESC}J`
3233
const ERASE_SCROLLBACK = `${ESC}3J`
3334
const CURSOR_TO_START = `${ESC}1;1H`
35+
const HIDE_CURSOR = `${ESC}?25l`
36+
const SHOW_CURSOR = `${ESC}?25h`
3437
const CLEAR_SCREEN = '\x1Bc'
3538

3639
export class Logger {
37-
logUpdate: ReturnType<typeof createLogUpdate>
38-
3940
private _clearScreenPending: string | undefined
4041
private _highlights = new Map<string, string>()
42+
private cleanupListeners: Listener[] = []
4143
public console: Console
4244

4345
constructor(
@@ -46,9 +48,11 @@ export class Logger {
4648
public errorStream: NodeJS.WriteStream | Writable = process.stderr,
4749
) {
4850
this.console = new Console({ stdout: outputStream, stderr: errorStream })
49-
this.logUpdate = createLogUpdate(this.outputStream)
5051
this._highlights.clear()
52+
this.addCleanupListeners()
5153
this.registerUnhandledRejection()
54+
55+
;(this.outputStream as Writable).write(HIDE_CURSOR)
5256
}
5357

5458
log(...args: any[]) {
@@ -303,6 +307,44 @@ export class Logger {
303307
this.log(c.red(divider()))
304308
}
305309

310+
getColumns() {
311+
return 'columns' in this.outputStream ? this.outputStream.columns : 80
312+
}
313+
314+
onTerminalCleanup(listener: Listener) {
315+
this.cleanupListeners.push(listener)
316+
}
317+
318+
private addCleanupListeners() {
319+
const cleanup = () => {
320+
this.cleanupListeners.forEach(fn => fn())
321+
;(this.outputStream as Writable).write(SHOW_CURSOR)
322+
}
323+
324+
const onExit = (signal?: string | number, exitCode?: number) => {
325+
cleanup()
326+
327+
// Interrupted signals don't set exit code automatically.
328+
// Use same exit code as node: https://nodejs.org/api/process.html#signal-events
329+
if (process.exitCode === undefined) {
330+
process.exitCode = exitCode !== undefined ? (128 + exitCode) : Number(signal)
331+
}
332+
333+
process.exit()
334+
}
335+
336+
process.once('SIGINT', onExit)
337+
process.once('SIGTERM', onExit)
338+
process.once('exit', onExit)
339+
340+
this.ctx.onClose(() => {
341+
process.off('SIGINT', onExit)
342+
process.off('SIGTERM', onExit)
343+
process.off('exit', onExit)
344+
cleanup()
345+
})
346+
}
347+
306348
private registerUnhandledRejection() {
307349
const onUnhandledRejection = (err: unknown) => {
308350
process.exitCode = 1

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export abstract class BaseReporter implements Reporter {
7171
}
7272
}
7373

74+
/**
75+
* Callback invoked with a single `Task` from `onTaskUpdate`
76+
*/
7477
protected printTask(task: Task) {
7578
if (
7679
!('filepath' in task)
@@ -438,7 +441,7 @@ export abstract class BaseReporter implements Reporter {
438441
const benches = getTests(files)
439442
const topBenches = benches.filter(i => i.result?.benchmark?.rank === 1)
440443

441-
this.log(withLabel('cyan', 'BENCH', 'Summary\n'))
444+
this.log(`\n${withLabel('cyan', 'BENCH', 'Summary\n')}`)
442445

443446
for (const bench of topBenches) {
444447
const group = bench.suite || bench.file
@@ -448,7 +451,7 @@ export abstract class BaseReporter implements Reporter {
448451
}
449452

450453
const groupName = getFullName(group, c.dim(' > '))
451-
this.log(` ${bench.name}${c.dim(` - ${groupName}`)}`)
454+
this.log(` ${formatProjectName(bench.file.projectName)}${bench.name}${c.dim(` - ${groupName}`)}`)
452455

453456
const siblings = group.tasks
454457
.filter(i => i.meta.benchmark && i.result?.benchmark && i !== bench)
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { VerboseReporter } from '../verbose'
2-
import { TableReporter } from './table'
1+
import { BenchmarkReporter } from './reporter'
2+
import { VerboseBenchmarkReporter } from './verbose'
33

44
export const BenchmarkReportsMap = {
5-
default: TableReporter,
6-
verbose: VerboseReporter,
5+
default: BenchmarkReporter,
6+
verbose: VerboseBenchmarkReporter,
77
}
8+
89
export type BenchmarkBuiltinReporters = keyof typeof BenchmarkReportsMap
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { File } from '@vitest/runner'
2+
import type { BenchmarkResult } from '../../../runtime/types/benchmark'
3+
import { getFullName, getTasks } from '@vitest/runner/utils'
4+
5+
interface Report {
6+
files: {
7+
filepath: string
8+
groups: Group[]
9+
}[]
10+
}
11+
12+
interface Group {
13+
fullName: string
14+
benchmarks: FormattedBenchmarkResult[]
15+
}
16+
17+
export type FormattedBenchmarkResult = BenchmarkResult & {
18+
id: string
19+
}
20+
21+
export function createBenchmarkJsonReport(files: File[]) {
22+
const report: Report = { files: [] }
23+
24+
for (const file of files) {
25+
const groups: Group[] = []
26+
27+
for (const task of getTasks(file)) {
28+
if (task?.type === 'suite') {
29+
const benchmarks: FormattedBenchmarkResult[] = []
30+
31+
for (const t of task.tasks) {
32+
const benchmark = t.meta.benchmark && t.result?.benchmark
33+
34+
if (benchmark) {
35+
benchmarks.push({ id: t.id, ...benchmark, samples: [] })
36+
}
37+
}
38+
39+
if (benchmarks.length) {
40+
groups.push({
41+
fullName: getFullName(task, ' > '),
42+
benchmarks,
43+
})
44+
}
45+
}
46+
}
47+
48+
report.files.push({
49+
filepath: file.filepath,
50+
groups,
51+
})
52+
}
53+
54+
return report
55+
}
56+
57+
export function flattenFormattedBenchmarkReport(report: Report) {
58+
const flat: Record<FormattedBenchmarkResult['id'], FormattedBenchmarkResult> = {}
59+
60+
for (const file of report.files) {
61+
for (const group of file.groups) {
62+
for (const t of group.benchmarks) {
63+
flat[t.id] = t
64+
}
65+
}
66+
}
67+
68+
return flat
69+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Task, TaskResultPack } from '@vitest/runner'
2+
import type { Vitest } from '../../core'
3+
import fs from 'node:fs'
4+
import { getFullName } from '@vitest/runner/utils'
5+
import * as pathe from 'pathe'
6+
import c from 'tinyrainbow'
7+
import { DefaultReporter } from '../default'
8+
import { formatProjectName, getStateSymbol } from '../renderers/utils'
9+
import { createBenchmarkJsonReport, flattenFormattedBenchmarkReport } from './json-formatter'
10+
import { renderTable } from './tableRender'
11+
12+
export class BenchmarkReporter extends DefaultReporter {
13+
compare?: Parameters<typeof renderTable>[0]['compare']
14+
15+
async onInit(ctx: Vitest) {
16+
super.onInit(ctx)
17+
18+
if (this.ctx.config.benchmark?.compare) {
19+
const compareFile = pathe.resolve(
20+
this.ctx.config.root,
21+
this.ctx.config.benchmark?.compare,
22+
)
23+
try {
24+
this.compare = flattenFormattedBenchmarkReport(
25+
JSON.parse(await fs.promises.readFile(compareFile, 'utf-8')),
26+
)
27+
}
28+
catch (e) {
29+
this.error(`Failed to read '${compareFile}'`, e)
30+
}
31+
}
32+
}
33+
34+
onTaskUpdate(packs: TaskResultPack[]): void {
35+
for (const pack of packs) {
36+
const task = this.ctx.state.idMap.get(pack[0])
37+
38+
if (task?.type === 'suite' && task.result?.state !== 'run') {
39+
task.tasks.filter(task => task.result?.benchmark)
40+
.sort((benchA, benchB) => benchA.result!.benchmark!.mean - benchB.result!.benchmark!.mean)
41+
.forEach((bench, idx) => {
42+
bench.result!.benchmark!.rank = Number(idx) + 1
43+
})
44+
}
45+
}
46+
47+
super.onTaskUpdate(packs)
48+
}
49+
50+
printTask(task: Task) {
51+
if (task?.type !== 'suite' || !task.result?.state || task.result?.state === 'run' || task.result?.state === 'queued') {
52+
return
53+
}
54+
55+
const benches = task.tasks.filter(t => t.meta.benchmark)
56+
const duration = task.result.duration
57+
58+
if (benches.length > 0 && benches.every(t => t.result?.state !== 'run' && t.result?.state !== 'queued')) {
59+
let title = `\n ${getStateSymbol(task)} ${formatProjectName(task.file.projectName)}${getFullName(task, c.dim(' > '))}`
60+
61+
if (duration != null && duration > this.ctx.config.slowTestThreshold) {
62+
title += c.yellow(` ${Math.round(duration)}${c.dim('ms')}`)
63+
}
64+
65+
this.log(title)
66+
this.log(renderTable({
67+
tasks: benches,
68+
level: 1,
69+
shallow: true,
70+
columns: this.ctx.logger.getColumns(),
71+
compare: this.compare,
72+
showHeap: this.ctx.config.logHeapUsage,
73+
slowTestThreshold: this.ctx.config.slowTestThreshold,
74+
}))
75+
}
76+
}
77+
78+
async onFinished(files = this.ctx.state.getFiles(), errors = this.ctx.state.getUnhandledErrors()) {
79+
super.onFinished(files, errors)
80+
81+
// write output for future comparison
82+
let outputFile = this.ctx.config.benchmark?.outputJson
83+
84+
if (outputFile) {
85+
outputFile = pathe.resolve(this.ctx.config.root, outputFile)
86+
const outputDirectory = pathe.dirname(outputFile)
87+
88+
if (!fs.existsSync(outputDirectory)) {
89+
await fs.promises.mkdir(outputDirectory, { recursive: true })
90+
}
91+
92+
const output = createBenchmarkJsonReport(files)
93+
await fs.promises.writeFile(outputFile, JSON.stringify(output, null, 2))
94+
this.log(`Benchmark report written to ${outputFile}`)
95+
}
96+
}
97+
}

0 commit comments

Comments
 (0)