diff --git a/AGENTS.md b/AGENTS.md index 2c027633072a..4758f3850141 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Vitest is a next-generation testing framework powered by Vite. This is a monorep - **Core directory test**: `CI=true pnpm test ` (for `test/core`) - **Browser tests**: `CI=true pnpm test:browser:playwright` or `CI=true pnpm test:browser:webdriverio` -When writing tests, AVOID using `toContain` for validation. Prefer using `toMatchSnapshot` to include the test error and its stack. +When writing tests, AVOID using `toContain` for validation. Prefer using `toMatchInlineSnapshot` to include the test error and its stack. If snapshot is failing, update the snapshot instead of reverting it to `toContain`. If you need to typecheck tests, run `pnpm typecheck` from the root of the workspace. diff --git a/docs/guide/test-context.md b/docs/guide/test-context.md index ec71382f3480..7a55e5625d53 100644 --- a/docs/guide/test-context.md +++ b/docs/guide/test-context.md @@ -800,7 +800,7 @@ Note that you cannot introduce new fixtures inside `test.override`. Extend the t ### Type-Safe Hooks -When using `test.extend`, the extended `test` object provides type-safe `beforeEach` and `afterEach` hooks that are aware of the new context: +When using `test.extend`, the extended `test` object provides type-safe hooks that are aware of the extended context: ```ts const test = baseTest @@ -815,3 +815,75 @@ test.afterEach(({ counter }) => { console.log('Final count:', counter.value) }) ``` + +#### Suite-Level Hooks with Fixtures 4.1.0 {#suite-level-hooks} + +The extended `test` object also provides `beforeAll`, `afterAll`, and `aroundAll` hooks that can access file-scoped and worker-scoped fixtures: + +```ts +const test = baseTest + .extend('config', { scope: 'file' }, () => loadConfig()) + .extend('database', { scope: 'file' }, async ({ config }, onCleanup) => { + const db = await createDatabase(config) + onCleanup(() => db.close()) + return db + }) + +// Access file-scoped fixtures in suite-level hooks +test.beforeAll(async ({ database }) => { + await database.migrate() +}) + +test.afterAll(async ({ database }) => { + await database.cleanup() +}) + +test.aroundAll(async ({ database }, run) => { + await database.beginTransaction() + await run() + await database.rollbackTransaction() +}) +``` + +::: warning IMPORTANT +Suite-level hooks (`beforeAll`, `afterAll`, `aroundAll`) **must be called on the `test` object returned from `test.extend()`** to have access to the extended fixtures. Using the global `beforeAll`/`afterAll`/`aroundAll` functions will not have access to your custom fixtures: + +```ts +import { test as baseTest, beforeAll } from 'vitest' + +const test = baseTest + .extend('database', { scope: 'file' }, async ({}, onCleanup) => { + const db = await createDatabase() + onCleanup(() => db.close()) + return db + }) + +// ❌ WRONG: Global beforeAll doesn't have access to 'database' +beforeAll(({ database }) => { + // Error: 'database' is undefined +}) + +// ✅ CORRECT: Use test.beforeAll to access fixtures +test.beforeAll(({ database }) => { + // 'database' is available +}) +``` + +This applies to all suite-level hooks: `beforeAll`, `afterAll`, and `aroundAll`. +::: + +::: tip +Suite-level hooks can only access **file-scoped** and **worker-scoped** fixtures. Test-scoped fixtures are not available in these hooks because they run outside the context of individual tests. If you try to access a test-scoped fixture in a suite-level hook, Vitest will throw an error. + +```ts +const test = baseTest + .extend('testFixture', () => 'test-scoped') + .extend('fileFixture', { scope: 'file' }, () => 'file-scoped') + +// ❌ Error: test-scoped fixtures not available in beforeAll +test.beforeAll(({ testFixture }) => {}) + +// ✅ Works: file-scoped fixtures are available +test.beforeAll(({ fileFixture }) => {}) +``` +::: diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index 65ecc5d5442b..7e9a30818dfc 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -3,7 +3,7 @@ import type { File, SuiteHooks } from './types/tasks' import { processError } from '@vitest/utils/error' // TODO: load dynamically import { toArray } from '@vitest/utils/helpers' import { collectorContext, setFileContext } from './context' -import { getHooks, setHooks } from './map' +import { getHooks, getSuiteContext, setHooks, setSuiteContext } from './map' import { runSetupFiles } from './setup' import { clearCollectorContext, @@ -79,6 +79,12 @@ export async function collectTests( const defaultTasks = await getDefaultSuite().collect(file) + // Copy suite context from default suite to file for beforeAll/afterAll/aroundAll hooks + const defaultSuiteContext = getSuiteContext(defaultTasks) + if (defaultSuiteContext) { + setSuiteContext(file, defaultSuiteContext) + } + const fileHooks = createSuiteHooks() mergeHooks(fileHooks, getHooks(defaultTasks)) diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index 44724ac87ac3..aee6d2cb65c7 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -1,5 +1,5 @@ import type { VitestRunner } from './types' -import type { FixtureOptions, TestContext } from './types/tasks' +import type { File, FixtureOptions, TestContext } from './types/tasks' import { createDefer, filterOutComments, isObject } from '@vitest/utils/helpers' import { getFileContext } from './context' import { getTestFixture } from './map' @@ -175,10 +175,38 @@ export async function callFixtureCleanupFrom(context: object, fromIndex: number) cleanupFnArray.length = fromIndex } -export function withFixtures(runner: VitestRunner, fn: Function, testContext?: TestContext) { +export interface WithFixturesOptions { + /** + * Whether this is a suite-level hook (beforeAll/afterAll/aroundAll). + * Suite hooks can only access file/worker scoped fixtures and static values. + */ + isSuiteHook?: boolean + /** + * The original function to parse for fixture props. + * Use this when wrapping the function and the wrapper has different signature. + */ + originalFn?: Function + /** + * The index of the argument that contains fixtures (for functions where + * fixtures are not in the first argument, like aroundEach/aroundAll). + */ + contextArgumentIndex?: number + /** + * The test context to use. If not provided, the hookContext passed to the + * returned function will be used. + */ + context?: TestContext + /** + * Error with stack trace captured at hook registration time. + * Used to provide better error messages with proper stack traces. + */ + stackTraceError?: Error +} + +export function withFixtures(runner: VitestRunner, fn: Function, file: File, options?: WithFixturesOptions) { return (hookContext?: TestContext): any => { const context: (TestContext & { [key: string]: any }) | undefined - = hookContext || testContext + = hookContext || options?.context if (!context) { return fn({}) @@ -189,7 +217,9 @@ export function withFixtures(runner: VitestRunner, fn: Function, testContext?: T return fn(context) } - const usedProps = getUsedProps(fn) + const fnToAnalyze = options?.originalFn ?? fn + const argumentIndex = (fn as any).__VITEST_FIXTURE_INDEX__ ?? options?.contextArgumentIndex + const usedProps = getUsedProps(fnToAnalyze, argumentIndex) const hasAutoFixture = fixtures.some(({ auto }) => auto) if (!usedProps.length && !hasAutoFixture) { return fn(context) @@ -211,6 +241,25 @@ export function withFixtures(runner: VitestRunner, fn: Function, testContext?: T ) const pendingFixtures = resolveDeps(usedFixtures) + // Check if suite-level hook is trying to access test-scoped fixtures + // Suite hooks (beforeAll/afterAll/aroundAll) can only access file/worker scoped fixtures + if (options?.isSuiteHook) { + const testScopedFixtures = pendingFixtures.filter(f => f.scope === 'test' && f.isFn) + if (testScopedFixtures.length > 0) { + const fixtureNames = testScopedFixtures.map(f => `"${f.prop}"`).join(', ') + const error = new Error( + `[@vitest/runner] Test-scoped fixtures cannot be used in beforeAll/afterAll/aroundAll hooks. ` + + `The following fixtures are test-scoped: ${fixtureNames}. ` + + `Use file or worker scoped fixtures instead, or move the logic to beforeEach/afterEach hooks.`, + ) + // Use stack trace from hook registration for better error location + if (options.stackTraceError?.stack) { + error.stack = error.message + options.stackTraceError.stack.replace(options.stackTraceError.message, '') + } + throw error + } + } + if (!pendingFixtures.length) { return fn(context) } @@ -227,6 +276,7 @@ export function withFixtures(runner: VitestRunner, fn: Function, testContext?: T fixture, context!, cleanupFnArray, + file, ) context![fixture.prop] = resolvedValue fixtureValueMap.set(fixture, resolvedValue) @@ -250,8 +300,9 @@ function resolveFixtureValue( fixture: FixtureItem, context: TestContext & { [key: string]: any }, cleanupFnArray: (() => void | Promise)[], + file: File, ) { - const fileContext = getFileContext(context.task.file) + const fileContext = getFileContext(file) const workerContext = runner.getWorkerContext?.() if (!fixture.isFn) { @@ -385,7 +436,7 @@ function resolveDeps( return pendingFixtures } -function getUsedProps(fn: Function) { +function getUsedProps(fn: Function, fixtureIndex: number = 0) { let fnString = filterOutComments(fn.toString()) // match lowered async function and strip it off // example code on esbuild-try https://esbuild.github.io/try/#YgAwLjI0LjAALS1zdXBwb3J0ZWQ6YXN5bmMtYXdhaXQ9ZmFsc2UAZQBlbnRyeS50cwBjb25zdCBvID0gewogIGYxOiBhc3luYyAoKSA9PiB7fSwKICBmMjogYXN5bmMgKGEpID0+IHt9LAogIGYzOiBhc3luYyAoYSwgYikgPT4ge30sCiAgZjQ6IGFzeW5jIGZ1bmN0aW9uKGEpIHt9LAogIGY1OiBhc3luYyBmdW5jdGlvbiBmZihhKSB7fSwKICBhc3luYyBmNihhKSB7fSwKCiAgZzE6IGFzeW5jICgpID0+IHt9LAogIGcyOiBhc3luYyAoeyBhIH0pID0+IHt9LAogIGczOiBhc3luYyAoeyBhIH0sIGIpID0+IHt9LAogIGc0OiBhc3luYyBmdW5jdGlvbiAoeyBhIH0pIHt9LAogIGc1OiBhc3luYyBmdW5jdGlvbiBnZyh7IGEgfSkge30sCiAgYXN5bmMgZzYoeyBhIH0pIHt9LAoKICBoMTogYXN5bmMgKCkgPT4ge30sCiAgLy8gY29tbWVudCBiZXR3ZWVuCiAgaDI6IGFzeW5jIChhKSA9PiB7fSwKfQ @@ -405,12 +456,9 @@ function getUsedProps(fn: Function) { return [] } - let first = args[0] - if ('__VITEST_FIXTURE_INDEX__' in fn) { - first = args[(fn as any).__VITEST_FIXTURE_INDEX__] - if (!first) { - return [] - } + const first = args[fixtureIndex] + if (!first) { + return [] } if (!(first[0] === '{' && first.endsWith('}'))) { diff --git a/packages/runner/src/hooks.ts b/packages/runner/src/hooks.ts index dad7c9c9170a..c6505a0ee5ec 100644 --- a/packages/runner/src/hooks.ts +++ b/packages/runner/src/hooks.ts @@ -68,17 +68,19 @@ export function getBeforeHookCleanupCallback(hook: Function, result: any, contex * }); * ``` */ -export function beforeAll( - fn: BeforeAllListener, +export function beforeAll( + fn: BeforeAllListener, timeout: number = getDefaultHookTimeout(), ): void { assertTypes(fn, '"beforeAll" callback', ['function']) const stackTraceError = new Error('STACK_TRACE_ERROR') - return getCurrentSuite().on( + const runner = getRunner() + + return getCurrentSuite().on( 'beforeAll', Object.assign( withTimeout( - fn, + withBeforeAfterAllFixtures(runner, fn, stackTraceError), timeout, true, stackTraceError, @@ -108,19 +110,71 @@ export function beforeAll( * }); * ``` */ -export function afterAll(fn: AfterAllListener, timeout?: number): void { +export function afterAll( + fn: AfterAllListener, + timeout?: number, +): void { assertTypes(fn, '"afterAll" callback', ['function']) - return getCurrentSuite().on( + const stackTraceError = new Error('STACK_TRACE_ERROR') + const runner = getRunner() + return getCurrentSuite().on( 'afterAll', withTimeout( - fn, + withBeforeAfterAllFixtures(runner, fn, stackTraceError), timeout ?? getDefaultHookTimeout(), true, - new Error('STACK_TRACE_ERROR'), + stackTraceError, ), ) } +/** + * Wraps a beforeAll/afterAll listener to support fixtures. + * Handles the signature where: + * - First arg is context (where fixtures are destructured from) + * - Second arg is suite + */ +function withBeforeAfterAllFixtures( + runner: VitestRunner, + fn: BeforeAllListener | AfterAllListener, + stackTraceError: Error, +): BeforeAllListener { + const wrapper: BeforeAllListener = (context, suite) => { + const innerFn = (ctx: any) => fn(ctx, suite) + + const fixtureResolver = withFixtures(runner, innerFn, suite.file, { + isSuiteHook: true, + originalFn: fn, + stackTraceError, + }) + return fixtureResolver(context as any) + } + + return wrapper +} + +/** + * Wraps a beforeEach/afterEach listener to support fixtures. + * Handles the signature where: + * - First arg is context (where fixtures are destructured from) + * - Second arg is suite + */ +function withBeforeAfterEachFixtures( + runner: VitestRunner, + fn: BeforeEachListener | AfterEachListener, +): BeforeEachListener { + const wrapper: BeforeEachListener = (context, suite) => { + const innerFn = (ctx: any) => fn(ctx, suite) + + const fixtureResolver = withFixtures(runner, innerFn, suite.file, { + originalFn: fn, + }) + return fixtureResolver(context) + } + + return wrapper +} + /** * Registers a callback function to be executed before each test within the current suite. * This hook is useful for scenarios where you need to reset or reinitialize the test environment before each test runs, such as resetting database states, clearing caches, or reinitializing variables. @@ -149,11 +203,11 @@ export function beforeEach( 'beforeEach', Object.assign( withTimeout( - withFixtures(runner, fn), + withBeforeAfterEachFixtures(runner, fn), timeout ?? getDefaultHookTimeout(), true, stackTraceError, - abortIfTimeout, + ([context], error) => abortIfTimeout([context], error), ), { [CLEANUP_TIMEOUT_KEY]: timeout, @@ -189,11 +243,11 @@ export function afterEach( return getCurrentSuite().on( 'afterEach', withTimeout( - withFixtures(runner, fn), + withBeforeAfterEachFixtures(runner, fn), timeout ?? getDefaultHookTimeout(), true, new Error('STACK_TRACE_ERROR'), - abortIfTimeout, + ([context], error) => abortIfTimeout([context], error), ), ) } @@ -280,9 +334,6 @@ export const onTestFinished: TaskHook = createTestHook( * **Note:** When multiple `aroundAll` hooks are registered, they are nested inside each other. * The first registered hook is the outermost wrapper. * - * **Note:** Unlike `aroundEach`, the `aroundAll` hook does not receive test context or support fixtures, - * as it runs at the suite level before any individual test context is created. - * * @param {Function} fn - The callback function that wraps the suite. Must call `runSuite()` to run the tests. * @param {number} [timeout] - Optional timeout in milliseconds for the hook. If not provided, the default hook timeout from the runner's configuration is used. * @returns {void} @@ -295,23 +346,24 @@ export const onTestFinished: TaskHook = createTestHook( * ``` * @example * ```ts - * // Example of using aroundAll with AsyncLocalStorage context - * aroundAll(async (runSuite) => { - * await asyncLocalStorage.run({ suiteId: 'my-suite' }, runSuite); + * // Example of using aroundAll with fixtures + * aroundAll(async (runSuite, { db }) => { + * await db.transaction(() => runSuite()); * }); * ``` */ -export function aroundAll( - fn: AroundAllListener, +export function aroundAll( + fn: AroundAllListener, timeout?: number, ): void { assertTypes(fn, '"aroundAll" callback', ['function']) const stackTraceError = new Error('STACK_TRACE_ERROR') const resolvedTimeout = timeout ?? getDefaultHookTimeout() + const runner = getRunner() - return getCurrentSuite().on( + return getCurrentSuite().on( 'aroundAll', - Object.assign(fn, { + Object.assign(withAroundAllFixtures(runner, fn, stackTraceError), { [AROUND_TIMEOUT_KEY]: resolvedTimeout, [AROUND_STACK_TRACE_KEY]: stackTraceError, }), @@ -379,24 +431,46 @@ function withAroundEachFixtures( runner: VitestRunner, fn: AroundEachListener, ): AroundEachListener { - // Create the wrapper that will be returned const wrapper: AroundEachListener = (runTest, context, suite) => { - // Create inner function that will be passed to withFixtures - // This function receives context (with fixtures resolved) and calls original fn const innerFn = (ctx: any) => fn(runTest, ctx, suite) - // Set fixture index to 1 to tell parser to look at second arg of original fn - // Set toString to return original fn string so parser extracts correct params - ;(innerFn as any).__VITEST_FIXTURE_INDEX__ = 1 - ;(innerFn as any).toString = () => fn.toString() - // Use withFixtures to resolve fixtures, passing context as the hook context - const fixtureResolver = withFixtures(runner, innerFn) + const fixtureResolver = withFixtures(runner, innerFn, suite.file, { + originalFn: fn, + contextArgumentIndex: 1, + }) return fixtureResolver(context) } return wrapper } +/** + * Wraps an aroundAll listener to support fixtures. + * Similar to withAroundEachFixtures, but handles the aroundAll signature where: + * - First arg is runSuite function + * - Second arg is context (where fixtures are destructured from) + * - Third arg is suite + */ +function withAroundAllFixtures( + runner: VitestRunner, + fn: AroundAllListener, + stackTraceError: Error, +): AroundAllListener { + const wrapper: AroundAllListener = (runSuite, context, suite) => { + const innerFn = (ctx: any) => fn(runSuite, ctx, suite) + + const fixtureResolver = withFixtures(runner, innerFn, suite.file, { + isSuiteHook: true, + originalFn: fn, + contextArgumentIndex: 1, + stackTraceError, + }) + return fixtureResolver(context as any) + } + + return wrapper +} + export function getAroundHookTimeout(hook: Function): number { return AROUND_TIMEOUT_KEY in hook && typeof hook[AROUND_TIMEOUT_KEY] === 'number' ? hook[AROUND_TIMEOUT_KEY] diff --git a/packages/runner/src/map.ts b/packages/runner/src/map.ts index 4f375cb63d0c..382c238e9391 100644 --- a/packages/runner/src/map.ts +++ b/packages/runner/src/map.ts @@ -6,6 +6,7 @@ import type { Suite, SuiteHooks, Test, TestContext } from './types/tasks' const fnMap = new WeakMap() const testFixtureMap = new WeakMap() const hooksMap = new WeakMap() +const suiteContextMap = new WeakMap>() export function setFn(key: Test, fn: () => Awaitable): void { fnMap.set(key, fn) @@ -33,3 +34,15 @@ export function setHooks(key: Suite, hooks: SuiteHooks): void { export function getHooks(key: Suite): SuiteHooks { return hooksMap.get(key) } + +export function setSuiteContext(suite: Suite, context: Record): void { + suiteContextMap.set(suite, context) +} + +export function getSuiteContext(suite: Suite): Record { + const suiteContext = suiteContextMap.get(suite) + if (!suiteContext) { + throw new Error(`Cannot find suite context for suite: ${suite.name}. This is likely a Vitest bug. Please, open a new issue with reproduction`) + } + return suiteContext +} diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index 9b648860e610..cca182f4088a 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -26,7 +26,7 @@ import { abortContextSignal, getFileContext } from './context' import { AroundHookMultipleCallsError, AroundHookSetupError, AroundHookTeardownError, PendingError, TestRunAbortError } from './errors' import { callFixtureCleanup, callFixtureCleanupFrom, getFixtureCleanupCount } from './fixture' import { getAroundHookStackTrace, getAroundHookTimeout, getBeforeHookCleanupCallback } from './hooks' -import { getFn, getHooks } from './map' +import { getFn, getHooks, getSuiteContext } from './map' import { addRunningTest, getRunningTests, setCurrentTest } from './test-state' import { limitConcurrency } from './utils/limit-concurrency' import { partitionSuiteChildren } from './utils/suite' @@ -409,11 +409,12 @@ async function callAroundAllHooks( suite: Suite, runSuiteInner: () => Promise, ): Promise { + const suiteContext = getSuiteContext(suite) await callAroundHooks(runSuiteInner, { hooks: getAroundAllHooks(suite), hookName: 'aroundAll', callbackName: 'runSuite()', - invokeHook: (hook, use) => hook(use, suite), + invokeHook: (hook, use) => hook(use, suiteContext, suite), }) } @@ -822,6 +823,7 @@ export async function runSuite(suite: Suite, runner: VitestRunner): Promise { suiteRan = true try { @@ -832,7 +834,7 @@ export async function runSuite(suite: Suite, runner: VitestRunner): Promise callSuiteHook(suite, suite, 'afterAll', runner, [suite])) + await $('suite.afterAll', () => callSuiteHook(suite, suite, 'afterAll', runner, [suiteContext, suite])) if (beforeAllCleanups.length) { await $('suite.cleanup', () => callCleanupHooks(runner, beforeAllCleanups)) } diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index 482f563eb2a1..95d1746f2fa9 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -36,7 +36,7 @@ import { } from './context' import { mergeContextFixtures, mergeScopedFixtures, withFixtures } from './fixture' import { afterAll, afterEach, aroundAll, aroundEach, beforeAll, beforeEach } from './hooks' -import { getHooks, setFn, setHooks, setTestFixture } from './map' +import { getHooks, setFn, setHooks, setSuiteContext, setTestFixture } from './map' import { getCurrentTest } from './test-state' import { findTestFileStackTrace } from './utils' import { createChainable } from './utils/chain' @@ -237,8 +237,9 @@ export function clearCollectorContext( } defaultSuite.file = file collectorContext.tasks.length = 0 - defaultSuite.clear() + // Set currentSuite before clear() so initSuite() can access the file collectorContext.currentSuite = defaultSuite + defaultSuite.clear() } export function getCurrentSuite(): SuiteCollector { @@ -415,7 +416,7 @@ function createSuiteCollector( setFn( task, withTimeout( - withAwaitAsyncAssertions(withFixtures(runner, handler, context), task), + withAwaitAsyncAssertions(withFixtures(runner, handler, task.file, { context }), task), timeout, false, stackTraceError, @@ -498,6 +499,27 @@ function createSuiteCollector( collectorFixtures = parsed.fixtures } }, + mergeFixtureItems(items) { + if (!items?.length) { + return + } + const existingProps = new Set(collectorFixtures?.map(f => f.prop) || []) + const newItems = items.filter(item => !existingProps.has(item.prop)) + if (!newItems.length) { + return + } + // Clone items to avoid mutation and add to collector fixtures + collectorFixtures = [...(collectorFixtures || []), ...newItems.map(item => ({ ...item }))] + // Resolve dependencies for all fixtures + for (const fixture of collectorFixtures) { + if (fixture.isFn && fixture.depProps?.length) { + fixture.deps = fixture.depProps + .filter(prop => prop !== fixture.prop) + .map(prop => collectorFixtures!.find(f => f.prop === prop)) + .filter((f): f is FixtureItem => f != null) + } + } + }, } function addHook(name: T, ...fn: SuiteHooks[T]) { @@ -573,6 +595,12 @@ function createSuiteCollector( suite.tasks = allChildren + // Set suite context with the collector's fixtures for use in beforeAll/afterAll/aroundAll hooks + // Suite context doesn't have `task` (that's only for test context) + const suiteContext: Record = Object.create(null) + setTestFixture(suiteContext as any, collectorFixtures) + setSuiteContext(suite, suiteContext) + return suite } @@ -1003,10 +1031,29 @@ export function createTaskCollector( taskFn.suite = suite taskFn.beforeEach = beforeEach taskFn.afterEach = afterEach - taskFn.beforeAll = beforeAll - taskFn.afterAll = afterAll taskFn.aroundEach = aroundEach - taskFn.aroundAll = aroundAll + + // Set up suite-level hooks - merge context fixtures if present + const fixtures = (context as { fixtures?: FixtureItem[] } | undefined)?.fixtures + if (fixtures?.length) { + taskFn.beforeAll = (...args: Parameters) => { + getCurrentSuite().mergeFixtureItems(fixtures) + return beforeAll(...args) + } + taskFn.afterAll = (...args: Parameters) => { + getCurrentSuite().mergeFixtureItems(fixtures) + return afterAll(...args) + } + taskFn.aroundAll = (...args: Parameters) => { + getCurrentSuite().mergeFixtureItems(fixtures) + return aroundAll(...args) + } + } + else { + taskFn.beforeAll = beforeAll + taskFn.afterAll = afterAll + taskFn.aroundAll = aroundAll + } const _test = createChainable( ['concurrent', 'sequential', 'skip', 'only', 'todo', 'fails'], diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 91a81a87fd4a..c0be5bb0ada2 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -591,12 +591,19 @@ interface ExtendedAPI { } interface Hooks { - beforeAll: typeof beforeAll - afterAll: typeof afterAll + /** + * Suite-level hooks only receive file/worker scoped fixtures. + * Test-scoped fixtures are NOT available in beforeAll/afterAll/aroundAll. + */ + beforeAll: typeof beforeAll> + afterAll: typeof afterAll> + aroundAll: typeof aroundAll> + /** + * Test-level hooks receive all fixtures including test-scoped ones. + */ beforeEach: typeof beforeEach afterEach: typeof afterEach aroundEach: typeof aroundEach - aroundAll: typeof aroundAll } export type TestAPI = ChainableTestAPI @@ -680,18 +687,18 @@ export type TestAPI = ChainableTestAPI ): TestAPI> // Non-function value overloads (simple values without cleanup) - // Static values are always test-scoped (scope/auto don't apply to pre-initialized values) + // Static values are treated as worker-scoped since they're available everywhere // Overload 5: Static value with options (only injected is allowed) ( name: K, options: StaticFixtureOptions, value: T extends (...args: any[]) => any ? never : T, - ): TestAPI> + ): TestAPI> // Overload 6: Static value default (no options) - must exclude functions ( name: K, value: T extends (...args: any[]) => any ? never : T, - ): TestAPI> + ): TestAPI> // Object syntax overloads // Overload 7: Scoped fixtures with { $test?, $file?, $worker? } structure @@ -886,6 +893,18 @@ export type ExtractBuilderTest = C extends { $__test?: infer T } ? T extends Record ? T : object : object +/** + * Extracts only file and worker fixtures from a context for use in suite-level hooks. + * Test-scoped function fixtures are NOT available in beforeAll/afterAll/aroundAll hooks. + * Static values are tracked as worker-scoped since they're available everywhere. + * If the context has scope tracking, only file/worker fixtures are extracted. + * If no scope tracking exists (legacy fixtures), all fixtures are included for backward compatibility. + */ +export type ExtractSuiteContext + = C extends { $__worker?: any } | { $__file?: any } | { $__test?: any } + ? ExtractBuilderWorker & ExtractBuilderFile + : C + /** * Adds a worker fixture to the context with proper scope tracking. */ @@ -1046,12 +1065,12 @@ export type SuiteAPI = ChainableSuiteAPI & runIf: (condition: any) => ChainableSuiteAPI } -export interface BeforeAllListener { - (suite: Readonly): Awaitable +export interface BeforeAllListener { + (context: ExtraContext, suite: Readonly): Awaitable } -export interface AfterAllListener { - (suite: Readonly): Awaitable +export interface AfterAllListener { + (context: ExtraContext, suite: Readonly): Awaitable } export interface BeforeEachListener { @@ -1076,20 +1095,21 @@ export interface AroundEachListener { ): Awaitable } -export interface AroundAllListener { +export interface AroundAllListener { ( runSuite: () => Promise, + context: ExtraContext, suite: Readonly ): Awaitable } export interface SuiteHooks { - beforeAll: BeforeAllListener[] - afterAll: AfterAllListener[] + beforeAll: BeforeAllListener[] + afterAll: AfterAllListener[] beforeEach: BeforeEachListener[] afterEach: AfterEachListener[] aroundEach: AroundEachListener[] - aroundAll: AroundAllListener[] + aroundAll: AroundAllListener[] } export interface TaskCustomOptions extends TestOptions { @@ -1121,6 +1141,12 @@ export interface SuiteCollector { | SuiteCollector )[] scoped: (fixtures: Fixtures) => void + /** + * Merge FixtureItem[] directly into the collector's fixtures. + * Used internally to flow fixtures from test.extend() to the collector + * without round-trip conversion through object format. + */ + mergeFixtureItems: (items: FixtureItem[]) => void fixtures: () => FixtureItem[] | undefined file?: File suite?: Suite diff --git a/test/cli/fixtures/public-api/custom.spec.ts b/test/cli/fixtures/public-api/custom.spec.ts index 866ee53f9a0e..dfef64d3f8db 100644 --- a/test/cli/fixtures/public-api/custom.spec.ts +++ b/test/cli/fixtures/public-api/custom.spec.ts @@ -7,7 +7,7 @@ declare module 'vitest' { } } -afterAll((suite) => { +afterAll(({}, suite) => { suite.meta.done = true }) diff --git a/test/cli/test/around-each.test.ts b/test/cli/test/around-each.test.ts index 810a6a9b5646..a23b09731224 100644 --- a/test/cli/test/around-each.test.ts +++ b/test/cli/test/around-each.test.ts @@ -1597,13 +1597,13 @@ test('aroundAll teardown phase timeout', async () => { `) }) -test('aroundAll receives suite as second argument', async () => { +test('aroundAll receives suite as third argument', async () => { const { stdout, stderr, errorTree } = await runInlineTests({ 'suite-arg.test.ts': ` import { test, describe, aroundAll } from 'vitest' describe('my suite', () => { - aroundAll(async (runSuite, suite) => { + aroundAll(async (runSuite, {}, suite) => { console.log('>> suite name:', suite.name) await runSuite() }) diff --git a/test/cli/test/scoped-fixtures.test.ts b/test/cli/test/scoped-fixtures.test.ts index 0230a67ea3bb..21a7ce1f7a5b 100644 --- a/test/cli/test/scoped-fixtures.test.ts +++ b/test/cli/test/scoped-fixtures.test.ts @@ -413,6 +413,339 @@ test('file fixtures are available in beforeEach and afterEach', async () => { `) }) +test('extend fixtures are available in beforeAll and afterAll', async () => { + const { stderr, fixtures, tests } = await runFixtureTests(() => { + return it.extend('value', 'extended-value') + }, { + 'basic.test.ts': ({ extendedTest }) => { + // No need for override - extended fixtures should be available in hooks + extendedTest.beforeAll(({ value }) => { + console.log('>> fixture | beforeAll |', value) + }) + extendedTest.afterAll(({ value }) => { + console.log('>> fixture | afterAll |', value) + }) + extendedTest('test1', ({}) => {}) + extendedTest('test2', ({}) => {}) + }, + }) + + expect(stderr).toBe('') + expect(fixtures).toMatchInlineSnapshot(` + ">> fixture | beforeAll | extended-value + >> fixture | afterAll | extended-value" + `) + expect(tests).toMatchInlineSnapshot(` + " ✓ basic.test.ts > test1