diff --git a/docs/advanced/runner.md b/docs/advanced/runner.md index 16ed1a35a..1f80150de 100644 --- a/docs/advanced/runner.md +++ b/docs/advanced/runner.md @@ -20,26 +20,26 @@ export interface VitestRunner { /** * Called when test runner should cancel next test runs. * Runner should listen for this method and mark tests and suites as skipped in - * "onBeforeRunSuite" and "onBeforeRunTest" when called. + * "onBeforeRunSuite" and "onBeforeRunTask" when called. */ onCancel?(reason: CancelReason): unknown /** * Called before running a single test. Doesn't have "result" yet. */ - onBeforeRunTest?(test: Test): unknown + onBeforeRunTask?(test: TaskPopulated): unknown /** * Called before actually running the test function. Already has "result" with "state" and "startTime". */ - onBeforeTryTest?(test: Test, retryCount: number): unknown + onBeforeTryTask?(test: TaskPopulated, options: { retry: number; repeats: number }): unknown /** * Called after result and state are set. */ - onAfterRunTest?(test: Test): unknown + onAfterRunTask?(test: TaskPopulated): unknown /** * Called right after running the test function. Doesn't have new state yet. Will not be called, if the test function throws. */ - onAfterTryTest?(test: Test, retryCount: number): unknown + onAfterTryTask?(test: TaskPopulated, options: { retry: number; repeats: number }): unknown /** * Called before running a single suite. Doesn't have "result" yet. @@ -59,7 +59,7 @@ export interface VitestRunner { * If defined, will be called instead of usual Vitest handling. Useful, if you have your custom test function. * "before" and "after" hooks will not be ignored. */ - runTest?(test: Test): Promise + runTask?(test: TaskPopulated): Promise /** * Called, when a task is updated. The same as "onTaskUpdate" in a reporter, but this is running in the same thread as tests. @@ -69,16 +69,20 @@ export interface VitestRunner { /** * Called before running all tests in collected paths. */ - onBeforeRun?(files: File[]): unknown + onBeforeRunFiles?(files: File[]): unknown /** * Called right after running all tests in collected paths. */ - onAfterRun?(files: File[]): unknown + onAfterRunFiles?(files: File[]): unknown /** * Called when new context for a test is defined. Useful, if you want to add custom properties to the context. * If you only want to define custom context with a runner, consider using "beforeAll" in "setupFiles" instead. + * + * This method is called for both "test" and "custom" handlers. + * + * @see https://vitest.dev/advanced/runner.html#your-task-function */ - extendTestContext?(context: TestContext): TestContext + extendTaskContext?(context: TaskContext): TaskContext /** * Called, when certain files are imported. Can be called in two situations: when collecting tests and when importing setup files. */ @@ -108,18 +112,23 @@ You can extend Vitest task system with your tasks. A task is an object that is p ```js // ./utils/custom.js -import { getCurrentSuite, setFn } from 'vitest/suite' +import { createTaskCollector, getCurrentSuite, setFn } from 'vitest/suite' export { describe, beforeAll, afterAll } from 'vitest' -// this function will be called, when Vitest collects tasks -export const myCustomTask = function (name, fn) { - const task = getCurrentSuite().custom(name) - task.meta = { - customPropertyToDifferentiateTask: true - } - setFn(task, fn || (() => {})) -} +// this function will be called when Vitest collects tasks +// createTaskCollector just provides all "todo"/"each"/... support, you don't have to use it +// To support custom tasks, you just need to call "getCurrentSuite().task()" +export const myCustomTask = createTaskCollector(function (name, fn, timeout) { + getCurrentSuite().task(name, { + ...this, // so "todo"/"skip" is tracked correctly + meta: { + customPropertyToDifferentiateTask: true + }, + handler: fn, + timeout, + }) +}) ``` ```js @@ -135,6 +144,9 @@ describe('take care of the garden', () => { myCustomTask('weed the grass', () => { gardener.weedTheGrass() }) + myCustomTask.todo('mow the lawn', () => { + gardener.mowerTheLawn() + }) myCustomTask('water flowers', () => { gardener.waterFlowers() }) diff --git a/packages/browser/src/client/runner.ts b/packages/browser/src/client/runner.ts index bf8b694b2..0829e951c 100644 --- a/packages/browser/src/client/runner.ts +++ b/packages/browser/src/client/runner.ts @@ -22,7 +22,7 @@ export function createBrowserRunner(original: any, coverageModule: CoverageHandl this.hashMap = options.browserHashMap } - async onAfterRunTest(task: Test) { + async onAfterRunTask(task: Test) { await super.onAfterRunTest?.(task) task.result?.errors?.forEach((error) => { console.error(error.message) @@ -39,7 +39,7 @@ export function createBrowserRunner(original: any, coverageModule: CoverageHandl } } - async onAfterRun() { + async onAfterRunFiles() { await super.onAfterRun?.() const coverage = await coverageModule?.takeCoverage?.() if (coverage) diff --git a/packages/runner/src/context.ts b/packages/runner/src/context.ts index 6e357f8f1..dec0a3050 100644 --- a/packages/runner/src/context.ts +++ b/packages/runner/src/context.ts @@ -1,6 +1,6 @@ import type { Awaitable } from '@vitest/utils' import { getSafeTimers } from '@vitest/utils' -import type { RuntimeContext, SuiteCollector, Test, TestContext } from './types' +import type { Custom, ExtendedContext, RuntimeContext, SuiteCollector, TaskContext, Test } from './types' import type { VitestRunner } from './types/runner' import { PendingError } from './errors' @@ -42,12 +42,11 @@ export function withTimeout any)>( }) as T } -export function createTestContext(test: Test, runner: VitestRunner): TestContext { +export function createTestContext(test: T, runner: VitestRunner): ExtendedContext { const context = function () { throw new Error('done() callback is deprecated, use promise instead') - } as unknown as TestContext + } as unknown as TaskContext - context.meta = test context.task = test context.skip = () => { @@ -60,7 +59,7 @@ export function createTestContext(test: Test, runner: VitestRunner): TestContext test.onFailed.push(fn) } - return runner.extendTestContext?.(context) || context + return runner.extendTaskContext?.(context) as ExtendedContext || context } function makeTimeoutMsg(isHook: boolean, timeout: number) { diff --git a/packages/runner/src/hooks.ts b/packages/runner/src/hooks.ts index 7372caeef..47a724fc2 100644 --- a/packages/runner/src/hooks.ts +++ b/packages/runner/src/hooks.ts @@ -1,4 +1,4 @@ -import type { OnTestFailedHandler, SuiteHooks, Test } from './types' +import type { OnTestFailedHandler, SuiteHooks, TaskPopulated } from './types' import { getCurrentSuite, getRunner } from './suite' import { getCurrentTest } from './test-state' import { withTimeout } from './context' @@ -27,7 +27,7 @@ export const onTestFailed = createTestHook('onTestFailed', test.onFailed.push(handler) }) -function createTestHook(name: string, handler: (test: Test, handler: T) => void) { +function createTestHook(name: string, handler: (test: TaskPopulated, handler: T) => void) { return (fn: T) => { const current = getCurrentTest() diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts index 841356efe..6bc4238ff 100644 --- a/packages/runner/src/index.ts +++ b/packages/runner/src/index.ts @@ -1,5 +1,5 @@ export { startTests, updateTask } from './run' -export { test, it, describe, suite, getCurrentSuite } from './suite' +export { test, it, describe, suite, getCurrentSuite, createTaskCollector } from './suite' export { beforeAll, beforeEach, afterAll, afterEach, onTestFailed } from './hooks' export { setFn, getFn } from './map' export { getCurrentTest } from './test-state' diff --git a/packages/runner/src/map.ts b/packages/runner/src/map.ts index e5454b5c9..32540d2a0 100644 --- a/packages/runner/src/map.ts +++ b/packages/runner/src/map.ts @@ -1,5 +1,5 @@ import type { Awaitable } from '@vitest/utils' -import type { Suite, SuiteHooks, Test, TestContext } from './types' +import type { Custom, Suite, SuiteHooks, Test, TestContext } from './types' import type { FixtureItem } from './fixture' // use WeakMap here to make the Test and Suite object serializable @@ -7,11 +7,11 @@ const fnMap = new WeakMap() const fixtureMap = new WeakMap() const hooksMap = new WeakMap() -export function setFn(key: Test, fn: (() => Awaitable)) { +export function setFn(key: Test | Custom, fn: (() => Awaitable)) { fnMap.set(key, fn) } -export function getFn(key: Task): (() => Awaitable) { +export function getFn(key: Task): (() => Awaitable) { return fnMap.get(key as any) } diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index 798ac93f9..cd6077061 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -3,7 +3,7 @@ import { getSafeTimers, shuffle } from '@vitest/utils' import { processError } from '@vitest/utils/error' import type { DiffOptions } from '@vitest/utils/diff' import type { VitestRunner } from './types/runner' -import type { File, HookCleanupCallback, HookListener, SequenceHooks, Suite, SuiteHooks, Task, TaskMeta, TaskResult, TaskResultPack, TaskState, Test } from './types' +import type { Custom, File, HookCleanupCallback, HookListener, SequenceHooks, Suite, SuiteHooks, Task, TaskMeta, TaskResult, TaskResultPack, TaskState, Test } from './types' import { partitionSuiteChildren } from './utils/suite' import { getFn, getHooks } from './map' import { collectTests } from './collect' @@ -114,8 +114,8 @@ async function callCleanupHooks(cleanups: HookCleanupCallback[]) { })) } -export async function runTest(test: Test, runner: VitestRunner) { - await runner.onBeforeRunTest?.(test) +export async function runTest(test: Test | Custom, runner: VitestRunner) { + await runner.onBeforeRunTask?.(test) if (test.mode !== 'run') return @@ -142,14 +142,14 @@ export async function runTest(test: Test, runner: VitestRunner) { for (let retryCount = 0; retryCount <= retry; retryCount++) { let beforeEachCleanups: HookCleanupCallback[] = [] try { - await runner.onBeforeTryTest?.(test, { retry: retryCount, repeats: repeatCount }) + await runner.onBeforeTryTask?.(test, { retry: retryCount, repeats: repeatCount }) test.result.repeatCount = repeatCount beforeEachCleanups = await callSuiteHook(test.suite, test, 'beforeEach', runner, [test.context, test.suite]) - if (runner.runTest) { - await runner.runTest(test) + if (runner.runTask) { + await runner.runTask(test) } else { const fn = getFn(test) @@ -165,7 +165,7 @@ export async function runTest(test: Test, runner: VitestRunner) { throw errors } - await runner.onAfterTryTest?.(test, { retry: retryCount, repeats: repeatCount }) + await runner.onAfterTryTask?.(test, { retry: retryCount, repeats: repeatCount }) if (test.result.state !== 'fail') { if (!test.repeats) @@ -230,7 +230,7 @@ export async function runTest(test: Test, runner: VitestRunner) { test.result.duration = now() - start - await runner.onAfterRunTest?.(test) + await runner.onAfterRunTask?.(test) updateTask(test, runner) } @@ -356,7 +356,7 @@ export async function runSuite(suite: Suite, runner: VitestRunner) { } async function runSuiteChild(c: Task, runner: VitestRunner) { - if (c.type === 'test') + if (c.type === 'test' || c.type === 'custom') return runTest(c, runner) else if (c.type === 'suite') @@ -385,11 +385,11 @@ export async function startTests(paths: string[], runner: VitestRunner) { const files = await collectTests(paths, runner) runner.onCollected?.(files) - await runner.onBeforeRun?.(files) + await runner.onBeforeRunFiles?.(files) await runFiles(files, runner) - await runner.onAfterRun?.(files) + await runner.onAfterRunFiles?.(files) await sendTasksUpdate(runner) diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index 2a0ae39fd..26b9ef28b 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -1,5 +1,5 @@ import { format, isObject, noop, objDisplay, objectAttr } from '@vitest/utils' -import type { File, Fixtures, RunMode, Suite, SuiteAPI, SuiteCollector, SuiteFactory, SuiteHooks, Task, TaskCustom, Test, TestAPI, TestFunction, TestOptions } from './types' +import type { Custom, CustomAPI, File, Fixtures, RunMode, Suite, SuiteAPI, SuiteCollector, SuiteFactory, SuiteHooks, Task, TaskCustomOptions, Test, TestAPI, TestFunction, TestOptions } from './types' import type { VitestRunner } from './types/runner' import { createChainable } from './utils/chain' import { collectTask, collectorContext, createTestContext, runWithSuite, withTimeout } from './context' @@ -54,70 +54,68 @@ export function createSuiteHooks() { // implementations function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, mode: RunMode, concurrent?: boolean, sequential?: boolean, shuffle?: boolean, each?: boolean, suiteOptions?: TestOptions) { - const tasks: (Test | TaskCustom | Suite | SuiteCollector)[] = [] + const tasks: (Test | Custom | Suite | SuiteCollector)[] = [] const factoryQueue: (Test | Suite | SuiteCollector)[] = [] let suite: Suite initSuite() - const test = createTest(function (name: string | Function, fn = noop, options) { - const mode = this.only ? 'only' : this.skip ? 'skip' : this.todo ? 'todo' : 'run' - - if (typeof options === 'number') - options = { timeout: options } - - // inherit repeats, retry, timeout from suite - if (typeof suiteOptions === 'object') - options = Object.assign({}, suiteOptions, options) - - const test: Test = { + const task = function (name = '', options: TaskCustomOptions = {}) { + const task: Custom = { id: '', - type: 'test', - name: formatName(name), - each: this.each, - mode, + name, suite: undefined!, - fails: this.fails, - retry: options?.retry ?? runner.config.retry, - repeats: options?.repeats, - meta: Object.create(null), - } as Omit as Test - - if (this.concurrent || (!sequential && (concurrent || runner.config.sequence.concurrent))) - test.concurrent = true + each: options.each, + fails: options.fails, + context: undefined!, + type: 'custom', + retry: options.retry ?? runner.config.retry, + repeats: options.repeats, + mode: options.only ? 'only' : options.skip ? 'skip' : options.todo ? 'todo' : 'run', + meta: options.meta ?? Object.create(null), + } + const handler = options.handler + if (options.concurrent || (!sequential && (concurrent || runner.config.sequence.concurrent))) + task.concurrent = true if (shuffle) - test.shuffle = true + task.shuffle = true - const context = createTestContext(test, runner) + const context = createTestContext(task, runner) // create test context - Object.defineProperty(test, 'context', { + Object.defineProperty(task, 'context', { value: context, enumerable: false, }) + setFixture(context, options.fixtures) - setFixture(context, this.fixtures) - setFn(test, withTimeout( - withFixtures(fn, context), - options?.timeout ?? runner.config.testTimeout, - )) - - tasks.push(test) - }) - - const custom = function (this: Record, name = '') { - const self = this || {} - const task: TaskCustom = { - id: '', - name, - type: 'custom', - mode: self.only ? 'only' : self.skip ? 'skip' : self.todo ? 'todo' : 'run', - meta: Object.create(null), + if (handler) { + setFn(task, withTimeout( + withFixtures(handler, context), + options?.timeout ?? runner.config.testTimeout, + )) } + tasks.push(task) return task } + const test = createTest(function (name: string | Function, fn = noop, options) { + if (typeof options === 'number') + options = { timeout: options } + + // inherit repeats, retry, timeout from suite + if (typeof suiteOptions === 'object') + options = Object.assign({}, suiteOptions, options) + + const test = task( + formatName(name), + { ...this, ...options, handler: fn as any }, + ) as unknown as Test + + test.type = 'test' + }) + const collector: SuiteCollector = { type: 'collector', name, @@ -126,7 +124,7 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m test, tasks, collect, - custom, + task, clear, on: addHook, } @@ -230,17 +228,13 @@ function createSuite() { ) as unknown as SuiteAPI } -function createTest(fn: ( - ( - this: Record<'concurrent' | 'sequential' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined> & { fixtures?: FixtureItem[] }, - title: string, - fn?: TestFunction, - options?: number | TestOptions - ) => void -), context?: Record) { - const testFn = fn as any +export function createTaskCollector( + fn: (...args: any[]) => any, + context?: Record, +) { + const taskFn = fn as any - testFn.each = function(this: { withContext: () => SuiteAPI; setContext: (key: string, value: boolean | undefined) => SuiteAPI }, cases: ReadonlyArray, ...args: any[]) { + taskFn.each = function(this: { withContext: () => SuiteAPI; setContext: (key: string, value: boolean | undefined) => SuiteAPI }, cases: ReadonlyArray, ...args: any[]) { const test = this.withContext() this.setContext('each', true) @@ -262,10 +256,10 @@ function createTest(fn: ( } } - testFn.skipIf = (condition: any) => (condition ? test.skip : test) as TestAPI - testFn.runIf = (condition: any) => (condition ? test : test.skip) as TestAPI + taskFn.skipIf = (condition: any) => (condition ? test.skip : test) as TestAPI + taskFn.runIf = (condition: any) => (condition ? test : test.skip) as TestAPI - testFn.extend = function (fixtures: Fixtures>) { + taskFn.extend = function (fixtures: Fixtures>) { const _context = mergeContextFixtures(fixtures, context) return createTest(function fn(name: string | Function, fn?: TestFunction, options?: number | TestOptions) { @@ -275,8 +269,8 @@ function createTest(fn: ( const _test = createChainable( ['concurrent', 'skip', 'only', 'todo', 'fails'], - testFn, - ) as TestAPI + taskFn, + ) as CustomAPI if (context) (_test as any).mergeContext(context) @@ -284,6 +278,17 @@ function createTest(fn: ( return _test } +function createTest(fn: ( + ( + this: Record<'concurrent' | 'sequential' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined> & { fixtures?: FixtureItem[] }, + title: string, + fn?: TestFunction, + options?: number | TestOptions + ) => void +), context?: Record) { + return createTaskCollector(fn, context) as TestAPI +} + function formatName(name: string | Function) { return typeof name === 'string' ? name : name instanceof Function ? (name.name || '') : String(name) } diff --git a/packages/runner/src/test-state.ts b/packages/runner/src/test-state.ts index 1761e8130..a661eea3a 100644 --- a/packages/runner/src/test-state.ts +++ b/packages/runner/src/test-state.ts @@ -1,11 +1,11 @@ -import type { Test } from './types' +import type { Custom, Test } from './types' -let _test: Test | undefined +let _test: Test | Custom | undefined -export function setCurrentTest(test: Test | undefined) { +export function setCurrentTest(test: T | undefined) { _test = test } -export function getCurrentTest() { - return _test +export function getCurrentTest(): T { + return _test as T } diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index f512731c9..0f6fe71e6 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -1,5 +1,5 @@ import type { DiffOptions } from '@vitest/utils/diff' -import type { File, SequenceHooks, SequenceSetupFiles, Suite, TaskResultPack, Test, TestContext } from './tasks' +import type { Custom, ExtendedContext, File, SequenceHooks, SequenceSetupFiles, Suite, TaskContext, TaskPopulated, TaskResultPack, Test } from './tasks' export interface VitestRunnerConfig { root: string @@ -46,26 +46,26 @@ export interface VitestRunner { /** * Called when test runner should cancel next test runs. * Runner should listen for this method and mark tests and suites as skipped in - * "onBeforeRunSuite" and "onBeforeRunTest" when called. + * "onBeforeRunSuite" and "onBeforeRunTask" when called. */ onCancel?(reason: CancelReason): unknown /** * Called before running a single test. Doesn't have "result" yet. */ - onBeforeRunTest?(test: Test): unknown + onBeforeRunTask?(test: TaskPopulated): unknown /** * Called before actually running the test function. Already has "result" with "state" and "startTime". */ - onBeforeTryTest?(test: Test, options: { retry: number; repeats: number }): unknown + onBeforeTryTask?(test: TaskPopulated, options: { retry: number; repeats: number }): unknown /** * Called after result and state are set. */ - onAfterRunTest?(test: Test): unknown + onAfterRunTask?(test: TaskPopulated): unknown /** * Called right after running the test function. Doesn't have new state yet. Will not be called, if the test function throws. */ - onAfterTryTest?(test: Test, options: { retry: number; repeats: number }): unknown + onAfterTryTask?(test: TaskPopulated, options: { retry: number; repeats: number }): unknown /** * Called before running a single suite. Doesn't have "result" yet. @@ -85,7 +85,7 @@ export interface VitestRunner { * If defined, will be called instead of usual Vitest handling. Useful, if you have your custom test function. * "before" and "after" hooks will not be ignored. */ - runTest?(test: Test): Promise + runTask?(test: TaskPopulated): Promise /** * Called, when a task is updated. The same as "onTaskUpdate" in a reporter, but this is running in the same thread as tests. @@ -95,16 +95,20 @@ export interface VitestRunner { /** * Called before running all tests in collected paths. */ - onBeforeRun?(files: File[]): unknown + onBeforeRunFiles?(files: File[]): unknown /** * Called right after running all tests in collected paths. */ - onAfterRun?(files: File[]): unknown + onAfterRunFiles?(files: File[]): unknown /** * Called when new context for a test is defined. Useful, if you want to add custom properties to the context. * If you only want to define custom context, consider using "beforeAll" in "setupFiles" instead. + * + * This method is called for both "test" and "custom" handlers. + * + * @see https://vitest.dev/advanced/runner.html#your-task-function */ - extendTestContext?(context: TestContext): TestContext + extendTaskContext?(context: TaskContext): ExtendedContext /** * Called, when files are imported. Can be called in two situations: when collecting tests and when importing setup files. */ diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 4315baf8c..b8f5ba6f7 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -1,5 +1,6 @@ import type { Awaitable, ErrorWithDiff } from '@vitest/utils' import type { ChainableFunction } from '../utils/chain' +import type { FixtureItem } from '../fixture' export type RunMode = 'run' | 'skip' | 'only' | 'todo' export type TaskState = RunMode | 'pass' | 'fail' @@ -19,12 +20,20 @@ export interface TaskBase { repeats?: number } -export interface TaskMeta {} - -export interface TaskCustom extends TaskBase { - type: 'custom' +export interface TaskPopulated extends TaskBase { + suite: Suite + pending?: boolean + result?: TaskResult + fails?: boolean + onFailed?: OnTestFailedHandler[] + /** + * Store promises (from async expects) to wait for them before finishing the test + */ + promises?: Promise[] } +export interface TaskMeta {} + export interface TaskResult { state: TaskState duration?: number @@ -56,24 +65,20 @@ export interface File extends Suite { setupDuration?: number } -export interface Test extends TaskBase { +export interface Test extends TaskPopulated { type: 'test' - suite: Suite - pending?: boolean - result?: TaskResult - fails?: boolean - context: TestContext & ExtraContext - onFailed?: OnTestFailedHandler[] - /** - * Store promises (from async expects) to wait for them before finishing the test - */ - promises?: Promise[] + context: TaskContext & ExtraContext & TestContext +} + +export interface Custom extends TaskPopulated { + type: 'custom' + context: TaskContext & ExtraContext & TestContext } -export type Task = Test | Suite | TaskCustom | File +export type Task = Test | Suite | Custom | File export type DoneCallback = (error?: any) => void -export type TestFunction = (context: TestContext & ExtraContext) => Awaitable | void +export type TestFunction = (context: ExtendedContext & ExtraContext) => Awaitable | void // jest's ExtractEachCallbackArgs type ExtractEachCallbackArgs> = { @@ -179,10 +184,20 @@ export interface TestOptions { repeats?: number } -export type TestAPI = ChainableTestAPI & { +interface ExtendedAPI { each: TestEachFunction skipIf(condition: any): ChainableTestAPI runIf(condition: any): ChainableTestAPI +} + +export type CustomAPI = ChainableTestAPI & ExtendedAPI & { + extend = {}>(fixtures: Fixtures): CustomAPI<{ + [K in keyof T | keyof ExtraContext]: + K extends keyof T ? T[K] : + K extends keyof ExtraContext ? ExtraContext[K] : never }> +} + +export type TestAPI = ChainableTestAPI & ExtendedAPI & { extend = {}>(fixtures: Fixtures): TestAPI<{ [K in keyof T | keyof ExtraContext]: K extends keyof T ? T[K] : @@ -238,8 +253,21 @@ export type HookCleanupCallback = (() => Awaitable) | void export interface SuiteHooks { beforeAll: HookListener<[Readonly], HookCleanupCallback>[] afterAll: HookListener<[Readonly]>[] - beforeEach: HookListener<[TestContext & ExtraContext, Readonly], HookCleanupCallback>[] - afterEach: HookListener<[TestContext & ExtraContext, Readonly]>[] + beforeEach: HookListener<[ExtendedContext & ExtraContext, Readonly], HookCleanupCallback>[] + afterEach: HookListener<[ExtendedContext & ExtraContext, Readonly]>[] +} + +export interface TaskCustomOptions extends TestOptions { + concurrent?: boolean + sequential?: boolean + skip?: boolean + only?: boolean + todo?: boolean + fails?: boolean + each?: boolean + meta?: Record + fixtures?: FixtureItem[] + handler?: (context: TaskContext) => Awaitable } export interface SuiteCollector { @@ -248,8 +276,8 @@ export interface SuiteCollector { options?: TestOptions type: 'collector' test: TestAPI - tasks: (Suite | TaskCustom | Test | SuiteCollector)[] - custom: (name: string) => TaskCustom + tasks: (Suite | Custom | Test | SuiteCollector)[] + task: (name: string, options?: TaskCustomOptions) => Custom collect: (file?: File) => Promise clear: () => void on: >(name: T, ...fn: SuiteHooks[T]) => void @@ -262,18 +290,13 @@ export interface RuntimeContext { currentSuite: SuiteCollector | null } -export interface TestContext { - /** - * Metadata of the current test - * - * @deprecated Use `task` instead - */ - meta: Readonly +export interface TestContext {} +export interface TaskContext { /** * Metadata of the current test */ - task: Readonly + task: Readonly /** * Extract hooks on test failed @@ -286,6 +309,8 @@ export interface TestContext { skip: () => void } +export type ExtendedContext = TaskContext & TestContext + export type OnTestFailedHandler = (result: TaskResult) => Awaitable export type SequenceHooks = 'stack' | 'list' | 'parallel' diff --git a/packages/runner/src/utils/tasks.ts b/packages/runner/src/utils/tasks.ts index 40464ce8b..40244da59 100644 --- a/packages/runner/src/utils/tasks.ts +++ b/packages/runner/src/utils/tasks.ts @@ -1,12 +1,12 @@ import { type Arrayable, toArray } from '@vitest/utils' -import type { Suite, Task, TaskCustom, Test } from '../types' +import type { Custom, Suite, Task, Test } from '../types' -function isAtomTest(s: Task): s is Test | TaskCustom { +function isAtomTest(s: Task): s is Test | Custom { return s.type === 'test' || s.type === 'custom' } -export function getTests(suite: Arrayable): (Test | TaskCustom)[] { - const tests: (Test | TaskCustom)[] = [] +export function getTests(suite: Arrayable): (Test | Custom)[] { + const tests: (Test | Custom)[] = [] const suite_arr = toArray(suite) for (const s of suite_arr) { if (isAtomTest(s)) { diff --git a/packages/snapshot/README.md b/packages/snapshot/README.md index ec20b5b0d..82643f412 100644 --- a/packages/snapshot/README.md +++ b/packages/snapshot/README.md @@ -9,20 +9,18 @@ import { SnapshotClient } from '@vitest/snapshot' import { NodeSnapshotEnvironment } from '@vitest/snapshot/environment' import { SnapshotManager } from '@vitest/snapshot/manager' -export class CustomSnapshotClient extends SnapshotClient { - // by default, @vitest/snapshot checks equality with `!==` - // you need to provide your own equality check implementation +const client = new SnapshotClient({ + // you need to provide your own equality check implementation if you use it // this function is called when `.toMatchSnapshot({ property: 1 })` is called - equalityCheck(received, expected) { - return equals(received, expected, [iterableEquality, subsetEquality]) - } -} + isEqual: (received, expected) => equals(received, expected, [iterableEquality, subsetEquality]), +}) -const client = new CustomSnapshotClient() // class that implements snapshot saving and reading // by default uses fs module, but you can provide your own implementation depending on the environment const environment = new NodeSnapshotEnvironment() +// you need to implement this yourselves, +// this depends on your runner function getCurrentFilepath() { return '/file.spec.ts' } @@ -30,6 +28,8 @@ function getCurrentTestName() { return 'test1' } +// example for inline snapshots, nothing is required to support regular snapshots, +// just call `assert` with `isInline: false` function wrapper(received) { function __INLINE_SNAPSHOT__(inlineSnapshot, message) { client.assert({ @@ -37,8 +37,6 @@ function wrapper(received) { message, isInline: true, inlineSnapshot, - // you need to implement this yourselves, - // this depends on your runner filepath: getCurrentFilepath(), name: getCurrentTestName(), }) @@ -55,14 +53,20 @@ const options = { snapshotEnvironment: environment, } -await client.setTest(getCurrentFilepath(), getCurrentTestName(), options) +await client.startCurrentRun(getCurrentFilepath(), getCurrentTestName(), options) + +// this will save snapshot to a file which is returned by "snapshotEnvironment.resolvePath" +client.assert({ + received: 'some text', + isInline: false, +}) // uses "pretty-format", so it requires quotes // also naming is hard-coded when parsing test files wrapper('text 1').toMatchInlineSnapshot() wrapper('text 2').toMatchInlineSnapshot('"text 2"') -const result = await client.resetCurrent() // this saves files and returns SnapshotResult +const result = await client.finishCurrentRun() // this saves files and returns SnapshotResult // you can use manager to manage several clients const manager = new SnapshotManager(options) diff --git a/packages/snapshot/src/client.ts b/packages/snapshot/src/client.ts index 5e7a92ea2..a8d19c36b 100644 --- a/packages/snapshot/src/client.ts +++ b/packages/snapshot/src/client.ts @@ -39,25 +39,29 @@ interface AssertOptions { rawSnapshot?: RawSnapshotInfo } +export interface SnapshotClientOptions { + isEqual?: (received: unknown, expected: unknown) => boolean +} + export class SnapshotClient { filepath?: string name?: string snapshotState: SnapshotState | undefined snapshotStateMap = new Map() - constructor(private Service = SnapshotState) {} + constructor(private options: SnapshotClientOptions = {}) {} - async setTest(filepath: string, name: string, options: SnapshotStateOptions) { + async startCurrentRun(filepath: string, name: string, options: SnapshotStateOptions) { this.filepath = filepath this.name = name if (this.snapshotState?.testFilePath !== filepath) { - this.resetCurrent() + await this.finishCurrentRun() if (!this.getSnapshotState(filepath)) { this.snapshotStateMap.set( filepath, - await this.Service.create( + await SnapshotState.create( filepath, options, ), @@ -80,15 +84,6 @@ export class SnapshotClient { this.snapshotState?.markSnapshotsAsCheckedForTest(name) } - /** - * Should be overridden by the consumer. - * - * Vitest checks equality with @vitest/expect. - */ - equalityCheck(received: unknown, expected: unknown) { - return received === expected - } - assert(options: AssertOptions): void { const { filepath = this.filepath, @@ -111,7 +106,7 @@ export class SnapshotClient { throw new Error('Received value must be an object when the matcher has properties') try { - const pass = this.equalityCheck(received, properties) + const pass = this.options.isEqual?.(received, properties) ?? false // const pass = equals(received, properties, [iterableEquality, subsetEquality]) if (!pass) throw createMismatchError('Snapshot properties mismatched', received, properties) @@ -169,7 +164,7 @@ export class SnapshotClient { return this.assert(options) } - async resetCurrent() { + async finishCurrentRun() { if (!this.snapshotState) return null const result = await this.snapshotState.pack() diff --git a/packages/vitest/src/integrations/chai/index.ts b/packages/vitest/src/integrations/chai/index.ts index b25dc2f60..41b64d16a 100644 --- a/packages/vitest/src/integrations/chai/index.ts +++ b/packages/vitest/src/integrations/chai/index.ts @@ -2,7 +2,7 @@ import * as chai from 'chai' import './setup' -import type { Test } from '@vitest/runner' +import type { TaskPopulated, Test } from '@vitest/runner' import { getCurrentTest } from '@vitest/runner' import { GLOBAL_EXPECT, getState, setState } from '@vitest/expect' import type { Assertion, ExpectStatic } from '@vitest/expect' @@ -10,7 +10,7 @@ import type { MatcherState } from '../../types/chai' import { getFullName } from '../../utils/tasks' import { getCurrentEnvironment } from '../../utils/global' -export function createExpect(test?: Test) { +export function createExpect(test?: TaskPopulated) { const expect = ((value: any, message?: string): Assertion => { const { assertionCalls } = getState(expect) setState({ assertionCalls: assertionCalls + 1, soft: false }, expect) @@ -40,7 +40,7 @@ export function createExpect(test?: Test) { expectedAssertionsNumberErrorGen: null, environment: getCurrentEnvironment(), testPath: test ? test.suite.file?.filepath : globalState.testPath, - currentTestName: test ? getFullName(test) : globalState.currentTestName, + currentTestName: test ? getFullName(test as Test) : globalState.currentTestName, }, expect) // @ts-expect-error untyped diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 2b2e1dac2..76d18b4c5 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -1,16 +1,20 @@ import type { ChaiPlugin } from '@vitest/expect' +import { equals, iterableEquality, subsetEquality } from '@vitest/expect' +import { SnapshotClient, addSerializer, stripSnapshotIndentation } from '@vitest/snapshot' import type { Test } from '@vitest/runner' import { getNames } from '@vitest/runner/utils' -import type { SnapshotClient } from '@vitest/snapshot' -import { addSerializer, stripSnapshotIndentation } from '@vitest/snapshot' import { recordAsyncExpect } from '../../../../expect/src/utils' -import { VitestSnapshotClient } from './client' let _client: SnapshotClient export function getSnapshotClient(): SnapshotClient { - if (!_client) - _client = new VitestSnapshotClient() + if (!_client) { + _client = new SnapshotClient({ + isEqual: (received, expected) => { + return equals(received, expected, [iterableEquality, subsetEquality]) + }, + }) + } return _client } diff --git a/packages/vitest/src/integrations/snapshot/client.ts b/packages/vitest/src/integrations/snapshot/client.ts deleted file mode 100644 index 05aa2d777..000000000 --- a/packages/vitest/src/integrations/snapshot/client.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { equals, iterableEquality, subsetEquality } from '@vitest/expect' -import { SnapshotClient } from '@vitest/snapshot' - -export class VitestSnapshotClient extends SnapshotClient { - equalityCheck(received: unknown, expected: unknown): boolean { - return equals(received, expected, [iterableEquality, subsetEquality]) - } -} diff --git a/packages/vitest/src/runtime/benchmark.ts b/packages/vitest/src/runtime/benchmark.ts index 6c1afac7b..d09e37888 100644 --- a/packages/vitest/src/runtime/benchmark.ts +++ b/packages/vitest/src/runtime/benchmark.ts @@ -1,18 +1,18 @@ -import type { TaskCustom } from '@vitest/runner' +import type { Custom } from '@vitest/runner' import { getCurrentSuite } from '@vitest/runner' import { createChainable } from '@vitest/runner/utils' import { noop } from '@vitest/utils' import type { BenchFunction, BenchOptions, BenchmarkAPI } from '../types' import { isRunningInBenchmark } from '../utils' -const benchFns = new WeakMap() +const benchFns = new WeakMap() const benchOptsMap = new WeakMap() -export function getBenchOptions(key: TaskCustom): BenchOptions { +export function getBenchOptions(key: Custom): BenchOptions { return benchOptsMap.get(key) } -export function getBenchFn(key: TaskCustom): BenchFunction { +export function getBenchFn(key: Custom): BenchFunction { return benchFns.get(key)! } @@ -21,10 +21,12 @@ export const bench = createBenchmark( if (!isRunningInBenchmark()) throw new Error('`bench()` is only available in benchmark mode.') - const task = getCurrentSuite().custom.call(this, formatName(name)) - task.meta = { - benchmark: true, - } + const task = getCurrentSuite().task(formatName(name), { + ...this, + meta: { + benchmark: true, + }, + }) benchFns.set(task, fn) benchOptsMap.set(task, options) }, diff --git a/packages/vitest/src/runtime/runners/benchmark.ts b/packages/vitest/src/runtime/runners/benchmark.ts index ecd8a4167..86310dc58 100644 --- a/packages/vitest/src/runtime/runners/benchmark.ts +++ b/packages/vitest/src/runtime/runners/benchmark.ts @@ -142,7 +142,7 @@ export class NodeBenchmarkRunner implements VitestRunner { await runBenchmarkSuite(suite, this) } - async runTest(): Promise { + async runTask(): Promise { throw new Error('`test()` and `it()` is only available in test mode.') } } diff --git a/packages/vitest/src/runtime/runners/index.ts b/packages/vitest/src/runtime/runners/index.ts index fe22c048b..a9ed72112 100644 --- a/packages/vitest/src/runtime/runners/index.ts +++ b/packages/vitest/src/runtime/runners/index.ts @@ -62,15 +62,15 @@ export async function resolveTestRunner(config: ResolvedConfig, executor: Vitest await originalOnCollected?.call(testRunner, files) } - const originalOnAfterRun = testRunner.onAfterRun - testRunner.onAfterRun = async (files) => { + const originalOnAfterRun = testRunner.onAfterRunFiles + testRunner.onAfterRunFiles = async (files) => { const coverage = await takeCoverageInsideWorker(config.coverage, executor) rpc().onAfterSuiteRun({ coverage }) await originalOnAfterRun?.call(testRunner, files) } - const originalOnAfterRunTest = testRunner.onAfterRunTest - testRunner.onAfterRunTest = async (test) => { + const originalOnAfterRunTask = testRunner.onAfterRunTask + testRunner.onAfterRunTask = async (test) => { if (config.bail && test.result?.state === 'fail') { const previousFailures = await rpc().getCountOfFailedTests() const currentFailures = 1 + previousFailures @@ -80,7 +80,7 @@ export async function resolveTestRunner(config: ResolvedConfig, executor: Vitest testRunner.onCancel?.('test-failure') } } - await originalOnAfterRunTest?.call(testRunner, test) + await originalOnAfterRunTask?.call(testRunner, test) } return testRunner diff --git a/packages/vitest/src/runtime/runners/test.ts b/packages/vitest/src/runtime/runners/test.ts index d3bb2017f..72e1d17e0 100644 --- a/packages/vitest/src/runtime/runners/test.ts +++ b/packages/vitest/src/runtime/runners/test.ts @@ -1,4 +1,4 @@ -import type { CancelReason, Suite, Test, TestContext, VitestRunner, VitestRunnerImportSource } from '@vitest/runner' +import type { CancelReason, Custom, ExtendedContext, Suite, TaskContext, Test, VitestRunner, VitestRunnerImportSource } from '@vitest/runner' import type { ExpectStatic } from '@vitest/expect' import { GLOBAL_EXPECT, getState, setState } from '@vitest/expect' import { getSnapshotClient } from '../../integrations/snapshot/chai' @@ -23,12 +23,12 @@ export class VitestTestRunner implements VitestRunner { return this.__vitest_executor.executeId(filepath) } - onBeforeRun() { + onBeforeRunFiles() { this.snapshotClient.clear() } - async onAfterRun() { - const result = await this.snapshotClient.resetCurrent() + async onAfterRunFiles() { + const result = await this.snapshotClient.finishCurrentRun() if (result) await rpc().snapshotSaved(result) } @@ -38,7 +38,7 @@ export class VitestTestRunner implements VitestRunner { suite.result!.heap = process.memoryUsage().heapUsed } - onAfterRunTest(test: Test) { + onAfterRunTask(test: Test) { this.snapshotClient.clearTest() if (this.config.logHeapUsage && typeof process !== 'undefined') @@ -51,7 +51,7 @@ export class VitestTestRunner implements VitestRunner { this.cancelRun = true } - async onBeforeRunTest(test: Test) { + async onBeforeRunTask(test: Test) { const name = getNames(test).slice(1).join(' > ') if (this.cancelRun) @@ -63,7 +63,7 @@ export class VitestTestRunner implements VitestRunner { } clearModuleMocks(this.config) - await this.snapshotClient.setTest(test.file!.filepath, name, this.workerState.config.snapshotOptions) + await this.snapshotClient.startCurrentRun(test.file!.filepath, name, this.workerState.config.snapshotOptions) this.workerState.current = test } @@ -73,7 +73,7 @@ export class VitestTestRunner implements VitestRunner { suite.mode = 'skip' } - onBeforeTryTest(test: Test) { + onBeforeTryTask(test: Test) { setState({ assertionCalls: 0, isExpectingAssertions: false, @@ -86,7 +86,7 @@ export class VitestTestRunner implements VitestRunner { }, (globalThis as any)[GLOBAL_EXPECT]) } - onAfterTryTest(test: Test) { + onAfterTryTask(test: Test) { const { assertionCalls, expectedAssertionsNumber, @@ -103,12 +103,12 @@ export class VitestTestRunner implements VitestRunner { throw isExpectingAssertionsError } - extendTestContext(context: TestContext): TestContext { + extendTaskContext(context: TaskContext): ExtendedContext { let _expect: ExpectStatic | undefined Object.defineProperty(context, 'expect', { get() { if (!_expect) - _expect = createExpect(context.meta) + _expect = createExpect(context.task) return _expect }, }) @@ -117,7 +117,7 @@ export class VitestTestRunner implements VitestRunner { return _expect != null }, }) - return context + return context as ExtendedContext } } diff --git a/packages/vitest/src/suite.ts b/packages/vitest/src/suite.ts index c2f67b8df..12f80795a 100644 --- a/packages/vitest/src/suite.ts +++ b/packages/vitest/src/suite.ts @@ -1,2 +1,2 @@ -export { getCurrentSuite, getFn, setFn } from '@vitest/runner' +export { getCurrentSuite, createTaskCollector, getFn, setFn } from '@vitest/runner' export { createChainable } from '@vitest/runner/utils' diff --git a/packages/vitest/src/types/benchmark.ts b/packages/vitest/src/types/benchmark.ts index 74cc58ebf..62a36dd6c 100644 --- a/packages/vitest/src/types/benchmark.ts +++ b/packages/vitest/src/types/benchmark.ts @@ -1,4 +1,4 @@ -import type { TaskCustom } from '@vitest/runner' +import type { Custom } from '@vitest/runner' import type { ChainableFunction } from '@vitest/runner/utils' import type { Arrayable } from '@vitest/utils' import type { Bench as BenchFactory, Options as BenchOptions, Task as BenchTask, TaskResult as BenchTaskResult, TaskResult as TinybenchResult } from 'tinybench' @@ -39,7 +39,7 @@ export interface BenchmarkUserOptions { outputFile?: string | (Partial> & Record) } -export interface Benchmark extends TaskCustom { +export interface Benchmark extends Custom { meta: { benchmark: true result?: BenchTaskResult diff --git a/packages/vitest/src/types/tasks.ts b/packages/vitest/src/types/tasks.ts index d6bd456f8..50f54043f 100644 --- a/packages/vitest/src/types/tasks.ts +++ b/packages/vitest/src/types/tasks.ts @@ -20,6 +20,10 @@ export type { SuiteFactory, RuntimeContext, TestContext, + TaskContext, + ExtendedContext, + Custom, + TaskCustomOptions, OnTestFailedHandler, TaskMeta, } from '@vitest/runner' diff --git a/test/core/src/custom/gardener.ts b/test/core/src/custom/gardener.ts new file mode 100644 index 000000000..1880ead52 --- /dev/null +++ b/test/core/src/custom/gardener.ts @@ -0,0 +1,42 @@ +export class Gardener { + _state = 'wake up' + + states: string[] = [this._state] + + get state() { + return this._state + } + + set state(state: string) { + this._state = state + this.states.push(state) + } + + putWorkingClothes() { + this.state = 'working clothes' + } + + weedTheGrass() { + this.state = 'weed the grass' + } + + mowerTheLawn() { + this.state = 'mower the lawn' + } + + waterFlowers() { + this.state = 'water flowers' + } + + rest() { + this.state = 'rest' + } + + standup() { + this.state = 'standup' + } + + goHome() { + this.state = 'home' + } +} diff --git a/test/core/test/custom.test.ts b/test/core/test/custom.test.ts new file mode 100644 index 000000000..965fadd3e --- /dev/null +++ b/test/core/test/custom.test.ts @@ -0,0 +1,66 @@ +import { createChainable, getCurrentSuite } from 'vitest/suite' +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from 'vitest' +import { Gardener } from '../src/custom/gardener.js' + +// this function will be called, when Vitest collects tasks +const myCustomTask = createChainable(['todo'], function (name: string, fn: () => void) { + getCurrentSuite().task(name, { + ...this, + meta: { + customPropertyToDifferentiateTask: true, + }, + handler: fn, + }) +}) + +const gardener = new Gardener() + +describe('take care of the garden', () => { + beforeAll(() => { + gardener.putWorkingClothes() + }) + + beforeEach(() => { + gardener.standup() + }) + + afterEach(() => { + gardener.rest() + }) + + myCustomTask('weed the grass', () => { + gardener.weedTheGrass() + }) + myCustomTask.todo('mow the lawn', () => { + gardener.mowerTheLawn() + }) + myCustomTask('water flowers', () => { + gardener.waterFlowers() + }) + + afterAll(() => { + gardener.goHome() + }) +}) + +test('states are filled correctly', () => { + expect(gardener.states).toEqual([ + 'wake up', + 'working clothes', + 'standup', + 'weed the grass', + 'rest', + 'standup', + 'water flowers', + 'rest', + 'home', + ]) +})