Skip to content

Commit 9cbfc23

Browse files
feat(vitest): allow per-file and per-worker fixtures (#7704)
Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>
1 parent 407c0e4 commit 9cbfc23

File tree

31 files changed

+1182
-197
lines changed

31 files changed

+1182
-197
lines changed

docs/guide/test-context.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,46 @@ describe('another type of schema', () => {
373373
})
374374
```
375375
376+
#### Per-Scope Context <Version>3.2.0</Version>
377+
378+
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:
379+
380+
```ts
381+
import { test as baseTest } from 'vitest'
382+
383+
export const test = baseTest.extend({
384+
perFile: [
385+
({}, { use }) => use([]),
386+
{ scope: 'file' },
387+
],
388+
perWorker: [
389+
({}, { use }) => use([]),
390+
{ scope: 'worker' },
391+
],
392+
})
393+
```
394+
395+
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.
396+
397+
```ts
398+
const test = baseTest.extend({
399+
perFile: [
400+
({}, { use }) => use([]),
401+
{
402+
scope: 'file',
403+
// always run this hook before any test
404+
auto: true
405+
},
406+
],
407+
})
408+
```
409+
410+
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.
411+
412+
However, if you disable [isolation](/config/#isolate), then the number of workers is limited by the [`maxWorkers`](/config/#maxworkers) or [`poolOptions`](/config/#pooloptions) configuration.
413+
414+
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).
415+
376416
#### TypeScript
377417
378418
To provide fixture types for all your custom contexts, you can pass the fixtures type as a generic.

packages/browser/src/client/public/esm-client-injector.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
wrapModule,
2121
wrapDynamicImport: wrapModule,
2222
moduleCache,
23+
cleanups: [],
2324
config: { __VITEST_CONFIG__ },
2425
viteConfig: { __VITEST_VITE_CONFIG__ },
2526
type: { __VITEST_TYPE__ },

packages/browser/src/client/tester/runner.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { CancelReason, File, Suite, Task, TaskEventPack, TaskResultPack, TestAnnotation, VitestRunner } from '@vitest/runner'
2-
import type { RunnerTestCase, SerializedConfig, TestExecutionMethod, WorkerGlobalState } from 'vitest'
1+
import type { CancelReason, File, Suite, Task, TaskEventPack, TaskResultPack, Test, TestAnnotation, VitestRunner } from '@vitest/runner'
2+
import type { SerializedConfig, TestExecutionMethod, WorkerGlobalState } from 'vitest'
33
import type { VitestExecutor } from 'vitest/execute'
44
import type { VitestBrowserClientMocker } from './mocker'
55
import { globalChannel, onCancel } from '@vitest/browser/client'
@@ -59,7 +59,7 @@ export function createBrowserRunner(
5959
await super.onBeforeTryTask?.(...args)
6060
}
6161

62-
onAfterRunTask = async (task: Task) => {
62+
onAfterRunTask = async (task: Test) => {
6363
await super.onAfterRunTask?.(task)
6464

6565
if (this.config.bail && task.result?.state === 'fail') {
@@ -146,7 +146,7 @@ export function createBrowserRunner(
146146
return rpc().onCollected(this.method, files)
147147
}
148148

149-
onTestAnnotate = (test: RunnerTestCase, annotation: TestAnnotation): Promise<TestAnnotation> => {
149+
onTestAnnotate = (test: Test, annotation: TestAnnotation): Promise<TestAnnotation> => {
150150
if (annotation.location) {
151151
// the file should be the test file
152152
// tests from other files are not supported

packages/browser/src/client/tester/state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const state: WorkerGlobalState = {
3030
throw new Error('Not called in the browser')
3131
},
3232
},
33+
onCleanup: fn => getBrowserState().cleanups.push(fn),
3334
moduleCache: getBrowserState().moduleCache,
3435
rpc: null as any,
3536
durations: {

packages/browser/src/client/tester/tester.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,10 @@ async function cleanup() {
234234
await userEvent.cleanup()
235235
.catch(error => unhandledError(error, 'Cleanup Error'))
236236

237+
await Promise.all(
238+
getBrowserState().cleanups.map(fn => fn()),
239+
).catch(error => unhandledError(error, 'Cleanup Error'))
240+
237241
// if isolation is disabled, Vitest reuses the same iframe and we
238242
// don't need to switch the context back at all
239243
if (contextSwitched) {

packages/browser/src/client/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export interface BrowserRunnerState {
8181
method: 'run' | 'collect'
8282
orchestrator?: IframeOrchestrator
8383
commands: CommandsManager
84+
cleanups: Array<() => unknown>
8485
cdp?: {
8586
on: (event: string, listener: (payload: any) => void) => void
8687
once: (event: string, listener: (payload: any) => void) => void

packages/runner/src/context.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Awaitable } from '@vitest/utils'
22
import type { VitestRunner } from './types/runner'
33
import type {
4+
File,
45
RuntimeContext,
56
SuiteCollector,
67
Test,
@@ -271,6 +272,20 @@ function makeTimeoutError(isHook: boolean, timeout: number, stackTraceError?: Er
271272
return error
272273
}
273274

275+
const fileContexts = new WeakMap<File, Record<string, unknown>>()
276+
277+
export function getFileContext(file: File): Record<string, unknown> {
278+
const context = fileContexts.get(file)
279+
if (!context) {
280+
throw new Error(`Cannot find file context for ${file.name}`)
281+
}
282+
return context
283+
}
284+
285+
export function setFileContext(file: File, context: Record<string, unknown>): void {
286+
fileContexts.set(file, context)
287+
}
288+
274289
const table: string[] = []
275290
for (let i = 65; i < 91; i++) {
276291
table.push(String.fromCharCode(i))

packages/runner/src/fixture.ts

Lines changed: 111 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import type { VitestRunner } from './types'
12
import type { FixtureOptions, TestContext } from './types/tasks'
23
import { createDefer, isObject } from '@vitest/utils'
4+
import { getFileContext } from './context'
35
import { getTestFixture } from './map'
46

57
export interface FixtureItem extends FixtureOptions {
68
prop: string
79
value: any
10+
scope: 'test' | 'file' | 'worker'
811
/**
912
* Indicates whether the fixture is a function
1013
*/
@@ -43,9 +46,9 @@ export function mergeScopedFixtures(
4346
export function mergeContextFixtures<T extends { fixtures?: FixtureItem[] }>(
4447
fixtures: Record<string, any>,
4548
context: T,
46-
inject: (key: string) => unknown,
49+
runner: VitestRunner,
4750
): T {
48-
const fixtureOptionKeys = ['auto', 'injected']
51+
const fixtureOptionKeys = ['auto', 'injected', 'scope']
4952
const fixtureArray: FixtureItem[] = Object.entries(fixtures).map(
5053
([prop, value]) => {
5154
const fixtureItem = { value } as FixtureItem
@@ -60,10 +63,14 @@ export function mergeContextFixtures<T extends { fixtures?: FixtureItem[] }>(
6063
Object.assign(fixtureItem, value[1])
6164
const userValue = value[0]
6265
fixtureItem.value = fixtureItem.injected
63-
? (inject(prop) ?? userValue)
66+
? (runner.injectValue?.(prop) ?? userValue)
6467
: userValue
6568
}
6669

70+
fixtureItem.scope = fixtureItem.scope || 'test'
71+
if (fixtureItem.scope === 'worker' && !runner.getWorkerContext) {
72+
fixtureItem.scope = 'file'
73+
}
6774
fixtureItem.prop = prop
6875
fixtureItem.isFn = typeof fixtureItem.value === 'function'
6976
return fixtureItem
@@ -86,6 +93,25 @@ export function mergeContextFixtures<T extends { fixtures?: FixtureItem[] }>(
8693
({ prop }) => prop !== fixture.prop && usedProps.includes(prop),
8794
)
8895
}
96+
// test can access anything, so we ignore it
97+
if (fixture.scope !== 'test') {
98+
fixture.deps?.forEach((dep) => {
99+
if (!dep.isFn) {
100+
// non fn fixtures are always resolved and available to anyone
101+
return
102+
}
103+
// worker scope can only import from worker scope
104+
if (fixture.scope === 'worker' && dep.scope === 'worker') {
105+
return
106+
}
107+
// file scope an import from file and worker scopes
108+
if (fixture.scope === 'file' && dep.scope !== 'test') {
109+
return
110+
}
111+
112+
throw new SyntaxError(`cannot use the ${dep.scope} fixture "${dep.prop}" inside the ${fixture.scope} fixture "${fixture.prop}"`)
113+
})
114+
}
89115
}
90116
})
91117

@@ -94,19 +120,19 @@ export function mergeContextFixtures<T extends { fixtures?: FixtureItem[] }>(
94120

95121
const fixtureValueMaps = new Map<TestContext, Map<FixtureItem, any>>()
96122
const cleanupFnArrayMap = new Map<
97-
TestContext,
123+
object,
98124
Array<() => void | Promise<void>>
99125
>()
100126

101-
export async function callFixtureCleanup(context: TestContext): Promise<void> {
127+
export async function callFixtureCleanup(context: object): Promise<void> {
102128
const cleanupFnArray = cleanupFnArrayMap.get(context) ?? []
103129
for (const cleanup of cleanupFnArray.reverse()) {
104130
await cleanup()
105131
}
106132
cleanupFnArrayMap.delete(context)
107133
}
108134

109-
export function withFixtures(fn: Function, testContext?: TestContext) {
135+
export function withFixtures(runner: VitestRunner, fn: Function, testContext?: TestContext) {
110136
return (hookContext?: TestContext): any => {
111137
const context: (TestContext & { [key: string]: any }) | undefined
112138
= hookContext || testContext
@@ -153,21 +179,94 @@ export function withFixtures(fn: Function, testContext?: TestContext) {
153179
continue
154180
}
155181

156-
const resolvedValue = fixture.isFn
157-
? await resolveFixtureFunction(fixture.value, context, cleanupFnArray)
158-
: fixture.value
182+
const resolvedValue = await resolveFixtureValue(
183+
runner,
184+
fixture,
185+
context!,
186+
cleanupFnArray,
187+
)
159188
context![fixture.prop] = resolvedValue
160189
fixtureValueMap.set(fixture, resolvedValue)
161-
cleanupFnArray.unshift(() => {
162-
fixtureValueMap.delete(fixture)
163-
})
190+
191+
if (fixture.scope === 'test') {
192+
cleanupFnArray.unshift(() => {
193+
fixtureValueMap.delete(fixture)
194+
})
195+
}
164196
}
165197
}
166198

167199
return resolveFixtures().then(() => fn(context))
168200
}
169201
}
170202

203+
const globalFixturePromise = new WeakMap<FixtureItem, Promise<unknown>>()
204+
205+
function resolveFixtureValue(
206+
runner: VitestRunner,
207+
fixture: FixtureItem,
208+
context: TestContext & { [key: string]: any },
209+
cleanupFnArray: (() => void | Promise<void>)[],
210+
) {
211+
const fileContext = getFileContext(context.task.file)
212+
const workerContext = runner.getWorkerContext?.()
213+
214+
if (!fixture.isFn) {
215+
fileContext[fixture.prop] ??= fixture.value
216+
if (workerContext) {
217+
workerContext[fixture.prop] ??= fixture.value
218+
}
219+
return fixture.value
220+
}
221+
222+
if (fixture.scope === 'test') {
223+
return resolveFixtureFunction(
224+
fixture.value,
225+
context,
226+
cleanupFnArray,
227+
)
228+
}
229+
230+
// in case the test runs in parallel
231+
if (globalFixturePromise.has(fixture)) {
232+
return globalFixturePromise.get(fixture)!
233+
}
234+
235+
let fixtureContext: Record<string, unknown>
236+
237+
if (fixture.scope === 'worker') {
238+
if (!workerContext) {
239+
throw new TypeError('[@vitest/runner] The worker context is not available in the current test runner. Please, provide the `getWorkerContext` method when initiating the runner.')
240+
}
241+
fixtureContext = workerContext
242+
}
243+
else {
244+
fixtureContext = fileContext
245+
}
246+
247+
if (fixture.prop in fixtureContext) {
248+
return fixtureContext[fixture.prop]
249+
}
250+
251+
if (!cleanupFnArrayMap.has(fixtureContext)) {
252+
cleanupFnArrayMap.set(fixtureContext, [])
253+
}
254+
const cleanupFnFileArray = cleanupFnArrayMap.get(fixtureContext)!
255+
256+
const promise = resolveFixtureFunction(
257+
fixture.value,
258+
fixtureContext,
259+
cleanupFnFileArray,
260+
).then((value) => {
261+
fixtureContext[fixture.prop] = value
262+
globalFixturePromise.delete(fixture)
263+
return value
264+
})
265+
266+
globalFixturePromise.set(fixture, promise)
267+
return promise
268+
}
269+
171270
async function resolveFixtureFunction(
172271
fixtureFn: (
173272
context: unknown,

packages/runner/src/hooks.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,12 @@ export function beforeEach<ExtraContext = object>(
139139
): void {
140140
assertTypes(fn, '"beforeEach" callback', ['function'])
141141
const stackTraceError = new Error('STACK_TRACE_ERROR')
142+
const runner = getRunner()
142143
return getCurrentSuite<ExtraContext>().on(
143144
'beforeEach',
144145
Object.assign(
145146
withTimeout(
146-
withFixtures(fn),
147+
withFixtures(runner, fn),
147148
timeout ?? getDefaultHookTimeout(),
148149
true,
149150
stackTraceError,
@@ -179,10 +180,11 @@ export function afterEach<ExtraContext = object>(
179180
timeout?: number,
180181
): void {
181182
assertTypes(fn, '"afterEach" callback', ['function'])
183+
const runner = getRunner()
182184
return getCurrentSuite<ExtraContext>().on(
183185
'afterEach',
184186
withTimeout(
185-
withFixtures(fn),
187+
withFixtures(runner, fn),
186188
timeout ?? getDefaultHookTimeout(),
187189
true,
188190
new Error('STACK_TRACE_ERROR'),

0 commit comments

Comments
 (0)