Skip to content

Commit fec9ca0

Browse files
authored
feat(reporters): support custom options (#5111)
1 parent 0bf5253 commit fec9ca0

15 files changed

+292
-58
lines changed

docs/guide/reporters.md

+29-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ export default defineConfig({
2626
})
2727
```
2828

29+
Some reporters can be customized by passing additional options to them. Reporter specific options are described in sections below.
30+
31+
:::tip
32+
Since Vitest v1.3.0
33+
:::
34+
35+
```ts
36+
export default defineConfig({
37+
test: {
38+
reporters: [
39+
'default',
40+
['junit', { suiteName: 'UI tests' }]
41+
],
42+
},
43+
})
44+
```
45+
2946
## Reporter Output
3047

3148
By default, Vitest's reporters will print their output to the terminal. When using the `json`, `html` or `junit` reporters, you can instead write your tests' output to a file by including an `outputFile` [configuration option](/config/#outputfile) either in your Vite configuration file or via CLI.
@@ -234,7 +251,18 @@ AssertionError: expected 5 to be 4 // Object.is equality
234251
</testsuite>
235252
</testsuites>
236253
```
237-
The outputted XML contains nested `testsuites` and `testcase` tags. You can use the environment variables `VITEST_JUNIT_SUITE_NAME` and `VITEST_JUNIT_CLASSNAME` to configure their `name` and `classname` attributes, respectively.
254+
255+
The outputted XML contains nested `testsuites` and `testcase` tags. You can use the environment variables `VITEST_JUNIT_SUITE_NAME` and `VITEST_JUNIT_CLASSNAME` to configure their `name` and `classname` attributes, respectively. These can also be customized via reporter options:
256+
257+
```ts
258+
export default defineConfig({
259+
test: {
260+
reporters: [
261+
['junit', { suiteName: 'custom suite name', classname: 'custom-classname' }]
262+
]
263+
},
264+
})
265+
```
238266

239267
### JSON Reporter
240268

packages/ui/node/reporter.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { stringify } from 'flatted'
99
import type { File, ModuleGraphData, Reporter, ResolvedConfig, Vitest } from 'vitest'
1010
import { getModuleGraph } from '../../vitest/src/utils/graph'
1111

12+
export interface HTMLOptions {
13+
outputFile?: string
14+
}
15+
1216
interface PotentialConfig {
1317
outputFile?: string | Partial<Record<string, string>>
1418
}
@@ -37,6 +41,11 @@ export default class HTMLReporter implements Reporter {
3741
start = 0
3842
ctx!: Vitest
3943
reportUIPath!: string
44+
options: HTMLOptions
45+
46+
constructor(options: HTMLOptions) {
47+
this.options = options
48+
}
4049

4150
async onInit(ctx: Vitest) {
4251
this.ctx = ctx
@@ -60,7 +69,7 @@ export default class HTMLReporter implements Reporter {
6069
}
6170

6271
async writeReport(report: string) {
63-
const htmlFile = getOutputFile(this.ctx.config) || 'html/index.html'
72+
const htmlFile = this.options.outputFile || getOutputFile(this.ctx.config) || 'html/index.html'
6473
const htmlFileName = basename(htmlFile)
6574
const htmlDir = resolve(this.ctx.config.root, dirname(htmlFile))
6675

packages/vitest/src/node/config.ts

+46-4
Original file line numberDiff line numberDiff line change
@@ -358,21 +358,63 @@ export function resolveConfig(
358358
if (options.related)
359359
resolved.related = toArray(options.related).map(file => resolve(resolved.root, file))
360360

361+
/*
362+
* Reporters can be defined in many different ways:
363+
* { reporter: 'json' }
364+
* { reporter: { onFinish() { method() } } }
365+
* { reporter: ['json', { onFinish() { method() } }] }
366+
* { reporter: [[ 'json' ]] }
367+
* { reporter: [[ 'json' ], 'html'] }
368+
* { reporter: [[ 'json', { outputFile: 'test.json' } ], 'html'] }
369+
*/
370+
if (options.reporters) {
371+
if (!Array.isArray(options.reporters)) {
372+
// Reporter name, e.g. { reporters: 'json' }
373+
if (typeof options.reporters === 'string')
374+
resolved.reporters = [[options.reporters, {}]]
375+
// Inline reporter e.g. { reporters: { onFinish() { method() } } }
376+
else
377+
resolved.reporters = [options.reporters]
378+
}
379+
// It's an array of reporters
380+
else {
381+
resolved.reporters = []
382+
383+
for (const reporter of options.reporters) {
384+
if (Array.isArray(reporter)) {
385+
// Reporter with options, e.g. { reporters: [ [ 'json', { outputFile: 'test.json' } ] ] }
386+
resolved.reporters.push([reporter[0], reporter[1] || {}])
387+
}
388+
else if (typeof reporter === 'string') {
389+
// Reporter name in array, e.g. { reporters: ["html", "json"]}
390+
resolved.reporters.push([reporter, {}])
391+
}
392+
else {
393+
// Inline reporter, e.g. { reporter: [{ onFinish() { method() } }] }
394+
resolved.reporters.push(reporter)
395+
}
396+
}
397+
}
398+
}
399+
361400
if (mode !== 'benchmark') {
362401
// @ts-expect-error "reporter" is from CLI, should be absolute to the running directory
363402
// it is passed down as "vitest --reporter ../reporter.js"
364-
const cliReporters = toArray(resolved.reporter || []).map((reporter: string) => {
403+
const reportersFromCLI = resolved.reporter
404+
405+
const cliReporters = toArray(reportersFromCLI || []).map((reporter: string) => {
365406
// ./reporter.js || ../reporter.js, but not .reporters/reporter.js
366407
if (/^\.\.?\//.test(reporter))
367408
return resolve(process.cwd(), reporter)
368409
return reporter
369410
})
370-
const reporters = cliReporters.length ? cliReporters : resolved.reporters
371-
resolved.reporters = Array.from(new Set(toArray(reporters as 'json'[]))).filter(Boolean)
411+
412+
if (cliReporters.length)
413+
resolved.reporters = Array.from(new Set(toArray(cliReporters))).filter(Boolean).map(reporter => [reporter, {}])
372414
}
373415

374416
if (!resolved.reporters.length)
375-
resolved.reporters.push('default')
417+
resolved.reporters.push(['default', {}])
376418

377419
if (resolved.changed)
378420
resolved.passWithNoTests ??= true

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

+15-2
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import type { Reporter } from '../../types'
22
import { BasicReporter } from './basic'
33
import { DefaultReporter } from './default'
44
import { DotReporter } from './dot'
5-
import { JsonReporter } from './json'
5+
import { type JsonOptions, JsonReporter } from './json'
66
import { VerboseReporter } from './verbose'
77
import { TapReporter } from './tap'
8-
import { JUnitReporter } from './junit'
8+
import { type JUnitOptions, JUnitReporter } from './junit'
99
import { TapFlatReporter } from './tap-flat'
1010
import { HangingProcessReporter } from './hanging-process'
1111
import type { BaseReporter } from './base'
@@ -39,4 +39,17 @@ export const ReportersMap = {
3939

4040
export type BuiltinReporters = keyof typeof ReportersMap
4141

42+
export interface BuiltinReporterOptions {
43+
default: never
44+
basic: never
45+
verbose: never
46+
dot: never
47+
json: JsonOptions
48+
tap: never
49+
'tap-flat': never
50+
junit: JUnitOptions
51+
'hanging-process': never
52+
html: { outputFile?: string } // TODO: Any better place for defining this UI package's reporter options?
53+
}
54+
4255
export * from './benchmark'

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

+10-1
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,18 @@ export interface JsonTestResults {
6262
// wasInterrupted: boolean
6363
}
6464

65+
export interface JsonOptions {
66+
outputFile?: string
67+
}
68+
6569
export class JsonReporter implements Reporter {
6670
start = 0
6771
ctx!: Vitest
72+
options: JsonOptions
73+
74+
constructor(options: JsonOptions) {
75+
this.options = options
76+
}
6877

6978
onInit(ctx: Vitest): void {
7079
this.ctx = ctx
@@ -162,7 +171,7 @@ export class JsonReporter implements Reporter {
162171
* @param report
163172
*/
164173
async writeReport(report: string) {
165-
const outputFile = getOutputFile(this.ctx.config, 'json')
174+
const outputFile = this.options.outputFile ?? getOutputFile(this.ctx.config, 'json')
166175

167176
if (outputFile) {
168177
const reportFile = resolve(this.ctx.config.root, outputFile)

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

+16-3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import { F_POINTER } from '../../utils/figures'
1212
import { getOutputFile } from '../../utils/config-helpers'
1313
import { IndentedLogger } from './renderers/indented-logger'
1414

15+
export interface JUnitOptions {
16+
outputFile?: string
17+
classname?: string
18+
suiteName?: string
19+
}
20+
1521
function flattenTasks(task: Task, baseName = ''): Task[] {
1622
const base = baseName ? `${baseName} > ` : ''
1723

@@ -80,11 +86,16 @@ export class JUnitReporter implements Reporter {
8086
private logger!: IndentedLogger<Promise<void>>
8187
private _timeStart = new Date()
8288
private fileFd?: fs.FileHandle
89+
private options: JUnitOptions
90+
91+
constructor(options: JUnitOptions) {
92+
this.options = options
93+
}
8394

8495
async onInit(ctx: Vitest): Promise<void> {
8596
this.ctx = ctx
8697

87-
const outputFile = getOutputFile(this.ctx.config, 'junit')
98+
const outputFile = this.options.outputFile ?? getOutputFile(this.ctx.config, 'junit')
8899

89100
if (outputFile) {
90101
this.reportFile = resolve(this.ctx.config.root, outputFile)
@@ -173,7 +184,8 @@ export class JUnitReporter implements Reporter {
173184
async writeTasks(tasks: Task[], filename: string): Promise<void> {
174185
for (const task of tasks) {
175186
await this.writeElement('testcase', {
176-
classname: process.env.VITEST_JUNIT_CLASSNAME ?? filename,
187+
// TODO: v2.0.0 Remove env variable in favor of custom reporter options, e.g. "reporters: [['json', { classname: 'something' }]]"
188+
classname: this.options.classname ?? process.env.VITEST_JUNIT_CLASSNAME ?? filename,
177189
name: task.name,
178190
time: getDuration(task),
179191
}, async () => {
@@ -258,7 +270,8 @@ export class JUnitReporter implements Reporter {
258270
stats.failures += file.stats.failures
259271
return stats
260272
}, {
261-
name: process.env.VITEST_JUNIT_SUITE_NAME || 'vitest tests',
273+
// TODO: v2.0.0 Remove env variable in favor of custom reporter options, e.g. "reporters: [['json', { suiteName: 'something' }]]"
274+
name: this.options.suiteName || process.env.VITEST_JUNIT_SUITE_NAME || 'vitest tests',
262275
tests: 0,
263276
failures: 0,
264277
errors: 0, // we cannot detect those

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

+14-11
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type { ViteNodeRunner } from 'vite-node/client'
2-
import type { Reporter, Vitest } from '../../types'
2+
import type { Reporter, ResolvedConfig, Vitest } from '../../types'
33
import { BenchmarkReportsMap, ReportersMap } from './index'
44
import type { BenchmarkBuiltinReporters, BuiltinReporters } from './index'
55

6-
async function loadCustomReporterModule<C extends Reporter>(path: string, runner: ViteNodeRunner): Promise<new () => C> {
6+
async function loadCustomReporterModule<C extends Reporter>(path: string, runner: ViteNodeRunner): Promise<new (options?: unknown) => C> {
77
let customReporterModule: { default: new () => C }
88
try {
99
customReporterModule = await runner.executeId(path)
@@ -18,24 +18,27 @@ async function loadCustomReporterModule<C extends Reporter>(path: string, runner
1818
return customReporterModule.default
1919
}
2020

21-
function createReporters(reporterReferences: Array<string | Reporter | BuiltinReporters>, ctx: Vitest) {
21+
function createReporters(reporterReferences: ResolvedConfig['reporters'], ctx: Vitest) {
2222
const runner = ctx.runner
2323
const promisedReporters = reporterReferences.map(async (referenceOrInstance) => {
24-
if (typeof referenceOrInstance === 'string') {
25-
if (referenceOrInstance === 'html') {
24+
if (Array.isArray(referenceOrInstance)) {
25+
const [reporterName, reporterOptions] = referenceOrInstance
26+
27+
if (reporterName === 'html') {
2628
await ctx.packageInstaller.ensureInstalled('@vitest/ui', runner.root)
2729
const CustomReporter = await loadCustomReporterModule('@vitest/ui/reporter', runner)
28-
return new CustomReporter()
30+
return new CustomReporter(reporterOptions)
2931
}
30-
else if (referenceOrInstance in ReportersMap) {
31-
const BuiltinReporter = ReportersMap[referenceOrInstance as BuiltinReporters]
32-
return new BuiltinReporter()
32+
else if (reporterName in ReportersMap) {
33+
const BuiltinReporter = ReportersMap[reporterName as BuiltinReporters]
34+
return new BuiltinReporter(reporterOptions)
3335
}
3436
else {
35-
const CustomReporter = await loadCustomReporterModule(referenceOrInstance, runner)
36-
return new CustomReporter()
37+
const CustomReporter = await loadCustomReporterModule(reporterName, runner)
38+
return new CustomReporter(reporterOptions)
3739
}
3840
}
41+
3942
return referenceOrInstance
4043
})
4144
return Promise.all(promisedReporters)

packages/vitest/src/types/config.ts

+13-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { PrettyFormatOptions } from 'pretty-format'
33
import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers'
44
import type { SequenceHooks, SequenceSetupFiles } from '@vitest/runner'
55
import type { ViteNodeServerOptions } from 'vite-node'
6-
import type { BuiltinReporters } from '../node/reporters'
6+
import type { BuiltinReporterOptions, BuiltinReporters } from '../node/reporters'
77
import type { TestSequencerConstructor } from '../node/sequencers/types'
88
import type { ChaiConfig } from '../integrations/chai/config'
99
import type { CoverageOptions, ResolvedCoverageOptions } from './coverage'
@@ -189,6 +189,15 @@ interface DepsOptions {
189189
moduleDirectories?: string[]
190190
}
191191

192+
type InlineReporter = Reporter
193+
type ReporterName = BuiltinReporters | 'html' | (string & {})
194+
type ReporterWithOptions<Name extends ReporterName = ReporterName> =
195+
Name extends keyof BuiltinReporterOptions
196+
? BuiltinReporterOptions[Name] extends never
197+
? [Name, {}]
198+
: [Name, Partial<BuiltinReporterOptions[Name]>]
199+
: [Name, Record<string, unknown>]
200+
192201
export interface InlineConfig {
193202
/**
194203
* Name of the project. Will be used to display in the reporter.
@@ -365,8 +374,9 @@ export interface InlineConfig {
365374
* Custom reporter for output. Can contain one or more built-in report names, reporter instances,
366375
* and/or paths to custom reporters.
367376
*/
368-
reporters?: Arrayable<BuiltinReporters | 'html' | Reporter | Omit<string, BuiltinReporters>>
377+
reporters?: Arrayable<ReporterName | InlineReporter> | ((ReporterName | InlineReporter) | [ReporterName] | ReporterWithOptions)[]
369378

379+
// TODO: v2.0.0 Remove in favor of custom reporter options, e.g. "reporters: [['json', { outputFile: 'some-dir/file.html' }]]"
370380
/**
371381
* Write test results to a file when the --reporter=json` or `--reporter=junit` option is also specified.
372382
* Also definable individually per reporter by using an object instead.
@@ -786,7 +796,7 @@ export interface ResolvedConfig extends Omit<Required<UserConfig>, 'config' | 'f
786796
pool: Pool
787797
poolOptions?: PoolOptions
788798

789-
reporters: (Reporter | BuiltinReporters)[]
799+
reporters: (InlineReporter | ReporterWithOptions)[]
790800

791801
defines: Record<string, any>
792802

test/reporters/src/custom-reporter.ts

+8
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@ import type { Reporter, Vitest } from 'vitest'
22

33
export default class TestReporter implements Reporter {
44
ctx!: Vitest
5+
options?: unknown
6+
7+
constructor(options?: unknown) {
8+
this.options = options
9+
}
510

611
onInit(ctx: Vitest) {
712
this.ctx = ctx
813
}
914

1015
onFinished() {
1116
this.ctx.logger.log('hello from custom reporter')
17+
18+
if (this.options)
19+
this.ctx.logger.log(`custom reporter options ${JSON.stringify(this.options)}`)
1220
}
1321
}

0 commit comments

Comments
 (0)