Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(reporters): support custom options #5111

Merged
merged 1 commit into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
30 changes: 29 additions & 1 deletion docs/guide/reporters.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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)}`)
}
}