Skip to content

Commit

Permalink
feat(reporters): support custom options (#5111)
Browse files Browse the repository at this point in the history
  • Loading branch information
AriPerkkio committed Feb 5, 2024
1 parent 0bf5253 commit fec9ca0
Show file tree
Hide file tree
Showing 15 changed files with 292 additions and 58 deletions.
30 changes: 29 additions & 1 deletion docs/guide/reporters.md
Expand Up @@ -26,6 +26,23 @@ export default defineConfig({
})
```

Some reporters can be customized by passing additional options to them. Reporter specific options are described in sections below.

:::tip
Since Vitest v1.3.0
:::

```ts
export default defineConfig({
test: {
reporters: [
'default',
['junit', { suiteName: 'UI tests' }]
],
},
})
```

## Reporter Output

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.
Expand Down Expand Up @@ -234,7 +251,18 @@ AssertionError: expected 5 to be 4 // Object.is equality
</testsuite>
</testsuites>
```
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.

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:

```ts
export default defineConfig({
test: {
reporters: [
['junit', { suiteName: 'custom suite name', classname: 'custom-classname' }]
]
},
})
```

### JSON Reporter

Expand Down
11 changes: 10 additions & 1 deletion packages/ui/node/reporter.ts
Expand Up @@ -9,6 +9,10 @@ import { stringify } from 'flatted'
import type { File, ModuleGraphData, Reporter, ResolvedConfig, Vitest } from 'vitest'
import { getModuleGraph } from '../../vitest/src/utils/graph'

export interface HTMLOptions {
outputFile?: string
}

interface PotentialConfig {
outputFile?: string | Partial<Record<string, string>>
}
Expand Down Expand Up @@ -37,6 +41,11 @@ export default class HTMLReporter implements Reporter {
start = 0
ctx!: Vitest
reportUIPath!: string
options: HTMLOptions

constructor(options: HTMLOptions) {
this.options = options
}

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

async writeReport(report: string) {
const htmlFile = getOutputFile(this.ctx.config) || 'html/index.html'
const htmlFile = this.options.outputFile || getOutputFile(this.ctx.config) || 'html/index.html'
const htmlFileName = basename(htmlFile)
const htmlDir = resolve(this.ctx.config.root, dirname(htmlFile))

Expand Down
50 changes: 46 additions & 4 deletions packages/vitest/src/node/config.ts
Expand Up @@ -358,21 +358,63 @@ export function resolveConfig(
if (options.related)
resolved.related = toArray(options.related).map(file => resolve(resolved.root, file))

/*
* Reporters can be defined in many different ways:
* { reporter: 'json' }
* { reporter: { onFinish() { method() } } }
* { reporter: ['json', { onFinish() { method() } }] }
* { reporter: [[ 'json' ]] }
* { reporter: [[ 'json' ], 'html'] }
* { reporter: [[ 'json', { outputFile: 'test.json' } ], 'html'] }
*/
if (options.reporters) {
if (!Array.isArray(options.reporters)) {
// Reporter name, e.g. { reporters: 'json' }
if (typeof options.reporters === 'string')
resolved.reporters = [[options.reporters, {}]]
// Inline reporter e.g. { reporters: { onFinish() { method() } } }
else
resolved.reporters = [options.reporters]
}
// It's an array of reporters
else {
resolved.reporters = []

for (const reporter of options.reporters) {
if (Array.isArray(reporter)) {
// Reporter with options, e.g. { reporters: [ [ 'json', { outputFile: 'test.json' } ] ] }
resolved.reporters.push([reporter[0], reporter[1] || {}])
}
else if (typeof reporter === 'string') {
// Reporter name in array, e.g. { reporters: ["html", "json"]}
resolved.reporters.push([reporter, {}])
}
else {
// Inline reporter, e.g. { reporter: [{ onFinish() { method() } }] }
resolved.reporters.push(reporter)
}
}
}
}

if (mode !== 'benchmark') {
// @ts-expect-error "reporter" is from CLI, should be absolute to the running directory
// it is passed down as "vitest --reporter ../reporter.js"
const cliReporters = toArray(resolved.reporter || []).map((reporter: string) => {
const reportersFromCLI = resolved.reporter

const cliReporters = toArray(reportersFromCLI || []).map((reporter: string) => {
// ./reporter.js || ../reporter.js, but not .reporters/reporter.js
if (/^\.\.?\//.test(reporter))
return resolve(process.cwd(), reporter)
return reporter
})
const reporters = cliReporters.length ? cliReporters : resolved.reporters
resolved.reporters = Array.from(new Set(toArray(reporters as 'json'[]))).filter(Boolean)

if (cliReporters.length)
resolved.reporters = Array.from(new Set(toArray(cliReporters))).filter(Boolean).map(reporter => [reporter, {}])
}

if (!resolved.reporters.length)
resolved.reporters.push('default')
resolved.reporters.push(['default', {}])

if (resolved.changed)
resolved.passWithNoTests ??= true
Expand Down
17 changes: 15 additions & 2 deletions packages/vitest/src/node/reporters/index.ts
Expand Up @@ -2,10 +2,10 @@ import type { Reporter } from '../../types'
import { BasicReporter } from './basic'
import { DefaultReporter } from './default'
import { DotReporter } from './dot'
import { JsonReporter } from './json'
import { type JsonOptions, JsonReporter } from './json'
import { VerboseReporter } from './verbose'
import { TapReporter } from './tap'
import { JUnitReporter } from './junit'
import { type JUnitOptions, JUnitReporter } from './junit'
import { TapFlatReporter } from './tap-flat'
import { HangingProcessReporter } from './hanging-process'
import type { BaseReporter } from './base'
Expand Down Expand Up @@ -39,4 +39,17 @@ export const ReportersMap = {

export type BuiltinReporters = keyof typeof ReportersMap

export interface BuiltinReporterOptions {
default: never
basic: never
verbose: never
dot: never
json: JsonOptions
tap: never
'tap-flat': never
junit: JUnitOptions
'hanging-process': never
html: { outputFile?: string } // TODO: Any better place for defining this UI package's reporter options?
}

export * from './benchmark'
11 changes: 10 additions & 1 deletion packages/vitest/src/node/reporters/json.ts
Expand Up @@ -62,9 +62,18 @@ export interface JsonTestResults {
// wasInterrupted: boolean
}

export interface JsonOptions {
outputFile?: string
}

export class JsonReporter implements Reporter {
start = 0
ctx!: Vitest
options: JsonOptions

constructor(options: JsonOptions) {
this.options = options
}

onInit(ctx: Vitest): void {
this.ctx = ctx
Expand Down Expand Up @@ -162,7 +171,7 @@ export class JsonReporter implements Reporter {
* @param report
*/
async writeReport(report: string) {
const outputFile = getOutputFile(this.ctx.config, 'json')
const outputFile = this.options.outputFile ?? getOutputFile(this.ctx.config, 'json')

if (outputFile) {
const reportFile = resolve(this.ctx.config.root, outputFile)
Expand Down
19 changes: 16 additions & 3 deletions packages/vitest/src/node/reporters/junit.ts
Expand Up @@ -12,6 +12,12 @@ import { F_POINTER } from '../../utils/figures'
import { getOutputFile } from '../../utils/config-helpers'
import { IndentedLogger } from './renderers/indented-logger'

export interface JUnitOptions {
outputFile?: string
classname?: string
suiteName?: string
}

function flattenTasks(task: Task, baseName = ''): Task[] {
const base = baseName ? `${baseName} > ` : ''

Expand Down Expand Up @@ -80,11 +86,16 @@ export class JUnitReporter implements Reporter {
private logger!: IndentedLogger<Promise<void>>
private _timeStart = new Date()
private fileFd?: fs.FileHandle
private options: JUnitOptions

constructor(options: JUnitOptions) {
this.options = options
}

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

const outputFile = getOutputFile(this.ctx.config, 'junit')
const outputFile = this.options.outputFile ?? getOutputFile(this.ctx.config, 'junit')

if (outputFile) {
this.reportFile = resolve(this.ctx.config.root, outputFile)
Expand Down Expand Up @@ -173,7 +184,8 @@ export class JUnitReporter implements Reporter {
async writeTasks(tasks: Task[], filename: string): Promise<void> {
for (const task of tasks) {
await this.writeElement('testcase', {
classname: process.env.VITEST_JUNIT_CLASSNAME ?? filename,
// TODO: v2.0.0 Remove env variable in favor of custom reporter options, e.g. "reporters: [['json', { classname: 'something' }]]"
classname: this.options.classname ?? process.env.VITEST_JUNIT_CLASSNAME ?? filename,
name: task.name,
time: getDuration(task),
}, async () => {
Expand Down Expand Up @@ -258,7 +270,8 @@ export class JUnitReporter implements Reporter {
stats.failures += file.stats.failures
return stats
}, {
name: process.env.VITEST_JUNIT_SUITE_NAME || 'vitest tests',
// TODO: v2.0.0 Remove env variable in favor of custom reporter options, e.g. "reporters: [['json', { suiteName: 'something' }]]"
name: this.options.suiteName || process.env.VITEST_JUNIT_SUITE_NAME || 'vitest tests',
tests: 0,
failures: 0,
errors: 0, // we cannot detect those
Expand Down
25 changes: 14 additions & 11 deletions packages/vitest/src/node/reporters/utils.ts
@@ -1,9 +1,9 @@
import type { ViteNodeRunner } from 'vite-node/client'
import type { Reporter, Vitest } from '../../types'
import type { Reporter, ResolvedConfig, Vitest } from '../../types'
import { BenchmarkReportsMap, ReportersMap } from './index'
import type { BenchmarkBuiltinReporters, BuiltinReporters } from './index'

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

function createReporters(reporterReferences: Array<string | Reporter | BuiltinReporters>, ctx: Vitest) {
function createReporters(reporterReferences: ResolvedConfig['reporters'], ctx: Vitest) {
const runner = ctx.runner
const promisedReporters = reporterReferences.map(async (referenceOrInstance) => {
if (typeof referenceOrInstance === 'string') {
if (referenceOrInstance === 'html') {
if (Array.isArray(referenceOrInstance)) {
const [reporterName, reporterOptions] = referenceOrInstance

if (reporterName === 'html') {
await ctx.packageInstaller.ensureInstalled('@vitest/ui', runner.root)
const CustomReporter = await loadCustomReporterModule('@vitest/ui/reporter', runner)
return new CustomReporter()
return new CustomReporter(reporterOptions)
}
else if (referenceOrInstance in ReportersMap) {
const BuiltinReporter = ReportersMap[referenceOrInstance as BuiltinReporters]
return new BuiltinReporter()
else if (reporterName in ReportersMap) {
const BuiltinReporter = ReportersMap[reporterName as BuiltinReporters]
return new BuiltinReporter(reporterOptions)
}
else {
const CustomReporter = await loadCustomReporterModule(referenceOrInstance, runner)
return new CustomReporter()
const CustomReporter = await loadCustomReporterModule(reporterName, runner)
return new CustomReporter(reporterOptions)
}
}

return referenceOrInstance
})
return Promise.all(promisedReporters)
Expand Down
16 changes: 13 additions & 3 deletions packages/vitest/src/types/config.ts
Expand Up @@ -3,7 +3,7 @@ import type { PrettyFormatOptions } from 'pretty-format'
import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers'
import type { SequenceHooks, SequenceSetupFiles } from '@vitest/runner'
import type { ViteNodeServerOptions } from 'vite-node'
import type { BuiltinReporters } from '../node/reporters'
import type { BuiltinReporterOptions, BuiltinReporters } from '../node/reporters'
import type { TestSequencerConstructor } from '../node/sequencers/types'
import type { ChaiConfig } from '../integrations/chai/config'
import type { CoverageOptions, ResolvedCoverageOptions } from './coverage'
Expand Down Expand Up @@ -189,6 +189,15 @@ interface DepsOptions {
moduleDirectories?: string[]
}

type InlineReporter = Reporter
type ReporterName = BuiltinReporters | 'html' | (string & {})
type ReporterWithOptions<Name extends ReporterName = ReporterName> =
Name extends keyof BuiltinReporterOptions
? BuiltinReporterOptions[Name] extends never
? [Name, {}]
: [Name, Partial<BuiltinReporterOptions[Name]>]
: [Name, Record<string, unknown>]

export interface InlineConfig {
/**
* Name of the project. Will be used to display in the reporter.
Expand Down Expand Up @@ -365,8 +374,9 @@ export interface InlineConfig {
* Custom reporter for output. Can contain one or more built-in report names, reporter instances,
* and/or paths to custom reporters.
*/
reporters?: Arrayable<BuiltinReporters | 'html' | Reporter | Omit<string, BuiltinReporters>>
reporters?: Arrayable<ReporterName | InlineReporter> | ((ReporterName | InlineReporter) | [ReporterName] | ReporterWithOptions)[]

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

reporters: (Reporter | BuiltinReporters)[]
reporters: (InlineReporter | ReporterWithOptions)[]

defines: Record<string, any>

Expand Down
8 changes: 8 additions & 0 deletions test/reporters/src/custom-reporter.ts
Expand Up @@ -2,12 +2,20 @@ import type { Reporter, Vitest } from 'vitest'

export default class TestReporter implements Reporter {
ctx!: Vitest
options?: unknown

constructor(options?: unknown) {
this.options = options
}

onInit(ctx: Vitest) {
this.ctx = ctx
}

onFinished() {
this.ctx.logger.log('hello from custom reporter')

if (this.options)
this.ctx.logger.log(`custom reporter options ${JSON.stringify(this.options)}`)
}
}

0 comments on commit fec9ca0

Please sign in to comment.