From 5eaec14b3ade6e6390c60911c90dff9b3ecdc2ed Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Mar 2025 16:44:56 +0100 Subject: [PATCH 01/30] feat(vitest): allow per-file and per-worker fixtures --- docs/guide/test-context.md | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/guide/test-context.md b/docs/guide/test-context.md index 7cf515db0a8d..ecf73084e35c 100644 --- a/docs/guide/test-context.md +++ b/docs/guide/test-context.md @@ -306,6 +306,46 @@ describe('another type of schema', () => { }) ``` +#### Per-Scope Context ? + +You can define context that will be initiated once per file or a worker. It is initiated the same way as a regular fixture with an objects parameter: + +```ts +import { test as baseTest } from 'vitest' + +export const test = baseTest.extend({ + perFile: [ + ({}, { use }) => use([]), + { scope: 'file' }, + ], + perWorker: [ + ({}, { use }) => use([]), + { scope: 'worker' }, + ], +}) +``` + +The value is initialised the first time any test has accessed it, unless the fixture options have `auto: true` - in this case the value is initialised before any test has run. + +```ts +const test = baseTest.extend({ + perFile: [ + ({}, { use }) => use([]), + { + scope: 'file', + // always run this hook before any test + auto: true + }, + ], +}) +``` + +The `worker` scope will run the fixture once per worker. The amount of running workers depends on a lot of factors. By default, every file runs in a separate worker, so `file` and `worker` scopes work the same way. + +But if you disable [isolation](/config/#isolate), then the amount of workers is limited by the [`maxWorkers`](/config/#maxworkers) or [`poolOptions`](/config/#pooloptions) configuration. + +Note that specifying `scope: 'worker'` when running tests in `vmThreads` or `vmForks` will work the same was as `scope: 'file'`. This limitation exists because every test file has its own VM context, so if Vitest would initiate it once, then one context could leak to another and create a lot of reference errors (instances of the same class would reference different constructors, for example). + #### TypeScript To provide fixture types for all your custom contexts, you can pass the fixtures type as a generic. From 5f468a36f11a565f5b16b7e97859992b7835d8dc Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 19 Mar 2025 16:47:50 +0100 Subject: [PATCH 02/30] chore: better grammar --- docs/guide/test-context.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guide/test-context.md b/docs/guide/test-context.md index ecf73084e35c..5419d0975e1d 100644 --- a/docs/guide/test-context.md +++ b/docs/guide/test-context.md @@ -340,11 +340,11 @@ const test = baseTest.extend({ }) ``` -The `worker` scope will run the fixture once per worker. The amount of running workers depends on a lot of factors. By default, every file runs in a separate worker, so `file` and `worker` scopes work the same way. +The `worker` scope will run the fixture once per worker. The number of running workers depends on various factors. By default, every file runs in a separate worker, so `file` and `worker` scopes work the same way. -But if you disable [isolation](/config/#isolate), then the amount of workers is limited by the [`maxWorkers`](/config/#maxworkers) or [`poolOptions`](/config/#pooloptions) configuration. +However, if you disable [isolation](/config/#isolate), then the number of workers is limited by the [`maxWorkers`](/config/#maxworkers) or [`poolOptions`](/config/#pooloptions) configuration. -Note that specifying `scope: 'worker'` when running tests in `vmThreads` or `vmForks` will work the same was as `scope: 'file'`. This limitation exists because every test file has its own VM context, so if Vitest would initiate it once, then one context could leak to another and create a lot of reference errors (instances of the same class would reference different constructors, for example). +Note that specifying `scope: 'worker'` when running tests in `vmThreads` or `vmForks` will work the same way as `scope: 'file'`. This limitation exists because every test file has its own VM context, so if Vitest were to initiate it once, one context could leak to another and create many reference inconsistencies (instances of the same class would reference different constructors, for example). #### TypeScript From 3b6ad921a3c4dac58991693e4c00404b4105bafa Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 3 Apr 2025 17:02:01 +0200 Subject: [PATCH 03/30] feat: draft implementation --- packages/runner/src/collect.ts | 30 +++++++++++- packages/runner/src/fixture.ts | 72 ++++++++++++++++++++++++---- packages/runner/src/run.ts | 3 ++ packages/runner/src/types/tasks.ts | 11 +++++ packages/runner/src/utils/collect.ts | 1 + 5 files changed, 106 insertions(+), 11 deletions(-) diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index 8e2a2a8d1cdd..146bc7c5d8e1 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -1,9 +1,10 @@ +import type { FixtureItem } from './fixture' import type { FileSpecification, VitestRunner } from './types/runner' -import type { File, SuiteHooks } from './types/tasks' +import type { File, SuiteHooks, Task } from './types/tasks' import { toArray } from '@vitest/utils' import { processError } from '@vitest/utils/error' import { collectorContext } from './context' -import { getHooks, setHooks } from './map' +import { getHooks, getTestFixture, setHooks, setTestFixture } from './map' import { runSetupFiles } from './setup' import { clearCollectorContext, @@ -104,11 +105,36 @@ export async function collectTests( } files.push(file) + + // TODO: any + setTestFixture(file.context as any, getFileFixtires(file)) } return files } +function getFileFixtires(file: File): FixtureItem[] { + const fixtures = new Set() + function traverse(children: Task[]) { + for (const child of children) { + if (child.type === 'test') { + const childFixtures = getTestFixture(child.context) || [] + for (const fixture of childFixtures) { + // TODO: what if overriden? + if (fixture.scope === 'file' && !fixtures.has(fixture)) { + fixtures.add(fixture) + } + } + } + else { + traverse(child.tasks) + } + } + } + traverse(file.tasks) + return Array.from(fixtures) +} + function mergeHooks(baseHooks: SuiteHooks, hooks: SuiteHooks): SuiteHooks { for (const _key in hooks) { const key = _key as keyof SuiteHooks diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index 32afc6aad088..0b61e297638e 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -45,7 +45,7 @@ export function mergeContextFixtures( context: T, inject: (key: string) => unknown, ): T { - const fixtureOptionKeys = ['auto', 'injected'] + const fixtureOptionKeys = ['auto', 'injected', 'scope'] const fixtureArray: FixtureItem[] = Object.entries(fixtures).map( ([prop, value]) => { const fixtureItem = { value } as FixtureItem @@ -94,11 +94,11 @@ export function mergeContextFixtures( const fixtureValueMaps = new Map>() const cleanupFnArrayMap = new Map< - TestContext, + object, Array<() => void | Promise> >() -export async function callFixtureCleanup(context: TestContext): Promise { +export async function callFixtureCleanup(context: object): Promise { const cleanupFnArray = cleanupFnArrayMap.get(context) ?? [] for (const cleanup of cleanupFnArray.reverse()) { await cleanup() @@ -153,14 +153,19 @@ export function withFixtures(fn: Function, testContext?: TestContext) { continue } - const resolvedValue = fixture.isFn - ? await resolveFixtureFunction(fixture.value, context, cleanupFnArray) - : fixture.value + const resolvedValue = await resolveFixtureValue( + fixture, + context!, + cleanupFnArray, + ) context![fixture.prop] = resolvedValue fixtureValueMap.set(fixture, resolvedValue) - cleanupFnArray.unshift(() => { - fixtureValueMap.delete(fixture) - }) + + if (!fixture.scope || fixture.scope === 'test') { + cleanupFnArray.unshift(() => { + fixtureValueMap.delete(fixture) + }) + } } } @@ -168,6 +173,55 @@ export function withFixtures(fn: Function, testContext?: TestContext) { } } +const fileFixturePromise = new WeakMap>() + +function resolveFixtureValue( + fixture: FixtureItem, + context: TestContext & { [key: string]: any }, + cleanupFnArray: (() => void | Promise)[], +) { + if (!fixture.isFn) { + return fixture.value + } + + if (!fixture.scope || fixture.scope === 'test') { + return resolveFixtureFunction( + fixture.value, + context, + cleanupFnArray, + ) + } + + const fileContext = context.task.file.context + + if (fixture.prop in fileContext) { + return fileContext[fixture.prop] + } + + // in case the test runs in parallel + if (fileFixturePromise.has(fixture)) { + return fileFixturePromise.get(fixture)! + } + + if (!cleanupFnArrayMap.has(fileContext)) { + cleanupFnArrayMap.set(fileContext, []) + } + const cleanupFnFileArray = cleanupFnArrayMap.get(fileContext)! + + const promise = resolveFixtureFunction( + fixture.value, + fileContext, + cleanupFnFileArray, + ).then((value) => { + fileContext[fixture.prop] = value + fileFixturePromise.delete(fixture) + return value + }) + + fileFixturePromise.set(fixture, promise) + return promise +} + async function resolveFixtureFunction( fixtureFn: ( context: unknown, diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index e8828ceedca9..ab481f5aa97d 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -487,6 +487,9 @@ export async function runSuite(suite: Suite, runner: VitestRunner): Promise } export interface Test extends TaskPopulated { @@ -479,12 +481,21 @@ export type { TestAPI as CustomAPI } export interface FixtureOptions { /** * Whether to automatically set up current fixture, even though it's not being used in tests. + * @default false */ auto?: boolean /** * Indicated if the injected value from the config should be preferred over the fixture value */ injected?: boolean + /** + * When should the fixture be set up. + * - **test**: fixture will be set up before ever test + * - **worker**: fixture will be set up once per worker + * - **file**: fixture will be set up once per file + * @default 'test' + */ + scope?: 'test' | 'worker' | 'file' } export type Use = (value: T) => Promise diff --git a/packages/runner/src/utils/collect.ts b/packages/runner/src/utils/collect.ts index fb1a9544e0b5..b7eec84d6ba0 100644 --- a/packages/runner/src/utils/collect.ts +++ b/packages/runner/src/utils/collect.ts @@ -192,6 +192,7 @@ export function createFileTask( projectName, file: undefined!, pool, + context: Object.create(null), } file.file = file return file From 988c4091fa7fe7bbe82d3f6ae82e6f9e7c83cd55 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 3 Apr 2025 17:05:18 +0200 Subject: [PATCH 04/30] chore: undo --- packages/runner/src/collect.ts | 30 ++---------------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index 146bc7c5d8e1..8e2a2a8d1cdd 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -1,10 +1,9 @@ -import type { FixtureItem } from './fixture' import type { FileSpecification, VitestRunner } from './types/runner' -import type { File, SuiteHooks, Task } from './types/tasks' +import type { File, SuiteHooks } from './types/tasks' import { toArray } from '@vitest/utils' import { processError } from '@vitest/utils/error' import { collectorContext } from './context' -import { getHooks, getTestFixture, setHooks, setTestFixture } from './map' +import { getHooks, setHooks } from './map' import { runSetupFiles } from './setup' import { clearCollectorContext, @@ -105,36 +104,11 @@ export async function collectTests( } files.push(file) - - // TODO: any - setTestFixture(file.context as any, getFileFixtires(file)) } return files } -function getFileFixtires(file: File): FixtureItem[] { - const fixtures = new Set() - function traverse(children: Task[]) { - for (const child of children) { - if (child.type === 'test') { - const childFixtures = getTestFixture(child.context) || [] - for (const fixture of childFixtures) { - // TODO: what if overriden? - if (fixture.scope === 'file' && !fixtures.has(fixture)) { - fixtures.add(fixture) - } - } - } - else { - traverse(child.tasks) - } - } - } - traverse(file.tasks) - return Array.from(fixtures) -} - function mergeHooks(baseHooks: SuiteHooks, hooks: SuiteHooks): SuiteHooks { for (const _key in hooks) { const key = _key as keyof SuiteHooks From 9df74d435dfe9c98c2df12d744a10ded6300096d Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 3 Apr 2025 17:24:49 +0200 Subject: [PATCH 05/30] chore: validation --- packages/runner/src/fixture.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index 0b61e297638e..013a35410c54 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -5,6 +5,7 @@ import { getTestFixture } from './map' export interface FixtureItem extends FixtureOptions { prop: string value: any + scope: 'test' | 'file' | 'worker' /** * Indicates whether the fixture is a function */ @@ -64,6 +65,7 @@ export function mergeContextFixtures( : userValue } + fixtureItem.scope = fixtureItem.scope || 'test' fixtureItem.prop = prop fixtureItem.isFn = typeof fixtureItem.value === 'function' return fixtureItem @@ -86,6 +88,13 @@ export function mergeContextFixtures( ({ prop }) => prop !== fixture.prop && usedProps.includes(prop), ) } + if (fixture.scope !== 'test') { + fixture.deps?.forEach((dep) => { + if (dep.isFn && dep.scope !== fixture.scope) { + throw new Error(`cannot use ${dep.scope} fixture "${dep.prop}" inside ${fixture.scope} fixture "${fixture.prop}"`) + } + }) + } } }) @@ -180,7 +189,10 @@ function resolveFixtureValue( context: TestContext & { [key: string]: any }, cleanupFnArray: (() => void | Promise)[], ) { + const fileContext = context.task.file.context + if (!fixture.isFn) { + fileContext[fixture.prop] ??= fixture.value return fixture.value } @@ -192,8 +204,6 @@ function resolveFixtureValue( ) } - const fileContext = context.task.file.context - if (fixture.prop in fileContext) { return fileContext[fixture.prop] } From a5f972340d3bca150f4b509c259b1b0203f82b04 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 4 Apr 2025 11:31:04 +0200 Subject: [PATCH 06/30] fix: validate worker/file scopes --- packages/runner/src/fixture.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index 013a35410c54..ef704920cfc6 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -88,11 +88,23 @@ export function mergeContextFixtures( ({ prop }) => prop !== fixture.prop && usedProps.includes(prop), ) } + // test can access anything, so we ignore it if (fixture.scope !== 'test') { fixture.deps?.forEach((dep) => { - if (dep.isFn && dep.scope !== fixture.scope) { - throw new Error(`cannot use ${dep.scope} fixture "${dep.prop}" inside ${fixture.scope} fixture "${fixture.prop}"`) + if (!dep.isFn) { + // non fn fixtures are always resolved an available to anyone + return } + // worker scope can only import from worker scope + if (fixture.scope === 'worker' && dep.scope === 'worker') { + return + } + // file scope an import from file and worker scopes + if (fixture.scope === 'file' && dep.scope !== 'test') { + return + } + + throw new SyntaxError(`cannot use ${dep.scope} fixture "${dep.prop}" inside ${fixture.scope} fixture "${fixture.prop}"`) }) } } From 9ee54f3e0ed01692e8d02ca375baf2632fe26fae Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 7 Apr 2025 15:08:41 +0200 Subject: [PATCH 07/30] chore: draft worker context --- packages/runner/src/fixture.ts | 53 ++++++++++++++------- packages/runner/src/hooks.ts | 6 ++- packages/runner/src/suite.ts | 2 +- packages/runner/src/types/runner.ts | 17 ++++--- packages/vitest/src/runtime/runners/test.ts | 13 +++-- packages/vitest/src/types/global.ts | 2 + 6 files changed, 64 insertions(+), 29 deletions(-) diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index ef704920cfc6..d3c4ad994ef4 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -1,3 +1,4 @@ +import type { VitestRunner } from './types' import type { FixtureOptions, TestContext } from './types/tasks' import { createDefer, isObject } from '@vitest/utils' import { getTestFixture } from './map' @@ -104,7 +105,7 @@ export function mergeContextFixtures( return } - throw new SyntaxError(`cannot use ${dep.scope} fixture "${dep.prop}" inside ${fixture.scope} fixture "${fixture.prop}"`) + throw new SyntaxError(`cannot use the ${dep.scope} fixture "${dep.prop}" inside the ${fixture.scope} fixture "${fixture.prop}"`) }) } } @@ -127,7 +128,7 @@ export async function callFixtureCleanup(context: object): Promise { cleanupFnArrayMap.delete(context) } -export function withFixtures(fn: Function, testContext?: TestContext) { +export function withFixtures(runner: VitestRunner, fn: Function, testContext?: TestContext) { return (hookContext?: TestContext): any => { const context: (TestContext & { [key: string]: any }) | undefined = hookContext || testContext @@ -175,6 +176,7 @@ export function withFixtures(fn: Function, testContext?: TestContext) { } const resolvedValue = await resolveFixtureValue( + runner, fixture, context!, cleanupFnArray, @@ -182,7 +184,7 @@ export function withFixtures(fn: Function, testContext?: TestContext) { context![fixture.prop] = resolvedValue fixtureValueMap.set(fixture, resolvedValue) - if (!fixture.scope || fixture.scope === 'test') { + if (fixture.scope === 'test') { cleanupFnArray.unshift(() => { fixtureValueMap.delete(fixture) }) @@ -194,21 +196,26 @@ export function withFixtures(fn: Function, testContext?: TestContext) { } } -const fileFixturePromise = new WeakMap>() +const globalFixturePromise = new WeakMap>() function resolveFixtureValue( + runner: VitestRunner, fixture: FixtureItem, context: TestContext & { [key: string]: any }, cleanupFnArray: (() => void | Promise)[], ) { const fileContext = context.task.file.context + const workerContext = runner.getWorkerContext?.() if (!fixture.isFn) { fileContext[fixture.prop] ??= fixture.value + if (workerContext) { + workerContext[fixture.prop] ??= fixture.value + } return fixture.value } - if (!fixture.scope || fixture.scope === 'test') { + if (fixture.scope === 'test') { return resolveFixtureFunction( fixture.value, context, @@ -216,31 +223,43 @@ function resolveFixtureValue( ) } - if (fixture.prop in fileContext) { - return fileContext[fixture.prop] + // in case the test runs in parallel + if (globalFixturePromise.has(fixture)) { + return globalFixturePromise.get(fixture)! } - // in case the test runs in parallel - if (fileFixturePromise.has(fixture)) { - return fileFixturePromise.get(fixture)! + let fixtureContext: Record + + if (fixture.scope === 'worker') { + if (!workerContext) { + throw new TypeError('[@vitets/runner] The worker context is not available by the current test runner. Please, provide the `getWorkerContext` method when initiating the runner.') + } + fixtureContext = workerContext + } + else { + fixtureContext = fileContext + } + + if (fixture.prop in fixtureContext) { + return fixtureContext[fixture.prop] } - if (!cleanupFnArrayMap.has(fileContext)) { - cleanupFnArrayMap.set(fileContext, []) + if (!cleanupFnArrayMap.has(fixtureContext)) { + cleanupFnArrayMap.set(fixtureContext, []) } - const cleanupFnFileArray = cleanupFnArrayMap.get(fileContext)! + const cleanupFnFileArray = cleanupFnArrayMap.get(fixtureContext)! const promise = resolveFixtureFunction( fixture.value, - fileContext, + fixtureContext, cleanupFnFileArray, ).then((value) => { - fileContext[fixture.prop] = value - fileFixturePromise.delete(fixture) + fixtureContext[fixture.prop] = value + globalFixturePromise.delete(fixture) return value }) - fileFixturePromise.set(fixture, promise) + globalFixturePromise.set(fixture, promise) return promise } diff --git a/packages/runner/src/hooks.ts b/packages/runner/src/hooks.ts index 9740240906fc..59de863c2e80 100644 --- a/packages/runner/src/hooks.ts +++ b/packages/runner/src/hooks.ts @@ -128,11 +128,12 @@ export function beforeEach( ): void { assertTypes(fn, '"beforeEach" callback', ['function']) const stackTraceError = new Error('STACK_TRACE_ERROR') + const runner = getRunner() return getCurrentSuite().on( 'beforeEach', Object.assign( withTimeout( - withFixtures(fn), + withFixtures(runner, fn), timeout ?? getDefaultHookTimeout(), true, stackTraceError, @@ -167,10 +168,11 @@ export function afterEach( timeout?: number, ): void { assertTypes(fn, '"afterEach" callback', ['function']) + const runner = getRunner() return getCurrentSuite().on( 'afterEach', withTimeout( - withFixtures(fn), + withFixtures(runner, fn), timeout ?? getDefaultHookTimeout(), true, new Error('STACK_TRACE_ERROR'), diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index 2b53016623f0..c09ed8950f39 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -346,7 +346,7 @@ function createSuiteCollector( setFn( task, withTimeout( - withAwaitAsyncAssertions(withFixtures(handler, context), task), + withAwaitAsyncAssertions(withFixtures(runner, handler, context), task), timeout, ), ) diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index 81bd8d73b094..e98befa64268 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -4,7 +4,6 @@ import type { SequenceHooks, SequenceSetupFiles, Suite, - Task, TaskEventPack, TaskResultPack, Test, @@ -82,12 +81,12 @@ export interface VitestRunner { /** * Called before running a single test. Doesn't have "result" yet. */ - onBeforeRunTask?: (test: Task) => unknown + onBeforeRunTask?: (test: Test) => unknown /** * Called before actually running the test function. Already has "result" with "state" and "startTime". */ onBeforeTryTask?: ( - test: Task, + test: Test, options: { retry: number; repeats: number } ) => unknown /** @@ -97,12 +96,12 @@ export interface VitestRunner { /** * Called after result and state are set. */ - onAfterRunTask?: (test: Task) => unknown + onAfterRunTask?: (test: Test) => unknown /** * Called right after running the test function. Doesn't have new state yet. Will not be called, if the test function throws. */ onAfterTryTask?: ( - test: Task, + test: Test, options: { retry: number; repeats: number } ) => unknown @@ -124,7 +123,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. */ - runTask?: (test: Task) => Promise + runTask?: (test: Test) => Promise /** * Called, when a task is updated. The same as "onTaskUpdate" in a reporter, but this is running in the same thread as tests. @@ -163,6 +162,12 @@ export interface VitestRunner { */ pool?: string + /** + * Return the worker context for fixutres specified with `scope: 'worker'` + */ + getWorkerContext?: () => Record + onCleanupWorkerContext?: (cleanup: () => unknown) => void + /** @private */ _currentTaskStartTime?: number /** @private */ diff --git a/packages/vitest/src/runtime/runners/test.ts b/packages/vitest/src/runtime/runners/test.ts index d48adec04017..fdd4b6e6af9d 100644 --- a/packages/vitest/src/runtime/runners/test.ts +++ b/packages/vitest/src/runtime/runners/test.ts @@ -4,6 +4,7 @@ import type { File, Suite, Task, + Test, TestContext, VitestRunner, VitestRunnerImportSource, @@ -19,6 +20,9 @@ import { vi } from '../../integrations/vi' import { rpc } from '../rpc' import { getWorkerState } from '../utils' +// worker context is shared between all tests +const workerContext = Object.create(null) + export class VitestTestRunner implements VitestRunner { private snapshotClient = getSnapshotClient() private workerState = getWorkerState() @@ -47,6 +51,10 @@ export class VitestTestRunner implements VitestRunner { this.workerState.current = undefined } + getWorkerContext(): Record { + return workerContext + } + async onAfterRunSuite(suite: Suite): Promise { if (this.config.logHeapUsage && typeof process !== 'undefined') { suite.result!.heap = process.memoryUsage().heapUsed @@ -132,7 +140,7 @@ export class VitestTestRunner implements VitestRunner { ) } - onAfterTryTask(test: Task): void { + onAfterTryTask(test: Test): void { const { assertionCalls, expectedAssertionsNumber, @@ -140,8 +148,7 @@ export class VitestTestRunner implements VitestRunner { isExpectingAssertions, isExpectingAssertionsError, } - // @ts-expect-error _local is untyped - = 'context' in test && test.context._local + = test.context._local ? test.context.expect.getState() : getState((globalThis as any)[GLOBAL_EXPECT]) if ( diff --git a/packages/vitest/src/types/global.ts b/packages/vitest/src/types/global.ts index 1b039f61e0fa..91dfa6440960 100644 --- a/packages/vitest/src/types/global.ts +++ b/packages/vitest/src/types/global.ts @@ -107,6 +107,8 @@ declare module '@vitest/expect' { declare module '@vitest/runner' { interface TestContext { expect: ExpectStatic + /** @internal */ + _local: boolean } interface TaskMeta { From 3f5912b7582a8963b2bbcc290754df8c6a006953 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 5 May 2025 19:48:59 +0200 Subject: [PATCH 08/30] chore: types --- .../components/views/ViewReport.spec.ts | 3 + packages/vitest/src/typecheck/collect.ts | 1 + .../fixtures/custom-pool/pool/custom-pool.ts | 24 ++----- test/reporters/src/data.ts | 68 ++++++++----------- test/reporters/tests/junit.test.ts | 14 +--- 5 files changed, 41 insertions(+), 69 deletions(-) diff --git a/packages/ui/client/components/views/ViewReport.spec.ts b/packages/ui/client/components/views/ViewReport.spec.ts index 8c8c55b05d87..1137b6aa13be 100644 --- a/packages/ui/client/components/views/ViewReport.spec.ts +++ b/packages/ui/client/components/views/ViewReport.spec.ts @@ -56,6 +56,7 @@ const fileWithTextStacks: File = { tasks: [], projectName: '', file: null!, + context: {}, } fileWithTextStacks.file = fileWithTextStacks @@ -113,6 +114,7 @@ describe('ViewReport', () => { tasks: [], projectName: '', file: null!, + context: {}, } file.file = file const container = render(ViewReport, { @@ -171,6 +173,7 @@ describe('ViewReport', () => { tasks: [], projectName: '', file: null!, + context: {}, } file.file = file const container = render(ViewReport, { diff --git a/packages/vitest/src/typecheck/collect.ts b/packages/vitest/src/typecheck/collect.ts index 533d8ae50c4b..5946f332c6ac 100644 --- a/packages/vitest/src/typecheck/collect.ts +++ b/packages/vitest/src/typecheck/collect.ts @@ -67,6 +67,7 @@ export async function collectTests( projectName, meta: { typecheck: true }, file: null!, + context: {}, } file.file = file const definitions: LocalCallDefinition[] = [] diff --git a/test/cli/fixtures/custom-pool/pool/custom-pool.ts b/test/cli/fixtures/custom-pool/pool/custom-pool.ts index 391a4e4ac689..294f2ab714a7 100644 --- a/test/cli/fixtures/custom-pool/pool/custom-pool.ts +++ b/test/cli/fixtures/custom-pool/pool/custom-pool.ts @@ -7,8 +7,7 @@ import type { RunnerTask } from 'vitest' import type { ProcessPool, Vitest } from 'vitest/node' -import { createMethodsRPC } from 'vitest/node' -import { generateFileHash } from '@vitest/runner/utils' +import { createFileTask, generateFileHash } from '@vitest/runner/utils' import { normalize, relative } from 'pathe' export default (vitest: Vitest): ProcessPool => { @@ -24,22 +23,11 @@ export default (vitest: Vitest): ProcessPool => { for (const [project, file] of specs) { vitest.state.clearFiles(project) vitest.logger.console.warn('[pool] running tests for', project.name, 'in', normalize(file).toLowerCase().replace(normalize(process.cwd()).toLowerCase(), '')) - const path = relative(project.config.root, file) - const taskFile: RunnerTestFile = { - id: generateFileHash(path, project.config.name), - name: path, - mode: 'run', - meta: {}, - projectName: project.name, - filepath: file, - type: 'suite', - tasks: [], - result: { - state: 'pass', - }, - file: null!, - } - taskFile.file = taskFile + const taskFile = createFileTask( + file, + project.config.root, + project.name, + ) const taskTest: RunnerTestCase = { type: 'test', name: 'custom test', diff --git a/test/reporters/src/data.ts b/test/reporters/src/data.ts index b946f56fe464..7b80dd2aa676 100644 --- a/test/reporters/src/data.ts +++ b/test/reporters/src/data.ts @@ -1,21 +1,19 @@ -import type { ErrorWithDiff, File, Suite, Task } from 'vitest' +import type { ErrorWithDiff, Suite, Task } from 'vitest' +import { createFileTask } from '@vitest/runner/utils' -const file: File = { - id: '1223128da3', - name: 'test/core/test/basic.test.ts', - type: 'suite', - meta: {}, - mode: 'run', - filepath: '/vitest/test/core/test/basic.test.ts', - result: { state: 'fail', duration: 145.99284195899963 }, - tasks: [], - projectName: '', - file: null!, +const file = createFileTask( + '/vitest/test/core/test/basic.test.ts', + '/vitest/test/core/test', + '', +) +file.mode = 'run' +file.result = { + state: 'fail', + duration: 145.99284195899963, } -file.file = file const suite: Suite = { - id: '1223128da3_0', + id: `${file.id}_0`, type: 'suite', name: 'suite', mode: 'run', @@ -25,23 +23,15 @@ const suite: Suite = { tasks: [], } -const passedFile: File = { - id: '1223128da3', - name: 'basic.test.ts', - type: 'suite', - suite, - meta: {}, - mode: 'run', - filepath: '/vitest/test/core/test/basic.test.ts', - result: { state: 'pass', duration: 145.99284195899963 }, - tasks: [ - ], - projectName: '', - file: null!, -} -passedFile.file = passedFile +const passedFile = createFileTask( + '/vitest/test/core/test/basic.test.ts', + '/vitest/test/core/test', + '', +) +passedFile.mode = 'run' +passedFile.result = { state: 'pass', duration: 145.99284195899963 } passedFile.tasks.push({ - id: '1223128da3_0_0', + id: `${file.id}_1`, type: 'test', name: 'Math.sqrt()', mode: 'run', @@ -79,7 +69,7 @@ error.stack = 'AssertionError: expected 2.23606797749979 to equal 2\n' const tasks: Task[] = [ { - id: '1223128da3_0_0', + id: `${suite.id}_0`, type: 'test', name: 'Math.sqrt()', mode: 'run', @@ -100,7 +90,7 @@ const tasks: Task[] = [ context: null as any, }, { - id: '1223128da3_0_1', + id: `${suite.id}_1`, type: 'test', name: 'JSON', mode: 'run', @@ -113,7 +103,7 @@ const tasks: Task[] = [ context: null as any, }, { - id: '1223128da3_0_3', + id: `${suite.id}_3`, type: 'test', name: 'async with timeout', mode: 'skip', @@ -126,7 +116,7 @@ const tasks: Task[] = [ context: null as any, }, { - id: '1223128da3_0_4', + id: `${suite.id}_4`, type: 'test', name: 'timeout', mode: 'run', @@ -139,7 +129,7 @@ const tasks: Task[] = [ context: null as any, }, { - id: '1223128da3_0_5', + id: `${suite.id}_5`, type: 'test', name: 'callback setup success ', mode: 'run', @@ -152,7 +142,7 @@ const tasks: Task[] = [ context: null as any, }, { - id: '1223128da3_0_6', + id: `${suite.id}_6`, type: 'test', name: 'callback test success ', mode: 'run', @@ -165,7 +155,7 @@ const tasks: Task[] = [ context: null as any, }, { - id: '1223128da3_0_7', + id: `${suite.id}_7`, type: 'test', name: 'callback setup success done(false)', mode: 'run', @@ -178,7 +168,7 @@ const tasks: Task[] = [ context: null as any, }, { - id: '1223128da3_0_8', + id: `${suite.id}_8`, type: 'test', name: 'callback test success done(false)', mode: 'run', @@ -199,7 +189,7 @@ const tasks: Task[] = [ ], }, { - id: '1223128da3_0_9', + id: `${suite.id}_9`, type: 'test', name: 'todo test', mode: 'todo', diff --git a/test/reporters/tests/junit.test.ts b/test/reporters/tests/junit.test.ts index dd5310b055fe..0729187ef958 100644 --- a/test/reporters/tests/junit.test.ts +++ b/test/reporters/tests/junit.test.ts @@ -1,4 +1,5 @@ import type { File, Suite, Task, TaskResult } from 'vitest' +import { createFileTask } from '@vitest/runner/utils' import { resolve } from 'pathe' import { expect, test } from 'vitest' import { getDuration } from '../../../packages/vitest/src/node/reporters/junit' @@ -8,18 +9,7 @@ const root = resolve(__dirname, '../fixtures') test('calc the duration used by junit', () => { const result: TaskResult = { state: 'pass', duration: 0 } - const file: File = { - id: '1', - filepath: 'test.ts', - file: null!, - projectName: '', - type: 'suite', - tasks: [], - name: 'test.ts', - mode: 'run', - meta: {}, - } - file.file = file + const file: File = createFileTask('/test.ts', '/', 'test') const suite: Suite = { id: '1_0', type: 'suite', From 5fb4a2d95f1ef4bc577f9ac8d65dda758815174b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 6 May 2025 15:22:24 +0200 Subject: [PATCH 09/30] test: fix custom pool --- packages/browser/src/client/tester/runner.ts | 4 ++-- test/cli/fixtures/custom-pool/pool/custom-pool.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index a877aad7789f..2741ad41a84d 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -1,4 +1,4 @@ -import type { CancelReason, File, Suite, Task, TaskEventPack, TaskResultPack, VitestRunner } from '@vitest/runner' +import type { CancelReason, File, Suite, Task, TaskEventPack, TaskResultPack, Test, VitestRunner } from '@vitest/runner' import type { SerializedConfig, TestExecutionMethod, WorkerGlobalState } from 'vitest' import type { VitestExecutor } from 'vitest/execute' import type { VitestBrowserClientMocker } from './mocker' @@ -54,7 +54,7 @@ export function createBrowserRunner( await super.onBeforeTryTask?.(...args) } - onAfterRunTask = async (task: Task) => { + onAfterRunTask = async (task: Test) => { await super.onAfterRunTask?.(task) if (this.config.bail && task.result?.state === 'fail') { diff --git a/test/cli/fixtures/custom-pool/pool/custom-pool.ts b/test/cli/fixtures/custom-pool/pool/custom-pool.ts index 294f2ab714a7..d68efaa98af5 100644 --- a/test/cli/fixtures/custom-pool/pool/custom-pool.ts +++ b/test/cli/fixtures/custom-pool/pool/custom-pool.ts @@ -27,11 +27,14 @@ export default (vitest: Vitest): ProcessPool => { file, project.config.root, project.name, + 'custom' ) + taskFile.mode = 'run' + taskFile.result = { state: 'pass' } const taskTest: RunnerTestCase = { type: 'test', name: 'custom test', - id: 'custom-test', + id: `${taskFile.id}_0`, context: {} as any, suite: taskFile, mode: 'run', From fda4b2e4a1c7f9f26144cb46573ddb48ed635c94 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 6 May 2025 15:39:00 +0200 Subject: [PATCH 10/30] refactor: move context to weakmap --- packages/runner/src/context.ts | 15 +++++++++++++++ packages/runner/src/fixture.ts | 5 +++-- packages/runner/src/run.ts | 5 +++-- packages/runner/src/types/tasks.ts | 2 -- packages/runner/src/utils/collect.ts | 3 ++- .../ui/client/components/views/ViewReport.spec.ts | 3 --- packages/vitest/src/typecheck/collect.ts | 1 - 7 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/runner/src/context.ts b/packages/runner/src/context.ts index 781050945ff1..5661d37ae93d 100644 --- a/packages/runner/src/context.ts +++ b/packages/runner/src/context.ts @@ -1,6 +1,7 @@ import type { Awaitable } from '@vitest/utils' import type { VitestRunner } from './types/runner' import type { + File, RuntimeContext, SuiteCollector, Test, @@ -194,3 +195,17 @@ function makeTimeoutError(isHook: boolean, timeout: number, stackTraceError?: Er } return error } + +const fileContexts = new WeakMap>() + +export function getFileContext(file: File): Record { + const context = fileContexts.get(file) + if (!context) { + throw new Error(`Cannot find file context for ${file.name}`) + } + return context +} + +export function setFileContext(file: File, context: Record): void { + fileContexts.set(file, context) +} diff --git a/packages/runner/src/fixture.ts b/packages/runner/src/fixture.ts index d9ade056ac83..8a59e1e15be4 100644 --- a/packages/runner/src/fixture.ts +++ b/packages/runner/src/fixture.ts @@ -1,6 +1,7 @@ import type { VitestRunner } from './types' import type { FixtureOptions, TestContext } from './types/tasks' import { createDefer, isObject } from '@vitest/utils' +import { getFileContext } from './context' import { getTestFixture } from './map' export interface FixtureItem extends FixtureOptions { @@ -204,7 +205,7 @@ function resolveFixtureValue( context: TestContext & { [key: string]: any }, cleanupFnArray: (() => void | Promise)[], ) { - const fileContext = context.task.file.context + const fileContext = getFileContext(context.task.file) const workerContext = runner.getWorkerContext?.() if (!fixture.isFn) { @@ -232,7 +233,7 @@ function resolveFixtureValue( if (fixture.scope === 'worker') { if (!workerContext) { - throw new TypeError('[@vitets/runner] The worker context is not available by the current test runner. Please, provide the `getWorkerContext` method when initiating the runner.') + throw new TypeError('[@vitets/runner] The worker context is not available in the current test runner. Please, provide the `getWorkerContext` method when initiating the runner.') } fixtureContext = workerContext } diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index 12139e25f64d..602c620debb5 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -20,7 +20,7 @@ import type { import { shuffle } from '@vitest/utils' import { processError } from '@vitest/utils/error' import { collectTests } from './collect' -import { abortContextSignal } from './context' +import { abortContextSignal, getFileContext } from './context' import { PendingError, TestRunAbortError } from './errors' import { callFixtureCleanup } from './fixture' import { getBeforeHookCleanupCallback } from './hooks' @@ -538,7 +538,8 @@ export async function runSuite(suite: Suite, runner: VitestRunner): Promise } export interface Test extends TaskPopulated { diff --git a/packages/runner/src/utils/collect.ts b/packages/runner/src/utils/collect.ts index b7eec84d6ba0..3f7231a3a7f4 100644 --- a/packages/runner/src/utils/collect.ts +++ b/packages/runner/src/utils/collect.ts @@ -1,6 +1,7 @@ import type { File, Suite, TaskBase } from '../types/tasks' import { processError } from '@vitest/utils/error' import { relative } from 'pathe' +import { setFileContext } from '../context' /** * If any tasks been marked as `only`, mark all other tasks as `skip`. @@ -192,9 +193,9 @@ export function createFileTask( projectName, file: undefined!, pool, - context: Object.create(null), } file.file = file + setFileContext(file, Object.create(null)) return file } diff --git a/packages/ui/client/components/views/ViewReport.spec.ts b/packages/ui/client/components/views/ViewReport.spec.ts index 1137b6aa13be..8c8c55b05d87 100644 --- a/packages/ui/client/components/views/ViewReport.spec.ts +++ b/packages/ui/client/components/views/ViewReport.spec.ts @@ -56,7 +56,6 @@ const fileWithTextStacks: File = { tasks: [], projectName: '', file: null!, - context: {}, } fileWithTextStacks.file = fileWithTextStacks @@ -114,7 +113,6 @@ describe('ViewReport', () => { tasks: [], projectName: '', file: null!, - context: {}, } file.file = file const container = render(ViewReport, { @@ -173,7 +171,6 @@ describe('ViewReport', () => { tasks: [], projectName: '', file: null!, - context: {}, } file.file = file const container = render(ViewReport, { diff --git a/packages/vitest/src/typecheck/collect.ts b/packages/vitest/src/typecheck/collect.ts index 5946f332c6ac..533d8ae50c4b 100644 --- a/packages/vitest/src/typecheck/collect.ts +++ b/packages/vitest/src/typecheck/collect.ts @@ -67,7 +67,6 @@ export async function collectTests( projectName, meta: { typecheck: true }, file: null!, - context: {}, } file.file = file const definitions: LocalCallDefinition[] = [] From 792ba8686d887581f4130147a1e44068794f295b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 6 May 2025 16:52:28 +0200 Subject: [PATCH 11/30] chore: fix reporter output --- .../__snapshots__/reporters.spec.ts.snap | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/reporters/tests/__snapshots__/reporters.spec.ts.snap b/test/reporters/tests/__snapshots__/reporters.spec.ts.snap index c9965e6e9073..998fc0c125a8 100644 --- a/test/reporters/tests/__snapshots__/reporters.spec.ts.snap +++ b/test/reporters/tests/__snapshots__/reporters.spec.ts.snap @@ -993,7 +993,7 @@ exports[`json reporter with outputFile object in non-existing directory 2`] = ` exports[`tap reporter 1`] = ` "TAP version 13 1..1 -not ok 1 - test/core/test/basic.test.ts # time=145.99ms { +not ok 1 - basic.test.ts # time=145.99ms { 1..1 ok 1 - suite # time=1.90ms { 1..9 @@ -1022,7 +1022,7 @@ not ok 1 - test/core/test/basic.test.ts # time=145.99ms { exports[`tap-flat reporter 1`] = ` "TAP version 13 1..9 -not ok 1 - test/core/test/basic.test.ts > suite > Math.sqrt() # time=1.44ms +not ok 1 - basic.test.ts > suite > Math.sqrt() # time=1.44ms --- error: name: "AssertionError" @@ -1031,13 +1031,13 @@ not ok 1 - test/core/test/basic.test.ts > suite > Math.sqrt() # time=1.44ms actual: "2.23606797749979" expected: "2" ... -ok 2 - test/core/test/basic.test.ts > suite > JSON # time=1.02ms -ok 3 - test/core/test/basic.test.ts > suite > async with timeout # SKIP -ok 4 - test/core/test/basic.test.ts > suite > timeout # time=100.51ms -ok 5 - test/core/test/basic.test.ts > suite > callback setup success # time=20.18ms -ok 6 - test/core/test/basic.test.ts > suite > callback test success # time=0.33ms -ok 7 - test/core/test/basic.test.ts > suite > callback setup success done(false) # time=19.74ms -ok 8 - test/core/test/basic.test.ts > suite > callback test success done(false) # time=0.19ms -ok 9 - test/core/test/basic.test.ts > suite > todo test # TODO +ok 2 - basic.test.ts > suite > JSON # time=1.02ms +ok 3 - basic.test.ts > suite > async with timeout # SKIP +ok 4 - basic.test.ts > suite > timeout # time=100.51ms +ok 5 - basic.test.ts > suite > callback setup success # time=20.18ms +ok 6 - basic.test.ts > suite > callback test success # time=0.33ms +ok 7 - basic.test.ts > suite > callback setup success done(false) # time=19.74ms +ok 8 - basic.test.ts > suite > callback test success done(false) # time=0.19ms +ok 9 - basic.test.ts > suite > todo test # TODO " `; From 87829928556a0e5f1d7247022d3b48ea30d65120 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 7 May 2025 14:01:29 +0200 Subject: [PATCH 12/30] test: add initial tests --- packages/runner/src/types/tasks.ts | 4 +- test/cli/test/scoped-fixtures.test.ts | 452 ++++++++++++++++++++++++++ test/cli/vitest.config.ts | 1 + test/test-utils/index.ts | 37 ++- test/test-utils/typed-object.ts | 31 ++ 5 files changed, 517 insertions(+), 8 deletions(-) create mode 100644 test/cli/test/scoped-fixtures.test.ts create mode 100644 test/test-utils/typed-object.ts diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 14b164743243..9e9f71506772 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -488,9 +488,11 @@ export interface FixtureOptions { injected?: boolean /** * When should the fixture be set up. - * - **test**: fixture will be set up before ever test + * - **test**: fixture will be set up before every test * - **worker**: fixture will be set up once per worker * - **file**: fixture will be set up once per file + * + * **Warning:** The `vmThreads` and `vmForks` pools initiate worker fixtures once per test file. * @default 'test' */ scope?: 'test' | 'worker' | 'file' diff --git a/test/cli/test/scoped-fixtures.test.ts b/test/cli/test/scoped-fixtures.test.ts new file mode 100644 index 000000000000..0a798701e2dd --- /dev/null +++ b/test/cli/test/scoped-fixtures.test.ts @@ -0,0 +1,452 @@ +/// + +import type { TestAPI } from 'vitest' +import type { ViteUserConfig } from 'vitest/config' +import type { TestFsStructure } from '../../test-utils' +import { runInlineTests } from '../../test-utils' + +declare module 'vitest' { + interface TestContext { + file: string + } +} + +test('test fixture cannot import from file fixture', async () => { + const { stderr } = await runInlineTests({ + 'basic.test.ts': () => { + const extendedTest = it.extend<{ + file: string + local: string + }>({ + local: ({}, use) => use('local'), + file: [ + ({ local }, use) => use(local), + { scope: 'file' }, + ], + }) + + extendedTest('not working', ({ file: _file }) => {}) + }, + 'vitest.config.js': { test: { globals: true } }, + }) + expect(stderr).toContain('cannot use the test fixture "local" inside the file fixture "file"') +}) + +test('can import file fixture inside the local fixture', async () => { + const { stderr, fixtures, tests } = await runFixtureTests( + ({ log }) => it.extend<{ + file: string + local: string + }>({ + local: async ({ file }, use) => { + log('init local') + await use(file) + log('teardown local') + }, + file: [ + async ({}, use) => { + log('init file') + await use('file') + log('teardown file') + }, + { scope: 'file' }, + ], + }), + { + 'basic.test.ts': ({ extendedTest }) => { + extendedTest('test1', ({ local: _local }) => {}) + }, + }, + ) + + expect(stderr).toBe('') + expect(fixtures).toMatchInlineSnapshot(` + ">> fixture | init file | test1 + >> fixture | init local | test1 + >> fixture | teardown local | test1 + >> fixture | teardown file | test1" + `) + expect(tests).toMatchInlineSnapshot(`" ✓ basic.test.ts > test1