diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57e8b3852476..3ed5e8fbef68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,8 +81,8 @@ jobs: - name: Test run: pnpm run test:ci - - name: Test Single Thread - run: pnpm run test:ci:single-thread + - name: Test No Threads + run: pnpm run test:ci:no-threads - name: Test Vm Threads run: pnpm run test:ci:vm-threads diff --git a/docs/api/vi.md b/docs/api/vi.md index ac84ddb02d5c..f1edba60755c 100644 --- a/docs/api/vi.md +++ b/docs/api/vi.md @@ -704,8 +704,15 @@ unmockedIncrement(30) === 31 To enable mocking timers, you need to call this method. It will wrap all further calls to timers (such as `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval`, `nextTick`, `setImmediate`, `clearImmediate`, and `Date`), until [`vi.useRealTimers()`](#vi-userealtimers) is called. + Mocking `nextTick` is not supported when running Vitest inside `node:child_process` by using `--no-threads`. NodeJS uses `process.nextTick` internally in `node:child_process` and hangs when it is mocked. Mocking `nextTick` is supported when running Vitest with `--threads`. + The implementation is based internally on [`@sinonjs/fake-timers`](https://github.com/sinonjs/fake-timers). + ::: tip + Since version `0.35.0` `vi.useFakeTimers()` no longer automatically mocks `process.nextTick`. + It can still be mocked by specyfing the option in `toFake` argument: `vi.useFakeTimers({ toFake: ['nextTick'] })`. + ::: + ## vi.isFakeTimers - **Type:** `() => boolean` diff --git a/docs/config/index.md b/docs/config/index.md index 035979eb28d6..73f02c9a7567 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -692,7 +692,7 @@ Percentage based memory limit [does not work on Linux CircleCI](https://github.c 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. +Disabling this option makes all tests run inside multiple child processes. ### singleThread diff --git a/docs/guide/debugging.md b/docs/guide/debugging.md index 93a835d0c42b..ff9e21d35521 100644 --- a/docs/guide/debugging.md +++ b/docs/guide/debugging.md @@ -59,7 +59,7 @@ Vitest also supports debugging tests without IDEs. However this requires that te vitest --inspect-brk --single-thread # To run in a child process -vitest --inspect-brk --no-threads +vitest --inspect-brk --single-thread --no-threads ``` Once Vitest starts it will stop execution and waits for you to open developer tools that can connect to [NodeJS inspector](https://nodejs.org/en/docs/guides/debugging-getting-started/). You can use Chrome DevTools for this by opening `chrome://inspect` on browser. diff --git a/package.json b/package.json index 0c30f82d0c8d..c05f4e6fe7cc 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "test:all": "CI=true pnpm -r --stream run test --allowOnly", "test:ci": "CI=true pnpm -r --stream --filter !test-fails --filter !test-browser --filter !test-esm --filter !test-browser run test --allowOnly", "test:ci:vm-threads": "CI=true pnpm -r --stream --filter !test-fails --filter !test-single-thread --filter !test-browser --filter !test-esm --filter !test-browser run test --allowOnly --experimental-vm-threads", - "test:ci:single-thread": "CI=true pnpm -r --stream --filter !test-fails --filter !test-coverage --filter !test-watch --filter !test-bail --filter !test-esm --filter !test-browser run test --allowOnly --no-threads", + "test:ci:no-threads": "CI=true pnpm -r --stream --filter !test-fails --filter !test-coverage --filter !test-watch --filter !test-bail --filter !test-esm --filter !test-browser run test --allowOnly --no-threads", "typecheck": "tsc --noEmit", "typecheck:why": "tsc --noEmit --explainFiles > explainTypes.txt", "ui:build": "vite build packages/ui", diff --git a/packages/ui/package.json b/packages/ui/package.json index 77c5de570261..2ce8a2a452a7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -69,7 +69,7 @@ "@vitest/ws-client": "workspace:*", "@vueuse/core": "^10.2.1", "ansi-to-html": "^0.7.2", - "birpc": "0.2.12", + "birpc": "0.2.14", "codemirror": "^5.65.13", "codemirror-theme-vars": "^0.1.2", "cypress": "^12.16.0", diff --git a/packages/vitest/package.json b/packages/vitest/package.json index c4f8b5b551fb..ac4b7a079707 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -162,7 +162,7 @@ "std-env": "^3.3.3", "strip-literal": "^1.0.1", "tinybench": "^2.5.0", - "tinypool": "^0.7.0", + "tinypool": "^0.8.1", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0", "vite-node": "workspace:*", "why-is-node-running": "^2.2.2" @@ -180,7 +180,7 @@ "@types/micromatch": "^4.0.2", "@types/prompts": "^2.4.4", "@types/sinonjs__fake-timers": "^8.1.2", - "birpc": "0.2.12", + "birpc": "0.2.14", "chai-subset": "^1.6.0", "cli-truncate": "^3.1.0", "event-target-polyfill": "^0.0.3", diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 2f4546de97d0..2b7a9078915a 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -21,7 +21,7 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, server?: Vit const wss = new WebSocketServer({ noServer: true }) - const clients = new Map>() + const clients = new Map>() ;(server || ctx.server).httpServer?.on('upgrade', (request, socket, head) => { if (!request.url) @@ -154,7 +154,7 @@ class WebSocketReporter implements Reporter { constructor( public ctx: Vitest, public wss: WebSocketServer, - public clients: Map>, + public clients: Map>, ) {} onCollected(files?: File[]) { diff --git a/packages/vitest/src/integrations/mock/timers.ts b/packages/vitest/src/integrations/mock/timers.ts index 84e1317590ff..00584297060a 100644 --- a/packages/vitest/src/integrations/mock/timers.ts +++ b/packages/vitest/src/integrations/mock/timers.ts @@ -130,7 +130,13 @@ export class FakeTimers { } if (!this._fakingTime) { - const toFake = Object.keys(this._fakeTimers.timers) as Array + const toFake = Object.keys(this._fakeTimers.timers) + // 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) + throw new Error('process.nextTick cannot be mocked inside child_process') this._clock = this._fakeTimers.install({ now: Date.now(), diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index d135dc346d03..9cc616542687 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -205,13 +205,21 @@ function createVitest(): VitestUtils { const utils: VitestUtils = { useFakeTimers(config?: FakeTimerInstallOpts) { - if (config) { - _timers.configure(config) + const workerState = getWorkerState() + + if (workerState.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 --threads if mocking nextTick is required.', + ) + } } - else { - const workerState = getWorkerState() + + if (config) + _timers.configure(config) + else _timers.configure(workerState.config.fakeTimers) - } + _timers.useFakeTimers() return utils }, diff --git a/packages/vitest/src/node/pools/child.ts b/packages/vitest/src/node/pools/child.ts index a22e91fb3c12..01e49f2b7282 100644 --- a/packages/vitest/src/node/pools/child.ts +++ b/packages/vitest/src/node/pools/child.ts @@ -1,21 +1,32 @@ 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 { cpus } from 'node:os' +import EventEmitter from 'node:events' +import { Tinypool } from 'tinypool' +import type { TinypoolChannel, Options as TinypoolOptions } from 'tinypool' import { createBirpc } from 'birpc' import { resolve } from 'pathe' import type { ContextTestEnvironment, ResolvedConfig, RunnerRPC, RuntimeRPC, Vitest } from '../../types' import type { ChildContext } from '../../types/child' -import type { PoolProcessOptions, ProcessPool, WorkspaceSpec } from '../pool' +import type { PoolProcessOptions, ProcessPool, RunWithFiles } from '../pool' import { distDir } from '../../paths' -import { groupBy } from '../../utils/base' -import { envsOrder, groupFilesByEnv } from '../../utils/test-helpers' import type { WorkspaceProject } from '../workspace' +import { envsOrder, groupFilesByEnv } from '../../utils/test-helpers' +import { groupBy } from '../../utils' import { createMethodsRPC } from './rpc' const childPath = fileURLToPath(pathToFileURL(resolve(distDir, './child.js')).href) -function setupChildProcessChannel(project: WorkspaceProject, fork: ChildProcess): void { +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), { @@ -23,15 +34,17 @@ function setupChildProcessChannel(project: WorkspaceProject, fork: ChildProcess) serialize: v8.serialize, deserialize: v => v8.deserialize(Buffer.from(v)), post(v) { - fork.send(v) + emitter.emit(events.message, v) }, on(fn) { - fork.on('message', fn) + emitter.on(events.response, fn) }, }, ) project.ctx.onCancel(reason => rpc.onCancel(reason)) + + return { channel, cleanup } } function stringifyRegex(input: RegExp | string): string { @@ -40,101 +53,180 @@ function stringifyRegex(input: RegExp | string): string { return `$$vitest:${input.toString()}` } -function getTestConfig(ctx: WorkspaceProject): ResolvedConfig { - 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 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 = { + runtime: 'child_process', + filename: childPath, + + maxThreads, + minThreads, + + env, + execArgv, + + terminateTimeout: ctx.config.teardownTimeout, } -} -export function createChildProcessPool(ctx: Vitest, { execArgv, env }: PoolProcessOptions): ProcessPool { - const children = new Set() + if (ctx.config.isolate) { + options.isolateWorkers = true + options.concurrentTasksPerWorker = 1 + } - const Sequencer = ctx.config.sequence.sequencer - const sequencer = new Sequencer(ctx) + if (ctx.config.singleThread) { + options.concurrentTasksPerWorker = 1 + options.maxThreads = 1 + options.minThreads = 1 + } - function runFiles(project: WorkspaceProject, files: string[], environment: ContextTestEnvironment, invalidates: string[] = []) { - const config = getTestConfig(project) - ctx.state.clearFiles(project, files) + 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: ChildContext = { + config, + files, + invalidates, + environment, + workerId, + } + 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(', ')}.`) - const data: ChildContext = { - command: 'start', - config, - files, - invalidates, - environment, - } + // Intentionally cancelled + else if (ctx.isCancelling && error instanceof Error && /The task has been cancelled/.test(error.message)) + ctx.state.cancelFiles(files, ctx.config.root) - const child = fork(childPath, [], { - execArgv, - env, - // TODO: investigate - // serialization: 'advanced', - }) - children.add(child) - setupChildProcessChannel(project, 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}`)) + throw error + } + finally { + cleanup() + } + } - children.delete(child) - }) - }) - } + const Sequencer = ctx.config.sequence.sequencer + const sequencer = new Sequencer(ctx) + + return async (specs, invalidates) => { + // Cancel pending tasks from pool when possible + ctx.onCancel(() => pool.cancelPendingTasks()) - async function runTests(specs: WorkspaceSpec[], invalidates: string[] = []): Promise { - const { shard } = ctx.config + const configs = new Map() + const getConfig = (project: WorkspaceProject): ResolvedConfig => { + if (configs.has(project)) + return configs.get(project)! - if (shard) - specs = await sequencer.shard(specs) + 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 workspaceMap = new Map() + for (const [project, file] of specs) { + const workspaceFiles = workspaceMap.get(file) ?? [] + workspaceFiles.push(project) + workspaceMap.set(file, workspaceFiles) + } - specs = await sequencer.sort(specs) + // it's possible that project defines a file that is also defined by another project + const { shard } = ctx.config + + if (shard) + specs = await sequencer.shard(specs) + + specs = await sequencer.sort(specs) + + // TODO: What to do about singleThread flag? + const singleThreads = specs.filter(([project]) => project.config.singleThread) + const multipleThreads = specs.filter(([project]) => !project.config.singleThread) + + if (multipleThreads.length) { + const filesByEnv = await groupFilesByEnv(multipleThreads) + const files = Object.values(filesByEnv).flat() + const results: PromiseSettledResult[] = [] + + if (ctx.config.isolate) { + results.push(...await Promise.allSettled(files.map(({ file, environment, project }) => + runFiles(project, getConfig(project), [file], environment, invalidates)))) + } + else { + // When isolation is disabled, we still need to isolate environments and workspace projects from each other. + // Tasks are still running parallel but environments are isolated between tasks. + const grouped = groupBy(files, ({ project, environment }) => project.getName() + environment.name + JSON.stringify(environment.options)) + + for (const group of Object.values(grouped)) { + // Push all files to pool's queue + results.push(...await Promise.allSettled(group.map(({ file, environment, project }) => + runFiles(project, getConfig(project), [file], environment, invalidates)))) + + // Once all tasks are running or finished, recycle worker for isolation. + // On-going workers will run in the previous environment. + await new Promise(resolve => pool.queueSize === 0 ? resolve() : pool.once('drain', resolve)) + await pool.recycleWorkers() + } + } + + 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.') + } - const filesByEnv = await groupFilesByEnv(specs) - const envs = envsOrder.concat( - Object.keys(filesByEnv).filter(env => !envsOrder.includes(env)), - ) + if (singleThreads.length) { + const filesByEnv = await groupFilesByEnv(singleThreads) + 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] + for (const env of envs) { + const files = filesByEnv[env] - if (!files?.length) - continue + if (!files?.length) + continue - const filesByOptions = groupBy(files, ({ project, environment }) => project.getName() + JSON.stringify(environment.options) + environment.transformMode) + const filesByOptions = groupBy(files, ({ project, environment }) => project.getName() + JSON.stringify(environment.options)) - for (const option in filesByOptions) { - const files = filesByOptions[option] + for (const files of Object.values(filesByOptions)) { + // Always run environments isolated between each other + await pool.recycleWorkers() - if (files?.length) { - const filenames = files.map(f => f.file) - await runFiles(files[0].project, filenames, files[0].environment, invalidates) + const filenames = files.map(f => f.file) + await runFiles(files[0].project, getConfig(files[0].project), filenames, files[0].environment, invalidates) + } } } } } return { - runTests, - async close() { - children.forEach((child) => { - if (!child.killed) - child.kill() - }) - children.clear() + runTests: runWithFiles('run'), + close: async () => { + await pool.destroy() }, } } diff --git a/packages/vitest/src/runtime/child.ts b/packages/vitest/src/runtime/child.ts index d3df2af516ac..b1b1d915b4f4 100644 --- a/packages/vitest/src/runtime/child.ts +++ b/packages/vitest/src/runtime/child.ts @@ -2,6 +2,8 @@ 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' @@ -12,10 +14,10 @@ import { createSafeRpc, rpcDone } from './rpc' import { setupInspect } from './inspector' async function init(ctx: ChildContext) { - const { config } = ctx + const { config, workerId } = ctx - process.env.VITEST_WORKER_ID = '1' - process.env.VITEST_POOL_ID = '1' + process.env.VITEST_WORKER_ID = String(workerId) + process.env.VITEST_POOL_ID = String(poolId) let setCancel = (_reason: CancelReason) => {} const onCancel = new Promise((resolve) => { @@ -33,7 +35,15 @@ async function init(ctx: ChildContext) { post(v) { process.send?.(v) }, - on(fn) { process.on('message', fn) }, + 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) + }) + }, }, )) @@ -58,6 +68,7 @@ async function init(ctx: ChildContext) { prepare: performance.now(), }, rpc, + isChildProcess: true, } // @ts-expect-error I know what I am doing :P @@ -88,6 +99,9 @@ function unwrapConfig(config: ResolvedConfig) { } export async function run(ctx: ChildContext) { + const exit = process.exit + + ctx.config = unwrapConfig(ctx.config) const inspectorCleanup = setupInspect(ctx.config) try { @@ -100,19 +114,6 @@ export async function run(ctx: ChildContext) { } finally { inspectorCleanup() + process.exit = exit } } - -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/rpc.ts b/packages/vitest/src/runtime/rpc.ts index 0357c059f5a1..e2ee890beebe 100644 --- a/packages/vitest/src/runtime/rpc.ts +++ b/packages/vitest/src/runtime/rpc.ts @@ -3,7 +3,7 @@ import { } from '@vitest/utils' import type { BirpcReturn } from 'birpc' import { getWorkerState } from '../utils/global' -import type { RuntimeRPC } from '../types/rpc' +import type { RunnerRPC, RuntimeRPC } from '../types/rpc' import type { WorkerRPC } from '../types' const { get } = Reflect @@ -73,7 +73,7 @@ export function createSafeRpc(rpc: WorkerRPC) { }) } -export function rpc(): BirpcReturn { +export function rpc(): BirpcReturn { const { rpc } = getWorkerState() return rpc } diff --git a/packages/vitest/src/runtime/vm.ts b/packages/vitest/src/runtime/vm.ts index 323249c16408..9a9e5bf246f9 100644 --- a/packages/vitest/src/runtime/vm.ts +++ b/packages/vitest/src/runtime/vm.ts @@ -7,7 +7,7 @@ import { createBirpc } from 'birpc' import { resolve } from 'pathe' import { installSourcemapsSupport } from 'vite-node/source-map' import type { CancelReason } from '@vitest/runner' -import type { RuntimeRPC, WorkerContext, WorkerGlobalState } from '../types' +import type { RunnerRPC, RuntimeRPC, WorkerContext, WorkerGlobalState } from '../types' import { distDir } from '../paths' import { loadEnvironment } from '../integrations/env' import { startVitestExecutor } from './execute' @@ -26,7 +26,7 @@ export async function run(ctx: WorkerContext) { setCancel = resolve }) - const rpc = createSafeRpc(createBirpc( + const rpc = createSafeRpc(createBirpc( { onCancel: setCancel, }, diff --git a/packages/vitest/src/types/child.ts b/packages/vitest/src/types/child.ts index 023e1dc77a96..722912fce5da 100644 --- a/packages/vitest/src/types/child.ts +++ b/packages/vitest/src/types/child.ts @@ -1,5 +1,5 @@ import type { ContextRPC } from './rpc' export interface ChildContext extends ContextRPC { - command: 'start' + workerId: number } diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index 7d3c4cde3a6f..afd77d668ed1 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -4,7 +4,7 @@ import type { ModuleCacheMap, ViteNodeResolveId } from 'vite-node' import type { BirpcReturn } from 'birpc' import type { MockMap } from './mocker' import type { ResolvedConfig } from './config' -import type { ContextRPC, RuntimeRPC } from './rpc' +import type { ContextRPC, RunnerRPC, RuntimeRPC } from './rpc' import type { Environment } from './general' export interface WorkerContext extends ContextRPC { @@ -18,7 +18,7 @@ export interface AfterSuiteRunMeta { coverage?: unknown } -export type WorkerRPC = BirpcReturn +export type WorkerRPC = BirpcReturn export interface WorkerGlobalState { ctx: ContextRPC @@ -35,4 +35,5 @@ export interface WorkerGlobalState { environment: number prepare: number } + isChildProcess?: boolean } diff --git a/packages/ws-client/package.json b/packages/ws-client/package.json index 57ee168a1011..3e211bb7de9f 100644 --- a/packages/ws-client/package.json +++ b/packages/ws-client/package.json @@ -39,7 +39,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "birpc": "0.2.12", + "birpc": "0.2.14", "flatted": "^3.2.7", "ws": "^8.13.0" }, diff --git a/packages/ws-client/src/index.ts b/packages/ws-client/src/index.ts index d34af68200d5..7a94d4be87b2 100644 --- a/packages/ws-client/src/index.ts +++ b/packages/ws-client/src/index.ts @@ -22,7 +22,7 @@ export interface VitestClientOptions { export interface VitestClient { ws: WebSocket state: StateManager - rpc: BirpcReturn + rpc: BirpcReturn waitForConnection(): Promise reconnect(): Promise } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fad8beaf1f41..436282092cf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1196,8 +1196,8 @@ importers: specifier: ^0.7.2 version: 0.7.2 birpc: - specifier: 0.2.12 - version: 0.2.12 + specifier: 0.2.14 + version: 0.2.14 codemirror: specifier: ^5.65.13 version: 5.65.13 @@ -1354,8 +1354,8 @@ importers: specifier: ^2.5.0 version: 2.5.0 tinypool: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^0.8.1 + version: 0.8.1 vite: specifier: ^4.4.9 version: 4.4.9(@types/node@18.7.13) @@ -1403,8 +1403,8 @@ importers: specifier: ^8.1.2 version: 8.1.2 birpc: - specifier: 0.2.12 - version: 0.2.12 + specifier: 0.2.14 + version: 0.2.14 chai-subset: specifier: ^1.6.0 version: 1.6.0 @@ -1497,8 +1497,8 @@ importers: packages/ws-client: dependencies: birpc: - specifier: 0.2.12 - version: 0.2.12 + specifier: 0.2.14 + version: 0.2.14 flatted: specifier: ^3.2.7 version: 3.2.7 @@ -13111,8 +13111,8 @@ packages: dev: true optional: true - /birpc@0.2.12: - resolution: {integrity: sha512-6Wz9FXuJ/FE4gDH+IGQhrYdalAvAQU1Yrtcu1UlMk3+9mMXxIRXiL+MxUcGokso42s+Fy+YoUXGLOdOs0siV3A==} + /birpc@0.2.14: + resolution: {integrity: sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==} /bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -26029,8 +26029,8 @@ packages: resolution: {integrity: sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA==} dev: false - /tinypool@0.7.0: - resolution: {integrity: sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==} + /tinypool@0.8.1: + resolution: {integrity: sha512-zBTCK0cCgRROxvs9c0CGK838sPkeokNGdQVUUwHAbynHFlmyJYj825f/oRs528HaIJ97lo0pLIlDUzwN+IorWg==} engines: {node: '>=14.0.0'} dev: false diff --git a/test/bail/test/bail.test.ts b/test/bail/test/bail.test.ts index ddd8197d9728..5dce88cc6c7f 100644 --- a/test/bail/test/bail.test.ts +++ b/test/bail/test/bail.test.ts @@ -15,7 +15,7 @@ for (const isolate of [true, false]) { for (const config of configs) { test(`should bail with "${JSON.stringify(config)}"`, async () => { - process.env.THREADS = config?.threads ? 'true' : 'false' + process.env.THREADS = config?.singleThread ? 'false' : 'true' const { exitCode, stdout } = await runVitest({ root: './fixtures', diff --git a/test/config/fixtures/test/fake-timers.test.ts b/test/config/fixtures/test/fake-timers.test.ts new file mode 100644 index 000000000000..c28f52c22ef5 --- /dev/null +++ b/test/config/fixtures/test/fake-timers.test.ts @@ -0,0 +1,7 @@ +import { test, vi } from 'vitest' + +test('uses fake timers', () => { + vi.useFakeTimers() + + vi.useRealTimers() +}) diff --git a/test/config/test/failures.test.ts b/test/config/test/failures.test.ts index 745006b313ba..3d0c73da7047 100644 --- a/test/config/test/failures.test.ts +++ b/test/config/test/failures.test.ts @@ -87,3 +87,23 @@ test('boolean browser flag without dot notation, with more dot notation options' expect(stderr).toMatch('Error: A boolean argument "--browser" was used with dot notation arguments "--browser.name".') expect(stderr).toMatch('Please specify the "--browser" argument with dot notation as well: "--browser.enabled"') }) + +test('nextTick cannot be mocked inside child_process', async () => { + const { stderr } = await runVitest({ + threads: false, + fakeTimers: { toFake: ['nextTick'] }, + include: ['./fixtures/test/fake-timers.test.ts'], + }) + + expect(stderr).toMatch('Error: vi.useFakeTimers({ toFake: ["nextTick"] }) is not supported in node:child_process. Use --threads if mocking nextTick is required.') +}) + +test('nextTick can be mocked inside worker_threads', async () => { + const { stderr } = await runVitest({ + threads: true, + fakeTimers: { toFake: ['nextTick'] }, + include: ['./fixtures/test/fake-timers.test.ts'], + }) + + expect(stderr).not.toMatch('Error') +}) diff --git a/test/core/test/fixtures/timers.suite.ts b/test/core/test/fixtures/timers.suite.ts index d20085a66c8c..37396025ebf0 100644 --- a/test/core/test/fixtures/timers.suite.ts +++ b/test/core/test/fixtures/timers.suite.ts @@ -15,6 +15,8 @@ import { FakeTimers } from '../../../../packages/vitest/src/integrations/mock/ti class FakeDate extends Date {} +const isChildProcess = globalThis.__vitest_worker__.isChildProcess + describe('FakeTimers', () => { afterEach(() => { vi.useRealTimers() @@ -49,7 +51,7 @@ describe('FakeTimers', () => { expect(global.clearInterval).not.toBe(undefined) }) - it('mocks process.nextTick if it exists on global', () => { + it.skipIf(isChildProcess)('mocks process.nextTick if it exists on global', () => { const origNextTick = () => {} const global = { Date: FakeDate, @@ -59,11 +61,35 @@ describe('FakeTimers', () => { }, setTimeout, } - const timers = new FakeTimers({ global }) + const timers = new FakeTimers({ global, config: { toFake: ['nextTick'] } }) timers.useFakeTimers() expect(global.process.nextTick).not.toBe(origNextTick) }) + it.runIf(isChildProcess)('does not mock process.nextTick if it exists on global and is child_process', () => { + const origNextTick = () => {} + const global = { + Date: FakeDate, + clearTimeout, + process: { + nextTick: origNextTick, + }, + setTimeout, + } + const timers = new FakeTimers({ global }) + timers.useFakeTimers() + expect(global.process.nextTick).toBe(origNextTick) + }) + + it.runIf(isChildProcess)('throws when is child_process and tries to mock nextTick', () => { + const global = { process, setTimeout, clearTimeout } + const timers = new FakeTimers({ global, config: { toFake: ['nextTick'] } }) + + expect(() => timers.useFakeTimers()).toThrow( + 'process.nextTick cannot be mocked inside child_process', + ) + }) + it('mocks setImmediate if it exists on global', () => { const origSetImmediate = () => {} const global = { @@ -96,7 +122,7 @@ describe('FakeTimers', () => { }) describe('runAllTicks', () => { - it('runs all ticks, in order', () => { + it.skipIf(isChildProcess)('runs all ticks, in order', () => { const global = { Date: FakeDate, clearTimeout, @@ -106,7 +132,7 @@ describe('FakeTimers', () => { setTimeout, } - const timers = new FakeTimers({ global }) + const timers = new FakeTimers({ global, config: { toFake: ['nextTick'] } }) timers.useFakeTimers() const runOrder = [] @@ -144,7 +170,7 @@ describe('FakeTimers', () => { expect(nextTick).toHaveBeenCalledTimes(0) }) - it('only runs a scheduled callback once', () => { + it.skipIf(isChildProcess)('only runs a scheduled callback once', () => { const global = { Date: FakeDate, clearTimeout, @@ -154,7 +180,7 @@ describe('FakeTimers', () => { setTimeout, } - const timers = new FakeTimers({ global }) + const timers = new FakeTimers({ global, config: { toFake: ['nextTick'] } }) timers.useFakeTimers() const mock1 = vi.fn() @@ -168,7 +194,7 @@ describe('FakeTimers', () => { expect(mock1).toHaveBeenCalledTimes(1) }) - it('throws before allowing infinite recursion', () => { + it.skipIf(isChildProcess)('throws before allowing infinite recursion', () => { const global = { Date: FakeDate, clearTimeout, @@ -178,7 +204,7 @@ describe('FakeTimers', () => { setTimeout, } - const timers = new FakeTimers({ global, config: { loopLimit: 100 } }) + const timers = new FakeTimers({ global, config: { loopLimit: 100, toFake: ['nextTick'] } }) timers.useFakeTimers() @@ -322,9 +348,9 @@ describe('FakeTimers', () => { ) }) - it('also clears ticks', () => { + it.skipIf(isChildProcess)('also clears ticks', () => { const global = { Date: FakeDate, clearTimeout, process, setTimeout } - const timers = new FakeTimers({ global }) + const timers = new FakeTimers({ global, config: { toFake: ['nextTick', 'setTimeout'] } }) timers.useFakeTimers() const fn = vi.fn() @@ -428,9 +454,9 @@ describe('FakeTimers', () => { ) }) - it('also clears ticks', async () => { + it.skipIf(isChildProcess)('also clears ticks', async () => { const global = { Date: FakeDate, clearTimeout, process, setTimeout, Promise } - const timers = new FakeTimers({ global }) + const timers = new FakeTimers({ global, config: { toFake: ['setTimeout', 'nextTick'] } }) timers.useFakeTimers() const fn = vi.fn() @@ -1062,7 +1088,7 @@ describe('FakeTimers', () => { expect(global.clearInterval).toBe(nativeClearInterval) }) - it('resets native process.nextTick when present', () => { + it.skipIf(isChildProcess)('resets native process.nextTick when present', () => { const nativeProcessNextTick = vi.fn() const global = { @@ -1071,7 +1097,7 @@ describe('FakeTimers', () => { process: { nextTick: nativeProcessNextTick }, setTimeout, } - const timers = new FakeTimers({ global }) + const timers = new FakeTimers({ global, config: { toFake: ['nextTick'] } }) timers.useFakeTimers() // Ensure that timers has overridden the native timer APIs @@ -1143,7 +1169,7 @@ describe('FakeTimers', () => { expect(global.clearInterval).not.toBe(nativeClearInterval) }) - it('resets mock process.nextTick when present', () => { + it.skipIf(isChildProcess)('resets mock process.nextTick when present', () => { const nativeProcessNextTick = vi.fn() const global = { @@ -1152,7 +1178,7 @@ describe('FakeTimers', () => { process: { nextTick: nativeProcessNextTick }, setTimeout, } - const timers = new FakeTimers({ global }) + const timers = new FakeTimers({ global, config: { toFake: ['nextTick'] } }) timers.useRealTimers() // Ensure that the real timers are installed at this point @@ -1216,8 +1242,8 @@ describe('FakeTimers', () => { timers.useRealTimers() }) - it('includes immediates and ticks', () => { - const timers = new FakeTimers({ global }) + it.skipIf(isChildProcess)('includes immediates and ticks', () => { + const timers = new FakeTimers({ global, config: { toFake: ['setTimeout', 'setImmediate', 'nextTick'] } }) timers.useFakeTimers() diff --git a/test/setup/tests/setup-files.test.ts b/test/setup/tests/setup-files.test.ts index 4146e2b9b44c..86d6cfa2c8d0 100644 --- a/test/setup/tests/setup-files.test.ts +++ b/test/setup/tests/setup-files.test.ts @@ -19,7 +19,7 @@ describe('setup files with forceRerunTrigger', () => { }) // Note that this test will fail locally if you have uncommitted changes - it('should run no tests if setup file is not changed', async () => { + it.runIf(process.env.GITHUB_ACTION)('should run no tests if setup file is not changed', async () => { const { stdout } = await run() expect(stdout).toContain('No test files found, exiting with code 0') }, 60_000) diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index fb4ffbeea0f6..31d33e1a58a6 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -12,6 +12,7 @@ import { dirname, resolve } from 'pathe' export async function runVitest(config: UserConfig, cliFilters: string[] = [], mode: VitestRunMode = 'test') { // Reset possible previous runs process.exitCode = 0 + let exitCode = process.exitCode // Prevent possible process.exit() calls, e.g. from --browser const exit = process.exit @@ -31,18 +32,18 @@ export async function runVitest(config: UserConfig, cliFilters: string[] = [], m return { stderr: `${getLogs().stderr}\n${e.message}`, stdout: getLogs().stdout, - exitCode: process.exitCode, + exitCode, vitest, } } finally { + exitCode = process.exitCode + process.exitCode = 0 + process.exit = exit + restore() } - const exitCode = process.exitCode - process.exitCode = 0 - process.exit = exit - return { ...getLogs(), exitCode, vitest } } diff --git a/test/ui/setup.ts b/test/ui/setup.ts index 92a2d9c6e96b..a9c6e4ce03ff 100644 --- a/test/ui/setup.ts +++ b/test/ui/setup.ts @@ -93,11 +93,10 @@ export async function startChromium() { } } -export async function startServerCommand(root: string, command: string, url: string) { +export async function startServerCommand(command: string, url: string) { let error: any const exitChromium = await startChromium() const subProcess = execaCommand(command, { - cwd: root, env: { ...process.env, CI: 'true', diff --git a/test/ui/test/html-report.spec.ts b/test/ui/test/html-report.spec.ts index 7b2d32de86bd..162dd8850a68 100644 --- a/test/ui/test/html-report.spec.ts +++ b/test/ui/test/html-report.spec.ts @@ -13,8 +13,7 @@ describe.skipIf(isWindows)('html report', () => { await runVitest({ root, reporters: 'html', outputFile: 'html/index.html' }) const exit = await startServerCommand( - root, - `npx vite preview --outDir html --strict-port --port ${port}`, + `pnpm exec vite preview --outDir fixtures/html --strict-port --port ${port}`, `http://localhost:${port}/`, ) diff --git a/test/ui/test/ui.spec.ts b/test/ui/test/ui.spec.ts index d13a133c7609..1995877d3252 100644 --- a/test/ui/test/ui.spec.ts +++ b/test/ui/test/ui.spec.ts @@ -1,16 +1,13 @@ -import { resolve } from 'node:path' import { beforeAll, describe, expect, it } from 'vitest' import { browserErrors, isWindows, page, ports, startServerCommand, untilUpdated } from '../setup' -const root = resolve(__dirname, '../fixtures') const port = ports.ui // TODO: fix flakyness on windows describe.skipIf(isWindows)('ui', () => { beforeAll(async () => { const exit = await startServerCommand( - root, - `npx vitest --ui --open false --api.port ${port} --watch --allowOnly`, + `pnpm exec vitest --root ./fixtures --ui --open false --api.port ${port} --watch --allowOnly`, `http://localhost:${port}/__vitest__/`, )