diff --git a/docs/config/index.md b/docs/config/index.md index 0363fafdc3f9..38ff9f807f50 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -256,7 +256,7 @@ Should Vitest process assets (.png, .svg, .jpg, etc) files and resolve them like This module will have a default export equal to the path to the asset, if no query is specified. ::: warning -At the moment, this option only works with [`vmThreads`](#vmthreads) pool. +At the moment, this option only works with [`vmThreads`](#vmthreads) and [`vmForks`](#vmForks) pools. ::: #### deps.web.transformCss @@ -269,7 +269,7 @@ Should Vitest process CSS (.css, .scss, .sass, etc) files and resolve them like If CSS files are disabled with [`css`](#css) options, this option will just silence `ERR_UNKNOWN_FILE_EXTENSION` errors. ::: warning -At the moment, this option only works with [`vmThreads`](#vmthreads) pool. +At the moment, this option only works with [`vmThreads`](#vmthreads) and [`vmForks`](#vmForks) pools. ::: #### deps.web.transformGlobPattern @@ -282,7 +282,7 @@ Regexp pattern to match external files that should be transformed. By default, files inside `node_modules` are externalized and not transformed, unless it's CSS or an asset, and corresponding option is not disabled. ::: warning -At the moment, this option only works with [`vmThreads`](#vmthreads) pool. +At the moment, this option only works with [`vmThreads`](#vmthreads) and [`vmForks`](#vmForks) pools. ::: #### deps.interopDefault @@ -545,7 +545,7 @@ export default defineConfig({ ### poolMatchGlobs 0.29.4+ -- **Type:** `[string, 'threads' | 'forks' | 'vmThreads' | 'typescript'][]` +- **Type:** `[string, 'threads' | 'forks' | 'vmThreads' | 'vmForks' | 'typescript'][]` - **Default:** `[]` Automatically assign pool in which tests will run based on globs. The first match will be used. @@ -610,7 +610,7 @@ By providing an object instead of a string you can define individual outputs whe ### pool 1.0.0+ -- **Type:** `'threads' | 'forks' | 'vmThreads'` +- **Type:** `'threads' | 'forks' | 'vmThreads' | 'vmForks'` - **Default:** `'threads'` - **CLI:** `--pool=threads` @@ -650,9 +650,13 @@ catch (err) { Please, be aware of these issues when using this option. Vitest team cannot fix any of the issues on our side. ::: +#### vmForks + +Similar as `vmThreads` pool but uses `child_process` instead of `worker_threads` via [tinypool](https://github.com/tinylibs/tinypool). Communication between tests and main process is not as fast as with `vmThreads` pool. Process related APIs such as `process.chdir()` are available in `vmForks` pool. Please be aware that this pool has the same pitfalls listed in `vmThreads`. + ### poolOptions 1.0.0+ -- **Type:** `Record<'threads' | 'forks' | 'vmThreads', {}>` +- **Type:** `Record<'threads' | 'forks' | 'vmThreads' | 'vmForks', {}>` - **Default:** `{}` #### poolOptions.threads @@ -873,6 +877,57 @@ Pass additional arguments to `node` process in the VM context. See [Command-line Be careful when using, it as some options may crash worker, e.g. --prof, --title. See https://github.com/nodejs/node/issues/41103. ::: + +#### poolOptions.vmForks + +Options for `vmForks` pool. + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + poolOptions: { + vmForks: { + // VM forks related options here + } + } + } +}) +``` + +##### poolOptions.vmForks.maxForks + +- **Type:** `number` +- **Default:** _available CPUs_ + +Maximum number of threads. You can also use `VITEST_MAX_FORKS` environment variable. + +##### poolOptions.vmForks.minForks + +- **Type:** `number` +- **Default:** _available CPUs_ + +Minimum number of threads. You can also use `VITEST_MIN_FORKS` environment variable. + +##### poolOptions.vmForks.memoryLimit + +- **Type:** `string | number` +- **Default:** `1 / CPU Cores` + +Specifies the memory limit for workers before they are recycled. This value heavily depends on your environment, so it's better to specify it manually instead of relying on the default. How the value is calculated is described in [`poolOptions.vmThreads.memoryLimit`](#pooloptions-vmthreads-memorylimit) + +##### poolOptions.vmForks.execArgv + +- **Type:** `string[]` +- **Default:** `[]` + +Pass additional arguments to `node` process in the VM context. See [Command-line API | Node.js](https://nodejs.org/docs/latest/api/cli.html) for more information. + +:::warning +Be careful when using, it as some options may crash worker, e.g. --prof, --title. See https://github.com/nodejs/node/issues/41103. +::: + ### fileParallelism 1.1.0+ - **Type:** `boolean` @@ -2064,7 +2119,7 @@ Path to a [workspace](/guide/workspace) config file relative to [root](#root). - **Default:** `true` - **CLI:** `--no-isolate`, `--isolate=false` -Run tests in an isolated environment. This option has no effect on `vmThreads` pool. +Run tests in an isolated environment. This option has no effect on `vmThreads` and `vmForks` pools. Disabling this option might [improve performance](/guide/improving-performance) if your code doesn't rely on side effects (which is usually true for projects with `node` environment). diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 155ff8e6568e..27ba972e22d5 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -52,6 +52,10 @@ "types": "./dist/execute.d.ts", "default": "./dist/execute.js" }, + "./workers": { + "types": "./dist/workers.d.ts", + "import": "./dist/workers.js" + }, "./browser": { "types": "./dist/browser.d.ts", "default": "./dist/browser.js" diff --git a/packages/vitest/rollup.config.js b/packages/vitest/rollup.config.js index ec507afb7eae..3c2d04caebf5 100644 --- a/packages/vitest/rollup.config.js +++ b/packages/vitest/rollup.config.js @@ -15,27 +15,33 @@ import { defineConfig } from 'rollup' const require = createRequire(import.meta.url) const pkg = require('./package.json') -const entries = [ - 'src/paths.ts', - 'src/index.ts', - 'src/node/cli.ts', - 'src/node/cli-wrapper.ts', - 'src/node.ts', - 'src/suite.ts', - 'src/browser.ts', - 'src/runners.ts', - 'src/environments.ts', - 'src/runtime/worker.ts', - 'src/runtime/vm.ts', - 'src/runtime/child.ts', - 'src/runtime/entry.ts', - 'src/runtime/entry-vm.ts', - 'src/integrations/spy.ts', - 'src/coverage.ts', - 'src/public/utils.ts', - 'src/public/execute.ts', - 'src/public/reporters.ts', -] +const entries = { + 'path': 'src/paths.ts', + 'index': 'src/index.ts', + 'cli': 'src/node/cli.ts', + 'cli-wrapper': 'src/node/cli-wrapper.ts', + 'node': 'src/node.ts', + 'suite': 'src/suite.ts', + 'browser': 'src/browser.ts', + 'runners': 'src/runners.ts', + 'environments': 'src/environments.ts', + 'spy': 'src/integrations/spy.ts', + 'coverage': 'src/coverage.ts', + 'utils': 'src/public/utils.ts', + 'execute': 'src/public/execute.ts', + 'reporters': 'src/public/reporters.ts', + // TODO: advanced docs + 'workers': 'src/workers.ts', + + // for performance reasons we bundle them separately so we don't import everything at once + 'worker': 'src/runtime/worker.ts', + 'workers/forks': 'src/runtime/workers/forks.ts', + 'workers/threads': 'src/runtime/workers/threads.ts', + 'workers/vmThreads': 'src/runtime/workers/vmThreads.ts', + 'workers/vmForks': 'src/runtime/workers/vmForks.ts', + + 'workers/runVmTests': 'src/runtime/runVmTests.ts', +} const dtsEntries = { index: 'src/index.ts', @@ -49,6 +55,7 @@ const dtsEntries = { utils: 'src/public/utils.ts', execute: 'src/public/execute.ts', reporters: 'src/public/reporters.ts', + workers: 'src/workers.ts', } const external = [ diff --git a/packages/vitest/src/integrations/env/happy-dom.ts b/packages/vitest/src/integrations/env/happy-dom.ts index 0f662c28b372..7766ea1100cb 100644 --- a/packages/vitest/src/integrations/env/happy-dom.ts +++ b/packages/vitest/src/integrations/env/happy-dom.ts @@ -16,7 +16,7 @@ export default ({ transformMode: 'web', async setupVM({ happyDOM = {} }) { const { Window } = await import('happy-dom') - const win = new Window({ + let win = new Window({ ...happyDOM, console: (console && globalThis.console) ? globalThis.console : undefined, url: happyDOM.url || 'http://localhost:3000', @@ -39,6 +39,7 @@ export default ({ }, async teardown() { await teardownWindow(win) + win = undefined }, } }, diff --git a/packages/vitest/src/integrations/env/jsdom.ts b/packages/vitest/src/integrations/env/jsdom.ts index ef4937585ed9..7141e95b60d9 100644 --- a/packages/vitest/src/integrations/env/jsdom.ts +++ b/packages/vitest/src/integrations/env/jsdom.ts @@ -48,7 +48,7 @@ export default ({ cookieJar = false, ...restOptions } = jsdom as any - const dom = new JSDOM( + let dom = new JSDOM( html, { pretendToBeVisual, @@ -97,6 +97,7 @@ export default ({ teardown() { clearWindowErrors() dom.window.close() + dom = undefined as any }, } }, diff --git a/packages/vitest/src/integrations/env/loader.ts b/packages/vitest/src/integrations/env/loader.ts index 96114ee79e6b..4631f68c4171 100644 --- a/packages/vitest/src/integrations/env/loader.ts +++ b/packages/vitest/src/integrations/env/loader.ts @@ -2,7 +2,7 @@ import { normalize, resolve } from 'pathe' import { ViteNodeRunner } from 'vite-node/client' import type { ViteNodeRunnerOptions } from 'vite-node' import type { BuiltinEnvironment, VitestEnvironment } from '../../types/config' -import type { Environment } from '../../types' +import type { ContextRPC, Environment, WorkerRPC } from '../../types' import { environments } from './index' function isBuiltinEnvironment(env: VitestEnvironment): env is BuiltinEnvironment { @@ -20,14 +20,19 @@ export async function createEnvironmentLoader(options: ViteNodeRunnerOptions) { return _loaders.get(options.root)! } -export async function loadEnvironment(name: VitestEnvironment, options: ViteNodeRunnerOptions): Promise { +export async function loadEnvironment(ctx: ContextRPC, rpc: WorkerRPC): Promise { + const name = ctx.environment.name if (isBuiltinEnvironment(name)) return environments[name] - const loader = await createEnvironmentLoader(options) + const loader = await createEnvironmentLoader({ + root: ctx.config.root, + fetchModule: id => rpc.fetch(id, 'ssr'), + resolveId: (id, importer) => rpc.resolveId(id, importer, 'ssr'), + }) const root = loader.root const packageId = name[0] === '.' || name[0] === '/' ? resolve(root, name) - : (await options.resolveId!(`vitest-environment-${name}`))?.id ?? resolve(root, name) + : (await rpc.resolveId(`vitest-environment-${name}`, undefined, 'ssr'))?.id ?? resolve(root, name) const pkg = await loader.executeId(normalize(packageId)) if (!pkg || !pkg.default || typeof pkg.default !== 'object') { throw new TypeError( diff --git a/packages/vitest/src/integrations/env/node.ts b/packages/vitest/src/integrations/env/node.ts index 4ba49e471748..9f45b522948a 100644 --- a/packages/vitest/src/integrations/env/node.ts +++ b/packages/vitest/src/integrations/env/node.ts @@ -36,8 +36,8 @@ export default ({ // this is largely copied from jest's node environment async setupVM() { const vm = await import('node:vm') - const context = vm.createContext() - const global = vm.runInContext( + let context = vm.createContext() + let global = vm.runInContext( 'this', context, ) @@ -108,7 +108,8 @@ export default ({ return context }, teardown() { - // + context = undefined as any + global = undefined }, } }, diff --git a/packages/vitest/src/integrations/mock/timers.ts b/packages/vitest/src/integrations/mock/timers.ts index 00584297060a..7fc811a2b56a 100644 --- a/packages/vitest/src/integrations/mock/timers.ts +++ b/packages/vitest/src/integrations/mock/timers.ts @@ -13,6 +13,7 @@ import type { import { withGlobal, } from '@sinonjs/fake-timers' +import { isChildProcess } from '../../utils/base' import { RealDate, mockDate, resetDate } from './date' export class FakeTimers { @@ -134,8 +135,7 @@ export class FakeTimers { // Do not mock nextTick by default. It can still be mocked through userConfig. .filter(timer => timer !== 'nextTick') as (keyof FakeTimerWithContext['timers'])[] - // @ts-expect-error -- untyped internal - if (this._userConfig?.toFake?.includes('nextTick') && globalThis.__vitest_worker__.isChildProcess) + if (this._userConfig?.toFake?.includes('nextTick') && isChildProcess()) throw new Error('process.nextTick cannot be mocked inside child_process') this._clock = this._fakeTimers.install({ diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index 7d6bfcb37dd0..052db9b9f141 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -6,6 +6,7 @@ import type { ResolvedConfig, RuntimeConfig } from '../types' import type { MockFactoryWithHelper } from '../types/mocker' import { getWorkerState } from '../utils/global' import { resetModules, waitForImportsToResolve } from '../utils/modules' +import { isChildProcess } from '../utils/base' import { FakeTimers } from './mock/timers' import type { MaybeMocked, MaybeMockedDeep, MaybePartiallyMocked, MaybePartiallyMockedDeep, MockInstance } from './spy' import { fn, isMockFunction, mocks, spyOn } from './spy' @@ -370,7 +371,7 @@ function createVitest(): VitestUtils { const utils: VitestUtils = { useFakeTimers(config?: FakeTimerInstallOpts) { - if (workerState.isChildProcess) { + if (isChildProcess()) { if (config?.toFake?.includes('nextTick') || workerState.config?.fakeTimers?.toFake?.includes('nextTick')) { throw new Error( 'vi.useFakeTimers({ toFake: ["nextTick"] }) is not supported in node:child_process. Use --pool=threads if mocking nextTick is required.', diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index 69714f4f6dbb..618c150cceb4 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -274,6 +274,10 @@ export function resolveConfig( ...resolved.poolOptions?.forks, maxForks: Number.parseInt(process.env.VITEST_MAX_FORKS), }, + vmForks: { + ...resolved.poolOptions?.vmForks, + maxForks: Number.parseInt(process.env.VITEST_MAX_FORKS), + }, } } @@ -284,6 +288,10 @@ export function resolveConfig( ...resolved.poolOptions?.forks, minForks: Number.parseInt(process.env.VITEST_MIN_FORKS), }, + vmForks: { + ...resolved.poolOptions?.vmForks, + minForks: Number.parseInt(process.env.VITEST_MIN_FORKS), + }, } } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index ce2e403895f7..4fc0c1e2d62b 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -59,11 +59,7 @@ export class Vitest { public projects: WorkspaceProject[] = [] private projectsTestFiles = new Map>() - projectFiles!: { - workerPath: string - forksPath: string - vmPath: string - } + public distPath!: string constructor( public readonly mode: VitestRunMode, @@ -102,11 +98,7 @@ export class Vitest { // if Vitest is running globally, then we should still import local vitest if possible const projectVitestPath = await this.vitenode.resolveId('vitest') const vitestDir = projectVitestPath ? resolve(projectVitestPath.id, '../..') : rootDir - this.projectFiles = { - workerPath: join(vitestDir, 'dist/worker.js'), - forksPath: join(vitestDir, 'dist/child.js'), - vmPath: join(vitestDir, 'dist/vm.js'), - } + this.distPath = join(vitestDir, 'dist') const node = this.vitenode this.runner = new ViteNodeRunner({ diff --git a/packages/vitest/src/node/error.ts b/packages/vitest/src/node/error.ts index 7b3749df5879..7a2e0ec42df1 100644 --- a/packages/vitest/src/node/error.ts +++ b/packages/vitest/src/node/error.ts @@ -58,8 +58,14 @@ export async function printError(error: unknown, project: WorkspaceProject | und const nearest = error instanceof TypeCheckError ? error.stacks[0] - : stacks.find(stack => - project.server && project.getModuleById(stack.file) && existsSync(stack.file), + : stacks.find((stack) => { + try { + return project.server && project.getModuleById(stack.file) && existsSync(stack.file) + } + catch { + return false + } + }, ) const errorProperties = getErrorProperties(e) diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index 2041e2d86acb..649f3bed31a6 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -2,12 +2,13 @@ import mm from 'micromatch' import type { Awaitable } from '@vitest/utils' import type { BuiltinPool, Pool } from '../types/pool-options' import type { Vitest } from './core' -import { createChildProcessPool } from './pools/child' +import { createForksPool } from './pools/forks' import { createThreadsPool } from './pools/threads' import { createBrowserPool } from './pools/browser' -import { createVmThreadsPool } from './pools/vm-threads' +import { createVmThreadsPool } from './pools/vmThreads' import type { WorkspaceProject } from './workspace' import { createTypecheckPool } from './pools/typecheck' +import { createVmForksPool } from './pools/vmForks' export type WorkspaceSpec = [project: WorkspaceProject, testFile: string] export type RunWithFiles = (files: WorkspaceSpec[], invalidates?: string[]) => Awaitable @@ -19,14 +20,11 @@ export interface ProcessPool { } export interface PoolProcessOptions { - workerPath: string - forksPath: string - vmPath: string execArgv: string[] env: Record } -export const builtinPools: BuiltinPool[] = ['forks', 'threads', 'browser', 'vmThreads', 'typescript'] +export const builtinPools: BuiltinPool[] = ['forks', 'threads', 'browser', 'vmThreads', 'vmForks', 'typescript'] export function createPool(ctx: Vitest): ProcessPool { const pools: Record = { @@ -34,6 +32,7 @@ export function createPool(ctx: Vitest): ProcessPool { threads: null, browser: null, vmThreads: null, + vmForks: null, typescript: null, } @@ -61,17 +60,16 @@ export function createPool(ctx: Vitest): ProcessPool { return getDefaultPoolName(project, file) } - async function runTests(files: WorkspaceSpec[], invalidate?: string[]) { - const conditions = ctx.server.config.resolve.conditions?.flatMap(c => ['--conditions', c]) || [] + const conditions = ctx.server.config.resolve.conditions?.flatMap(c => ['--conditions', c]) || [] - // Instead of passing whole process.execArgv to the workers, pick allowed options. - // Some options may crash worker, e.g. --prof, --title. nodejs/node#41103 - const execArgv = process.execArgv.filter(execArg => - execArg.startsWith('--cpu-prof') || execArg.startsWith('--heap-prof') || execArg.startsWith('--diagnostic-dir'), - ) + // Instead of passing whole process.execArgv to the workers, pick allowed options. + // Some options may crash worker, e.g. --prof, --title. nodejs/node#41103 + const execArgv = process.execArgv.filter(execArg => + execArg.startsWith('--cpu-prof') || execArg.startsWith('--heap-prof') || execArg.startsWith('--diagnostic-dir'), + ) + async function runTests(files: WorkspaceSpec[], invalidate?: string[]) { const options: PoolProcessOptions = { - ...ctx.projectFiles, execArgv: [ ...execArgv, ...conditions, @@ -90,14 +88,18 @@ export function createPool(ctx: Vitest): ProcessPool { async function resolveCustomPool(filepath: string) { if (customPools.has(filepath)) return customPools.get(filepath)! + const pool = await ctx.runner.executeId(filepath) if (typeof pool.default !== 'function') throw new Error(`Custom pool "${filepath}" must export a function as default export`) + const poolInstance = await pool.default(ctx, options) + if (typeof poolInstance?.name !== 'string') throw new Error(`Custom pool "${filepath}" should return an object with "name" property`) if (typeof poolInstance?.runTests !== 'function') throw new Error(`Custom pool "${filepath}" should return an object with "runTests" method`) + customPools.set(filepath, poolInstance) return poolInstance as ProcessPool } @@ -107,9 +109,19 @@ export function createPool(ctx: Vitest): ProcessPool { threads: [], browser: [], vmThreads: [], + vmForks: [], typescript: [], } + const factories: Record ProcessPool> = { + browser: () => createBrowserPool(ctx), + vmThreads: () => createVmThreadsPool(ctx, options), + threads: () => createThreadsPool(ctx, options), + forks: () => createForksPool(ctx, options), + vmForks: () => createVmForksPool(ctx, options), + typescript: () => createTypecheckPool(ctx), + } + for (const spec of files) { const pool = getPoolName(spec) filesByPool[pool] ??= [] @@ -133,29 +145,10 @@ export function createPool(ctx: Vitest): ProcessPool { const specs = await sortSpecs(files) - if (pool === 'browser') { - pools.browser ??= createBrowserPool(ctx) - return pools.browser.runTests(specs, invalidate) - } - - if (pool === 'vmThreads') { - pools.vmThreads ??= createVmThreadsPool(ctx, options) - return pools.vmThreads.runTests(specs, invalidate) - } - - if (pool === 'threads') { - pools.threads ??= createThreadsPool(ctx, options) - return pools.threads.runTests(specs, invalidate) - } - - if (pool === 'typescript') { - pools.typescript ??= createTypecheckPool(ctx) - return pools.typescript.runTests(specs) - } - - if (pool === 'forks') { - pools.forks ??= createChildProcessPool(ctx, options) - return pools.forks.runTests(specs, invalidate) + if (pool in factories) { + const factory = factories[pool] + pools[pool] ??= factory() + return pools[pool]!.runTests(specs, invalidate) } const poolHandler = await resolveCustomPool(pool) diff --git a/packages/vitest/src/node/pools/child.ts b/packages/vitest/src/node/pools/forks.ts similarity index 94% rename from packages/vitest/src/node/pools/child.ts rename to packages/vitest/src/node/pools/forks.ts index 6392744c6aed..c72689237b05 100644 --- a/packages/vitest/src/node/pools/child.ts +++ b/packages/vitest/src/node/pools/forks.ts @@ -4,12 +4,11 @@ import EventEmitter from 'node:events' import { Tinypool } from 'tinypool' import type { TinypoolChannel, Options as TinypoolOptions } from 'tinypool' import { createBirpc } from 'birpc' -import type { ContextTestEnvironment, ResolvedConfig, RunnerRPC, RuntimeRPC, Vitest } from '../../types' -import type { ChildContext } from '../../types/child' +import type { ContextRPC, ContextTestEnvironment, ResolvedConfig, RunnerRPC, RuntimeRPC, Vitest } from '../../types' import type { PoolProcessOptions, ProcessPool, RunWithFiles } from '../pool' import type { WorkspaceProject } from '../workspace' import { envsOrder, groupFilesByEnv } from '../../utils/test-helpers' -import { groupBy } from '../../utils' +import { groupBy, resolve } from '../../utils' import { createMethodsRPC } from './rpc' function createChildProcessChannel(project: WorkspaceProject) { @@ -48,7 +47,7 @@ function stringifyRegex(input: RegExp | string): string { return `$$vitest:${input.toString()}` } -export function createChildProcessPool(ctx: Vitest, { execArgv, env, forksPath }: PoolProcessOptions): ProcessPool { +export function createForksPool(ctx: Vitest, { execArgv, env }: PoolProcessOptions): ProcessPool { const numCpus = typeof nodeos.availableParallelism === 'function' ? nodeos.availableParallelism() @@ -63,9 +62,11 @@ export function createChildProcessPool(ctx: Vitest, { execArgv, env, forksPath } const maxThreads = poolOptions.maxForks ?? ctx.config.maxWorkers ?? threadsCount const minThreads = poolOptions.minForks ?? ctx.config.minWorkers ?? threadsCount + const worker = resolve(ctx.distPath, 'workers/forks.js') + const options: TinypoolOptions = { runtime: 'child_process', - filename: forksPath, + filename: resolve(ctx.distPath, 'worker.js'), maxThreads, minThreads, @@ -99,7 +100,9 @@ export function createChildProcessPool(ctx: Vitest, { execArgv, env, forksPath } ctx.state.clearFiles(project, files) const { channel, cleanup } = createChildProcessChannel(project) const workerId = ++id - const data: ChildContext = { + const data: ContextRPC = { + pool: 'forks', + worker, config, files, invalidates, @@ -221,8 +224,6 @@ export function createChildProcessPool(ctx: Vitest, { execArgv, env, forksPath } return { name: 'forks', runTests: runWithFiles('run'), - close: async () => { - await pool.destroy() - }, + close: () => pool.destroy(), } } diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts index 64454584d807..3144e3c73f42 100644 --- a/packages/vitest/src/node/pools/rpc.ts +++ b/packages/vitest/src/node/pools/rpc.ts @@ -31,28 +31,28 @@ export function createMethodsRPC(project: WorkspaceProject): RuntimeRPC { }, onPathsCollected(paths) { ctx.state.collectPaths(paths) - return project.report('onPathsCollected', paths) + return ctx.report('onPathsCollected', paths) }, onCollected(files) { ctx.state.collectFiles(files) - return project.report('onCollected', files) + return ctx.report('onCollected', files) }, onAfterSuiteRun(meta) { ctx.coverageProvider?.onAfterSuiteRun(meta) }, onTaskUpdate(packs) { ctx.state.updateTasks(packs) - return project.report('onTaskUpdate', packs) + return ctx.report('onTaskUpdate', packs) }, onUserConsoleLog(log) { ctx.state.updateUserLog(log) - return project.report('onUserConsoleLog', log) + ctx.report('onUserConsoleLog', log) }, onUnhandledError(err, type) { ctx.state.catchError(err, type) }, onFinished(files) { - return project.report('onFinished', files, ctx.state.getUnhandledErrors()) + return ctx.report('onFinished', files, ctx.state.getUnhandledErrors()) }, onCancel(reason) { ctx.cancelCurrentRun(reason) diff --git a/packages/vitest/src/node/pools/threads.ts b/packages/vitest/src/node/pools/threads.ts index 3dfa6402baa0..54a8067505f8 100644 --- a/packages/vitest/src/node/pools/threads.ts +++ b/packages/vitest/src/node/pools/threads.ts @@ -3,6 +3,7 @@ import * as nodeos from 'node:os' import { createBirpc } from 'birpc' import type { Options as TinypoolOptions } from 'tinypool' import Tinypool from 'tinypool' +import { resolve } from 'pathe' import type { ContextTestEnvironment, ResolvedConfig, RunnerRPC, RuntimeRPC, Vitest, WorkerContext } from '../../types' import type { PoolProcessOptions, ProcessPool, RunWithFiles } from '../pool' import { envsOrder, groupFilesByEnv } from '../../utils/test-helpers' @@ -33,7 +34,7 @@ function createWorkerChannel(project: WorkspaceProject) { return { workerPort, port } } -export function createThreadsPool(ctx: Vitest, { execArgv, env, workerPath }: PoolProcessOptions): ProcessPool { +export function createThreadsPool(ctx: Vitest, { execArgv, env }: PoolProcessOptions): ProcessPool { const numCpus = typeof nodeos.availableParallelism === 'function' ? nodeos.availableParallelism() @@ -48,8 +49,10 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env, workerPath }: Po const maxThreads = poolOptions.maxThreads ?? ctx.config.maxWorkers ?? threadsCount const minThreads = poolOptions.minThreads ?? ctx.config.minWorkers ?? threadsCount + const worker = resolve(ctx.distPath, 'workers/threads.js') + const options: TinypoolOptions = { - filename: workerPath, + filename: resolve(ctx.distPath, 'worker.js'), // TODO: investigate further // It seems atomics introduced V8 Fatal Error https://github.com/vitest-dev/vitest/issues/1191 useAtomics: poolOptions.useAtomics ?? false, @@ -87,6 +90,8 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env, workerPath }: Po const { workerPort, port } = createWorkerChannel(project) const workerId = ++id const data: WorkerContext = { + pool: 'threads', + worker, port: workerPort, config, files, @@ -201,11 +206,6 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env, workerPath }: Po return { name: 'threads', runTests: runWithFiles('run'), - close: async () => { - // node before 16.17 has a bug that causes FATAL ERROR because of the race condition - const nodeVersion = Number(process.version.match(/v(\d+)\.(\d+)/)?.[0].slice(1)) - if (nodeVersion >= 16.17) - await pool.destroy() - }, + close: () => pool.destroy(), } } diff --git a/packages/vitest/src/node/pools/vmForks.ts b/packages/vitest/src/node/pools/vmForks.ts new file mode 100644 index 000000000000..496ac3129b95 --- /dev/null +++ b/packages/vitest/src/node/pools/vmForks.ts @@ -0,0 +1,196 @@ +import * as nodeos from 'node:os' +import v8 from 'node:v8' +import EventEmitter from 'node:events' +import { createBirpc } from 'birpc' +import { resolve } from 'pathe' +import type { TinypoolChannel, Options as TinypoolOptions } from 'tinypool' +import Tinypool from 'tinypool' +import { rootDir } from '../../paths' +import type { ContextRPC, ContextTestEnvironment, ResolvedConfig, RunnerRPC, RuntimeRPC, Vitest } from '../../types' +import type { PoolProcessOptions, ProcessPool, RunWithFiles } from '../pool' +import { groupFilesByEnv } from '../../utils/test-helpers' +import { AggregateError } from '../../utils/base' +import type { WorkspaceProject } from '../workspace' +import { getWorkerMemoryLimit, stringToBytes } from '../../utils/memory-limit' +import { createMethodsRPC } from './rpc' + +const suppressWarningsPath = resolve(rootDir, './suppress-warnings.cjs') + +function createChildProcessChannel(project: WorkspaceProject) { + const emitter = new EventEmitter() + const cleanup = () => emitter.removeAllListeners() + + const events = { message: 'message', response: 'response' } + const channel: TinypoolChannel = { + onMessage: callback => emitter.on(events.message, callback), + postMessage: message => emitter.emit(events.response, message), + } + + const rpc = createBirpc( + createMethodsRPC(project), + { + eventNames: ['onCancel'], + serialize: v8.serialize, + deserialize: v => v8.deserialize(Buffer.from(v)), + post(v) { + emitter.emit(events.message, v) + }, + on(fn) { + emitter.on(events.response, fn) + }, + }, + ) + + project.ctx.onCancel(reason => rpc.onCancel(reason)) + + return { channel, cleanup } +} + +function stringifyRegex(input: RegExp | string): string { + if (typeof input === 'string') + return input + return `$$vitest:${input.toString()}` +} + +export function createVmForksPool(ctx: Vitest, { execArgv, env }: PoolProcessOptions): ProcessPool { + const numCpus + = typeof nodeos.availableParallelism === 'function' + ? nodeos.availableParallelism() + : nodeos.cpus().length + + const threadsCount = ctx.config.watch + ? Math.max(Math.floor(numCpus / 2), 1) + : Math.max(numCpus - 1, 1) + + const poolOptions = ctx.config.poolOptions?.vmForks ?? {} + + const maxThreads = poolOptions.maxForks ?? ctx.config.maxWorkers ?? threadsCount + const minThreads = poolOptions.maxForks ?? ctx.config.minWorkers ?? threadsCount + + const worker = resolve(ctx.distPath, 'workers/vmForks.js') + + const options: TinypoolOptions = { + runtime: 'child_process', + filename: resolve(ctx.distPath, 'worker.js'), + + maxThreads, + minThreads, + + env, + execArgv: [ + '--experimental-import-meta-resolve', + '--experimental-vm-modules', + '--require', + suppressWarningsPath, + ...poolOptions.execArgv ?? [], + ...execArgv, + ], + + terminateTimeout: ctx.config.teardownTimeout, + concurrentTasksPerWorker: 1, + maxMemoryLimitBeforeRecycle: getMemoryLimit(ctx.config) || undefined, + } + + if (poolOptions.singleFork || !ctx.config.fileParallelism) { + options.maxThreads = 1 + options.minThreads = 1 + } + + const pool = new Tinypool(options) + + const runWithFiles = (name: string): RunWithFiles => { + let id = 0 + + async function runFiles(project: WorkspaceProject, config: ResolvedConfig, files: string[], environment: ContextTestEnvironment, invalidates: string[] = []) { + ctx.state.clearFiles(project, files) + const { channel, cleanup } = createChildProcessChannel(project) + const workerId = ++id + const data: ContextRPC = { + pool: 'forks', + worker, + config, + files, + invalidates, + environment, + workerId, + projectName: project.getName(), + providedContext: project.getProvidedContext(), + } + try { + await pool.run(data, { name, channel }) + } + catch (error) { + // Worker got stuck and won't terminate - this may cause process to hang + if (error instanceof Error && /Failed to terminate worker/.test(error.message)) + ctx.state.addProcessTimeoutCause(`Failed to terminate worker while running ${files.join(', ')}.`) + + // Intentionally cancelled + else if (ctx.isCancelling && error instanceof Error && /The task has been cancelled/.test(error.message)) + ctx.state.cancelFiles(files, ctx.config.root, project.config.name) + + else + throw error + } + finally { + cleanup() + } + } + + return async (specs, invalidates) => { + // Cancel pending tasks from pool when possible + ctx.onCancel(() => pool.cancelPendingTasks()) + + const configs = new Map() + const getConfig = (project: WorkspaceProject): ResolvedConfig => { + if (configs.has(project)) + return configs.get(project)! + + const _config = project.getSerializableConfig() + + const config = { + ..._config, + // v8 serialize does not support regex + testNamePattern: _config.testNamePattern + ? stringifyRegex(_config.testNamePattern) + : undefined, + } as ResolvedConfig + configs.set(project, config) + return config + } + + const filesByEnv = await groupFilesByEnv(specs) + const promises = Object.values(filesByEnv).flat() + const results = await Promise.allSettled(promises + .map(({ file, environment, project }) => runFiles(project, getConfig(project), [file], environment, invalidates))) + + const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason) + if (errors.length > 0) + throw new AggregateError(errors, 'Errors occurred while running tests. For more information, see serialized error.') + } + } + + return { + name: 'vmForks', + runTests: runWithFiles('run'), + close: () => pool.destroy(), + } +} + +function getMemoryLimit(config: ResolvedConfig) { + const memory = nodeos.totalmem() + const limit = getWorkerMemoryLimit(config) + + if (typeof memory === 'number') { + return stringToBytes( + limit, + config.watch ? memory / 2 : memory, + ) + } + + // If totalmem is not supported we cannot resolve percentage based values like 0.5, "50%" + if ((typeof limit === 'number' && limit > 1) || (typeof limit === 'string' && limit.at(-1) !== '%')) + return stringToBytes(limit) + + // just ignore "memoryLimit" value because we cannot detect memory limit + return null +} diff --git a/packages/vitest/src/node/pools/vm-threads.ts b/packages/vitest/src/node/pools/vmThreads.ts similarity index 94% rename from packages/vitest/src/node/pools/vm-threads.ts rename to packages/vitest/src/node/pools/vmThreads.ts index 632ef29352cb..573382c3a013 100644 --- a/packages/vitest/src/node/pools/vm-threads.ts +++ b/packages/vitest/src/node/pools/vmThreads.ts @@ -38,7 +38,7 @@ function createWorkerChannel(project: WorkspaceProject) { return { workerPort, port } } -export function createVmThreadsPool(ctx: Vitest, { execArgv, env, vmPath }: PoolProcessOptions): ProcessPool { +export function createVmThreadsPool(ctx: Vitest, { execArgv, env }: PoolProcessOptions): ProcessPool { const numCpus = typeof nodeos.availableParallelism === 'function' ? nodeos.availableParallelism() @@ -53,8 +53,10 @@ export function createVmThreadsPool(ctx: Vitest, { execArgv, env, vmPath }: Pool const maxThreads = poolOptions.maxThreads ?? ctx.config.maxWorkers ?? threadsCount const minThreads = poolOptions.minThreads ?? ctx.config.minWorkers ?? threadsCount + const worker = resolve(ctx.distPath, 'workers/vmThreads.js') + const options: TinypoolOptions = { - filename: vmPath, + filename: resolve(ctx.distPath, 'worker.js'), // TODO: investigate further // It seems atomics introduced V8 Fatal Error https://github.com/vitest-dev/vitest/issues/1191 useAtomics: poolOptions.useAtomics ?? false, @@ -92,6 +94,8 @@ export function createVmThreadsPool(ctx: Vitest, { execArgv, env, vmPath }: Pool const { workerPort, port } = createWorkerChannel(project) const workerId = ++id const data: WorkerContext = { + pool: 'vmThreads', + worker, port: workerPort, config, files, @@ -150,12 +154,7 @@ export function createVmThreadsPool(ctx: Vitest, { execArgv, env, vmPath }: Pool return { name: 'vmThreads', runTests: runWithFiles('run'), - close: async () => { - // node before 16.17 has a bug that causes FATAL ERROR because of the race condition - const nodeVersion = Number(process.version.match(/v(\d+)\.(\d+)/)?.[0].slice(1)) - if (nodeVersion >= 16.17) - await pool.destroy() - }, + close: () => pool.destroy(), } } diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index 61b00db33d80..5a7c3ebc8e59 100644 --- a/packages/vitest/src/node/workspace.ts +++ b/packages/vitest/src/node/workspace.ts @@ -7,7 +7,7 @@ import { ViteNodeRunner } from 'vite-node/client' import { ViteNodeServer } from 'vite-node/server' import c from 'picocolors' import { createBrowserServer } from '../integrations/browser/server' -import type { ArgumentsType, ProvidedContext, Reporter, ResolvedConfig, UserConfig, UserWorkspaceConfig, Vitest } from '../types' +import type { ProvidedContext, ResolvedConfig, UserConfig, UserWorkspaceConfig, Vitest } from '../types' import { deepMerge } from '../utils' import type { Typechecker } from '../typecheck/typechecker' import type { BrowserProvider } from '../types/browser' @@ -309,10 +309,6 @@ export class WorkspaceProject { await this.initBrowserServer(this.server.config.configFile) } - async report(name: T, ...args: ArgumentsType) { - return this.ctx.report(name, ...args) - } - isBrowserEnabled() { return isBrowserEnabled(this.config) } diff --git a/packages/vitest/src/runtime/child.ts b/packages/vitest/src/runtime/child.ts deleted file mode 100644 index 962089a85158..000000000000 --- a/packages/vitest/src/runtime/child.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { performance } from 'node:perf_hooks' -import v8 from 'node:v8' -import { createBirpc } from 'birpc' -import { parseRegexp } from '@vitest/utils' -import { workerId as poolId } from 'tinypool' -import type { TinypoolWorkerMessage } from 'tinypool' -import type { CancelReason } from '@vitest/runner' -import type { ResolvedConfig, WorkerGlobalState } from '../types' -import type { RunnerRPC, RuntimeRPC } from '../types/rpc' -import type { ChildContext } from '../types/child' -import { loadEnvironment } from '../integrations/env/loader' -import { mockMap, moduleCache, startViteNode } from './execute' -import { createSafeRpc, rpcDone } from './rpc' -import { setupInspect } from './inspector' - -try { - process.title = `node (vitest ${poolId})` -} -catch {} - -async function init(ctx: ChildContext) { - const { config, workerId, providedContext } = ctx - - process.env.VITEST_WORKER_ID = String(workerId) - process.env.VITEST_POOL_ID = String(poolId) - - let setCancel = (_reason: CancelReason) => {} - const onCancel = new Promise((resolve) => { - setCancel = resolve - }) - - const rpc = createSafeRpc(createBirpc( - { - onCancel: setCancel, - }, - { - eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onCancel'], - serialize: v8.serialize, - deserialize: v => v8.deserialize(Buffer.from(v)), - post(v) { - process.send?.(v) - }, - on(fn) { - process.on('message', (message: any, ...extras: any) => { - // Do not react on Tinypool's internal messaging - if ((message as TinypoolWorkerMessage)?.__tinypool_worker_message__) - return - - return fn(message, ...extras) - }) - }, - }, - )) - - const environment = await loadEnvironment(ctx.environment.name, { - root: ctx.config.root, - fetchModule: id => rpc.fetch(id, 'ssr'), - resolveId: (id, importer) => rpc.resolveId(id, importer, 'ssr'), - }) - if (ctx.environment.transformMode) - environment.transformMode = ctx.environment.transformMode - - const state: WorkerGlobalState = { - ctx, - moduleCache, - config, - mockMap, - onCancel, - environment, - durations: { - environment: 0, - prepare: performance.now(), - }, - rpc, - providedContext, - isChildProcess: true, - } - - Object.defineProperty(globalThis, '__vitest_worker__', { - value: state, - configurable: true, - writable: true, - enumerable: false, - }) - - if (ctx.invalidates) { - ctx.invalidates.forEach((fsPath) => { - moduleCache.delete(fsPath) - moduleCache.delete(`mock:${fsPath}`) - }) - } - ctx.files.forEach(i => moduleCache.delete(i)) - - return state -} - -function parsePossibleRegexp(str: string | RegExp) { - const prefix = '$$vitest:' - if (typeof str === 'string' && str.startsWith(prefix)) - return parseRegexp(str.slice(prefix.length)) - return str -} - -function unwrapConfig(config: ResolvedConfig) { - if (config.testNamePattern) - config.testNamePattern = parsePossibleRegexp(config.testNamePattern) as RegExp - return config -} - -export async function run(ctx: ChildContext) { - const exit = process.exit - - ctx.config = unwrapConfig(ctx.config) - const inspectorCleanup = setupInspect(ctx.config) - - try { - const state = await init(ctx) - const { run, executor } = await startViteNode({ - state, - }) - await run(ctx.files, ctx.config, { environment: state.environment, options: ctx.environment.options }, executor) - await rpcDone() - } - finally { - inspectorCleanup() - process.exit = exit - } -} diff --git a/packages/vitest/src/runtime/execute.ts b/packages/vitest/src/runtime/execute.ts index db913c9a90dd..a8542517c9ab 100644 --- a/packages/vitest/src/runtime/execute.ts +++ b/packages/vitest/src/runtime/execute.ts @@ -1,26 +1,23 @@ -import { pathToFileURL } from 'node:url' import vm from 'node:vm' -import { DEFAULT_REQUEST_STUBS, ModuleCacheMap, ViteNodeRunner } from 'vite-node/client' +import { pathToFileURL } from 'node:url' +import type { ModuleCacheMap } from 'vite-node/client' +import { DEFAULT_REQUEST_STUBS, ViteNodeRunner } from 'vite-node/client' import { isInternalRequest, isNodeBuiltin, isPrimitive, toFilePath } from 'vite-node/utils' import type { ViteNodeRunnerOptions } from 'vite-node' -import { normalize, relative, resolve } from 'pathe' +import { normalize, relative } from 'pathe' 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 type { MockMap } from '../types/mocker' +import type { WorkerGlobalState } from '../types' import { VitestMocker } from './mocker' -import { ExternalModulesExecutor } from './external-executor' -import { FileMap } from './vm/file-map' - -const entryUrl = pathToFileURL(resolve(distDir, 'entry.js')).href +import type { ExternalModulesExecutor } from './external-executor' export interface ExecuteOptions extends ViteNodeRunnerOptions { mockMap: MockMap - packageCache: Map moduleDirectories?: string[] - context?: vm.Context state: WorkerGlobalState - transform: RuntimeRPC['transform'] + context?: vm.Context + externalModulesExecutor?: ExternalModulesExecutor } export async function createVitestExecutor(options: ExecuteOptions) { @@ -32,35 +29,15 @@ export async function createVitestExecutor(options: ExecuteOptions) { return runner } -let _viteNode: { - run: (files: string[], config: ResolvedConfig, environment: ResolvedTestEnvironment, executor: VitestExecutor) => Promise - executor: VitestExecutor -} - -export const packageCache = new Map() -export const moduleCache = new ModuleCacheMap() -export const mockMap: MockMap = new Map() -export const fileMap = new FileMap() const externalizeMap = new Map() -export async function startViteNode(options: ContextExecutorOptions) { - if (_viteNode) - return _viteNode - - const executor = await startVitestExecutor(options) - - const { run } = await import(entryUrl) - - _viteNode = { run, executor } - - return _viteNode -} - export interface ContextExecutorOptions { mockMap?: MockMap moduleCache?: ModuleCacheMap context?: vm.Context + externalModulesExecutor?: ExternalModulesExecutor state: WorkerGlobalState + requestStubs: Record } const bareVitestRegexp = /^@?vitest(\/|$)/ @@ -117,12 +94,8 @@ export async function startVitestExecutor(options: ContextExecutorOptions) { resolveId(id, importer) { return rpc().resolveId(id, importer, getTransformMode()) }, - transform(id) { - return rpc().transform(id, 'web') - }, - packageCache, - moduleCache, - mockMap, + get moduleCache() { return state().moduleCache }, + get mockMap() { return state().mockMap }, get interopDefault() { return state().config.deps.interopDefault }, get moduleDirectories() { return state().config.deps.moduleDirectories }, get root() { return state().config.root }, @@ -157,6 +130,24 @@ function removeStyle(id: string) { document.head.removeChild(sheet) } +export function getDefaultRequestStubs(context?: vm.Context) { + if (!context) { + const clientStub = { ...DEFAULT_REQUEST_STUBS['@vite/client'], updateStyle, removeStyle } + return { + '/@vite/client': clientStub, + '@vite/client': clientStub, + } + } + const clientStub = vm.runInContext( + `(defaultClient) => ({ ...defaultClient, updateStyle: ${updateStyle.toString()}, removeStyle: ${removeStyle.toString()} })`, + context, + )(DEFAULT_REQUEST_STUBS['@vite/client']) + return { + '/@vite/client': clientStub, + '@vite/client': clientStub, + } +} + export class VitestExecutor extends ViteNodeRunner { public mocker: VitestMocker public externalModules?: ExternalModulesExecutor @@ -182,33 +173,14 @@ export class VitestExecutor extends ViteNodeRunner { writable: true, configurable: true, }) - const clientStub = { ...DEFAULT_REQUEST_STUBS['@vite/client'], updateStyle, removeStyle } - this.options.requestStubs = { - '/@vite/client': clientStub, - '@vite/client': clientStub, - } - this.primitives = { - Object, - Reflect, - Symbol, - } + this.primitives = { Object, Reflect, Symbol } } - else { - const clientStub = vm.runInContext( - `(defaultClient) => ({ ...defaultClient, updateStyle: ${updateStyle.toString()}, removeStyle: ${removeStyle.toString()} })`, - options.context, - )(DEFAULT_REQUEST_STUBS['@vite/client']) - this.options.requestStubs = { - '/@vite/client': clientStub, - '@vite/client': clientStub, - } + else if (options.externalModulesExecutor) { this.primitives = vm.runInContext('({ Object, Reflect, Symbol })', options.context) - this.externalModules = new ExternalModulesExecutor({ - ...options, - fileMap, - context: options.context, - packageCache: options.packageCache, - }) + this.externalModules = options.externalModulesExecutor + } + else { + throw new Error('When context is provided, externalModulesExecutor must be provided as well.') } } diff --git a/packages/vitest/src/runtime/external-executor.ts b/packages/vitest/src/runtime/external-executor.ts index 6dd0ab494fc7..eb766ec6a750 100644 --- a/packages/vitest/src/runtime/external-executor.ts +++ b/packages/vitest/src/runtime/external-executor.ts @@ -6,7 +6,7 @@ import { dirname } from 'node:path' import { statSync } from 'node:fs' import { extname, join, normalize } from 'pathe' import { getCachedData, isNodeBuiltin, setCacheData } from 'vite-node/utils' -import type { ExecuteOptions } from './execute' +import type { RuntimeRPC } from '../types/rpc' import type { VMModule, VMSyntheticModule } from './vm/types' import { CommonjsExecutor } from './vm/commonjs-executor' import type { FileMap } from './vm/file-map' @@ -16,12 +16,16 @@ import { ViteExecutor } from './vm/vite-executor' const SyntheticModule: typeof VMSyntheticModule = (vm as any).SyntheticModule +// always defined when we use vm pool const nativeResolve = import.meta.resolve! -export interface ExternalModulesExecutorOptions extends ExecuteOptions { +export interface ExternalModulesExecutorOptions { context: vm.Context fileMap: FileMap packageCache: Map + transform: RuntimeRPC['transform'] + interopDefault?: boolean + viteClientModule: Record } interface ModuleInformation { @@ -55,7 +59,7 @@ export class ExternalModulesExecutor { esmExecutor: this.esm, context: this.context, transform: options.transform, - viteClientModule: options.requestStubs!['/@vite/client'], + viteClientModule: options.viteClientModule, }) this.resolvers = [this.vite.resolve] } diff --git a/packages/vitest/src/runtime/rpc.ts b/packages/vitest/src/runtime/rpc.ts index e2ee890beebe..5ee5f7cebf60 100644 --- a/packages/vitest/src/runtime/rpc.ts +++ b/packages/vitest/src/runtime/rpc.ts @@ -1,7 +1,9 @@ import { getSafeTimers, } from '@vitest/utils' -import type { BirpcReturn } from 'birpc' +import type { CancelReason } from '@vitest/runner' +import { createBirpc } from 'birpc' +import type { BirpcOptions, BirpcReturn } from 'birpc' import { getWorkerState } from '../utils/global' import type { RunnerRPC, RuntimeRPC } from '../types/rpc' import type { WorkerRPC } from '../types' @@ -53,6 +55,28 @@ export async function rpcDone() { return Promise.all(awaitable) } +export function createRuntimeRpc(options: Pick, 'on' | 'post' | 'serialize' | 'deserialize'>) { + let setCancel = (_reason: CancelReason) => {} + const onCancel = new Promise((resolve) => { + setCancel = resolve + }) + + const rpc = createSafeRpc(createBirpc( + { + onCancel: setCancel, + }, + { + eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onCancel'], + ...options, + }, + )) + + return { + rpc, + onCancel, + } +} + export function createSafeRpc(rpc: WorkerRPC) { return new Proxy(rpc, { get(target, p, handler) { diff --git a/packages/vitest/src/runtime/entry.ts b/packages/vitest/src/runtime/runBaseTests.ts similarity index 97% rename from packages/vitest/src/runtime/entry.ts rename to packages/vitest/src/runtime/runBaseTests.ts index 4744f7d6d46a..e89b3f42affa 100644 --- a/packages/vitest/src/runtime/entry.ts +++ b/packages/vitest/src/runtime/runBaseTests.ts @@ -24,9 +24,6 @@ export async function run(files: string[], config: ResolvedConfig, environment: workerState.onCancel.then(reason => runner.onCancel?.(reason)) workerState.durations.prepare = performance.now() - workerState.durations.prepare - - workerState.environment = environment.environment - workerState.durations.environment = performance.now() await withEnv(environment, environment.options || config.environmentOptions || {}, async () => { diff --git a/packages/vitest/src/runtime/entry-vm.ts b/packages/vitest/src/runtime/runVmTests.ts similarity index 91% rename from packages/vitest/src/runtime/entry-vm.ts rename to packages/vitest/src/runtime/runVmTests.ts index c6743f9ccba9..6f241a0d8912 100644 --- a/packages/vitest/src/runtime/entry-vm.ts +++ b/packages/vitest/src/runtime/runVmTests.ts @@ -53,12 +53,17 @@ export async function run(files: string[], config: ResolvedConfig, executor: Vit workerState.durations.prepare = performance.now() - workerState.durations.prepare + const { vi } = VitestIndex + for (const file of files) { workerState.filepath = file await startTests([file], runner) - workerState.filepath = undefined + // 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, executor) diff --git a/packages/vitest/src/runtime/vm.ts b/packages/vitest/src/runtime/vm.ts deleted file mode 100644 index a58a851e726e..000000000000 --- a/packages/vitest/src/runtime/vm.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { pathToFileURL } from 'node:url' -import { performance } from 'node:perf_hooks' -import { isContext } from 'node:vm' -import { ModuleCacheMap } from 'vite-node/client' -import { workerId as poolId } from 'tinypool' -import { createBirpc } from 'birpc' -import { resolve } from 'pathe' -import { installSourcemapsSupport } from 'vite-node/source-map' -import type { CancelReason } from '@vitest/runner' -import type { RunnerRPC, RuntimeRPC, WorkerContext, WorkerGlobalState } from '../types' -import { distDir } from '../paths' -import { loadEnvironment } from '../integrations/env/loader' -import { startVitestExecutor } from './execute' -import { createCustomConsole } from './console' -import { createSafeRpc } from './rpc' - -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, providedContext } = ctx - - let setCancel = (_reason: CancelReason) => {} - const onCancel = new Promise((resolve) => { - setCancel = resolve - }) - - const rpc = createSafeRpc(createBirpc( - { - onCancel: setCancel, - }, - { - eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected'], - post(v) { port.postMessage(v) }, - on(fn) { port.addListener('message', fn) }, - }, - )) - - const environment = await loadEnvironment(ctx.environment.name, { - root: ctx.config.root, - fetchModule: id => rpc.fetch(id, 'ssr'), - resolveId: (id, importer) => rpc.resolveId(id, importer, 'ssr'), - }) - - if (!environment.setupVM) { - const envName = ctx.environment.name - const packageId = envName[0] === '.' ? envName : `vitest-environment-${envName}` - throw new TypeError( - `Environment "${ctx.environment.name}" is not a valid environment. ` - + `Path "${packageId}" doesn't support vm environment because it doesn't provide "setupVM" method.`, - ) - } - - const state: WorkerGlobalState = { - ctx, - moduleCache, - config, - mockMap, - onCancel, - environment, - durations: { - environment: performance.now(), - prepare: performance.now(), - }, - rpc, - providedContext, - } - - installSourcemapsSupport({ - getSourceMap: source => moduleCache.getSourceMap(source), - }) - - const vm = await environment.setupVM(ctx.environment.options || ctx.config.environmentOptions || {}) - - state.durations.environment = performance.now() - state.durations.environment - - process.env.VITEST_WORKER_ID = String(ctx.workerId) - process.env.VITEST_POOL_ID = String(poolId) - process.env.VITEST_VM_POOL = '1' - - if (!vm.getVmContext) - throw new TypeError(`Environment ${ctx.environment.name} doesn't provide "getVmContext" method. It should return a context created by "vm.createContext" method.`) - - const context = vm.getVmContext() - - if (!isContext(context)) - throw new TypeError(`Environment ${ctx.environment.name} doesn't provide a valid context. It should be created by "vm.createContext" method.`) - - Object.defineProperty(context, '__vitest_worker__', { - value: state, - configurable: true, - writable: true, - enumerable: false, - }) - // this is unfortunately needed for our own dependencies - // we need to find a way to not rely on this by default - // because browser doesn't provide these globals - context.process = process - context.global = context - context.console = createCustomConsole(state) - // TODO: don't hardcode setImmediate in fake timers defaults - context.setImmediate = setImmediate - context.clearImmediate = clearImmediate - - if (ctx.invalidates) { - ctx.invalidates.forEach((fsPath) => { - moduleCache.delete(fsPath) - moduleCache.delete(`mock:${fsPath}`) - }) - } - ctx.files.forEach(i => moduleCache.delete(i)) - - const executor = await startVitestExecutor({ - context, - moduleCache, - mockMap, - state, - }) - - context.__vitest_mocker__ = executor.mocker - - const { run } = await executor.importExternalModule(entryFile) - - try { - await run(ctx.files, ctx.config, executor) - } - finally { - await vm.teardown?.() - state.environmentTeardownRun = true - } -} diff --git a/packages/vitest/src/runtime/vm/esm-executor.ts b/packages/vitest/src/runtime/vm/esm-executor.ts index ef0d55e9fd5e..0af0b2efd03e 100644 --- a/packages/vitest/src/runtime/vm/esm-executor.ts +++ b/packages/vitest/src/runtime/vm/esm-executor.ts @@ -26,8 +26,7 @@ export class EsmExecutor { if (m.status === 'unlinked') { this.esmLinkMap.set( m, - m.link((identifier, referencer) => this.executor.resolveModule(identifier, referencer.identifier), - ), + m.link((identifier, referencer) => this.executor.resolveModule(identifier, referencer.identifier)), ) } diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index 96f2cf81ce88..4c35bdc72cd3 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -1,94 +1,78 @@ -import { performance } from 'node:perf_hooks' -import { createBirpc } from 'birpc' +import { pathToFileURL } from 'node:url' import { workerId as poolId } from 'tinypool' -import type { CancelReason } from '@vitest/runner' -import type { RunnerRPC, RuntimeRPC, WorkerContext, WorkerGlobalState } from '../types' -import { getWorkerState } from '../utils/global' +import { ModuleCacheMap } from 'vite-node/client' +import type { ContextRPC } from '../types/rpc' import { loadEnvironment } from '../integrations/env/loader' -import { mockMap, moduleCache, startViteNode } from './execute' +import type { WorkerGlobalState } from '../types/worker' +import { isChildProcess, setProcessTitle } from '../utils/base' import { setupInspect } from './inspector' -import { createSafeRpc, rpcDone } from './rpc' +import { createRuntimeRpc, rpcDone } from './rpc' +import type { VitestWorker } from './workers/types' -async function init(ctx: WorkerContext) { - // @ts-expect-error untyped global - const isInitialized = typeof __vitest_worker__ !== 'undefined' - const isIsolatedThreads = ctx.config.pool === 'threads' && (ctx.config.poolOptions?.threads?.isolate ?? true) +if (isChildProcess()) + setProcessTitle(`vitest ${poolId}`) - 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.`) +// this is what every pool executes when running tests +export async function run(ctx: ContextRPC) { + const prepareStart = performance.now() - const { config, port, workerId, providedContext } = ctx + const inspectorCleanup = setupInspect(ctx.config) - process.env.VITEST_WORKER_ID = String(workerId) + process.env.VITEST_WORKER_ID = String(ctx.workerId) process.env.VITEST_POOL_ID = String(poolId) - let setCancel = (_reason: CancelReason) => {} - const onCancel = new Promise((resolve) => { - setCancel = resolve - }) + let state: WorkerGlobalState | null = null - const rpc = createSafeRpc(createBirpc( - { - onCancel: setCancel, - }, - { - eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onCancel'], - post(v) { port.postMessage(v) }, - on(fn) { port.addListener('message', fn) }, - }, - )) + try { + // worker is a filepath or URL to a file that exposes a default export with "getRpcOptions" and "runTests" methods + if (ctx.worker[0] === '.') + throw new Error(`Path to the test runner cannot be relative, received "${ctx.worker}"`) - const environment = await loadEnvironment(ctx.environment.name, { - root: ctx.config.root, - fetchModule: id => rpc.fetch(id, 'ssr'), - resolveId: (id, importer) => rpc.resolveId(id, importer, 'ssr'), - }) - if (ctx.environment.transformMode) - environment.transformMode = ctx.environment.transformMode + const file = ctx.worker.startsWith('file:') ? ctx.worker : pathToFileURL(ctx.worker).toString() + const testRunnerModule = await import(file) - const state: WorkerGlobalState = { - ctx, - moduleCache, - config, - mockMap, - onCancel, - environment, - durations: { - environment: 0, - prepare: performance.now(), - }, - rpc, - providedContext, - } + if (!testRunnerModule.default || typeof testRunnerModule.default !== 'object') + throw new TypeError(`Test worker object should be exposed as a default export. Received "${typeof testRunnerModule.default}"`) - Object.defineProperty(globalThis, '__vitest_worker__', { - value: state, - configurable: true, - writable: true, - enumerable: false, - }) + const worker = testRunnerModule.default as VitestWorker + if (!worker.getRpcOptions || typeof worker.getRpcOptions !== 'function') + throw new TypeError(`Test worker should expose "getRpcOptions" method. Received "${typeof worker.getRpcOptions}".`) - if (ctx.invalidates) { - ctx.invalidates.forEach((fsPath) => { - moduleCache.delete(fsPath) - moduleCache.delete(`mock:${fsPath}`) - }) - } - ctx.files.forEach(i => moduleCache.delete(i)) + // 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.getRpcOptions(ctx)) - return state -} + const beforeEnvironmentTime = performance.now() + const environment = await loadEnvironment(ctx, rpc) + if (ctx.environment.transformMode) + environment.transformMode = ctx.environment.transformMode -export async function run(ctx: WorkerContext) { - const inspectorCleanup = setupInspect(ctx.config) + state = { + ctx, + // here we create a new one, workers can reassign this if they need to keep it non-isolated + moduleCache: new ModuleCacheMap(), + mockMap: new Map(), + config: ctx.config, + onCancel, + environment, + durations: { + environment: beforeEnvironmentTime, + prepare: prepareStart, + }, + rpc, + providedContext: ctx.providedContext, + } - try { - const state = await init(ctx) - const { run, executor } = await startViteNode({ state }) - await run(ctx.files, ctx.config, { environment: state.environment, options: ctx.environment.options }, executor) - await rpcDone() + if (!worker.runTests || typeof worker.runTests !== 'function') + throw new TypeError(`Test worker should expose "runTests" method. Received "${typeof worker.runTests}".`) + + await worker.runTests(state) } finally { + await rpcDone().catch(() => {}) inspectorCleanup() + if (state) { + state.environment = null as any + state = null + } } } diff --git a/packages/vitest/src/runtime/workers/base.ts b/packages/vitest/src/runtime/workers/base.ts new file mode 100644 index 000000000000..dc19b139ed31 --- /dev/null +++ b/packages/vitest/src/runtime/workers/base.ts @@ -0,0 +1,47 @@ +import { ModuleCacheMap } from 'vite-node/client' +import type { WorkerGlobalState } from '../../types/worker' +import { provideWorkerState } from '../../utils/global' +import type { ContextExecutorOptions, VitestExecutor } from '../execute' +import { getDefaultRequestStubs, startVitestExecutor } from '../execute' +import type { MockMap } from '../../types/mocker' + +let _viteNode: VitestExecutor + +const moduleCache = new ModuleCacheMap() +const mockMap: MockMap = new Map() + +async function startViteNode(options: ContextExecutorOptions) { + if (_viteNode) + return _viteNode + + _viteNode = await startVitestExecutor(options) + return _viteNode +} + +export async function runBaseTests(state: WorkerGlobalState) { + const { ctx } = state + // state has new context, but we want to reuse existing ones + state.moduleCache = moduleCache + state.mockMap = mockMap + + provideWorkerState(globalThis, state) + + if (ctx.invalidates) { + ctx.invalidates.forEach((fsPath) => { + moduleCache.delete(fsPath) + moduleCache.delete(`mock:${fsPath}`) + }) + } + ctx.files.forEach(i => state.moduleCache.delete(i)) + + const [executor, { run }] = await Promise.all([ + startViteNode({ state, requestStubs: getDefaultRequestStubs() }), + import('../runBaseTests'), + ]) + await run( + ctx.files, + ctx.config, + { environment: state.environment, options: ctx.environment.options }, + executor, + ) +} diff --git a/packages/vitest/src/runtime/workers/forks.ts b/packages/vitest/src/runtime/workers/forks.ts new file mode 100644 index 000000000000..b63b03472d8f --- /dev/null +++ b/packages/vitest/src/runtime/workers/forks.ts @@ -0,0 +1,27 @@ +import v8 from 'node:v8' +import type { WorkerGlobalState } from '../../types/worker' +import { createForksRpcOptions, unwrapForksConfig } from './utils' +import { runBaseTests } from './base' +import type { VitestWorker } from './types' + +class ForksBaseWorker implements VitestWorker { + getRpcOptions() { + return createForksRpcOptions(v8) + } + + async runTests(state: WorkerGlobalState) { + // TODO: don't rely on reassigning process.exit + // https://github.com/vitest-dev/vitest/pull/4441#discussion_r1443771486 + const exit = process.exit + state.ctx.config = unwrapForksConfig(state.ctx.config) + + try { + await runBaseTests(state) + } + finally { + process.exit = exit + } + } +} + +export default new ForksBaseWorker() diff --git a/packages/vitest/src/runtime/workers/threads.ts b/packages/vitest/src/runtime/workers/threads.ts new file mode 100644 index 000000000000..20444b43e4ad --- /dev/null +++ b/packages/vitest/src/runtime/workers/threads.ts @@ -0,0 +1,16 @@ +import type { WorkerContext, WorkerGlobalState } from '../../types/worker' +import { runBaseTests } from './base' +import type { VitestWorker } from './types' +import { createThreadsRpcOptions } from './utils' + +class ThreadsBaseWorker implements VitestWorker { + getRpcOptions(ctx: WorkerContext) { + return createThreadsRpcOptions(ctx) + } + + runTests(state: WorkerGlobalState): unknown { + return runBaseTests(state) + } +} + +export default new ThreadsBaseWorker() diff --git a/packages/vitest/src/runtime/workers/types.ts b/packages/vitest/src/runtime/workers/types.ts new file mode 100644 index 000000000000..d55fca3950a3 --- /dev/null +++ b/packages/vitest/src/runtime/workers/types.ts @@ -0,0 +1,11 @@ +import type { BirpcOptions } from 'birpc' +import type { Awaitable } from '@vitest/utils' +import type { ContextRPC, RuntimeRPC } from '../../types/rpc' +import type { WorkerGlobalState } from '../../types/worker' + +export type WorkerRpcOptions = Pick, 'on' | 'post' | 'serialize' | 'deserialize'> + +export interface VitestWorker { + getRpcOptions(ctx: ContextRPC): WorkerRpcOptions + runTests(state: WorkerGlobalState): Awaitable +} diff --git a/packages/vitest/src/runtime/workers/utils.ts b/packages/vitest/src/runtime/workers/utils.ts new file mode 100644 index 000000000000..57d422c3c6ec --- /dev/null +++ b/packages/vitest/src/runtime/workers/utils.ts @@ -0,0 +1,42 @@ +import type { TinypoolWorkerMessage } from 'tinypool' +import { parseRegexp } from '@vitest/utils' +import type { WorkerContext } from '../../types/worker' +import type { ResolvedConfig } from '../../types/config' +import type { WorkerRpcOptions } from './types' + +export function createThreadsRpcOptions({ port }: WorkerContext): WorkerRpcOptions { + return { + post: (v) => { port.postMessage(v) }, + on: (fn) => { port.addListener('message', fn) }, + } +} + +export function createForksRpcOptions(nodeV8: typeof import('v8')): WorkerRpcOptions { + return { + serialize: nodeV8.serialize, + deserialize: v => nodeV8.deserialize(Buffer.from(v)), + post(v) { process.send!(v) }, + on(fn) { + process.on('message', (message: any, ...extras: any) => { + // Do not react on Tinypool's internal messaging + if ((message as TinypoolWorkerMessage)?.__tinypool_worker_message__) + return + + return fn(message, ...extras) + }) + }, + } +} + +function parsePossibleRegexp(str: string | RegExp) { + const prefix = '$$vitest:' + if (typeof str === 'string' && str.startsWith(prefix)) + return parseRegexp(str.slice(prefix.length)) + return str +} + +export function unwrapForksConfig(config: ResolvedConfig) { + if (config.testNamePattern) + config.testNamePattern = parsePossibleRegexp(config.testNamePattern) as RegExp + return config +} diff --git a/packages/vitest/src/runtime/workers/vm.ts b/packages/vitest/src/runtime/workers/vm.ts new file mode 100644 index 000000000000..55303e2c6653 --- /dev/null +++ b/packages/vitest/src/runtime/workers/vm.ts @@ -0,0 +1,86 @@ +import type { Context } from 'node:vm' +import { isContext } from 'node:vm' +import { pathToFileURL } from 'node:url' +import { resolve } from 'pathe' +import type { WorkerGlobalState } from '../../types/worker' +import { createCustomConsole } from '../console' +import { getDefaultRequestStubs, startVitestExecutor } from '../execute' +import { distDir } from '../../paths' +import { ExternalModulesExecutor } from '../external-executor' +import { FileMap } from '../vm/file-map' +import { provideWorkerState } from '../../utils' + +const entryFile = pathToFileURL(resolve(distDir, 'workers/runVmTests.js')).href + +const fileMap = new FileMap() +const packageCache = new Map() + +export async function runVmTests(state: WorkerGlobalState) { + const { environment, ctx, rpc } = state + + if (!environment.setupVM) { + const envName = ctx.environment.name + const packageId = envName[0] === '.' ? envName : `vitest-environment-${envName}` + throw new TypeError( + `Environment "${ctx.environment.name}" is not a valid environment. ` + + `Path "${packageId}" doesn't support vm environment because it doesn't provide "setupVM" method.`, + ) + } + + const vm = await environment.setupVM(ctx.environment.options || ctx.config.environmentOptions || {}) + + state.durations.environment = performance.now() - state.durations.environment + + process.env.VITEST_VM_POOL = '1' + + if (!vm.getVmContext) + throw new TypeError(`Environment ${environment.name} doesn't provide "getVmContext" method. It should return a context created by "vm.createContext" method.`) + + const context: Context | null = vm.getVmContext() + + if (!isContext(context)) + throw new TypeError(`Environment ${environment.name} doesn't provide a valid context. It should be created by "vm.createContext" method.`) + + provideWorkerState(context, state) + + // this is unfortunately needed for our own dependencies + // we need to find a way to not rely on this by default + // because browser doesn't provide these globals + context.process = process + context.global = context + context.console = createCustomConsole(state) + // TODO: don't hardcode setImmediate in fake timers defaults + context.setImmediate = setImmediate + context.clearImmediate = clearImmediate + + const stubs = getDefaultRequestStubs(context) + + const externalModulesExecutor = new ExternalModulesExecutor({ + context, + fileMap, + packageCache, + transform: rpc.transform, + viteClientModule: stubs['/@vite/client'], + }) + + const executor = await startVitestExecutor({ + context, + moduleCache: state.moduleCache, + mockMap: state.mockMap, + state, + externalModulesExecutor, + requestStubs: stubs, + }) + + context.__vitest_mocker__ = executor.mocker + + const { run } = await executor.importExternalModule(entryFile) as typeof import('../runVmTests') + + try { + await run(ctx.files, ctx.config, executor) + } + finally { + await vm.teardown?.() + state.environmentTeardownRun = true + } +} diff --git a/packages/vitest/src/runtime/workers/vmForks.ts b/packages/vitest/src/runtime/workers/vmForks.ts new file mode 100644 index 000000000000..5f43d89bec98 --- /dev/null +++ b/packages/vitest/src/runtime/workers/vmForks.ts @@ -0,0 +1,25 @@ +import v8 from 'node:v8' +import type { WorkerGlobalState } from '../../types/worker' +import { createForksRpcOptions, unwrapForksConfig } from './utils' +import type { VitestWorker } from './types' +import { runVmTests } from './vm' + +class ForksVmWorker implements VitestWorker { + getRpcOptions() { + return createForksRpcOptions(v8) + } + + async runTests(state: WorkerGlobalState) { + const exit = process.exit + state.ctx.config = unwrapForksConfig(state.ctx.config) + + try { + await runVmTests(state) + } + finally { + process.exit = exit + } + } +} + +export default new ForksVmWorker() diff --git a/packages/vitest/src/runtime/workers/vmThreads.ts b/packages/vitest/src/runtime/workers/vmThreads.ts new file mode 100644 index 000000000000..4b131fb490c7 --- /dev/null +++ b/packages/vitest/src/runtime/workers/vmThreads.ts @@ -0,0 +1,16 @@ +import type { WorkerContext, WorkerGlobalState } from '../../types/worker' +import type { VitestWorker, WorkerRpcOptions } from './types' +import { createThreadsRpcOptions } from './utils' +import { runVmTests } from './vm' + +class ThreadsVmWorker implements VitestWorker { + getRpcOptions(ctx: WorkerContext): WorkerRpcOptions { + return createThreadsRpcOptions(ctx) + } + + runTests(state: WorkerGlobalState): unknown { + return runVmTests(state) + } +} + +export default new ThreadsVmWorker() diff --git a/packages/vitest/src/types/child.ts b/packages/vitest/src/types/child.ts deleted file mode 100644 index 722912fce5da..000000000000 --- a/packages/vitest/src/types/child.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ContextRPC } from './rpc' - -export interface ChildContext extends ContextRPC { - workerId: number -} diff --git a/packages/vitest/src/types/pool-options.ts b/packages/vitest/src/types/pool-options.ts index 49b6471bbd47..d4ca2b3fe425 100644 --- a/packages/vitest/src/types/pool-options.ts +++ b/packages/vitest/src/types/pool-options.ts @@ -1,4 +1,4 @@ -export type BuiltinPool = 'browser' | 'threads' | 'forks' | 'vmThreads' | 'typescript' // | 'vmForks' +export type BuiltinPool = 'browser' | 'threads' | 'forks' | 'vmThreads' | 'vmForks' | 'typescript' export type Pool = BuiltinPool | (string & {}) export interface PoolOptions extends Record { @@ -33,7 +33,7 @@ export interface PoolOptions extends Record { * * This makes tests run faster, but VM module is unstable. Your tests might leak memory. */ - // vmForks?: ForksOptions & VmOptions + vmForks?: ForksOptions & VmOptions } interface ThreadsOptions { diff --git a/packages/vitest/src/types/rpc.ts b/packages/vitest/src/types/rpc.ts index 1ea4d688f430..3209c4a09695 100644 --- a/packages/vitest/src/types/rpc.ts +++ b/packages/vitest/src/types/rpc.ts @@ -1,6 +1,6 @@ import type { FetchResult, RawSourceMap, ViteNodeResolveId } from 'vite-node' import type { CancelReason } from '@vitest/runner' -import type { EnvironmentOptions, ResolvedConfig, VitestEnvironment } from './config' +import type { EnvironmentOptions, Pool, ResolvedConfig, VitestEnvironment } from './config' import type { Environment, UserConsoleLog } from './general' import type { SnapshotResult } from './snapshot' import type { File, TaskResultPack } from './tasks' @@ -34,7 +34,6 @@ export interface RunnerRPC { export interface ContextTestEnvironment { name: VitestEnvironment - environment?: Environment transformMode?: TransformMode options: EnvironmentOptions | null } @@ -45,10 +44,13 @@ export interface ResolvedTestEnvironment { } export interface ContextRPC { + pool: Pool + worker: string + workerId: number config: ResolvedConfig projectName: string files: string[] - invalidates?: string[] environment: ContextTestEnvironment providedContext: Record + invalidates?: string[] } diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index 71ee864b68aa..3b5e76e83c0f 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -8,7 +8,6 @@ import type { ContextRPC, RunnerRPC, RuntimeRPC } from './rpc' import type { Environment } from './general' export interface WorkerContext extends ContextRPC { - workerId: number port: MessagePort } @@ -38,5 +37,4 @@ export interface WorkerGlobalState { environment: number prepare: number } - isChildProcess?: boolean } diff --git a/packages/vitest/src/utils/base.ts b/packages/vitest/src/utils/base.ts index 00b0eb1ded3e..3cf5cd381a82 100644 --- a/packages/vitest/src/utils/base.ts +++ b/packages/vitest/src/utils/base.ts @@ -149,3 +149,14 @@ class AggregateErrorPonyfill extends Error { } } export { AggregateErrorPonyfill as AggregateError } + +export function isChildProcess(): boolean { + return !!process?.send +} + +export function setProcessTitle(title: string) { + try { + process.title = `node (${title})` + } + catch {} +} diff --git a/packages/vitest/src/utils/global.ts b/packages/vitest/src/utils/global.ts index 260a9751fc1b..e833728bbd00 100644 --- a/packages/vitest/src/utils/global.ts +++ b/packages/vitest/src/utils/global.ts @@ -14,6 +14,17 @@ export function getWorkerState(): WorkerGlobalState { return workerState } +export function provideWorkerState(context: any, state: WorkerGlobalState) { + Object.defineProperty(context, '__vitest_worker__', { + value: state, + configurable: true, + writable: true, + enumerable: false, + }) + + return state +} + export function getCurrentEnvironment(): string { const state = getWorkerState() return state?.environment.name diff --git a/packages/vitest/src/utils/test-helpers.ts b/packages/vitest/src/utils/test-helpers.ts index 6a682e3a6f32..a3eb3970ce04 100644 --- a/packages/vitest/src/utils/test-helpers.ts +++ b/packages/vitest/src/utils/test-helpers.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs' import mm from 'micromatch' -import type { EnvironmentOptions, TransformModePatterns, VitestEnvironment } from '../types' +import type { ContextTestEnvironment, EnvironmentOptions, TransformModePatterns, VitestEnvironment } from '../types' import type { WorkspaceProject } from '../node/workspace' import { groupBy } from './base' @@ -47,14 +47,15 @@ export async function groupFilesByEnv(files: (readonly [WorkspaceProject, string const envOptions = JSON.parse(code.match(/@(?:vitest|jest)-environment-options\s+?(.+)/)?.[1] || 'null') const envKey = env === 'happy-dom' ? 'happyDOM' : env + const environment: ContextTestEnvironment = { + name: env as VitestEnvironment, + transformMode, + options: envOptions ? { [envKey]: envOptions } as EnvironmentOptions : null, + } return { file, project, - environment: { - name: env as VitestEnvironment, - transformMode, - options: envOptions ? { [envKey]: envOptions } as EnvironmentOptions : null, - }, + environment, } })) diff --git a/packages/vitest/src/workers.ts b/packages/vitest/src/workers.ts new file mode 100644 index 000000000000..413fb3a42d79 --- /dev/null +++ b/packages/vitest/src/workers.ts @@ -0,0 +1,6 @@ +export { createForksRpcOptions, createThreadsRpcOptions, unwrapForksConfig } from './runtime/workers/utils' +export { provideWorkerState } from './utils/global' +export { run as runVitestWorker } from './runtime/worker' +export { runVmTests } from './runtime/workers/vm' +export { runBaseTests } from './runtime/workers/base' +export type { WorkerRpcOptions, VitestWorker } from './runtime/workers/types' diff --git a/packages/vitest/workers.d.ts b/packages/vitest/workers.d.ts new file mode 100644 index 000000000000..84e349033987 --- /dev/null +++ b/packages/vitest/workers.d.ts @@ -0,0 +1 @@ +export * from './dist/workers.js' diff --git a/test/core/test/fixtures/timers.suite.ts b/test/core/test/fixtures/timers.suite.ts index 37396025ebf0..da3c64f8ed94 100644 --- a/test/core/test/fixtures/timers.suite.ts +++ b/test/core/test/fixtures/timers.suite.ts @@ -15,7 +15,7 @@ import { FakeTimers } from '../../../../packages/vitest/src/integrations/mock/ti class FakeDate extends Date {} -const isChildProcess = globalThis.__vitest_worker__.isChildProcess +const isChildProcess = !!process.send describe('FakeTimers', () => { afterEach(() => {