Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
5eaec14
feat(vitest): allow per-file and per-worker fixtures
sheremet-va Mar 19, 2025
5f468a3
chore: better grammar
sheremet-va Mar 19, 2025
3b6ad92
feat: draft implementation
sheremet-va Apr 3, 2025
000c501
Merge branch 'main' of github.com:vitest-dev/vitest into feat/per-sco…
sheremet-va Apr 3, 2025
988c409
chore: undo
sheremet-va Apr 3, 2025
9df74d4
chore: validation
sheremet-va Apr 3, 2025
a5f9723
fix: validate worker/file scopes
sheremet-va Apr 4, 2025
9ee54f3
chore: draft worker context
sheremet-va Apr 7, 2025
3f5912b
chore: types
sheremet-va May 5, 2025
b846969
Merge branch 'main' of github.com:vitest-dev/vitest into feat/per-sco…
sheremet-va May 6, 2025
5fb4a2d
test: fix custom pool
sheremet-va May 6, 2025
fda4b2e
refactor: move context to weakmap
sheremet-va May 6, 2025
792ba86
chore: fix reporter output
sheremet-va May 6, 2025
8782992
test: add initial tests
sheremet-va May 7, 2025
a3d6453
chore: delete
sheremet-va May 7, 2025
2cc9557
chore: try teardown
sheremet-va May 9, 2025
c0bd699
Merge branch 'main' of github.com:vitest-dev/vitest into feat/per-sco…
sheremet-va May 23, 2025
1ebb162
chore: typos
sheremet-va May 23, 2025
a710f2f
feat: use `teardown` from tinypool
sheremet-va May 23, 2025
3262507
chore: remove unused
sheremet-va May 26, 2025
f78f4a4
test: fix tests
sheremet-va May 26, 2025
8a2a41c
test: isolate false
sheremet-va May 26, 2025
f2189a7
fix: remove unused cleanup
sheremet-va May 26, 2025
b5dc60c
fix: support worker fixture in vmThreads
sheremet-va May 26, 2025
8b735ce
test: add browser tests
sheremet-va May 26, 2025
a80a737
chore: remove only
sheremet-va May 26, 2025
4f222d1
Merge branch 'main' of github.com:vitest-dev/vitest into feat/per-sco…
sheremet-va May 27, 2025
d1e67a4
chore: refactor ctx->vitest
sheremet-va May 27, 2025
2236579
fix(pool): close ports and channel using `channel.onClose`
AriPerkkio May 29, 2025
cc378e4
Merge branch 'main' of github.com:vitest-dev/vitest into feat/per-sco…
sheremet-va May 30, 2025
7a921c7
test: update tests
sheremet-va May 30, 2025
6846346
chore: use a stable sequencer
sheremet-va May 30, 2025
705b2c9
chore: update assertion for parallel tests
sheremet-va May 30, 2025
3b275b5
chore: cleanup
sheremet-va May 30, 2025
f903811
Merge branch 'main' of github.com:vitest-dev/vitest into feat/per-sco…
sheremet-va May 31, 2025
2bfa3ed
chore: review
sheremet-va May 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/guide/test-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,46 @@ describe('another type of schema', () => {
})
```

#### Per-Scope Context <Version>3.2.0</Version>

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 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.

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 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

To provide fixture types for all your custom contexts, you can pass the fixtures type as a generic.
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/client/public/esm-client-injector.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
wrapModule,
wrapDynamicImport: wrapModule,
moduleCache,
cleanups: [],
config: { __VITEST_CONFIG__ },
viteConfig: { __VITEST_VITE_CONFIG__ },
type: { __VITEST_TYPE__ },
Expand Down
8 changes: 4 additions & 4 deletions packages/browser/src/client/tester/runner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CancelReason, File, Suite, Task, TaskEventPack, TaskResultPack, TestAnnotation, VitestRunner } from '@vitest/runner'
import type { RunnerTestCase, SerializedConfig, TestExecutionMethod, WorkerGlobalState } from 'vitest'
import type { CancelReason, File, Suite, Task, TaskEventPack, TaskResultPack, Test, TestAnnotation, VitestRunner } from '@vitest/runner'
import type { SerializedConfig, TestExecutionMethod, WorkerGlobalState } from 'vitest'
import type { VitestExecutor } from 'vitest/execute'
import type { VitestBrowserClientMocker } from './mocker'
import { globalChannel, onCancel } from '@vitest/browser/client'
Expand Down Expand Up @@ -59,7 +59,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') {
Expand Down Expand Up @@ -146,7 +146,7 @@ export function createBrowserRunner(
return rpc().onCollected(this.method, files)
}

onTestAnnotate = (test: RunnerTestCase, annotation: TestAnnotation): Promise<TestAnnotation> => {
onTestAnnotate = (test: Test, annotation: TestAnnotation): Promise<TestAnnotation> => {
if (annotation.location) {
// the file should be the test file
// tests from other files are not supported
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/client/tester/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const state: WorkerGlobalState = {
throw new Error('Not called in the browser')
},
},
onCleanup: fn => getBrowserState().cleanups.push(fn),
moduleCache: getBrowserState().moduleCache,
rpc: null as any,
durations: {
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/client/tester/tester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ async function cleanup() {
await userEvent.cleanup()
.catch(error => unhandledError(error, 'Cleanup Error'))

await Promise.all(
getBrowserState().cleanups.map(fn => fn()),
).catch(error => unhandledError(error, 'Cleanup Error'))

// if isolation is disabled, Vitest reuses the same iframe and we
// don't need to switch the context back at all
if (contextSwitched) {
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/client/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export interface BrowserRunnerState {
method: 'run' | 'collect'
orchestrator?: IframeOrchestrator
commands: CommandsManager
cleanups: Array<() => unknown>
cdp?: {
on: (event: string, listener: (payload: any) => void) => void
once: (event: string, listener: (payload: any) => void) => void
Expand Down
15 changes: 15 additions & 0 deletions packages/runner/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Awaitable } from '@vitest/utils'
import type { VitestRunner } from './types/runner'
import type {
File,
RuntimeContext,
SuiteCollector,
Test,
Expand Down Expand Up @@ -271,6 +272,20 @@ function makeTimeoutError(isHook: boolean, timeout: number, stackTraceError?: Er
return error
}

const fileContexts = new WeakMap<File, Record<string, unknown>>()

export function getFileContext(file: File): Record<string, unknown> {
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<string, unknown>): void {
fileContexts.set(file, context)
}

const table: string[] = []
for (let i = 65; i < 91; i++) {
table.push(String.fromCharCode(i))
Expand Down
123 changes: 111 additions & 12 deletions packages/runner/src/fixture.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
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 {
prop: string
value: any
scope: 'test' | 'file' | 'worker'
/**
* Indicates whether the fixture is a function
*/
Expand Down Expand Up @@ -43,9 +46,9 @@ export function mergeScopedFixtures(
export function mergeContextFixtures<T extends { fixtures?: FixtureItem[] }>(
fixtures: Record<string, any>,
context: T,
inject: (key: string) => unknown,
runner: VitestRunner,
): T {
const fixtureOptionKeys = ['auto', 'injected']
const fixtureOptionKeys = ['auto', 'injected', 'scope']
const fixtureArray: FixtureItem[] = Object.entries(fixtures).map(
([prop, value]) => {
const fixtureItem = { value } as FixtureItem
Expand All @@ -60,10 +63,14 @@ export function mergeContextFixtures<T extends { fixtures?: FixtureItem[] }>(
Object.assign(fixtureItem, value[1])
const userValue = value[0]
fixtureItem.value = fixtureItem.injected
? (inject(prop) ?? userValue)
? (runner.injectValue?.(prop) ?? userValue)
: userValue
}

fixtureItem.scope = fixtureItem.scope || 'test'
if (fixtureItem.scope === 'worker' && !runner.getWorkerContext) {
fixtureItem.scope = 'file'
}
fixtureItem.prop = prop
fixtureItem.isFn = typeof fixtureItem.value === 'function'
return fixtureItem
Expand All @@ -86,6 +93,25 @@ export function mergeContextFixtures<T extends { fixtures?: FixtureItem[] }>(
({ 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) {
// 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 the ${dep.scope} fixture "${dep.prop}" inside the ${fixture.scope} fixture "${fixture.prop}"`)
})
}
}
})

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

const fixtureValueMaps = new Map<TestContext, Map<FixtureItem, any>>()
const cleanupFnArrayMap = new Map<
TestContext,
object,
Array<() => void | Promise<void>>
>()

export async function callFixtureCleanup(context: TestContext): Promise<void> {
export async function callFixtureCleanup(context: object): Promise<void> {
const cleanupFnArray = cleanupFnArrayMap.get(context) ?? []
for (const cleanup of cleanupFnArray.reverse()) {
await cleanup()
}
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
Expand Down Expand Up @@ -153,21 +179,94 @@ export function withFixtures(fn: Function, testContext?: TestContext) {
continue
}

const resolvedValue = fixture.isFn
? await resolveFixtureFunction(fixture.value, context, cleanupFnArray)
: fixture.value
const resolvedValue = await resolveFixtureValue(
runner,
fixture,
context!,
cleanupFnArray,
)
context![fixture.prop] = resolvedValue
fixtureValueMap.set(fixture, resolvedValue)
cleanupFnArray.unshift(() => {
fixtureValueMap.delete(fixture)
})

if (fixture.scope === 'test') {
cleanupFnArray.unshift(() => {
fixtureValueMap.delete(fixture)
})
}
}
}

return resolveFixtures().then(() => fn(context))
}
}

const globalFixturePromise = new WeakMap<FixtureItem, Promise<unknown>>()

function resolveFixtureValue(
runner: VitestRunner,
fixture: FixtureItem,
context: TestContext & { [key: string]: any },
cleanupFnArray: (() => void | Promise<void>)[],
) {
const fileContext = getFileContext(context.task.file)
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 === 'test') {
return resolveFixtureFunction(
fixture.value,
context,
cleanupFnArray,
)
}

// in case the test runs in parallel
if (globalFixturePromise.has(fixture)) {
return globalFixturePromise.get(fixture)!
}

let fixtureContext: Record<string, unknown>

if (fixture.scope === 'worker') {
if (!workerContext) {
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.')
}
fixtureContext = workerContext
}
else {
fixtureContext = fileContext
}

if (fixture.prop in fixtureContext) {
return fixtureContext[fixture.prop]
}

if (!cleanupFnArrayMap.has(fixtureContext)) {
cleanupFnArrayMap.set(fixtureContext, [])
}
const cleanupFnFileArray = cleanupFnArrayMap.get(fixtureContext)!

const promise = resolveFixtureFunction(
fixture.value,
fixtureContext,
cleanupFnFileArray,
).then((value) => {
fixtureContext[fixture.prop] = value
globalFixturePromise.delete(fixture)
return value
})

globalFixturePromise.set(fixture, promise)
return promise
}

async function resolveFixtureFunction(
fixtureFn: (
context: unknown,
Expand Down
6 changes: 4 additions & 2 deletions packages/runner/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,12 @@ export function beforeEach<ExtraContext = object>(
): void {
assertTypes(fn, '"beforeEach" callback', ['function'])
const stackTraceError = new Error('STACK_TRACE_ERROR')
const runner = getRunner()
return getCurrentSuite<ExtraContext>().on(
'beforeEach',
Object.assign(
withTimeout(
withFixtures(fn),
withFixtures(runner, fn),
timeout ?? getDefaultHookTimeout(),
true,
stackTraceError,
Expand Down Expand Up @@ -179,10 +180,11 @@ export function afterEach<ExtraContext = object>(
timeout?: number,
): void {
assertTypes(fn, '"afterEach" callback', ['function'])
const runner = getRunner()
return getCurrentSuite<ExtraContext>().on(
'afterEach',
withTimeout(
withFixtures(fn),
withFixtures(runner, fn),
timeout ?? getDefaultHookTimeout(),
true,
new Error('STACK_TRACE_ERROR'),
Expand Down
Loading