From 7bf54505a127b976c7e69cbec25cbb2c5bc6219e Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 25 Feb 2023 09:10:31 +0100 Subject: [PATCH] feat!: use child_process when --no-threads is used (#2772) --- docs/config/index.md | 15 +- packages/utils/src/helpers.ts | 17 ++ packages/utils/src/timers.ts | 18 ++ packages/vitest/rollup.config.js | 1 + packages/vitest/src/node/cli.ts | 1 + packages/vitest/src/node/core.ts | 4 +- packages/vitest/src/node/pool.ts | 228 +++------------------- packages/vitest/src/node/pools/child.ts | 124 ++++++++++++ packages/vitest/src/node/pools/rpc.ts | 61 ++++++ packages/vitest/src/node/pools/threads.ts | 153 +++++++++++++++ packages/vitest/src/runtime/child.ts | 79 ++++++++ packages/vitest/src/runtime/entry.ts | 4 +- packages/vitest/src/runtime/execute.ts | 67 ++++++- packages/vitest/src/runtime/rpc.ts | 22 ++- packages/vitest/src/runtime/worker.ts | 72 +------ packages/vitest/src/types/child.ts | 5 + packages/vitest/src/types/config.ts | 7 + packages/vitest/src/types/index.ts | 1 + packages/vitest/src/types/rpc.ts | 37 ++++ packages/vitest/src/types/worker.ts | 40 +--- pnpm-lock.yaml | 9 +- test/core/test/timers.test.ts | 2 + 22 files changed, 643 insertions(+), 324 deletions(-) create mode 100644 packages/vitest/src/node/pools/child.ts create mode 100644 packages/vitest/src/node/pools/rpc.ts create mode 100644 packages/vitest/src/node/pools/threads.ts create mode 100644 packages/vitest/src/runtime/child.ts create mode 100644 packages/vitest/src/types/child.ts create mode 100644 packages/vitest/src/types/rpc.ts diff --git a/docs/config/index.md b/docs/config/index.md index 79d4dee7ae5f..9e3ce584f22c 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -489,10 +489,21 @@ By providing an object instead of a string you can define individual outputs whe - **Default:** `true` - **CLI:** `--threads`, `--threads=false` -Enable multi-threading using [tinypool](https://github.com/tinylibs/tinypool) (a lightweight fork of [Piscina](https://github.com/piscinajs/piscina)) +Enable multi-threading using [tinypool](https://github.com/tinylibs/tinypool) (a lightweight fork of [Piscina](https://github.com/piscinajs/piscina)). Prior to Vitest 0.29.0, Vitest was still running tests inside worker thread, even if this option was disabled. Since 0.29.0, if this option is disabled, Vitest uses `child_process` to spawn a process to run tests inside, meaning you can use `process.chdir` and other API that was not available inside workers. If you want to revert to the previous behaviour, use `--single-thread` option instead. + +Disabling this option also disables module isolation, meaning all tests with the same environment are running inside a single child process. + +### singleThread + +- **Type:** `boolean` +- **Default:** `false` +- **Version:** Since Vitest 0.29.0 + +Run all tests with the same environment inside a single worker thread. This will disable built-in module isolation (your source code or [inlined](#deps-inline) code will still be reevaluated for each test), but can improve test performance. Before Vitest 0.29.0 this was equivalent to using `--no-threads`. + :::warning -This option is different from Jest's `--runInBand`. Vitest uses workers not only for running tests in parallel, but also to provide isolation. By disabling this option, your tests will run sequentially, but in the same global context, so you must provide isolation yourself. +Even though this option will force tests to run one after another, this option is different from Jest's `--runInBand`. Vitest uses workers not only for running tests in parallel, but also to provide isolation. By disabling this option, your tests will run sequentially, but in the same global context, so you must provide isolation yourself. This might cause all sorts of issues, if you are relying on global state (frontend frameworks usually do) or your code relies on environment to be defined separately for each test. But can be a speed boost for your tests (up to 3 times faster), that don't necessarily rely on global state or can easily bypass that. ::: diff --git a/packages/utils/src/helpers.ts b/packages/utils/src/helpers.ts index ed03ab42daaa..ed1636fc148c 100644 --- a/packages/utils/src/helpers.ts +++ b/packages/utils/src/helpers.ts @@ -11,6 +11,23 @@ export function slash(path: string) { return path.replace(/\\/g, '/') } +// convert RegExp.toString to RegExp +export function parseRegexp(input: string): RegExp { + // Parse input + const m = input.match(/(\/?)(.+)\1([a-z]*)/i) + + // match nothing + if (!m) + return /$^/ + + // Invalid flags + if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3])) + return RegExp(input) + + // Create the regular expression + return new RegExp(m[2], m[3]) +} + export function toArray(array?: Nullable>): Array { if (array === null || array === undefined) array = [] diff --git a/packages/utils/src/timers.ts b/packages/utils/src/timers.ts index 15ded9f88261..c3388bbcc47f 100644 --- a/packages/utils/src/timers.ts +++ b/packages/utils/src/timers.ts @@ -6,13 +6,22 @@ export function getSafeTimers() { setInterval: safeSetInterval, clearInterval: safeClearInterval, clearTimeout: safeClearTimeout, + setImmediate: safeSetImmediate, + clearImmediate: safeClearImmediate, } = (globalThis as any)[SAFE_TIMERS_SYMBOL] || globalThis + const { + nextTick: safeNextTick, + } = (globalThis as any)[SAFE_TIMERS_SYMBOL] || globalThis.process || { nextTick: (cb: () => void) => cb() } + return { + nextTick: safeNextTick, setTimeout: safeSetTimeout, setInterval: safeSetInterval, clearInterval: safeClearInterval, clearTimeout: safeClearTimeout, + setImmediate: safeSetImmediate, + clearImmediate: safeClearImmediate, } } @@ -22,13 +31,22 @@ export function setSafeTimers() { setInterval: safeSetInterval, clearInterval: safeClearInterval, clearTimeout: safeClearTimeout, + setImmediate: safeSetImmediate, + clearImmediate: safeClearImmediate, } = globalThis + const { + nextTick: safeNextTick, + } = globalThis.process || { nextTick: cb => cb() } + const timers = { + nextTick: safeNextTick, setTimeout: safeSetTimeout, setInterval: safeSetInterval, clearInterval: safeClearInterval, clearTimeout: safeClearTimeout, + setImmediate: safeSetImmediate, + clearImmediate: safeClearImmediate, } ;(globalThis as any)[SAFE_TIMERS_SYMBOL] = timers diff --git a/packages/vitest/rollup.config.js b/packages/vitest/rollup.config.js index d803b1ffa218..c2139ae77643 100644 --- a/packages/vitest/rollup.config.js +++ b/packages/vitest/rollup.config.js @@ -23,6 +23,7 @@ const entries = [ 'src/runners.ts', 'src/environments.ts', 'src/runtime/worker.ts', + 'src/runtime/child.ts', 'src/runtime/loader.ts', 'src/runtime/entry.ts', 'src/integrations/spy.ts', diff --git a/packages/vitest/src/node/cli.ts b/packages/vitest/src/node/cli.ts index d2a03f80c52c..f75f2d47d29b 100644 --- a/packages/vitest/src/node/cli.ts +++ b/packages/vitest/src/node/cli.ts @@ -21,6 +21,7 @@ cli .option('--open', 'Open UI automatically (default: !process.env.CI))') .option('--api [api]', 'Serve API, available options: --api.port , --api.host [host] and --api.strictPort') .option('--threads', 'Enabled threads (default: true)') + .option('--single-thread', 'Run tests inside a single thread, requires --threads (default: false)') .option('--silent', 'Silent console output from tests') .option('--isolate', 'Isolate environment for each test file (default: true)') .option('--reporter ', 'Specify reporters') diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 32c7fe7dd223..1598cf960ad1 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -12,7 +12,7 @@ import { deepMerge, hasFailed, noop, slash, toArray } from '../utils' import { getCoverageProvider } from '../integrations/coverage' import { Typechecker } from '../typecheck/typechecker' import { createPool } from './pool' -import type { WorkerPool } from './pool' +import type { ProcessPool } from './pool' import { createBenchmarkReporters, createReporters } from './reporters/utils' import { StateManager } from './state' import { resolveConfig } from './config' @@ -32,7 +32,7 @@ export class Vitest { reporters: Reporter[] = undefined! coverageProvider: CoverageProvider | null | undefined logger: Logger - pool: WorkerPool | undefined + pool: ProcessPool | undefined typechecker: Typechecker | undefined vitenode: ViteNodeServer = undefined! diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index fcc85794a403..6dde6031bc49 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -1,37 +1,26 @@ -import { MessageChannel } from 'node:worker_threads' -import _url from 'node:url' -import { cpus } from 'node:os' +import { pathToFileURL } from 'node:url' import { resolve } from 'pathe' -import type { Options as TinypoolOptions } from 'tinypool' -import { Tinypool } from 'tinypool' -import { createBirpc } from 'birpc' -import type { RawSourceMap } from 'vite-node' -import type { ResolvedConfig, WorkerContext, WorkerRPC, WorkerTestEnvironment } from '../types' import { distDir, rootDir } from '../constants' -import { AggregateError, getEnvironmentTransformMode, groupBy } from '../utils' -import { envsOrder, groupFilesByEnv } from '../utils/test-helpers' import type { Vitest } from './core' +import { createChildProcessPool } from './pools/child' +import { createThreadsPool } from './pools/threads' export type RunWithFiles = (files: string[], invalidates?: string[]) => Promise -export interface WorkerPool { +export interface ProcessPool { runTests: RunWithFiles close: () => Promise } -const workerPath = _url.pathToFileURL(resolve(distDir, './worker.js')).href -const loaderPath = _url.pathToFileURL(resolve(distDir, './loader.js')).href +export interface PoolProcessOptions { + execArgv: string[] + env: Record +} +const loaderPath = pathToFileURL(resolve(distDir, './loader.js')).href const suppressLoaderWarningsPath = resolve(rootDir, './suppress-warnings.cjs') -export function createPool(ctx: Vitest): WorkerPool { - const threadsCount = ctx.config.watch - ? Math.max(Math.floor(cpus().length / 2), 1) - : Math.max(cpus().length - 1, 1) - - const maxThreads = ctx.config.maxThreads ?? threadsCount - const minThreads = ctx.config.minThreads ?? threadsCount - +export function createPool(ctx: Vitest): ProcessPool { const conditions = ctx.server.config.resolve.conditions?.flatMap(c => ['--conditions', c]) || [] // Instead of passing whole process.execArgv to the workers, pick allowed options. @@ -40,15 +29,7 @@ export function createPool(ctx: Vitest): WorkerPool { execArg.startsWith('--cpu-prof') || execArg.startsWith('--heap-prof'), ) - const options: TinypoolOptions = { - filename: workerPath, - // TODO: investigate further - // It seems atomics introduced V8 Fatal Error https://github.com/vitest-dev/vitest/issues/1191 - useAtomics: ctx.config.useAtomics ?? false, - - maxThreads, - minThreads, - + const options: PoolProcessOptions = { execArgv: ctx.config.deps.registerNodeLoader ? [ ...execArgv, @@ -56,188 +37,23 @@ export function createPool(ctx: Vitest): WorkerPool { suppressLoaderWarningsPath, '--experimental-loader', loaderPath, - ...conditions, + ...execArgv, ] : [ ...execArgv, ...conditions, ], - } - - if (ctx.config.isolate) { - options.isolateWorkers = true - options.concurrentTasksPerWorker = 1 - } - - if (!ctx.config.threads) { - options.concurrentTasksPerWorker = 1 - options.maxThreads = 1 - options.minThreads = 1 - } - - options.env = { - TEST: 'true', - VITEST: 'true', - NODE_ENV: ctx.config.mode || 'test', - VITEST_MODE: ctx.config.watch ? 'WATCH' : 'RUN', - ...process.env, - ...ctx.config.env, - } - - const pool = new Tinypool(options) - - const runWithFiles = (name: string): RunWithFiles => { - let id = 0 - - async function runFiles(config: ResolvedConfig, files: string[], environment: WorkerTestEnvironment, invalidates: string[] = []) { - ctx.state.clearFiles(files) - const { workerPort, port } = createChannel(ctx) - const workerId = ++id - const data: WorkerContext = { - port: workerPort, - config, - files, - invalidates, - environment, - workerId, - } - try { - await pool.run(data, { transferList: [workerPort], name }) - } - finally { - port.close() - workerPort.close() - } - } - - const Sequencer = ctx.config.sequence.sequencer - const sequencer = new Sequencer(ctx) - - return async (files, invalidates) => { - const config = ctx.getSerializableConfig() - - if (config.shard) - files = await sequencer.shard(files) - - files = await sequencer.sort(files) - - const filesByEnv = await groupFilesByEnv(files, config) - const envs = envsOrder.concat( - Object.keys(filesByEnv).filter(env => !envsOrder.includes(env)), - ) - - if (!ctx.config.threads) { - // always run environments isolated between each other - for (const env of envs) { - const files = filesByEnv[env] - - if (!files?.length) - continue - - const filesByOptions = groupBy(files, ({ environment }) => JSON.stringify(environment.options)) - - for (const option in filesByOptions) { - const files = filesByOptions[option] - - if (files?.length) { - const filenames = files.map(f => f.file) - await runFiles(config, filenames, files[0].environment, invalidates) - } - } - } - } - else { - const promises = Object.values(filesByEnv).flat() - const results = await Promise.allSettled(promises - .map(({ file, environment }) => runFiles(config, [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 { - 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() + env: { + TEST: 'true', + VITEST: 'true', + NODE_ENV: ctx.config.mode || 'test', + VITEST_MODE: ctx.config.watch ? 'WATCH' : 'RUN', + ...process.env, + ...ctx.config.env, }, } -} - -function createChannel(ctx: Vitest) { - const channel = new MessageChannel() - const port = channel.port2 - const workerPort = channel.port1 - - createBirpc<{}, WorkerRPC>( - { - async onWorkerExit(error, code) { - await ctx.logger.printError(error, false, 'Unexpected Exit') - process.exit(code || 1) - }, - snapshotSaved(snapshot) { - ctx.snapshot.add(snapshot) - }, - resolveSnapshotPath(testPath: string) { - return ctx.snapshot.resolvePath(testPath) - }, - async getSourceMap(id, force) { - if (force) { - const mod = ctx.server.moduleGraph.getModuleById(id) - if (mod) - ctx.server.moduleGraph.invalidateModule(mod) - } - const r = await ctx.vitenode.transformRequest(id) - return r?.map as RawSourceMap | undefined - }, - fetch(id, environment) { - const transformMode = getEnvironmentTransformMode(ctx.config, environment) - return ctx.vitenode.fetchModule(id, transformMode) - }, - resolveId(id, importer, environment) { - const transformMode = getEnvironmentTransformMode(ctx.config, environment) - return ctx.vitenode.resolveId(id, importer, transformMode) - }, - onPathsCollected(paths) { - ctx.state.collectPaths(paths) - ctx.report('onPathsCollected', paths) - }, - onCollected(files) { - ctx.state.collectFiles(files) - ctx.report('onCollected', files) - }, - onAfterSuiteRun(meta) { - ctx.coverageProvider?.onAfterSuiteRun(meta) - }, - onTaskUpdate(packs) { - ctx.state.updateTasks(packs) - ctx.report('onTaskUpdate', packs) - }, - onUserConsoleLog(log) { - ctx.state.updateUserLog(log) - ctx.report('onUserConsoleLog', log) - }, - onUnhandledError(err, type) { - ctx.state.catchError(err, type) - }, - onFinished(files) { - ctx.report('onFinished', files, ctx.state.getUnhandledErrors()) - }, - }, - { - post(v) { - port.postMessage(v) - }, - on(fn) { - port.on('message', fn) - }, - }, - ) - return { workerPort, port } + if (!ctx.config.threads) + return createChildProcessPool(ctx, options) + return createThreadsPool(ctx, options) } diff --git a/packages/vitest/src/node/pools/child.ts b/packages/vitest/src/node/pools/child.ts new file mode 100644 index 000000000000..3e074454b83a --- /dev/null +++ b/packages/vitest/src/node/pools/child.ts @@ -0,0 +1,124 @@ +import v8 from 'node:v8' +import type { ChildProcess } from 'node:child_process' +import { fork } from 'node:child_process' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createBirpc } from 'birpc' +import { resolve } from 'pathe' +import type { ContextTestEnvironment, ResolvedConfig, RuntimeRPC } from '../../types' +import type { Vitest } from '../core' +import type { ChildContext } from '../../types/child' +import type { PoolProcessOptions, ProcessPool } from '../pool' +import { distDir } from '../../constants' +import { groupBy } from '../../utils/base' +import { envsOrder, groupFilesByEnv } from '../../utils/test-helpers' +import { createMethodsRPC } from './rpc' + +const childPath = fileURLToPath(pathToFileURL(resolve(distDir, './child.js')).href) + +function setupChildProcessChannel(ctx: Vitest, fork: ChildProcess) { + createBirpc<{}, RuntimeRPC>( + createMethodsRPC(ctx), + { + serialize: v8.serialize, + deserialize: v => v8.deserialize(Buffer.from(v)), + post(v) { + fork.send(v) + }, + on(fn) { + fork.on('message', fn) + }, + }, + ) +} + +function stringifyRegex(input: RegExp | string): any { + if (typeof input === 'string') + return input + return `$$vitest:${input.toString()}` +} + +function getTestConfig(ctx: Vitest) { + const config = ctx.getSerializableConfig() + // v8 serialize does not support regex + return { + ...config, + testNamePattern: config.testNamePattern + ? stringifyRegex(config.testNamePattern) + : undefined, + } +} + +export function createChildProcessPool(ctx: Vitest, { execArgv, env }: PoolProcessOptions): ProcessPool { + const children = new Set() + + function runFiles(config: ResolvedConfig, files: string[], environment: ContextTestEnvironment, invalidates: string[] = []) { + const data: ChildContext = { + command: 'start', + config, + files, + invalidates, + environment, + } + + const child = fork(childPath, [], { + execArgv, + env, + }) + children.add(child) + setupChildProcessChannel(ctx, child) + + return new Promise((resolve, reject) => { + child.send(data, (err) => { + if (err) + reject(err) + }) + child.on('close', (code) => { + if (!code) + resolve() + else + reject(new Error(`Child process exited unexpectedly with code ${code}`)) + + children.delete(child) + }) + }) + } + + async function runWithFiles(files: string[], invalidates: string[] = []) { + ctx.state.clearFiles(files) + const config = getTestConfig(ctx) + + const filesByEnv = await groupFilesByEnv(files, config) + const envs = envsOrder.concat( + Object.keys(filesByEnv).filter(env => !envsOrder.includes(env)), + ) + + // always run environments isolated between each other + for (const env of envs) { + const files = filesByEnv[env] + + if (!files?.length) + continue + + const filesByOptions = groupBy(files, ({ environment }) => JSON.stringify(environment.options)) + + for (const option in filesByOptions) { + const files = filesByOptions[option] + + if (files?.length) { + const filenames = files.map(f => f.file) + await runFiles(config, filenames, files[0].environment, invalidates) + } + } + } + } + + return { + runTests: runWithFiles, + async close() { + children.forEach((child) => { + if (!child.killed) + child.kill() + }) + }, + } +} diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts new file mode 100644 index 000000000000..34a0a93d2bf7 --- /dev/null +++ b/packages/vitest/src/node/pools/rpc.ts @@ -0,0 +1,61 @@ +import type { RawSourceMap } from 'vite-node' +import type { RuntimeRPC } from '../../types' +import { getEnvironmentTransformMode } from '../../utils/base' +import type { Vitest } from '../core' + +export function createMethodsRPC(ctx: Vitest): RuntimeRPC { + return { + async onWorkerExit(error, code) { + await ctx.logger.printError(error, false, 'Unexpected Exit') + process.exit(code || 1) + }, + snapshotSaved(snapshot) { + ctx.snapshot.add(snapshot) + }, + resolveSnapshotPath(testPath: string) { + return ctx.snapshot.resolvePath(testPath) + }, + async getSourceMap(id, force) { + if (force) { + const mod = ctx.server.moduleGraph.getModuleById(id) + if (mod) + ctx.server.moduleGraph.invalidateModule(mod) + } + const r = await ctx.vitenode.transformRequest(id) + return r?.map as RawSourceMap | undefined + }, + fetch(id, environment) { + const transformMode = getEnvironmentTransformMode(ctx.config, environment) + return ctx.vitenode.fetchModule(id, transformMode) + }, + resolveId(id, importer, environment) { + const transformMode = getEnvironmentTransformMode(ctx.config, environment) + return ctx.vitenode.resolveId(id, importer, transformMode) + }, + onPathsCollected(paths) { + ctx.state.collectPaths(paths) + ctx.report('onPathsCollected', paths) + }, + onCollected(files) { + ctx.state.collectFiles(files) + ctx.report('onCollected', files) + }, + onAfterSuiteRun(meta) { + ctx.coverageProvider?.onAfterSuiteRun(meta) + }, + onTaskUpdate(packs) { + ctx.state.updateTasks(packs) + ctx.report('onTaskUpdate', packs) + }, + onUserConsoleLog(log) { + ctx.state.updateUserLog(log) + ctx.report('onUserConsoleLog', log) + }, + onUnhandledError(err, type) { + ctx.state.catchError(err, type) + }, + onFinished(files) { + ctx.report('onFinished', files, ctx.state.getUnhandledErrors()) + }, + } +} diff --git a/packages/vitest/src/node/pools/threads.ts b/packages/vitest/src/node/pools/threads.ts new file mode 100644 index 000000000000..eadaf824fcbc --- /dev/null +++ b/packages/vitest/src/node/pools/threads.ts @@ -0,0 +1,153 @@ +import { MessageChannel } from 'node:worker_threads' +import { cpus } from 'node:os' +import { pathToFileURL } from 'node:url' +import { createBirpc } from 'birpc' +import { resolve } from 'pathe' +import type { Options as TinypoolOptions } from 'tinypool' +import Tinypool from 'tinypool' +import { distDir } from '../../constants' +import type { ContextTestEnvironment, ResolvedConfig, RuntimeRPC, WorkerContext } from '../../types' +import type { Vitest } from '../core' +import type { PoolProcessOptions, ProcessPool, RunWithFiles } from '../pool' +import { envsOrder, groupFilesByEnv } from '../../utils/test-helpers' +import { groupBy } from '../../utils/base' +import { createMethodsRPC } from './rpc' + +const workerPath = pathToFileURL(resolve(distDir, './worker.js')).href + +function createWorkerChannel(ctx: Vitest) { + const channel = new MessageChannel() + const port = channel.port2 + const workerPort = channel.port1 + + createBirpc<{}, RuntimeRPC>( + createMethodsRPC(ctx), + { + post(v) { + port.postMessage(v) + }, + on(fn) { + port.on('message', fn) + }, + }, + ) + + return { workerPort, port } +} + +export function createThreadsPool(ctx: Vitest, { execArgv, env }: PoolProcessOptions): ProcessPool { + const threadsCount = ctx.config.watch + ? Math.max(Math.floor(cpus().length / 2), 1) + : Math.max(cpus().length - 1, 1) + + const maxThreads = ctx.config.maxThreads ?? threadsCount + const minThreads = ctx.config.minThreads ?? threadsCount + + const options: TinypoolOptions = { + filename: workerPath, + // TODO: investigate further + // It seems atomics introduced V8 Fatal Error https://github.com/vitest-dev/vitest/issues/1191 + useAtomics: ctx.config.useAtomics ?? false, + + maxThreads, + minThreads, + + env, + execArgv, + } + + if (ctx.config.isolate) { + options.isolateWorkers = true + options.concurrentTasksPerWorker = 1 + } + + if (ctx.config.singleThread) { + options.concurrentTasksPerWorker = 1 + options.maxThreads = 1 + options.minThreads = 1 + } + + const pool = new Tinypool(options) + + const runWithFiles = (name: string): RunWithFiles => { + let id = 0 + + async function runFiles(config: ResolvedConfig, files: string[], environment: ContextTestEnvironment, invalidates: string[] = []) { + ctx.state.clearFiles(files) + const { workerPort, port } = createWorkerChannel(ctx) + const workerId = ++id + const data: WorkerContext = { + port: workerPort, + config, + files, + invalidates, + environment, + workerId, + } + try { + await pool.run(data, { transferList: [workerPort], name }) + } + finally { + port.close() + workerPort.close() + } + } + + const Sequencer = ctx.config.sequence.sequencer + const sequencer = new Sequencer(ctx) + + return async (files, invalidates) => { + const config = ctx.getSerializableConfig() + + if (config.shard) + files = await sequencer.shard(files) + + files = await sequencer.sort(files) + + const filesByEnv = await groupFilesByEnv(files, config) + const envs = envsOrder.concat( + Object.keys(filesByEnv).filter(env => !envsOrder.includes(env)), + ) + + if (ctx.config.singleThread) { + // always run environments isolated between each other + for (const env of envs) { + const files = filesByEnv[env] + + if (!files?.length) + continue + + const filesByOptions = groupBy(files, ({ environment }) => JSON.stringify(environment.options)) + + for (const option in filesByOptions) { + const files = filesByOptions[option] + + if (files?.length) { + const filenames = files.map(f => f.file) + await runFiles(config, filenames, files[0].environment, invalidates) + } + } + } + } + else { + const promises = Object.values(filesByEnv).flat() + const results = await Promise.allSettled(promises + .map(({ file, environment }) => runFiles(config, [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 { + 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() + }, + } +} diff --git a/packages/vitest/src/runtime/child.ts b/packages/vitest/src/runtime/child.ts new file mode 100644 index 000000000000..b4509434ed77 --- /dev/null +++ b/packages/vitest/src/runtime/child.ts @@ -0,0 +1,79 @@ +import v8 from 'node:v8' +import { createBirpc } from 'birpc' +import { parseRegexp } from '@vitest/utils' +import type { ResolvedConfig } from '../types' +import type { RuntimeRPC } from '../types/rpc' +import type { ChildContext } from '../types/child' +import { mockMap, moduleCache, startViteNode } from './execute' +import { rpcDone } from './rpc' + +function init(ctx: ChildContext) { + const { config } = ctx + + process.env.VITEST_WORKER_ID = '1' + process.env.VITEST_POOL_ID = '1' + + // @ts-expect-error untyped global + globalThis.__vitest_environment__ = config.environment + // @ts-expect-error I know what I am doing :P + globalThis.__vitest_worker__ = { + ctx, + moduleCache, + config, + mockMap, + rpc: createBirpc( + {}, + { + eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onWorkerExit'], + serialize: v8.serialize, + deserialize: v => v8.deserialize(Buffer.from(v)), + post(v) { + process.send?.(v) + }, + on(fn) { process.on('message', fn) }, + }, + ), + } + + if (ctx.invalidates) { + ctx.invalidates.forEach((fsPath) => { + moduleCache.delete(fsPath) + moduleCache.delete(`mock:${fsPath}`) + }) + } + ctx.files.forEach(i => moduleCache.delete(i)) +} + +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) { + init(ctx) + const { run, executor } = await startViteNode(ctx) + await run(ctx.files, ctx.config, ctx.environment, executor) + await rpcDone() +} + +const procesExit = process.exit + +process.on('message', async (message: any) => { + if (typeof message === 'object' && message.command === 'start') { + try { + message.config = unwrapConfig(message.config) + await run(message) + } + finally { + procesExit() + } + } +}) diff --git a/packages/vitest/src/runtime/entry.ts b/packages/vitest/src/runtime/entry.ts index 4db8f2dfa453..e785b63a000e 100644 --- a/packages/vitest/src/runtime/entry.ts +++ b/packages/vitest/src/runtime/entry.ts @@ -1,7 +1,7 @@ import type { VitestRunner, VitestRunnerConstructor } from '@vitest/runner' import { startTests } from '@vitest/runner' import { resolve } from 'pathe' -import type { ResolvedConfig, WorkerTestEnvironment } from '../types' +import type { ContextTestEnvironment, ResolvedConfig } from '../types' import { getWorkerState, resetModules } from '../utils' import { vi } from '../integrations/vi' import { distDir } from '../constants' @@ -65,7 +65,7 @@ async function getTestRunner(config: ResolvedConfig, executor: VitestExecutor): } // browser shouldn't call this! -export async function run(files: string[], config: ResolvedConfig, environment: WorkerTestEnvironment, executor: VitestExecutor): Promise { +export async function run(files: string[], config: ResolvedConfig, environment: ContextTestEnvironment, executor: VitestExecutor): Promise { await setupGlobalEnv(config) await startCoverageInsideWorker(config.coverage, executor) diff --git a/packages/vitest/src/runtime/execute.ts b/packages/vitest/src/runtime/execute.ts index 6f2e271a998d..49fc0943f8dc 100644 --- a/packages/vitest/src/runtime/execute.ts +++ b/packages/vitest/src/runtime/execute.ts @@ -1,11 +1,16 @@ -import { ViteNodeRunner } from 'vite-node/client' -import { isInternalRequest } from 'vite-node/utils' +import { pathToFileURL } from 'node:url' +import { ModuleCacheMap, ViteNodeRunner } from 'vite-node/client' +import { isInternalRequest, isPrimitive } from 'vite-node/utils' import type { ViteNodeRunnerOptions } from 'vite-node' -import { normalize } from 'pathe' +import { normalize, relative, resolve } from 'pathe' import { isNodeBuiltin } from 'mlly' +import { processError } from '@vitest/runner/utils' import type { MockMap } from '../types/mocker' import { getCurrentEnvironment, getWorkerState } from '../utils/global' +import type { ContextRPC, ContextTestEnvironment, ResolvedConfig } from '../types' +import { distDir } from '../constants' import { VitestMocker } from './mocker' +import { rpc } from './rpc' export interface ExecuteOptions extends ViteNodeRunnerOptions { mockMap: MockMap @@ -19,6 +24,62 @@ export async function createVitestExecutor(options: ExecuteOptions) { return runner } +let _viteNode: { + run: (files: string[], config: ResolvedConfig, environment: ContextTestEnvironment, executor: VitestExecutor) => Promise + executor: VitestExecutor +} + +export const moduleCache = new ModuleCacheMap() +export const mockMap: MockMap = new Map() + +export async function startViteNode(ctx: ContextRPC) { + if (_viteNode) + return _viteNode + + const { config } = ctx + + const processExit = process.exit + + process.exit = (code = process.exitCode || 0): never => { + const error = new Error(`process.exit called with "${code}"`) + rpc().onWorkerExit(error, code) + return processExit(code) + } + + function catchError(err: unknown, type: string) { + const worker = getWorkerState() + const error = processError(err) + if (worker.filepath && !isPrimitive(error)) { + error.VITEST_TEST_NAME = worker.current?.name + error.VITEST_TEST_PATH = relative(config.root, worker.filepath) + } + rpc().onUnhandledError(error, type) + } + + process.on('uncaughtException', e => catchError(e, 'Uncaught Exception')) + process.on('unhandledRejection', e => catchError(e, 'Unhandled Rejection')) + + const executor = await createVitestExecutor({ + fetchModule(id) { + return rpc().fetch(id, ctx.environment.name) + }, + resolveId(id, importer) { + return rpc().resolveId(id, importer, ctx.environment.name) + }, + moduleCache, + mockMap, + interopDefault: config.deps.interopDefault, + root: config.root, + base: config.base, + }) + + const { run } = await import(pathToFileURL(resolve(distDir, 'entry.js')).href) + + _viteNode = { run, executor } + + return _viteNode +} + export class VitestExecutor extends ViteNodeRunner { public mocker: VitestMocker diff --git a/packages/vitest/src/runtime/rpc.ts b/packages/vitest/src/runtime/rpc.ts index 524ac976de00..b845e13d5b43 100644 --- a/packages/vitest/src/runtime/rpc.ts +++ b/packages/vitest/src/runtime/rpc.ts @@ -3,23 +3,39 @@ import { } from '@vitest/utils' import { getWorkerState } from '../utils' +const { get } = Reflect const safeRandom = Math.random function withSafeTimers(fn: () => void) { - const { setTimeout: safeSetTimeout } = getSafeTimers() + const { setTimeout, clearTimeout, nextTick, setImmediate, clearImmediate } = getSafeTimers() + const currentSetTimeout = globalThis.setTimeout + const currentClearTimeout = globalThis.clearTimeout const currentRandom = globalThis.Math.random + const currentNextTick = globalThis.process.nextTick + const currentSetImmediate = globalThis.setImmediate + const currentClearImmediate = globalThis.clearImmediate try { - globalThis.setTimeout = safeSetTimeout + globalThis.setTimeout = setTimeout + globalThis.clearTimeout = clearTimeout globalThis.Math.random = safeRandom + globalThis.process.nextTick = nextTick + globalThis.setImmediate = setImmediate + globalThis.clearImmediate = clearImmediate const result = fn() return result } finally { globalThis.setTimeout = currentSetTimeout + globalThis.clearTimeout = currentClearTimeout globalThis.Math.random = currentRandom + globalThis.setImmediate = currentSetImmediate + globalThis.clearImmediate = currentClearImmediate + nextTick(() => { + globalThis.process.nextTick = currentNextTick + }) } } @@ -36,7 +52,7 @@ export const rpc = () => { const { rpc } = getWorkerState() return new Proxy(rpc, { get(target, p, handler) { - const sendCall = Reflect.get(target, p, handler) + const sendCall = get(target, p, handler) const safeSendCall = (...args: any[]) => withSafeTimers(async () => { const result = sendCall(...args) promises.add(result) diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index 53e2f731f41b..46cacf264826 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -1,73 +1,9 @@ -import { pathToFileURL } from 'node:url' -import { relative, resolve } from 'pathe' import { createBirpc } from 'birpc' import { workerId as poolId } from 'tinypool' -import { processError } from '@vitest/runner/utils' -import { ModuleCacheMap } from 'vite-node/client' -import { isPrimitive } from 'vite-node/utils' -import type { ResolvedConfig, WorkerContext, WorkerRPC, WorkerTestEnvironment } from '../types' -import { distDir } from '../constants' +import type { RuntimeRPC, WorkerContext } from '../types' import { getWorkerState } from '../utils/global' -import type { MockMap } from '../types/mocker' -import type { VitestExecutor } from './execute' -import { createVitestExecutor } from './execute' -import { rpc, rpcDone } from './rpc' - -let _viteNode: { - run: (files: string[], config: ResolvedConfig, environment: WorkerTestEnvironment, executor: VitestExecutor) => Promise - executor: VitestExecutor -} - -const moduleCache = new ModuleCacheMap() -const mockMap: MockMap = new Map() - -async function startViteNode(ctx: WorkerContext) { - if (_viteNode) - return _viteNode - - const { config } = ctx - - const processExit = process.exit - - process.exit = (code = process.exitCode || 0): never => { - const error = new Error(`process.exit called with "${code}"`) - rpc().onWorkerExit(error, code) - return processExit(code) - } - - function catchError(err: unknown, type: string) { - const worker = getWorkerState() - const error = processError(err) - if (worker.filepath && !isPrimitive(error)) { - error.VITEST_TEST_NAME = worker.current?.name - error.VITEST_TEST_PATH = relative(config.root, worker.filepath) - } - rpc().onUnhandledError(error, type) - } - - process.on('uncaughtException', e => catchError(e, 'Uncaught Exception')) - process.on('unhandledRejection', e => catchError(e, 'Unhandled Rejection')) - - const executor = await createVitestExecutor({ - fetchModule(id) { - return rpc().fetch(id, ctx.environment.name) - }, - resolveId(id, importer) { - return rpc().resolveId(id, importer, ctx.environment.name) - }, - moduleCache, - mockMap, - interopDefault: config.deps.interopDefault, - root: config.root, - base: config.base, - }) - - const { run } = await import(pathToFileURL(resolve(distDir, 'entry.js')).href) - - _viteNode = { run, executor } - - return _viteNode -} +import { mockMap, moduleCache, startViteNode } from './execute' +import { rpcDone } from './rpc' function init(ctx: WorkerContext) { // @ts-expect-error untyped global @@ -87,7 +23,7 @@ function init(ctx: WorkerContext) { moduleCache, config, mockMap, - rpc: createBirpc( + rpc: createBirpc( {}, { eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onWorkerExit'], diff --git a/packages/vitest/src/types/child.ts b/packages/vitest/src/types/child.ts new file mode 100644 index 000000000000..023e1dc77a96 --- /dev/null +++ b/packages/vitest/src/types/child.ts @@ -0,0 +1,5 @@ +import type { ContextRPC } from './rpc' + +export interface ChildContext extends ContextRPC { + command: 'start' +} diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index c838b80de54d..5ddb7a3a704a 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -309,6 +309,13 @@ export interface InlineConfig { */ isolate?: boolean + /** + * Run tests inside a single thread. + * + * @default false + */ + singleThread?: boolean + /** * Coverage options */ diff --git a/packages/vitest/src/types/index.ts b/packages/vitest/src/types/index.ts index 74bbd2b7b56d..6df7616f26ef 100644 --- a/packages/vitest/src/types/index.ts +++ b/packages/vitest/src/types/index.ts @@ -6,6 +6,7 @@ export { assertType, type AssertType } from '../typecheck/assertType' export * from '../typecheck/types' export * from './config' export * from './tasks' +export * from './rpc' export * from './reporter' export * from './snapshot' export * from './worker' diff --git a/packages/vitest/src/types/rpc.ts b/packages/vitest/src/types/rpc.ts new file mode 100644 index 000000000000..eb10ba97ed35 --- /dev/null +++ b/packages/vitest/src/types/rpc.ts @@ -0,0 +1,37 @@ +import type { RawSourceMap } from 'source-map' +import type { FetchResult, ViteNodeResolveId } from 'vite-node' +import type { EnvironmentOptions, ResolvedConfig, VitestEnvironment } from './config' +import type { UserConsoleLog } from './general' +import type { SnapshotResult } from './snapshot' +import type { File, TaskResultPack } from './tasks' +import type { AfterSuiteRunMeta } from './worker' + +export interface RuntimeRPC { + fetch: (id: string, environment: VitestEnvironment) => Promise + resolveId: (id: string, importer: string | undefined, environment: VitestEnvironment) => Promise + getSourceMap: (id: string, force?: boolean) => Promise + + onFinished: (files: File[], errors?: unknown[]) => void + onWorkerExit: (error: unknown, code?: number) => void + onPathsCollected: (paths: string[]) => void + onUserConsoleLog: (log: UserConsoleLog) => void + onUnhandledError: (err: unknown, type: string) => void + onCollected: (files: File[]) => void + onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void + onTaskUpdate: (pack: TaskResultPack[]) => void + + snapshotSaved: (snapshot: SnapshotResult) => void + resolveSnapshotPath: (testPath: string) => string +} + +export interface ContextTestEnvironment { + name: VitestEnvironment + options: EnvironmentOptions | null +} + +export interface ContextRPC { + config: ResolvedConfig + files: string[] + invalidates?: string[] + environment: ContextTestEnvironment +} diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index fd231ed43020..165940c4589d 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -1,24 +1,14 @@ import type { MessagePort } from 'node:worker_threads' -import type { File, TaskResultPack, Test } from '@vitest/runner' -import type { FetchResult, ModuleCacheMap, RawSourceMap, ViteNodeResolveId } from 'vite-node' +import type { Test } from '@vitest/runner' +import type { ModuleCacheMap, ViteNodeResolveId } from 'vite-node' import type { BirpcReturn } from 'birpc' import type { MockMap } from './mocker' -import type { EnvironmentOptions, ResolvedConfig, VitestEnvironment } from './config' -import type { SnapshotResult } from './snapshot' -import type { UserConsoleLog } from './general' +import type { ResolvedConfig } from './config' +import type { ContextRPC, RuntimeRPC } from './rpc' -export interface WorkerTestEnvironment { - name: VitestEnvironment - options: EnvironmentOptions | null -} - -export interface WorkerContext { +export interface WorkerContext extends ContextRPC { workerId: number port: MessagePort - config: ResolvedConfig - files: string[] - environment: WorkerTestEnvironment - invalidates?: string[] } export type ResolveIdFunction = (id: string, importer?: string) => Promise @@ -27,28 +17,10 @@ export interface AfterSuiteRunMeta { coverage?: unknown } -export interface WorkerRPC { - fetch: (id: string, environment: VitestEnvironment) => Promise - resolveId: (id: string, importer: string | undefined, environment: VitestEnvironment) => Promise - getSourceMap: (id: string, force?: boolean) => Promise - - onFinished: (files: File[], errors?: unknown[]) => void - onWorkerExit: (error: unknown, code?: number) => void - onPathsCollected: (paths: string[]) => void - onUserConsoleLog: (log: UserConsoleLog) => void - onUnhandledError: (err: unknown, type: string) => void - onCollected: (files: File[]) => void - onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void - onTaskUpdate: (pack: TaskResultPack[]) => void - - snapshotSaved: (snapshot: SnapshotResult) => void - resolveSnapshotPath: (testPath: string) => string -} - export interface WorkerGlobalState { ctx: WorkerContext config: ResolvedConfig - rpc: BirpcReturn + rpc: BirpcReturn current?: Test filepath?: string moduleCache: ModuleCacheMap diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3187cd0cce72..5c420599a419 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -292,7 +292,7 @@ importers: '@types/react-test-renderer': 17.0.2 '@vitejs/plugin-react': 3.1.0_vite@4.0.0 '@vitest/ui': link:../../packages/ui - happy-dom: 8.7.2 + happy-dom: 8.9.0 jsdom: 21.1.0 react-test-renderer: 17.0.2_react@17.0.2 vite: 4.0.0 @@ -1070,7 +1070,7 @@ importers: devDependencies: '@vitejs/plugin-vue': 4.0.0_vite@4.0.0+vue@3.2.47 '@vue/test-utils': 2.3.0_vue@3.2.47 - happy-dom: 8.7.2 + happy-dom: 8.9.0 vite: 4.0.0 vitest: link:../../packages/vitest vue: 3.2.47 @@ -13771,11 +13771,12 @@ packages: - encoding dev: true - /happy-dom/8.7.2: - resolution: {integrity: sha512-lkm1l7SLNtI9svaU3PflbM8zahYahLrUZf0fZTUkQ8W6bo5gtXjC/2utOkcjpv9rhWTkHFUuDVjAvBWg4ClAxA==} + /happy-dom/8.9.0: + resolution: {integrity: sha512-JZwJuGdR7ko8L61136YzmrLv7LgTh5b8XaEM3P709mLjyQuXJ3zHTDXvUtBBahRjGlcYW0zGjIiEWizoTUGKfA==} dependencies: css.escape: 1.5.1 he: 1.2.0 + iconv-lite: 0.6.3 node-fetch: 2.6.7 webidl-conversions: 7.0.0 whatwg-encoding: 2.0.0 diff --git a/test/core/test/timers.test.ts b/test/core/test/timers.test.ts index 85d821ddffc9..744623608b3b 100644 --- a/test/core/test/timers.test.ts +++ b/test/core/test/timers.test.ts @@ -1186,6 +1186,8 @@ describe('FakeTimers', () => { expect(global.setImmediate).not.toBe(nativeSetImmediate) expect(global.clearImmediate).not.toBe(nativeClearImmediate) + + fakeTimers.useRealTimers() }) })