diff --git a/docs/config/index.md b/docs/config/index.md index 8f41fafcd5da..8324ff61e4f2 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -227,6 +227,7 @@ Custom reporters for output. Reporters can be [a Reporter instance](https://gith - `'dot'` - show each task as a single dot - `'junit'` - JUnit XML reporter - `'json'` - give a simple JSON summary + - path of a custom reporter (e.g. `'./path/to/reporter.ts'`, `'@scope/reporter'`) ### outputFile diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 0efda56474bf..72a80992e026 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -41,7 +41,7 @@ | `--threads` | Enable Threads (default: `true`) | | `--silent` | Silent console output from tests | | `--isolate` | Isolate environment for each test file (default: `true`) | -| `--reporter ` | Select reporter: `default`, `verbose`, `dot`, `junit` or `json` | +| `--reporter ` | Select reporter: `default`, `verbose`, `dot`, `junit`, `json`, or a path to a custom reporter | | `--outputFile ` | Write test results to a file when the `--reporter=json` or `--reporter=junit` option is also specified
Via [cac's dot notation] you can specify individual outputs for multiple reporters | | `--coverage` | Use c8 for coverage | | `--run` | Do not watch | diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 0e06e00d2973..4cdaf0227411 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -5,14 +5,15 @@ import { relative, toNamespacedPath } from 'pathe' import fg from 'fast-glob' import mm from 'micromatch' import c from 'picocolors' +import { ViteNodeRunner } from 'vite-node/client' import { ViteNodeServer } from 'vite-node/server' import type { ArgumentsType, Reporter, ResolvedConfig, UserConfig } from '../types' import { SnapshotManager } from '../integrations/snapshot/manager' import { clearTimeout, deepMerge, hasFailed, noop, setTimeout, slash, toArray } from '../utils' import { cleanCoverage, reportCoverage } from '../integrations/coverage' -import { ReportersMap } from './reporters' import { createPool } from './pool' import type { WorkerPool } from './pool' +import { createReporters } from './reporters/utils' import { StateManager } from './state' import { resolveConfig } from './config' import { printError } from './error' @@ -44,6 +45,7 @@ export class Vitest { isFirstRun = true restartsCount = 0 + runner: ViteNodeRunner = undefined! private _onRestartListeners: Array<() => void> = [] @@ -64,21 +66,24 @@ export class Vitest { this.config = resolved this.state = new StateManager() this.snapshot = new SnapshotManager({ ...resolved.snapshotOptions }) - this.reporters = resolved.reporters - .map((i) => { - if (typeof i === 'string') { - const Reporter = ReportersMap[i] - if (!Reporter) - throw new Error(`Unknown reporter: ${i}`) - return new Reporter() - } - return i - }) if (this.config.watch) this.registerWatcher() this.vitenode = new ViteNodeServer(server, this.config) + const node = this.vitenode + this.runner = new ViteNodeRunner({ + root: server.config.root, + base: server.config.base, + fetchModule(id: string) { + return node.fetchModule(id) + }, + resolveId(id: string, importer: string|undefined) { + return node.resolveId(id, importer) + }, + }) + + this.reporters = await createReporters(resolved.reporters, this.runner.executeFile.bind(this.runner)) this.runningPromise = undefined diff --git a/packages/vitest/src/node/plugins/globalSetup.ts b/packages/vitest/src/node/plugins/globalSetup.ts index 8143b008b0c6..5a5e55c301aa 100644 --- a/packages/vitest/src/node/plugins/globalSetup.ts +++ b/packages/vitest/src/node/plugins/globalSetup.ts @@ -1,5 +1,5 @@ import type { Plugin } from 'vite' -import { ViteNodeRunner } from 'vite-node/client' +import type { ViteNodeRunner } from 'vite-node/client' import c from 'picocolors' import type { Vitest } from '../core' import { toArray } from '../../utils' @@ -12,18 +12,8 @@ interface GlobalSetupFile { } async function loadGlobalSetupFiles(ctx: Vitest): Promise { - const node = ctx.vitenode const server = ctx.server - const runner = new ViteNodeRunner({ - root: server.config.root, - base: server.config.base, - fetchModule(id) { - return node.fetchModule(id) - }, - resolveId(id, importer) { - return node.resolveId(id, importer) - }, - }) + const runner = ctx.runner const globalSetupFiles = toArray(server.config.test?.globalSetup) return Promise.all(globalSetupFiles.map(file => loadGlobalSetupFile(file, runner))) } diff --git a/packages/vitest/src/node/reporters/utils.ts b/packages/vitest/src/node/reporters/utils.ts new file mode 100644 index 000000000000..72d42452a790 --- /dev/null +++ b/packages/vitest/src/node/reporters/utils.ts @@ -0,0 +1,37 @@ +import type { Reporter } from '../../types' +import { ReportersMap } from './index' +import type { BuiltinReporters } from './index' + +async function loadCustomReporterModule(path: string, fetchModule: (id: string) => Promise): Promise C> { + let customReporterModule: { default: new () => C } + try { + customReporterModule = await fetchModule(path) + } + catch (customReporterModuleError) { + throw new Error(`Failed to load custom Reporter from ${path}`, { cause: customReporterModuleError as Error }) + } + + if (customReporterModule.default === null || customReporterModule.default === undefined) + throw new Error(`Custom reporter loaded from ${path} was not the default export`) + + return customReporterModule.default +} + +function createReporters(reporterReferences: Array, fetchModule: (id: string) => Promise) { + const promisedReporters = reporterReferences.map(async (referenceOrInstance) => { + if (typeof referenceOrInstance === 'string') { + if (referenceOrInstance in ReportersMap) { + const BuiltinReporter = ReportersMap[referenceOrInstance as BuiltinReporters] + return new BuiltinReporter() + } + else { + const CustomReporter = await loadCustomReporterModule(referenceOrInstance, fetchModule) + return new CustomReporter() + } + } + return referenceOrInstance + }) + return Promise.all(promisedReporters) +} + +export { createReporters } diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index 29ae1c587bfc..3b51cef4df4e 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -128,9 +128,10 @@ export interface InlineConfig { root?: string /** - * Custom reporter for output + * Custom reporter for output. Can contain one or more built-in report names, reporter instances, + * and/or paths to custom reporters */ - reporters?: Arrayable + reporters?: Arrayable /** * diff output length diff --git a/test/reporters/custom-reporter-path.vitest.config.ts b/test/reporters/custom-reporter-path.vitest.config.ts new file mode 100644 index 000000000000..7e33b2151d86 --- /dev/null +++ b/test/reporters/custom-reporter-path.vitest.config.ts @@ -0,0 +1,11 @@ +import { resolve } from 'pathe' +import { defineConfig } from 'vitest/config' + +const customReporter = resolve(__dirname, './src/custom-reporter.ts') + +export default defineConfig({ + test: { + include: ['tests/reporters.spec.ts'], + reporters: [customReporter], + }, +}) diff --git a/test/reporters/custom-reporter.vitest.config.ts b/test/reporters/custom-reporter.vitest.config.ts index 8a9dc7cae073..95a52dd972c8 100644 --- a/test/reporters/custom-reporter.vitest.config.ts +++ b/test/reporters/custom-reporter.vitest.config.ts @@ -1,17 +1,5 @@ -import type { Reporter, Vitest } from 'vitest' import { defineConfig } from 'vitest/config' - -class TestReporter implements Reporter { - ctx!: Vitest - - onInit(ctx: Vitest) { - this.ctx = ctx - } - - onFinished() { - this.ctx.log('hello from custom reporter') - } -} +import TestReporter from './src/custom-reporter' export default defineConfig({ test: { diff --git a/test/reporters/src/custom-reporter.js b/test/reporters/src/custom-reporter.js new file mode 100644 index 000000000000..6b86abbf4440 --- /dev/null +++ b/test/reporters/src/custom-reporter.js @@ -0,0 +1,9 @@ +export default class TestReporter { + onInit(ctx) { + this.ctx = ctx + } + + onFinished() { + this.ctx.log('hello from custom reporter') + } +} diff --git a/test/reporters/src/custom-reporter.ts b/test/reporters/src/custom-reporter.ts new file mode 100644 index 000000000000..3826005e5edf --- /dev/null +++ b/test/reporters/src/custom-reporter.ts @@ -0,0 +1,13 @@ +import type { Reporter, Vitest } from 'vitest' + +export default class TestReporter implements Reporter { + ctx!: Vitest + + onInit(ctx: Vitest) { + this.ctx = ctx + } + + onFinished() { + this.ctx.log('hello from custom reporter') + } +} diff --git a/test/reporters/tests/custom-reporter.spec.ts b/test/reporters/tests/custom-reporter.spec.ts index 3edc892d87d3..2ab642fc58a7 100644 --- a/test/reporters/tests/custom-reporter.spec.ts +++ b/test/reporters/tests/custom-reporter.spec.ts @@ -1,15 +1,14 @@ import { execa } from 'execa' import { resolve } from 'pathe' -import { expect, test } from 'vitest' +import { describe, expect, test } from 'vitest' -test('custom reporters work', async () => { - // in Windows child_process is very unstable, we skip testing it - if (process.platform === 'win32' && process.env.CI) - return +const customTsReporterPath = resolve(__dirname, '../src/custom-reporter.ts') +const customJSReporterPath = resolve(__dirname, '../src/custom-reporter.js') +async function run(...runOptions: string[]): Promise { const root = resolve(__dirname, '..') - const { stdout } = await execa('npx', ['vitest', 'run', '--config', 'custom-reporter.vitest.config.ts'], { + const { stdout } = await execa('npx', ['vitest', 'run', ...runOptions], { cwd: root, env: { ...process.env, @@ -19,5 +18,32 @@ test('custom reporters work', async () => { windowsHide: false, }) - expect(stdout).toContain('hello from custom reporter') -}, 40000) + return stdout +} + +describe('Custom reporters', () => { + // On Windows child_process is very unstable, we skip testing it + if (process.platform === 'win32' && process.env.CI) + return test.skip('skip on windows') + + test('custom reporter instances defined in configuration works', async () => { + const stdout = await run('--config', 'custom-reporter.vitest.config.ts') + expect(stdout).includes('hello from custom reporter') + }, 40000) + + test('a path to a custom reporter defined in configuration works', async () => { + const stdout = await run('--config', 'custom-reporter-path.vitest.config.ts', '--reporter', customJSReporterPath) + expect(stdout).includes('hello from custom reporter') + }, 40000) + + test('custom TS reporters using ESM given as a CLI argument works', async () => { + const stdout = await run('--config', 'without-custom-reporter.vitest.config.ts', '--reporter', customTsReporterPath) + expect(stdout).includes('hello from custom reporter') + }, 40000) + + test('custom JS reporters using CJS given as a CLI argument works', async () => { + const stdout = await run('--config', 'without-custom-reporter.vitest.config.ts', '--reporter', customJSReporterPath) + expect(stdout).includes('hello from custom reporter') + }, 40000) +}) + diff --git a/test/reporters/tests/utils.test.ts b/test/reporters/tests/utils.test.ts new file mode 100644 index 000000000000..2c9a25031df0 --- /dev/null +++ b/test/reporters/tests/utils.test.ts @@ -0,0 +1,41 @@ +/** + * @format + */ +import { resolve } from 'pathe' +import { describe, expect, test } from 'vitest' +import { createReporters } from 'vitest/src/node/reporters/utils' +import { DefaultReporter } from '../../../../vitest/packages/vitest/src/node/reporters/default' +import TestReporter from '../src/custom-reporter' + +const customReporterPath = resolve(__dirname, '../src/custom-reporter.js') +const fetchModule = (id: string) => import(id) + +describe('Reporter Utils', () => { + test('passing an empty array returns nothing', async () => { + const promisedReporters = await createReporters([], fetchModule) + expect(promisedReporters).toHaveLength(0) + }) + + test('passing the name of a single built-in reporter returns a new instance', async () => { + const promisedReporters = await createReporters(['default'], fetchModule) + expect(promisedReporters).toHaveLength(1) + const reporter = promisedReporters[0] + expect(reporter).toBeInstanceOf(DefaultReporter) + }) + + test('passing in the path to a custom reporter returns a new instance', async () => { + const promisedReporters = await createReporters(([customReporterPath]), fetchModule) + expect(promisedReporters).toHaveLength(1) + const customReporter = promisedReporters[0] + expect(customReporter).toBeInstanceOf(TestReporter) + }) + + test('passing in a mix of built-in and custom reporters works', async () => { + const promisedReporters = await createReporters(['default', customReporterPath], fetchModule) + expect(promisedReporters).toHaveLength(2) + const defaultReporter = promisedReporters[0] + expect(defaultReporter).toBeInstanceOf(DefaultReporter) + const customReporter = promisedReporters[1] + expect(customReporter).toBeInstanceOf(TestReporter) + }) +}) diff --git a/test/reporters/without-custom-reporter.vitest.config.ts b/test/reporters/without-custom-reporter.vitest.config.ts new file mode 100644 index 000000000000..16794b138609 --- /dev/null +++ b/test/reporters/without-custom-reporter.vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/reporters.spec.ts'], + }, +})