diff --git a/docs/config/index.md b/docs/config/index.md index 273a519b3017..f05589c5bc14 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -957,7 +957,30 @@ Multiple globalSetup files are possible. setup and teardown are executed sequent ::: ::: warning -Beware that the global setup is running in a different global scope, so your tests don't have access to variables defined here. Also, since Vitest 1.0.0-beta, global setup runs only if there is at least one running test. This means that global setup might start running during watch mode after test file is changed, for example (the test file will wait for global setup to finish before running). +Since Vitest 1.0.0-beta, global setup runs only if there is at least one running test. This means that global setup might start running during watch mode after test file is changed (the test file will wait for global setup to finish before running). + +Beware that the global setup is running in a different global scope, so your tests don't have access to variables defined here. Hovewer, since 1.0.0 you can pass down serializable data to tests via `provide` method: + +```ts +// globalSetup.js +export default function setup({ provide }) { + provide('wsPort', 3000) +} +// example.test.js +import { inject } from 'vitest' + +inject('wsPort') === 3000 +``` + +If you are using TypeScript, you can extend `ProvidedContext` type to have type safe access to `provide/inject` methods: + +```ts +declare module 'vitest' { + export interface ProvidedContext { + wsPort: number + } +} +``` ::: diff --git a/packages/browser/src/client/main.ts b/packages/browser/src/client/main.ts index 841858af8735..778cff644e97 100644 --- a/packages/browser/src/client/main.ts +++ b/packages/browser/src/client/main.ts @@ -120,6 +120,7 @@ ws.addEventListener('open', async () => { environment: 0, prepare: 0, }, + providedContext: await client.rpc.getProvidedContext(), } // @ts-expect-error mocking vitest apis globalThis.__vitest_mocker__ = new VitestBrowserClientMocker() diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 39a900b98e8c..a0bd2b9a0b20 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -131,6 +131,10 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, server?: Vit getCountOfFailedTests() { return ctx.state.getCountOfFailedTests() }, + // browser should have a separate RPC in the future, UI doesn't care for provided context + getProvidedContext() { + return 'ctx' in vitestOrWorkspace ? vitestOrWorkspace.getProvidedContext() : ({} as any) + }, }, { post: msg => ws.send(msg), diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index 0933448d2b14..e736c8ff53fe 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -1,6 +1,6 @@ import type { TransformResult } from 'vite' import type { CancelReason } from '@vitest/runner' -import type { AfterSuiteRunMeta, File, ModuleGraphData, Reporter, ResolvedConfig, SnapshotResult, TaskResultPack, UserConsoleLog } from '../types' +import type { AfterSuiteRunMeta, File, ModuleGraphData, ProvidedContext, Reporter, ResolvedConfig, SnapshotResult, TaskResultPack, UserConsoleLog } from '../types' export interface TransformResultWithSource extends TransformResult { source?: string @@ -30,6 +30,7 @@ export interface WebSocketHandlers { snapshotSaved(snapshot: SnapshotResult): void rerun(files: string[]): Promise updateSnapshot(file?: File): Promise + getProvidedContext(): ProvidedContext } export interface WebSocketEvents extends Pick { diff --git a/packages/vitest/src/index.ts b/packages/vitest/src/index.ts index b7425e092cdb..9f5668d16649 100644 --- a/packages/vitest/src/index.ts +++ b/packages/vitest/src/index.ts @@ -15,6 +15,7 @@ export { runOnce, isFirstRun } from './integrations/run-once' export * from './integrations/chai' export * from './integrations/vi' export * from './integrations/utils' +export { inject } from './integrations/inject' export type { SnapshotEnvironment } from '@vitest/snapshot/environment' export * from './types' diff --git a/packages/vitest/src/integrations/inject.ts b/packages/vitest/src/integrations/inject.ts new file mode 100644 index 000000000000..ac23c73181b7 --- /dev/null +++ b/packages/vitest/src/integrations/inject.ts @@ -0,0 +1,11 @@ +import type { ProvidedContext } from '../types/general' +import { getWorkerState } from '../utils/global' + +/** + * Gives access to injected context provided from the main thread. + * This usually returns a value provided by `globalSetup` or an external library. + */ +export function inject(key: T): ProvidedContext[T] { + const workerState = getWorkerState() + return workerState.providedContext[key] +} diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index fb4ce7a82f44..46af9c23fc5e 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -353,15 +353,6 @@ function createVitest(): VitestUtils { const workerState = getWorkerState() - if (!workerState) { - const errorMsg = 'Vitest failed to access its internal state.' - + '\n\nOne of the following is possible:' - + '\n- "vitest" is imported directly without running "vitest" command' - + '\n- "vitest" is imported inside "globalSetup" (to fix this, use "setupFiles" instead, because "globalSetup" runs in a different context)' - + '\n- Otherwise, it might be a Vitest bug. Please report it to https://github.com/vitest-dev/vitest/issues\n' - throw new Error(errorMsg) - } - const _timers = new FakeTimers({ global: globalThis, config: workerState.config.fakeTimers, @@ -379,8 +370,6 @@ function createVitest(): VitestUtils { const utils: VitestUtils = { useFakeTimers(config?: FakeTimerInstallOpts) { - const workerState = getWorkerState() - if (workerState.isChildProcess) { if (config?.toFake?.includes('nextTick') || workerState.config?.fakeTimers?.toFake?.includes('nextTick')) { throw new Error( @@ -587,8 +576,7 @@ function createVitest(): VitestUtils { }, resetModules() { - const state = getWorkerState() - resetModules(state.moduleCache) + resetModules(workerState.moduleCache) return utils }, @@ -597,17 +585,14 @@ function createVitest(): VitestUtils { }, setConfig(config: RuntimeConfig) { - const state = getWorkerState() if (!_config) - _config = { ...state.config } - Object.assign(state.config, config) + _config = { ...workerState.config } + Object.assign(workerState.config, config) }, resetConfig() { - if (_config) { - const state = getWorkerState() - Object.assign(state.config, _config) - } + if (_config) + Object.assign(workerState.config, _config) }, } diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index 8a5c4a47083a..8742526dd147 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -301,6 +301,12 @@ export function resolveConfig( ?? resolve(resolved.root, file), ), ) + resolved.globalSetup = toArray(resolved.globalSetup || []).map(file => + normalize( + resolveModule(file, { paths: [resolved.root] }) + ?? resolve(resolved.root, file), + ), + ) resolved.coverage.exclude.push(...resolved.setupFiles.map(file => `${resolved.coverage.allowExternal ? '**/' : ''}${relative(resolved.root, file)}`)) resolved.forceRerunTriggers = [ diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 0e445a7e99b6..3991b81f0bd2 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -438,9 +438,8 @@ export class Vitest { const coreProject = this.getCoreWorkspaceProject() if (!projects.has(coreProject)) projects.add(coreProject) - await Promise.all( - Array.from(projects).map(project => project.initializeGlobalSetup()), - ) + for await (const project of projects) + await project.initializeGlobalSetup() } async runFiles(paths: WorkspaceSpec[]) { @@ -750,22 +749,28 @@ export class Vitest { async close() { if (!this.closingPromise) { - const closePromises: unknown[] = this.projects.map(w => w.close().then(() => w.server = undefined as any)) - // close the core workspace server only once - // it's possible that it's not initialized at all because it's not running any tests - if (!this.projects.includes(this.coreWorkspaceProject)) - closePromises.push(this.coreWorkspaceProject.close().then(() => this.server = undefined as any)) - - if (this.pool) - closePromises.push(this.pool.close().then(() => this.pool = undefined)) - - closePromises.push(...this._onClose.map(fn => fn())) - - this.closingPromise = Promise.allSettled(closePromises).then((results) => { - results.filter(r => r.status === 'rejected').forEach((err) => { - this.logger.error('error during close', (err as PromiseRejectedResult).reason) + this.closingPromise = (async () => { + // do teardown before closing the server + for await (const project of [...this.projects].reverse()) + await project.teardownGlobalSetup() + + const closePromises: unknown[] = this.projects.map(w => w.close().then(() => w.server = undefined as any)) + // close the core workspace server only once + // it's possible that it's not initialized at all because it's not running any tests + if (!this.projects.includes(this.coreWorkspaceProject)) + closePromises.push(this.coreWorkspaceProject.close().then(() => this.server = undefined as any)) + + if (this.pool) + closePromises.push(this.pool.close().then(() => this.pool = undefined)) + + closePromises.push(...this._onClose.map(fn => fn())) + + return Promise.allSettled(closePromises).then((results) => { + results.filter(r => r.status === 'rejected').forEach((err) => { + this.logger.error('error during close', (err as PromiseRejectedResult).reason) + }) }) - }) + })() } return this.closingPromise } diff --git a/packages/vitest/src/node/globalSetup.ts b/packages/vitest/src/node/globalSetup.ts index 656e0db061f3..04d5b0d3e2b1 100644 --- a/packages/vitest/src/node/globalSetup.ts +++ b/packages/vitest/src/node/globalSetup.ts @@ -1,9 +1,16 @@ import { toArray } from '@vitest/utils' import type { ViteNodeRunner } from 'vite-node/client' +import type { ProvidedContext } from '../types/general' +import type { ResolvedConfig } from '../types/config' + +export interface GlobalSetupContext { + config: ResolvedConfig + provide(key: T, value: ProvidedContext[T]): void +} export interface GlobalSetupFile { file: string - setup?: () => Promise | void + setup?: (context: GlobalSetupContext) => Promise | void teardown?: Function } diff --git a/packages/vitest/src/node/index.ts b/packages/vitest/src/node/index.ts index faf1941661fd..236915743837 100644 --- a/packages/vitest/src/node/index.ts +++ b/packages/vitest/src/node/index.ts @@ -5,6 +5,7 @@ export { VitestPlugin } from './plugins' export { startVitest } from './cli-api' export { registerConsoleShortcuts } from './stdin' export type { WorkspaceSpec } from './pool' +export type { GlobalSetupContext } from './globalSetup' export type { TestSequencer, TestSequencerConstructor } from './sequencers/types' export { BaseSequencer } from './sequencers/BaseSequencer' diff --git a/packages/vitest/src/node/pools/child.ts b/packages/vitest/src/node/pools/child.ts index f1eddcd6dde5..67efd9cd9a52 100644 --- a/packages/vitest/src/node/pools/child.ts +++ b/packages/vitest/src/node/pools/child.ts @@ -104,6 +104,7 @@ export function createChildProcessPool(ctx: Vitest, { execArgv, env, forksPath } environment, workerId, projectName: project.getName(), + providedContext: project.getProvidedContext(), } try { await pool.run(data, { name, channel }) diff --git a/packages/vitest/src/node/pools/threads.ts b/packages/vitest/src/node/pools/threads.ts index 7b1b8040f54a..38588fd17090 100644 --- a/packages/vitest/src/node/pools/threads.ts +++ b/packages/vitest/src/node/pools/threads.ts @@ -92,6 +92,7 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env, workerPath }: Po environment, workerId, projectName: project.getName(), + providedContext: project.getProvidedContext(), } try { await pool.run(data, { transferList: [workerPort], name }) diff --git a/packages/vitest/src/node/pools/vm-threads.ts b/packages/vitest/src/node/pools/vm-threads.ts index 5c8a298d4931..3f2d659dfa4b 100644 --- a/packages/vitest/src/node/pools/vm-threads.ts +++ b/packages/vitest/src/node/pools/vm-threads.ts @@ -97,6 +97,7 @@ export function createVmThreadsPool(ctx: Vitest, { execArgv, env, vmPath }: Pool environment, workerId, projectName: project.getName(), + providedContext: project.getProvidedContext(), } try { await pool.run(data, { transferList: [workerPort], name }) diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index 177eafbf1e5c..614d84611981 100644 --- a/packages/vitest/src/node/workspace.ts +++ b/packages/vitest/src/node/workspace.ts @@ -8,7 +8,7 @@ import { ViteNodeServer } from 'vite-node/server' import c from 'picocolors' import type { RawSourceMap } from 'vite-node' import { createBrowserServer } from '../integrations/browser/server' -import type { ArgumentsType, Reporter, ResolvedConfig, UserConfig, UserWorkspaceConfig, Vitest } from '../types' +import type { ArgumentsType, ProvidedContext, Reporter, ResolvedConfig, UserConfig, UserWorkspaceConfig, Vitest } from '../types' import { deepMerge } from '../utils' import type { Typechecker } from '../typecheck/typechecker' import type { BrowserProvider } from '../types/browser' @@ -69,8 +69,8 @@ export class WorkspaceProject { testFilesList: string[] = [] - private _globalSetupInit = false - private _globalSetups: GlobalSetupFile[] = [] + private _globalSetups: GlobalSetupFile[] | undefined + private _provided: ProvidedContext = {} as any constructor( public path: string | number, @@ -85,17 +85,38 @@ export class WorkspaceProject { return this.ctx.getCoreWorkspaceProject() === this } + provide = (key: string, value: unknown) => { + try { + structuredClone(value) + } + catch (err) { + throw new Error(`Cannot provide "${key}" because it's not serializable.`, { + cause: err, + }) + } + (this._provided as any)[key] = value + } + + getProvidedContext(): ProvidedContext { + if (this.isCore()) + return this._provided + // globalSetup can run even if core workspace is not part of the test run + // so we need to inherit its provided context + return { + ...this.ctx.getCoreWorkspaceProject().getProvidedContext(), + ...this._provided, + } + } + async initializeGlobalSetup() { - if (this._globalSetupInit) + if (this._globalSetups) return - this._globalSetupInit = true - this._globalSetups = await loadGlobalSetupFiles(this.runner, this.config.globalSetup) try { for (const globalSetupFile of this._globalSetups) { - const teardown = await globalSetupFile.setup?.() + const teardown = await globalSetupFile.setup?.({ provide: this.provide, config: this.config }) if (teardown == null || !!globalSetupFile.teardown) continue if (typeof teardown !== 'function') @@ -111,7 +132,7 @@ export class WorkspaceProject { } async teardownGlobalSetup() { - if (!this._globalSetupInit || !this._globalSetups.length) + if (!this._globalSetups) return for (const globalSetupFile of [...this._globalSetups].reverse()) { try { @@ -338,7 +359,7 @@ export class WorkspaceProject { this.server.close(), this.typechecker?.stop(), this.browser?.close(), - this.teardownGlobalSetup(), + () => this._provided = ({} as any), ].filter(Boolean)) } return this.closingPromise diff --git a/packages/vitest/src/runtime/child.ts b/packages/vitest/src/runtime/child.ts index 78cb84707b8d..70e1c1ed312b 100644 --- a/packages/vitest/src/runtime/child.ts +++ b/packages/vitest/src/runtime/child.ts @@ -14,7 +14,7 @@ import { createSafeRpc, rpcDone } from './rpc' import { setupInspect } from './inspector' async function init(ctx: ChildContext) { - const { config, workerId } = ctx + const { config, workerId, providedContext } = ctx process.env.VITEST_WORKER_ID = String(workerId) process.env.VITEST_POOL_ID = String(poolId) @@ -72,6 +72,7 @@ async function init(ctx: ChildContext) { prepare: performance.now(), }, rpc, + providedContext, isChildProcess: true, } diff --git a/packages/vitest/src/runtime/execute.ts b/packages/vitest/src/runtime/execute.ts index d4085e2e2b1f..fbbbf6771649 100644 --- a/packages/vitest/src/runtime/execute.ts +++ b/packages/vitest/src/runtime/execute.ts @@ -8,7 +8,6 @@ import { processError } from '@vitest/utils/error' import type { MockMap } from '../types/mocker' import type { ResolvedConfig, ResolvedTestEnvironment, RuntimeRPC, WorkerGlobalState } from '../types' import { distDir } from '../paths' -import { getWorkerState } from '../utils/global' import { VitestMocker } from './mocker' import { ExternalModulesExecutor } from './external-executor' import { FileMap } from './vm/file-map' @@ -64,7 +63,8 @@ export interface ContextExecutorOptions { } export async function startVitestExecutor(options: ContextExecutorOptions) { - const state = () => getWorkerState() || options.state + // @ts-expect-error injected untyped global + const state = () => globalThis.__vitest_worker__ || options.state const rpc = () => state().rpc const processExit = process.exit @@ -203,7 +203,8 @@ export class VitestExecutor extends ViteNodeRunner { } get state() { - return getWorkerState() || this.options.state + // @ts-expect-error injected untyped global + return globalThis.__vitest_worker__ || this.options.state } shouldResolveId(id: string, _importee?: string | undefined): boolean { diff --git a/packages/vitest/src/runtime/vm.ts b/packages/vitest/src/runtime/vm.ts index 15d30c982164..0214196cf7e4 100644 --- a/packages/vitest/src/runtime/vm.ts +++ b/packages/vitest/src/runtime/vm.ts @@ -19,7 +19,7 @@ const entryFile = pathToFileURL(resolve(distDir, 'entry-vm.js')).href export async function run(ctx: WorkerContext) { const moduleCache = new ModuleCacheMap() const mockMap = new Map() - const { config, port } = ctx + const { config, port, providedContext } = ctx let setCancel = (_reason: CancelReason) => {} const onCancel = new Promise((resolve) => { @@ -64,6 +64,7 @@ export async function run(ctx: WorkerContext) { prepare: performance.now(), }, rpc, + providedContext, } installSourcemapsSupport({ diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index 93f82ebfd776..a8d274ae0d60 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -17,7 +17,7 @@ async function init(ctx: WorkerContext) { if (isInitialized && isIsolatedThreads) throw new Error(`worker for ${ctx.files.join(',')} already initialized by ${getWorkerState().ctx.files.join(',')}. This is probably an internal bug of Vitest.`) - const { config, port, workerId } = ctx + const { config, port, workerId, providedContext } = ctx process.env.VITEST_WORKER_ID = String(workerId) process.env.VITEST_POOL_ID = String(poolId) @@ -58,6 +58,7 @@ async function init(ctx: WorkerContext) { prepare: performance.now(), }, rpc, + providedContext, } Object.defineProperty(globalThis, '__vitest_worker__', { diff --git a/packages/vitest/src/types/general.ts b/packages/vitest/src/types/general.ts index 776ac609adeb..dd62a7d18f5c 100644 --- a/packages/vitest/src/types/general.ts +++ b/packages/vitest/src/types/general.ts @@ -48,3 +48,5 @@ export interface ModuleGraphData { } export type OnServerRestartHandler = (reason?: string) => Promise | void + +export interface ProvidedContext {} diff --git a/packages/vitest/src/types/rpc.ts b/packages/vitest/src/types/rpc.ts index faad4cfe5107..d050f3928363 100644 --- a/packages/vitest/src/types/rpc.ts +++ b/packages/vitest/src/types/rpc.ts @@ -51,4 +51,5 @@ export interface ContextRPC { files: string[] invalidates?: string[] environment: ContextTestEnvironment + providedContext: Record } diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index 23e88a5eb21f..71ee864b68aa 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -33,6 +33,7 @@ export interface WorkerGlobalState { onCancel: Promise moduleCache: ModuleCacheMap mockMap: MockMap + providedContext: Record durations: { environment: number prepare: number diff --git a/packages/vitest/src/utils/global.ts b/packages/vitest/src/utils/global.ts index ea7398b6eb1e..260a9751fc1b 100644 --- a/packages/vitest/src/utils/global.ts +++ b/packages/vitest/src/utils/global.ts @@ -2,7 +2,16 @@ import type { WorkerGlobalState } from '../types' export function getWorkerState(): WorkerGlobalState { // @ts-expect-error untyped global - return globalThis.__vitest_worker__ + const workerState = globalThis.__vitest_worker__ + if (!workerState) { + const errorMsg = 'Vitest failed to access its internal state.' + + '\n\nOne of the following is possible:' + + '\n- "vitest" is imported directly without running "vitest" command' + + '\n- "vitest" is imported inside "globalSetup" (to fix this, use "setupFiles" instead, because "globalSetup" runs in a different context)' + + '\n- Otherwise, it might be a Vitest bug. Please report it to https://github.com/vitest-dev/vitest/issues\n' + throw new Error(errorMsg) + } + return workerState } export function getCurrentEnvironment(): string { diff --git a/test/workspaces/globalTest.ts b/test/workspaces/globalTest.ts index dccb99c98c08..f42631c2a19e 100644 --- a/test/workspaces/globalTest.ts +++ b/test/workspaces/globalTest.ts @@ -1,14 +1,37 @@ import { readFile } from 'node:fs/promises' import assert from 'node:assert/strict' +import type { GlobalSetupContext } from 'vitest/node' + +declare module 'vitest' { + interface ProvidedContext { + globalSetup: boolean + globalSetupOverriden: boolean + invalidValue: unknown + } +} + +export function setup({ provide }: GlobalSetupContext) { + provide('globalSetup', true) + provide('globalSetupOverriden', false) + try { + provide('invalidValue', () => {}) + throw new Error('Should throw') + } + catch (err: any) { + assert.equal(err.message, 'Cannot provide "invalidValue" because it\'s not serializable.') + assert.match(err.cause.message, /could not be cloned/) + assert.equal(err.cause.name, 'DataCloneError') + } +} export async function teardown() { const results = JSON.parse(await readFile('./results.json', 'utf-8')) try { assert.ok(results.success) - assert.equal(results.numTotalTestSuites, 9) - assert.equal(results.numTotalTests, 10) - assert.equal(results.numPassedTests, 10) + assert.equal(results.numTotalTestSuites, 11) + assert.equal(results.numTotalTests, 12) + assert.equal(results.numPassedTests, 12) const shared = results.testResults.filter((r: any) => r.name.includes('space_shared/test.spec.ts')) diff --git a/test/workspaces/space_3/global-provide.space-3-test.ts b/test/workspaces/space_3/global-provide.space-3-test.ts new file mode 100644 index 000000000000..4cf6b70dd48c --- /dev/null +++ b/test/workspaces/space_3/global-provide.space-3-test.ts @@ -0,0 +1,7 @@ +import { expect, inject, test } from 'vitest' + +test('global setup provides data correctly', () => { + expect(inject('globalSetup')).toBe(true) + expect(inject('globalSetupOverriden')).toBe(true) + expect(inject('invalidValue')).toBe(undefined) +}) diff --git a/test/workspaces/space_3/localSetup.ts b/test/workspaces/space_3/localSetup.ts new file mode 100644 index 000000000000..1e7eec44ca70 --- /dev/null +++ b/test/workspaces/space_3/localSetup.ts @@ -0,0 +1,5 @@ +import type { GlobalSetupContext } from 'vitest/node' + +export function setup({ provide }: GlobalSetupContext) { + provide('globalSetupOverriden', true) +} diff --git a/test/workspaces/space_3/vitest.config.ts b/test/workspaces/space_3/vitest.config.ts index 1270e126a7c1..55bdced7da5d 100644 --- a/test/workspaces/space_3/vitest.config.ts +++ b/test/workspaces/space_3/vitest.config.ts @@ -5,5 +5,6 @@ export default defineProject({ include: ['**/*.space-3-test.ts'], name: 'space_3', environment: 'node', + globalSetup: './localSetup.ts', }, })