From c9078a26e3b5fe38acdc59d7ba282eaa5740b6ef Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 3 Nov 2025 17:20:06 +0100 Subject: [PATCH] fix: create environment once per worker with `isolate: false` (#8915) --- packages/browser/src/client/client.ts | 13 +-- packages/browser/src/client/tester/runner.ts | 2 +- packages/browser/src/client/tester/state.ts | 1 + packages/browser/src/client/tester/tester.ts | 2 +- .../components/dashboard/ErrorEntry.vue | 11 --- packages/ui/client/shim.d.ts | 1 - packages/vitest/src/integrations/env/jsdom.ts | 1 + .../vitest/src/integrations/env/loader.ts | 7 +- packages/vitest/src/node/pool.ts | 5 +- packages/vitest/src/node/pools/pool.ts | 4 +- packages/vitest/src/node/pools/poolRunner.ts | 20 ++++- packages/vitest/src/node/pools/types.ts | 24 ++++-- .../src/node/pools/workers/forksWorker.ts | 10 +-- packages/vitest/src/node/printError.ts | 11 --- .../src/runtime/moduleRunner/errorCatcher.ts | 1 - packages/vitest/src/runtime/rpc.ts | 31 +++----- packages/vitest/src/runtime/runBaseTests.ts | 79 ++++++++----------- packages/vitest/src/runtime/runVmTests.ts | 2 +- packages/vitest/src/runtime/setup-node.ts | 42 ++-------- packages/vitest/src/runtime/worker.ts | 24 +++--- packages/vitest/src/runtime/workers/base.ts | 49 +++++++++--- packages/vitest/src/runtime/workers/forks.ts | 4 +- .../vitest/src/runtime/workers/init-forks.ts | 4 +- .../src/runtime/workers/init-threads.ts | 4 +- packages/vitest/src/runtime/workers/init.ts | 42 ++++++++-- .../vitest/src/runtime/workers/threads.ts | 4 +- packages/vitest/src/runtime/workers/types.ts | 4 +- packages/vitest/src/runtime/workers/vm.ts | 10 ++- packages/vitest/src/types/worker.ts | 28 ++++++- .../cli/test/__snapshots__/fails.test.ts.snap | 5 +- test/cli/test/shared-env.test.ts | 30 +++++++ 31 files changed, 264 insertions(+), 211 deletions(-) create mode 100644 test/cli/test/shared-env.test.ts diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index 9e5b72905..84e6a260e 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -20,10 +20,11 @@ export const ENTRY_URL: string = `${ location.protocol === 'https:' ? 'wss:' : 'ws:' }//${HOST}/__vitest_browser_api__?type=${PAGE_TYPE}&rpcId=${RPC_ID}&sessionId=${getBrowserState().sessionId}&projectName=${getBrowserState().config.name || ''}&method=${METHOD}&token=${(window as any).VITEST_API_TOKEN || '0'}` -let setCancel = (_: CancelReason) => {} -export const onCancel: Promise = new Promise((resolve) => { - setCancel = resolve -}) +const onCancelCallbacks: ((reason: CancelReason) => void)[] = [] + +export function onCancel(callback: (reason: CancelReason) => void): void { + onCancelCallbacks.push(callback) +} export interface VitestBrowserClient { rpc: BrowserRPC @@ -74,7 +75,9 @@ function createClient() { ctx.rpc = createBirpc( { - onCancel: setCancel, + async onCancel(reason) { + await Promise.all(onCancelCallbacks.map(fn => fn(reason))) + }, async createTesters(options) { const orchestrator = await waitForOrchestrator() return orchestrator.createTesters(options) diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 38ad26326..61bf65848 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -312,7 +312,7 @@ export async function initiateRunner( }) cachedRunner = runner - onCancel.then((reason) => { + onCancel((reason) => { runner.cancel?.(reason) }) diff --git a/packages/browser/src/client/tester/state.ts b/packages/browser/src/client/tester/state.ts index 7f02ed92e..66a0e1850 100644 --- a/packages/browser/src/client/tester/state.ts +++ b/packages/browser/src/client/tester/state.ts @@ -8,6 +8,7 @@ const sessionId = getBrowserState().sessionId const state: WorkerGlobalState = { ctx: { + rpc: null as any, pool: 'browser', workerId: 1, config, diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index 858e7b68f..55840f64b 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -103,6 +103,7 @@ async function prepareTestEnvironment(options: PrepareOptions) { state.metaEnv = import.meta.env state.onCancel = onCancel + state.ctx.rpc = rpc as any state.rpc = rpc as any const interceptor = createModuleMockerInterceptor() @@ -253,7 +254,6 @@ async function cleanup() { await rpc.wdioSwitchContext('parent') .catch(error => unhandledError(error, 'Cleanup Error')) } - state.environmentTeardownRun = true await stopCoverageInsideWorker(config.coverage, moduleRunner, { isolate: config.browser.isolate }).catch((error) => { return unhandledError(error, 'Coverage Error') }) diff --git a/packages/ui/client/components/dashboard/ErrorEntry.vue b/packages/ui/client/components/dashboard/ErrorEntry.vue index e814ca6e3..a593e55ca 100644 --- a/packages/ui/client/components/dashboard/ErrorEntry.vue +++ b/packages/ui/client/components/dashboard/ErrorEntry.vue @@ -30,15 +30,4 @@ defineProps<{ -
- This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes:
-
    -
  • - Cancel timeouts using clearTimeout and clearInterval. -
  • -
  • - Wait for promises to resolve using the await keyword. -
  • -
-
diff --git a/packages/ui/client/shim.d.ts b/packages/ui/client/shim.d.ts index d067630d9..10becd1f2 100644 --- a/packages/ui/client/shim.d.ts +++ b/packages/ui/client/shim.d.ts @@ -9,6 +9,5 @@ declare interface Window { declare interface Error { VITEST_TEST_NAME?: string - VITEST_AFTER_ENV_TEARDOWN?: boolean VITEST_TEST_PATH?: string } diff --git a/packages/vitest/src/integrations/env/jsdom.ts b/packages/vitest/src/integrations/env/jsdom.ts index 71c348a6e..e87912c33 100644 --- a/packages/vitest/src/integrations/env/jsdom.ts +++ b/packages/vitest/src/integrations/env/jsdom.ts @@ -7,6 +7,7 @@ function catchWindowErrors(window: DOMWindow) { let userErrorListenerCount = 0 function throwUnhandlerError(e: ErrorEvent) { if (userErrorListenerCount === 0 && e.error != null) { + e.preventDefault() process.emit('uncaughtException', e.error) } } diff --git a/packages/vitest/src/integrations/env/loader.ts b/packages/vitest/src/integrations/env/loader.ts index 6f174cf7c..05b1205b1 100644 --- a/packages/vitest/src/integrations/env/loader.ts +++ b/packages/vitest/src/integrations/env/loader.ts @@ -1,6 +1,6 @@ import type { BuiltinEnvironment, VitestEnvironment } from '../../node/types/config' import type { Environment } from '../../types/environment' -import type { ContextRPC, WorkerRPC } from '../../types/worker' +import type { WorkerRPC } from '../../types/worker' import { readFileSync } from 'node:fs' import { isBuiltin } from 'node:module' import { pathToFileURL } from 'node:url' @@ -54,14 +54,13 @@ export async function createEnvironmentLoader(root: string, rpc: WorkerRPC): Pro } export async function loadEnvironment( - ctx: ContextRPC, + name: string, + root: string, rpc: WorkerRPC, ): Promise<{ environment: Environment; loader?: ModuleRunner }> { - const name = ctx.environment.name if (isBuiltinEnvironment(name)) { return { environment: environments[name] } } - const root = ctx.config.root const loader = await createEnvironmentLoader(root, rpc) const packageId = name[0] === '.' || name[0] === '/' diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index cf32212f6..4a881cb51 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -147,15 +147,12 @@ export function createPool(ctx: Vitest): ProcessPool { taskGroup.push({ context: { - pool, - config: project.serializedConfig, files: specs.map(spec => ({ filepath: spec.moduleId, testLocations: spec.testLines })), invalidates, - environment, - projectName: project.name, providedContext: project.getProvidedContext(), workerId: workerId++, }, + environment, project, env, execArgv, diff --git a/packages/vitest/src/node/pools/pool.ts b/packages/vitest/src/node/pools/pool.ts index 6024f6f8c..a1f8c6ede 100644 --- a/packages/vitest/src/node/pools/pool.ts +++ b/packages/vitest/src/node/pools/pool.ts @@ -208,7 +208,7 @@ export class Pool { distPath: this.options.distPath, project: task.project, method, - environment: task.context.environment.name, + environment: task.environment, env: task.env, execArgv: task.execArgv, } @@ -280,7 +280,7 @@ function isEqualRunner(runner: PoolRunner, task: PoolTask) { return ( runner.worker.name === task.worker && runner.project === task.project - && runner.environment === task.context.environment.name + && runner.environment.name === task.environment.name && (!runner.worker.canReuse || runner.worker.canReuse(task)) ) } diff --git a/packages/vitest/src/node/pools/poolRunner.ts b/packages/vitest/src/node/pools/poolRunner.ts index 617cffe3c..8a40ed9ef 100644 --- a/packages/vitest/src/node/pools/poolRunner.ts +++ b/packages/vitest/src/node/pools/poolRunner.ts @@ -1,6 +1,7 @@ import type { DeferPromise } from '@vitest/utils/helpers' import type { BirpcReturn } from 'birpc' import type { RunnerRPC, RuntimeRPC } from '../../types/rpc' +import type { ContextTestEnvironment } from '../../types/worker' import type { TestProject } from '../project' import type { PoolOptions, PoolWorker, WorkerRequest, WorkerResponse } from './types' import { EventEmitter } from 'node:events' @@ -25,7 +26,7 @@ export class PoolRunner { public poolId: number | undefined = undefined public readonly project: TestProject - public readonly environment: string + public readonly environment: ContextTestEnvironment private _state: RunnerState = RunnerState.IDLE private _operationLock: DeferPromise | null = null @@ -109,6 +110,14 @@ export class PoolRunner { options: { reportMemory: this.worker.reportMemory ?? false, }, + context: { + environment: { + name: this.environment.name, + options: this.environment.options, + }, + config: this.project.serializedConfig, + pool: this.worker.name, + }, }) await startPromise @@ -232,11 +241,16 @@ export class PoolRunner { } private waitForStart() { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const onStart = (message: WorkerResponse) => { if (message.type === 'started') { this.off('message', onStart) - resolve() + if (message.error) { + reject(message.error) + } + else { + resolve() + } } } diff --git a/packages/vitest/src/node/pools/types.ts b/packages/vitest/src/node/pools/types.ts index c32269e39..edb524f36 100644 --- a/packages/vitest/src/node/pools/types.ts +++ b/packages/vitest/src/node/pools/types.ts @@ -1,5 +1,6 @@ -import type { ContextRPC } from '../../types/worker' +import type { ContextTestEnvironment, WorkerExecuteContext, WorkerTestEnvironment } from '../../types/worker' import type { TestProject } from '../project' +import type { SerializedConfig } from '../types/config' export interface PoolRunnerInitializer { readonly name: string @@ -11,7 +12,7 @@ export interface PoolOptions { project: TestProject method: 'run' | 'collect' cacheFs?: boolean - environment: string + environment: ContextTestEnvironment execArgv: string[] env: Partial } @@ -51,22 +52,31 @@ export interface PoolTask { * so modifying it once will modify it for every task. */ execArgv: string[] - context: ContextRPC + context: WorkerExecuteContext + environment: ContextTestEnvironment memoryLimit: number | null } export type WorkerRequest = { __vitest_worker_request__: true } & ( - | { type: 'start'; options: { reportMemory: boolean } } + | { + type: 'start' + options: { reportMemory: boolean } + context: { + environment: WorkerTestEnvironment + config: SerializedConfig + pool: string + } + } | { type: 'stop' } - | { type: 'run'; context: ContextRPC; poolId: number } - | { type: 'collect'; context: ContextRPC; poolId: number } + | { type: 'run'; context: WorkerExecuteContext; poolId: number } + | { type: 'collect'; context: WorkerExecuteContext; poolId: number } | { type: 'cancel' } ) export type WorkerResponse = { __vitest_worker_response__: true } & ( - | { type: 'started' } + | { type: 'started'; error?: unknown } | { type: 'stopped'; error?: unknown } | { type: 'testfileFinished'; usedMemory?: number; error?: unknown } ) diff --git a/packages/vitest/src/node/pools/workers/forksWorker.ts b/packages/vitest/src/node/pools/workers/forksWorker.ts index 5fe464610..39b69784a 100644 --- a/packages/vitest/src/node/pools/workers/forksWorker.ts +++ b/packages/vitest/src/node/pools/workers/forksWorker.ts @@ -40,14 +40,8 @@ export class ForksPoolWorker implements PoolWorker { } send(message: WorkerRequest): void { - if ('context' in message) { - message = { - ...message, - context: { - ...message.context, - config: wrapSerializableConfig(message.context.config), - }, - } + if ('context' in message && 'config' in message.context) { + message.context.config = wrapSerializableConfig(message.context.config) } this.fork.send(v8.serialize(message)) diff --git a/packages/vitest/src/node/printError.ts b/packages/vitest/src/node/printError.ts index 4d829eeab..4454769d8 100644 --- a/packages/vitest/src/node/printError.ts +++ b/packages/vitest/src/node/printError.ts @@ -219,7 +219,6 @@ function printErrorInner( const testPath = (e as any).VITEST_TEST_PATH const testName = (e as any).VITEST_TEST_NAME - const afterEnvTeardown = (e as any).VITEST_AFTER_ENV_TEARDOWN // testName has testPath inside if (testPath) { logger.error( @@ -241,15 +240,6 @@ function printErrorInner( ), ) } - if (afterEnvTeardown) { - logger.error( - c.red( - 'This error was caught after test environment was torn down. Make sure to cancel any running tasks before test finishes:' - + '\n- cancel timeouts using clearTimeout and clearInterval' - + '\n- wait for promises to resolve using the await keyword', - ), - ) - } if (typeof e.cause === 'object' && e.cause && 'name' in e.cause) { (e.cause as any).name = `Caused by: ${(e.cause as any).name}` @@ -292,7 +282,6 @@ const skipErrorProperties = new Set([ 'columnNumber', 'VITEST_TEST_NAME', 'VITEST_TEST_PATH', - 'VITEST_AFTER_ENV_TEARDOWN', '__vitest_rollup_error__', ...Object.getOwnPropertyNames(Error.prototype), ...Object.getOwnPropertyNames(Object.prototype), diff --git a/packages/vitest/src/runtime/moduleRunner/errorCatcher.ts b/packages/vitest/src/runtime/moduleRunner/errorCatcher.ts index 956805169..2b9e906cd 100644 --- a/packages/vitest/src/runtime/moduleRunner/errorCatcher.ts +++ b/packages/vitest/src/runtime/moduleRunner/errorCatcher.ts @@ -29,7 +29,6 @@ export function listenForErrors(state: () => WorkerGlobalState): void { if (worker.filepath) { error.VITEST_TEST_PATH = worker.filepath } - error.VITEST_AFTER_ENV_TEARDOWN = worker.environmentTeardownRun } state().rpc.onUnhandledError(error, type) } diff --git a/packages/vitest/src/runtime/rpc.ts b/packages/vitest/src/runtime/rpc.ts index deb83aff9..206272023 100644 --- a/packages/vitest/src/runtime/rpc.ts +++ b/packages/vitest/src/runtime/rpc.ts @@ -61,28 +61,24 @@ export async function rpcDone(): Promise { return Promise.all(awaitable) } -let previousRpc: undefined | WorkerRPC +const onCancelCallbacks: ((reason: CancelReason) => void)[] = [] + +export function onCancel(callback: (reason: CancelReason) => void): void { + onCancelCallbacks.push(callback) +} export function createRuntimeRpc( options: Pick< BirpcOptions, 'on' | 'post' | 'serialize' | 'deserialize' >, -): { rpc: WorkerRPC; onCancel: Promise } { - if (previousRpc) { - previousRpc.$close() - previousRpc = undefined - } - - let setCancel = (_reason: CancelReason) => {} - const onCancel = new Promise((resolve) => { - setCancel = resolve - }) - - const rpc = createSafeRpc( +): WorkerRPC { + return createSafeRpc( createBirpc( { - onCancel: setCancel, + async onCancel(reason) { + await Promise.all(onCancelCallbacks.map(fn => fn(reason))) + }, }, { eventNames: [ @@ -95,13 +91,6 @@ export function createRuntimeRpc( }, ), ) - - previousRpc = rpc - - return { - rpc, - onCancel, - } } export function createSafeRpc(rpc: WorkerRPC): WorkerRPC { diff --git a/packages/vitest/src/runtime/runBaseTests.ts b/packages/vitest/src/runtime/runBaseTests.ts index ef8df5735..ba4b184b6 100644 --- a/packages/vitest/src/runtime/runBaseTests.ts +++ b/packages/vitest/src/runtime/runBaseTests.ts @@ -1,18 +1,18 @@ import type { FileSpecification } from '@vitest/runner' -import type { ResolvedTestEnvironment } from '../types/environment' +import type { Environment } from '../types/environment' import type { SerializedConfig } from './config' import type { VitestModuleRunner } from './moduleRunner/moduleRunner' import { performance } from 'node:perf_hooks' import { collectTests, startTests } from '@vitest/runner' -import { setupChaiConfig } from '../integrations/chai/config' import { startCoverageInsideWorker, stopCoverageInsideWorker, } from '../integrations/coverage' +import { resolveSnapshotEnvironment } from '../integrations/snapshot/environments/resolveSnapshotEnvironment' import { vi } from '../integrations/vi' import { closeInspector } from './inspector' import { resolveTestRunner } from './runners' -import { setupGlobalEnv, withEnv } from './setup-node' +import { setupGlobalEnv } from './setup-node' import { getWorkerState, resetModules } from './utils' // browser shouldn't call this! @@ -20,59 +20,50 @@ export async function run( method: 'run' | 'collect', files: FileSpecification[], config: SerializedConfig, - environment: ResolvedTestEnvironment, moduleRunner: VitestModuleRunner, + environment: Environment, ): Promise { const workerState = getWorkerState() - await setupGlobalEnv(config, environment, moduleRunner) - await startCoverageInsideWorker(config.coverage, moduleRunner, { isolate: config.isolate }) - - if (config.chaiConfig) { - setupChaiConfig(config.chaiConfig) - } - - const runner = await resolveTestRunner(config, moduleRunner) + const [testRunner] = await Promise.all([ + resolveTestRunner(config, moduleRunner), + setupGlobalEnv(config, environment), + startCoverageInsideWorker(config.coverage, moduleRunner, { isolate: config.isolate }), + (async () => { + if (!workerState.config.snapshotOptions.snapshotEnvironment) { + workerState.config.snapshotOptions.snapshotEnvironment + = await resolveSnapshotEnvironment(config, moduleRunner) + } + })(), + ]) - workerState.onCancel.then((reason) => { + workerState.onCancel((reason) => { closeInspector(config) - runner.cancel?.(reason) + testRunner.cancel?.(reason) }) workerState.durations.prepare = performance.now() - workerState.durations.prepare - workerState.durations.environment = performance.now() - await withEnv( - environment, - environment.options || config.environmentOptions || {}, - async () => { - workerState.durations.environment - = performance.now() - workerState.durations.environment + for (const file of files) { + if (config.isolate) { + moduleRunner.mocker.reset() + resetModules(workerState.evaluatedModules, true) + } - for (const file of files) { - if (config.isolate) { - moduleRunner.mocker.reset() - resetModules(workerState.evaluatedModules, true) - } + workerState.filepath = file.filepath - workerState.filepath = file.filepath + if (method === 'run') { + await startTests([file], testRunner) + } + else { + await collectTests([file], testRunner) + } - if (method === 'run') { - await startTests([file], runner) - } - else { - await collectTests([file], runner) - } - - // reset after tests, because user might call `vi.setConfig` in setupFile - vi.resetConfig() - // mocks should not affect different files - vi.restoreAllMocks() - } - - await stopCoverageInsideWorker(config.coverage, moduleRunner, { isolate: config.isolate }) - }, - ) + // reset after tests, because user might call `vi.setConfig` in setupFile + vi.resetConfig() + // mocks should not affect different files + vi.restoreAllMocks() + } - workerState.environmentTeardownRun = true + await stopCoverageInsideWorker(config.coverage, moduleRunner, { isolate: config.isolate }) } diff --git a/packages/vitest/src/runtime/runVmTests.ts b/packages/vitest/src/runtime/runVmTests.ts index 9c28292d5..c091bb499 100644 --- a/packages/vitest/src/runtime/runVmTests.ts +++ b/packages/vitest/src/runtime/runVmTests.ts @@ -78,7 +78,7 @@ export async function run( runner.getWorkerContext = undefined - workerState.onCancel.then((reason) => { + workerState.onCancel((reason) => { closeInspector(config) runner.cancel?.(reason) }) diff --git a/packages/vitest/src/runtime/setup-node.ts b/packages/vitest/src/runtime/setup-node.ts index dc8af4d44..a7ecf2161 100644 --- a/packages/vitest/src/runtime/setup-node.ts +++ b/packages/vitest/src/runtime/setup-node.ts @@ -1,24 +1,18 @@ -import type { ResolvedTestEnvironment } from '../types/environment' +import type { Environment } from '../types/environment' import type { SerializedConfig } from './config' -import type { VitestModuleRunner } from './moduleRunner/moduleRunner' import { createRequire } from 'node:module' import timers from 'node:timers' import timersPromises from 'node:timers/promises' import util from 'node:util' import { KNOWN_ASSET_TYPES } from '@vitest/utils/constants' -import { getSafeTimers } from '@vitest/utils/timers' -import { expect } from '../integrations/chai' -import { resolveSnapshotEnvironment } from '../integrations/snapshot/environments/resolveSnapshotEnvironment' import * as VitestIndex from '../public/index' import { setupCommonEnv } from './setup-common' -import { getWorkerState } from './utils' // this should only be used in Node let globalSetup = false export async function setupGlobalEnv( config: SerializedConfig, - { environment }: ResolvedTestEnvironment, - moduleRunner: VitestModuleRunner, + environment: Environment, ): Promise { await setupCommonEnv(config) @@ -27,12 +21,9 @@ export async function setupGlobalEnv( enumerable: false, }) - const state = getWorkerState() - - if (!state.config.snapshotOptions.snapshotEnvironment) { - state.config.snapshotOptions.snapshotEnvironment - = await resolveSnapshotEnvironment(config, moduleRunner) - } + VitestIndex.expect.setState({ + environment: environment.name, + }) if (globalSetup) { return @@ -83,26 +74,3 @@ export async function setupConsoleLogSpy(): Promise { globalThis.console = createCustomConsole() } - -export async function withEnv( - { environment }: ResolvedTestEnvironment, - options: Record, - fn: () => Promise, -): Promise { - // @ts-expect-error untyped global - globalThis.__vitest_environment__ = environment.name - expect.setState({ - environment: environment.name, - }) - const env = await environment.setup(globalThis, options) - try { - await fn() - } - finally { - // Run possible setTimeouts, e.g. the onces used by ConsoleLogSpy - const { setTimeout } = getSafeTimers() - await new Promise(resolve => setTimeout(resolve)) - - await env.teardown(globalThis) - } -} diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index 62214e487..618e333d2 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -1,11 +1,9 @@ -import type { ModuleRunner } from 'vite/module-runner' import type { ContextRPC, WorkerGlobalState } from '../types/worker' import type { VitestWorker } from './workers/types' import { createStackString, parseStacktrace } from '@vitest/utils/source-map' -import { loadEnvironment } from '../integrations/env/loader' import { setupInspect } from './inspector' import { VitestEvaluatedModules } from './moduleRunner/evaluatedModules' -import { createRuntimeRpc, rpcDone } from './rpc' +import { onCancel, rpcDone } from './rpc' const resolvingModules = new Set() const globalListeners = new Set<() => unknown>() @@ -15,10 +13,8 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC, worker: Vites const cleanups: (() => void | Promise)[] = [setupInspect(ctx)] - let environmentLoader: ModuleRunner | undefined - // RPC is used to communicate between worker (be it a thread worker or child process or a custom implementation) and the main thread - const { rpc, onCancel } = createRuntimeRpc(worker) + const rpc = ctx.rpc try { // do not close the RPC channel so that we can get the error messages sent to the main thread @@ -28,10 +24,6 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC, worker: Vites })) }) - const beforeEnvironmentTime = performance.now() - const { environment, loader } = await loadEnvironment(ctx, rpc) - environmentLoader = loader - const state = { ctx, // here we create a new one, workers can reassign this if they need to keep it non-isolated @@ -39,13 +31,14 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC, worker: Vites resolvingModules, moduleExecutionInfo: new Map(), config: ctx.config, - onCancel, - environment, + // this is set later by vm or base + environment: null!, durations: { - environment: beforeEnvironmentTime, + environment: 0, prepare: prepareStart, }, rpc, + onCancel, onCleanup: listener => globalListeners.add(listener), providedContext: ctx.providedContext, onFilterStackTrace(stack) { @@ -67,7 +60,6 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC, worker: Vites finally { await rpcDone().catch(() => {}) await Promise.all(cleanups.map(fn => fn())).catch(() => {}) - await environmentLoader?.close() } } @@ -83,10 +75,12 @@ export async function teardown(): Promise { await Promise.all([...globalListeners].map(l => l())) } +const env = process.env + function createImportMetaEnvProxy(): WorkerGlobalState['metaEnv'] { // packages/vitest/src/node/plugins/index.ts:146 const booleanKeys = ['DEV', 'PROD', 'SSR'] - return new Proxy(process.env, { + return new Proxy(env, { get(_, key) { if (typeof key !== 'string') { return undefined diff --git a/packages/vitest/src/runtime/workers/base.ts b/packages/vitest/src/runtime/workers/base.ts index 01fb70dba..add80b543 100644 --- a/packages/vitest/src/runtime/workers/base.ts +++ b/packages/vitest/src/runtime/workers/base.ts @@ -1,8 +1,11 @@ -import type { WorkerGlobalState } from '../../types/worker' +import type { Environment } from '../../types/environment' +import type { WorkerGlobalState, WorkerSetupContext } from '../../types/worker' import type { VitestModuleRunner } from '../moduleRunner/moduleRunner' import type { ContextModuleRunnerOptions } from '../moduleRunner/startModuleRunner' import { runInThisContext } from 'node:vm' import * as spyModule from '@vitest/spy' +import { setupChaiConfig } from '../../integrations/chai/config' +import { loadEnvironment } from '../../integrations/env/loader' import { VitestEvaluatedModules } from '../moduleRunner/evaluatedModules' import { createNodeImportMeta } from '../moduleRunner/moduleRunner' import { startVitestModuleRunner } from '../moduleRunner/startModuleRunner' @@ -23,9 +26,38 @@ function startModuleRunner(options: ContextModuleRunnerOptions) { return _moduleRunner } +let _currentEnvironment!: Environment +let _environmentTime: number + +export async function setupEnvironment(context: WorkerSetupContext): Promise<() => Promise> { + const startTime = performance.now() + const { + environment: { name: environmentName, options: environmentOptions }, + rpc, + config, + } = context + + const { environment, loader } = await loadEnvironment(environmentName, config.root, rpc) + _currentEnvironment = environment + const env = await environment.setup(globalThis, environmentOptions || config.environmentOptions || {}) + + _environmentTime = performance.now() - startTime + + if (config.chaiConfig) { + setupChaiConfig(config.chaiConfig) + } + + return async () => { + await env.teardown(globalThis) + await loader?.close() + } +} + /** @experimental */ export async function runBaseTests(method: 'run' | 'collect', state: WorkerGlobalState): Promise { const { ctx } = state + state.environment = _currentEnvironment + state.durations.environment = _environmentTime // state has new context, but we want to reuse existing ones state.evaluatedModules = evaluatedModules state.moduleExecutionInfo = moduleExecutionInfo @@ -41,24 +73,19 @@ export async function runBaseTests(method: 'run' | 'collect', state: WorkerGloba }) } ctx.files.forEach((i) => { - const filepath = typeof i === 'string' ? i : i.filepath + const filepath = i.filepath const modules = state.evaluatedModules.fileToModulesMap.get(filepath) || [] modules.forEach((module) => { state.evaluatedModules.invalidateModule(module) }) }) - const executor = startModuleRunner({ + const moduleRunner = startModuleRunner({ state, evaluatedModules: state.evaluatedModules, spyModule, createImportMeta: createNodeImportMeta, }) - const fileSpecs = ctx.files.map(f => - typeof f === 'string' - ? { filepath: f, testLocations: undefined } - : f, - ) // we could load @vite/env, but it would take ~8ms, while this takes ~0,02ms if (ctx.config.serializedDefines) { try { @@ -74,9 +101,9 @@ export async function runBaseTests(method: 'run' | 'collect', state: WorkerGloba await run( method, - fileSpecs, + ctx.files, ctx.config, - { environment: state.environment, options: ctx.environment.options }, - executor, + moduleRunner, + _currentEnvironment, ) } diff --git a/packages/vitest/src/runtime/workers/forks.ts b/packages/vitest/src/runtime/workers/forks.ts index de912bfae..3cfb00b63 100644 --- a/packages/vitest/src/runtime/workers/forks.ts +++ b/packages/vitest/src/runtime/workers/forks.ts @@ -1,4 +1,4 @@ -import { runBaseTests } from './base' +import { runBaseTests, setupEnvironment } from './base' import workerInit from './init-forks' -workerInit({ runTests: runBaseTests }) +workerInit({ runTests: runBaseTests, setup: setupEnvironment }) diff --git a/packages/vitest/src/runtime/workers/init-forks.ts b/packages/vitest/src/runtime/workers/init-forks.ts index 4a8059b30..cac002a0b 100644 --- a/packages/vitest/src/runtime/workers/init-forks.ts +++ b/packages/vitest/src/runtime/workers/init-forks.ts @@ -1,5 +1,5 @@ import type { ResolvedConfig, SerializedConfig } from '../../node/types/config' -import type { WorkerGlobalState } from '../../types/worker' +import type { WorkerGlobalState, WorkerSetupContext } from '../../types/worker' import v8 from 'node:v8' import { init } from './init' @@ -29,6 +29,7 @@ if (isProfiling) { export default function workerInit(options: { runTests: (method: 'run' | 'collect', state: WorkerGlobalState) => Promise + setup?: (context: WorkerSetupContext) => Promise<() => Promise> }): void { const { runTests } = options @@ -41,6 +42,7 @@ export default function workerInit(options: { deserialize: v => v8.deserialize(Buffer.from(v)), runTests: state => executeTests('run', state), collectTests: state => executeTests('collect', state), + setup: options.setup, }) async function executeTests(method: 'run' | 'collect', state: WorkerGlobalState) { diff --git a/packages/vitest/src/runtime/workers/init-threads.ts b/packages/vitest/src/runtime/workers/init-threads.ts index 881b7684e..b8b2b4f81 100644 --- a/packages/vitest/src/runtime/workers/init-threads.ts +++ b/packages/vitest/src/runtime/workers/init-threads.ts @@ -1,4 +1,4 @@ -import type { WorkerGlobalState } from '../../types/worker' +import type { WorkerGlobalState, WorkerSetupContext } from '../../types/worker' import { isMainThread, parentPort } from 'node:worker_threads' import { init } from './init' @@ -8,6 +8,7 @@ if (isMainThread || !parentPort) { export default function workerInit(options: { runTests: (method: 'run' | 'collect', state: WorkerGlobalState) => Promise + setup?: (context: WorkerSetupContext) => Promise<() => Promise> }): void { const { runTests } = options @@ -18,5 +19,6 @@ export default function workerInit(options: { teardown: () => parentPort!.removeAllListeners('message'), runTests: async state => runTests('run', state), collectTests: async state => runTests('collect', state), + setup: options.setup, }) } diff --git a/packages/vitest/src/runtime/workers/init.ts b/packages/vitest/src/runtime/workers/init.ts index 537ec713a..ecee6942a 100644 --- a/packages/vitest/src/runtime/workers/init.ts +++ b/packages/vitest/src/runtime/workers/init.ts @@ -1,6 +1,8 @@ import type { WorkerRequest, WorkerResponse } from '../../node/pools/types' +import type { WorkerSetupContext } from '../../types/worker' import type { VitestWorker } from './types' import { serializeError } from '@vitest/utils/error' +import { createRuntimeRpc } from '../rpc' import * as entrypoint from '../worker' interface Options extends VitestWorker { @@ -17,6 +19,8 @@ export function init(worker: Options): void { let runPromise: Promise | undefined let isRunning = false + let workerTeardown: (() => Promise) | undefined + let setupContext!: WorkerSetupContext function send(response: WorkerResponse) { worker.post(worker.serialize ? worker.serialize(response) : response) @@ -34,7 +38,24 @@ export function init(worker: Options): void { switch (message.type) { case 'start': { reportMemory = message.options.reportMemory - send({ type: 'started', __vitest_worker_response__ }) + + const { environment, config, pool } = message.context + try { + const rpc = createRuntimeRpc(worker) + setupContext = { + environment, + config, + pool, + rpc, + projectName: config.name || '', + } + workerTeardown = await worker.setup?.(setupContext) + + send({ type: 'started', __vitest_worker_response__ }) + } + catch (error) { + send({ type: 'started', __vitest_worker_response__, error: serializeError(error) }) + } break } @@ -66,7 +87,7 @@ export function init(worker: Options): void { isRunning = true try { - runPromise = entrypoint.run(message.context, worker) + runPromise = entrypoint.run({ ...setupContext, ...message.context }, worker) .catch(error => serializeError(error)) const error = await runPromise @@ -112,7 +133,7 @@ export function init(worker: Options): void { isRunning = true try { - runPromise = entrypoint.collect(message.context, worker) + runPromise = entrypoint.collect({ ...setupContext, ...message.context }, worker) .catch(error => serializeError(error)) const error = await runPromise @@ -133,10 +154,19 @@ export function init(worker: Options): void { case 'stop': { await runPromise - const error = await entrypoint.teardown() - .catch(error => serializeError(error)) - send({ type: 'stopped', error, __vitest_worker_response__ }) + try { + const error = await entrypoint.teardown() + .catch(error => serializeError(error)) + + await workerTeardown?.() + + send({ type: 'stopped', error, __vitest_worker_response__ }) + } + catch (error) { + send({ type: 'stopped', error: serializeError(error), __vitest_worker_response__ }) + } + worker.teardown?.() break diff --git a/packages/vitest/src/runtime/workers/threads.ts b/packages/vitest/src/runtime/workers/threads.ts index 2604a971c..82830296b 100644 --- a/packages/vitest/src/runtime/workers/threads.ts +++ b/packages/vitest/src/runtime/workers/threads.ts @@ -1,4 +1,4 @@ -import { runBaseTests } from './base' +import { runBaseTests, setupEnvironment } from './base' import workerInit from './init-threads' -workerInit({ runTests: runBaseTests }) +workerInit({ runTests: runBaseTests, setup: setupEnvironment }) diff --git a/packages/vitest/src/runtime/workers/types.ts b/packages/vitest/src/runtime/workers/types.ts index 43f1d2b55..4d259e60c 100644 --- a/packages/vitest/src/runtime/workers/types.ts +++ b/packages/vitest/src/runtime/workers/types.ts @@ -1,7 +1,7 @@ import type { Awaitable } from '@vitest/utils' import type { BirpcOptions } from 'birpc' import type { RuntimeRPC } from '../../types/rpc' -import type { WorkerGlobalState } from '../../types/worker' +import type { WorkerGlobalState, WorkerSetupContext } from '../../types/worker' type WorkerRpcOptions = Pick< BirpcOptions, @@ -11,4 +11,6 @@ type WorkerRpcOptions = Pick< export interface VitestWorker extends WorkerRpcOptions { runTests: (state: WorkerGlobalState) => Awaitable collectTests: (state: WorkerGlobalState) => Awaitable + + setup?: (context: WorkerSetupContext) => Promise<() => Promise> } diff --git a/packages/vitest/src/runtime/workers/vm.ts b/packages/vitest/src/runtime/workers/vm.ts index 96a825ae9..e24fa4663 100644 --- a/packages/vitest/src/runtime/workers/vm.ts +++ b/packages/vitest/src/runtime/workers/vm.ts @@ -3,6 +3,7 @@ import type { WorkerGlobalState } from '../../types/worker' import { pathToFileURL } from 'node:url' import { isContext, runInContext } from 'node:vm' import { resolve } from 'pathe' +import { loadEnvironment } from '../../integrations/env/loader' import { distDir } from '../../paths' import { createCustomConsole } from '../console' import { ExternalModulesExecutor } from '../external-executor' @@ -18,7 +19,11 @@ const fileMap = new FileMap() const packageCache = new Map() export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalState): Promise { - const { environment, ctx, rpc } = state + const { ctx, rpc } = state + + const beforeEnvironmentTime = performance.now() + const { environment } = await loadEnvironment(ctx.environment.name, ctx.config.root, rpc) + state.environment = environment if (!environment.setupVM) { const envName = ctx.environment.name @@ -34,7 +39,7 @@ export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalS ctx.environment.options || ctx.config.environmentOptions || {}, ) - state.durations.environment = performance.now() - state.durations.environment + state.durations.environment = performance.now() - beforeEnvironmentTime process.env.VITEST_VM_POOL = '1' @@ -126,6 +131,5 @@ export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalS } finally { await vm.teardown?.() - state.environmentTeardownRun = true } } diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index 49e311adc..459c2ca5e 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -12,14 +12,29 @@ export interface ContextTestEnvironment { options: Record | null } +export interface WorkerTestEnvironment { + name: string + options: Record | null +} + export type TestExecutionMethod = 'run' | 'collect' +export interface WorkerExecuteContext { + files: FileSpecification[] + providedContext: Record + invalidates?: string[] + + /** Exposed to test runner as `VITEST_WORKER_ID`. Value is unique per each isolated worker. */ + workerId: number +} + export interface ContextRPC { pool: string config: SerializedConfig projectName: string + environment: WorkerTestEnvironment + rpc: WorkerRPC files: FileSpecification[] - environment: ContextTestEnvironment providedContext: Record invalidates?: string[] @@ -27,6 +42,14 @@ export interface ContextRPC { workerId: number } +export interface WorkerSetupContext { + environment: WorkerTestEnvironment + pool: string + config: SerializedConfig + projectName: string + rpc: WorkerRPC +} + export interface WorkerGlobalState { ctx: ContextRPC config: SerializedConfig @@ -42,11 +65,10 @@ export interface WorkerGlobalState { SSR: boolean } environment: Environment - environmentTeardownRun?: boolean - onCancel: Promise evaluatedModules: EvaluatedModules resolvingModules: Set moduleExecutionInfo: Map + onCancel: (listener: (reason: CancelReason) => unknown) => void onCleanup: (listener: () => unknown) => void providedContext: Record durations: { diff --git a/test/cli/test/__snapshots__/fails.test.ts.snap b/test/cli/test/__snapshots__/fails.test.ts.snap index cd2e1903c..fa1a7e84a 100644 --- a/test/cli/test/__snapshots__/fails.test.ts.snap +++ b/test/cli/test/__snapshots__/fails.test.ts.snap @@ -123,9 +123,6 @@ Error: Test timed out in 10ms. Error: Hook timed out in 16ms." `; -exports[`should fail unhandled.test.ts 1`] = ` -"Error: some error -Error: Uncaught [Error: some error]" -`; +exports[`should fail unhandled.test.ts 1`] = `"Error: some error"`; exports[`should fail unhandled-suite.test.ts 1`] = `"Unknown Error: promise error"`; diff --git a/test/cli/test/shared-env.test.ts b/test/cli/test/shared-env.test.ts new file mode 100644 index 000000000..fa5eda537 --- /dev/null +++ b/test/cli/test/shared-env.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'vitest' +import { runInlineTests, StableTestFileOrderSorter } from '../../test-utils' + +test.each([ + 1, + 2, +])('the environment is shared between tests with maxWorkers: %s', async (maxWorkers) => { + const testCode = ` +test('document is the same', () => { + expect(__vitest_worker__.ctx.config.isolate).toBe(false) + expect(__vitest_worker__.ctx.config.maxWorkers).toBe(${maxWorkers}) + expect(globalThis.__document ??= document).toBe(document) +}) + ` + const { stderr } = await runInlineTests({ + '1.test.js': testCode, + '2.test.js': testCode, + '3.test.js': testCode, + '4.test.js': testCode, + 'vitest.config.js': { + test: { + environment: 'happy-dom', + globals: true, + isolate: false, + }, + }, + }, { sequence: { sequencer: StableTestFileOrderSorter }, maxWorkers }) + + expect(stderr).toBe('') +}) -- 2.51.2