Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow custom pools #4417

Merged
merged 15 commits into from
Nov 18, 2023
6 changes: 5 additions & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,13 @@ export default withPwa(defineConfig({
link: '/advanced/metadata',
},
{
text: 'Extending default reporters',
text: 'Extending Reporters',
link: '/advanced/reporters',
},
{
text: 'Custom Pool',
link: '/advanced/pool',
},
],
},
],
Expand Down
90 changes: 90 additions & 0 deletions docs/advanced/pool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Custom Pool

::: warning
This is advanced API. If you are just running tests, you probably don't need this. It is primarily used by library authors.
:::

Vitest runs tests in pools. By default, there are several pools:

- `threads` to run tests using `node:worker_threads` (isolation is provided with a new worker context)
- `forks` to run tests using `node:child_process` (isolation is provided with a new `child_process.fork` process)
- `vmThreads` to run tests using `node:worker_threads` (but isolation is provided with `vm` module instead of a new worker context)
- `browser` to run tests using browser providers
- `typescript` to run typechecking on tests

You can provide your own pool by specifying a file path:

```ts
export default defineConfig({
test: {
// will run every file with a custom pool by default
pool: './my-custom-pool.ts',
AriPerkkio marked this conversation as resolved.
Show resolved Hide resolved
// you can provide options using `poolOptions` object
AriPerkkio marked this conversation as resolved.
Show resolved Hide resolved
poolOptions: {
myCustomPool: {
customProperty: true,
},
},
// you can also specify pool for a subset of files
poolMatchGlobs: [
['**/*.custom.test.ts', './my-custom-pool.ts'],
],
},
})
```

## API

The file specified in `pool` option should export a function (can be async) that accepts `Vitest` interface as its first option. This function needs to return an object matching `ProcessPool` interface:

```ts
import { ProcessPool, WorkspaceProject } from 'vitest/node'

export interface ProcessPool {
name: string
runTests: (files: [project: WorkspaceProject, testFile: string][], invalidates?: string[]) => Promise<void>
close?: () => Promise<void>
}
```

The function is called only once (unless the server config was updated), and it's generally a good idea to initialize everything you need for tests inside that function and reuse it when `runTests` is called.

Vitest calls `runTest` when new tests are scheduled to run. It will not call it if `files` is empty. The first argument is an array of tuples: the first element is a reference to a workspace project and the second one is an absolute path to a test file. Files are sorted using [`sequencer`](/config/#sequence.sequencer) before `runTests` is called. It's possible (but unlikely) to have the same file twice, but it will always have a different project - this is implemented via [`vitest.workspace.ts`](/guide/workspace) configuration.

Vitest will wait until `runTests` is executed before finishing a run (i.e., it will emit [`onFinished`](/guide/reporters) only after `runTests` is resolved).

If you are using a custom pool, you will have to provide test files and their results yourself - you can reference [`vitest.state`](https://github.com/vitest-dev/vitest/blob/feat/custom-pool/packages/vitest/src/node/state.ts) for that (most important are `collectFiles` and `updateTasks`). Vitest uses `startTests` function from `@vitest/runner` package to do that.

To communicate between different processes, you can create methods object using `createMethodsRPC` from `vitest/node`, and use any form of communication that you prefer. For example, to use websockets with `birpc` you can write something like this:

```ts
import { createBirpc } from 'birpc'
import { parse, stringify } from 'flatted'
import { WorkspaceProject, createMethodsRPC } from 'vitest/node'

function createRpc(project: WorkspaceProject, wss: WebSocketServer) {
return createBirpc(
createMethodsRPC(project),
{
post: msg => wss.send(msg),
on: fn => wss.on('message', fn),
serialize: stringify,
deserialize: parse,
},
)
}
```

To make sure every test is collected, you would call `ctx.state.collectFiles` and report it to Vitest reporters:

```ts
async function runTests(project: WorkspaceProject, tests: string[]) {
// ... running tests, put into "files" and "tasks"
const methods = createMethodsRPC(project)
await methods.onCollected(files)
// most reporters rely on results being updated in "onTaskUpdate"
await methods.onTaskUpdate(tasks)
}
```

You can see a simple example in [pool/custom-pool.ts](https://github.com/vitest-dev/vitest/blob/feat/custom-pool/test/run/pool-custom-fixtures/pool/custom-pool.ts).
6 changes: 3 additions & 3 deletions docs/advanced/reporters.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Extending default reporters
# Extending Reporters

You can import reporters from `vitest/reporters` and extend them to create your custom reporters.

## Extending built-in reporters
## Extending Built-in Reporters

In general, you don't need to create your reporter from scratch. `vitest` comes with several default reporting programs that you can extend.

Expand Down Expand Up @@ -56,7 +56,7 @@ export default defineConfig({
})
```

## Exported reporters
## Exported Reporters

`vitest` comes with a few [built-in reporters](/guide/reporters) that you can use out of the box.

Expand Down
1 change: 1 addition & 0 deletions packages/runner/src/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
shuffle,
tasks: [],
meta: Object.create(null),
projectName: '',
}

setHooks(suite, createSuiteHooks())
Expand Down
2 changes: 1 addition & 1 deletion packages/runner/src/types/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export interface Suite extends TaskBase {
type: 'suite'
tasks: Task[]
filepath?: string
projectName?: string
projectName: string
}

export interface File extends Suite {
Expand Down
20 changes: 20 additions & 0 deletions packages/vitest/src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import type { ApiConfig, ResolvedConfig, UserConfig, VitestRunMode } from '../ty
import { defaultBrowserPort, defaultPort } from '../constants'
import { benchmarkConfigDefaults, configDefaults } from '../defaults'
import { isCI, stdProvider, toArray } from '../utils'
import type { BuiltinPool } from '../types/pool-options'
import { VitestCache } from './cache'
import { BaseSequencer } from './sequencers/BaseSequencer'
import { RandomSequencer } from './sequencers/RandomSequencer'
import type { BenchmarkBuiltinReporters } from './reporters'
import { builtinPools } from './pool'

const extraInlineDeps = [
/^(?!.*(?:node_modules)).*\.mjs$/,
Expand Down Expand Up @@ -222,6 +224,8 @@ export function resolveConfig(
if (options.resolveSnapshotPath)
delete (resolved as UserConfig).resolveSnapshotPath

resolved.pool ??= 'threads'

if (process.env.VITEST_MAX_THREADS) {
resolved.poolOptions = {
...resolved.poolOptions,
Expand Down Expand Up @@ -270,6 +274,22 @@ export function resolveConfig(
}
}

if (!builtinPools.includes(resolved.pool as BuiltinPool)) {
resolved.pool = normalize(
resolveModule(resolved.pool, { paths: [resolved.root] })
?? resolve(resolved.root, resolved.pool),
)
}
resolved.poolMatchGlobs = (resolved.poolMatchGlobs || []).map(([glob, pool]) => {
if (!builtinPools.includes(pool as BuiltinPool)) {
pool = normalize(
resolveModule(pool, { paths: [resolved.root] })
?? resolve(resolved.root, pool),
)
}
return [glob, pool]
})

if (mode === 'benchmark') {
resolved.benchmark = {
...benchmarkConfigDefaults,
Expand Down
10 changes: 7 additions & 3 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export class Vitest {
this.unregisterWatcher?.()
clearTimeout(this._rerunTimer)
this.restartsCount += 1
this.pool?.close()
this.pool?.close?.()
this.pool = undefined
this.coverageProvider = undefined
this.runningPromise = undefined
Expand Down Expand Up @@ -761,8 +761,12 @@ export class Vitest {
if (!this.projects.includes(this.coreWorkspaceProject))
closePromises.push(this.coreWorkspaceProject.close().then(() => this.server = undefined as any))

if (this.pool)
closePromises.push(this.pool.close().then(() => this.pool = undefined))
if (this.pool) {
closePromises.push((async () => {
await this.pool?.close?.()
this.pool = undefined
})())
}

closePromises.push(...this._onClose.map(fn => fn()))

Expand Down
3 changes: 2 additions & 1 deletion packages/vitest/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ export { createVitest } from './create'
export { VitestPlugin } from './plugins'
export { startVitest } from './cli-api'
export { registerConsoleShortcuts } from './stdin'
export type { WorkspaceSpec } from './pool'
export type { GlobalSetupContext } from './globalSetup'
export type { WorkspaceSpec, ProcessPool } from './pool'
export { createMethodsRPC } from './pools/rpc'

export type { TestSequencer, TestSequencerConstructor } from './sequencers/types'
export { BaseSequencer } from './sequencers/BaseSequencer'
Expand Down
65 changes: 51 additions & 14 deletions packages/vitest/src/node/pool.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import mm from 'micromatch'
import type { Pool } from '../types'
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 { createThreadsPool } from './pools/threads'
Expand All @@ -9,11 +10,12 @@ import type { WorkspaceProject } from './workspace'
import { createTypecheckPool } from './pools/typecheck'

export type WorkspaceSpec = [project: WorkspaceProject, testFile: string]
export type RunWithFiles = (files: WorkspaceSpec[], invalidates?: string[]) => Promise<void>
export type RunWithFiles = (files: WorkspaceSpec[], invalidates?: string[]) => Awaitable<void>

export interface ProcessPool {
name: string
runTests: RunWithFiles
close: () => Promise<void>
close?: () => Awaitable<void>
}

export interface PoolProcessOptions {
Expand All @@ -24,6 +26,8 @@ export interface PoolProcessOptions {
env: Record<string, string>
}

export const builtinPools: BuiltinPool[] = ['forks', 'threads', 'browser', 'vmThreads', 'typescript']

export function createPool(ctx: Vitest): ProcessPool {
const pools: Record<Pool, ProcessPool | null> = {
forks: null,
Expand All @@ -48,7 +52,7 @@ export function createPool(ctx: Vitest): ProcessPool {
}

function getPoolName([project, file]: WorkspaceSpec) {
for (const [glob, pool] of project.config.poolMatchGlobs || []) {
for (const [glob, pool] of project.config.poolMatchGlobs) {
if ((pool as Pool) === 'browser')
throw new Error('Since Vitest 0.31.0 "browser" pool is not supported in "poolMatchGlobs". You can create a workspace to run some of your tests in browser in parallel. Read more: https://vitest.dev/guide/workspace')
if (mm.isMatch(file, glob, { cwd: project.config.root }))
Expand Down Expand Up @@ -82,6 +86,22 @@ export function createPool(ctx: Vitest): ProcessPool {
},
}

const customPools = new Map<string, 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
}

const filesByPool: Record<Pool, WorkspaceSpec[]> = {
forks: [],
threads: [],
Expand All @@ -92,46 +112,63 @@ export function createPool(ctx: Vitest): ProcessPool {

for (const spec of files) {
const pool = getPoolName(spec)
if (!(pool in filesByPool))
throw new Error(`Unknown pool name "${pool}" for ${spec[1]}. Available pools: ${Object.keys(filesByPool).join(', ')}`)
filesByPool[pool] ??= []
filesByPool[pool].push(spec)
}

await Promise.all(Object.entries(filesByPool).map((entry) => {
const Sequencer = ctx.config.sequence.sequencer
const sequencer = new Sequencer(ctx)

async function sortSpecs(specs: WorkspaceSpec[]) {
if (ctx.config.shard)
specs = await sequencer.shard(specs)
return sequencer.sort(specs)
}

await Promise.all(Object.entries(filesByPool).map(async (entry) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This issue seems to be in main branch too, but all pools are executed parallel here, right?

If user has threads pool and vmThreads pools set, and each are using default max-threads, the execution will spawn too many workers at the same time.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is how it works. That's why I wanted agnostic implementation of tinypool.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tinypool can run worker_threads and child_process parallel.

https://github.com/tinylibs/tinypool/blob/999778ed8acccf18303760558f807f0e208c8b3b/test/runtime.test.ts#L151-L170

It's possible to push all threads tasks in the pool, then wait for queue to be empty (remaining tasks are running, nothing in queue), and then change the runtime and push more tasks in the queue to wait for previous ones to finish. Like here is done:

// Once all tasks are running or finished, recycle worker for isolation.
// On-going workers will run in the previous environment.
await new Promise<void>(resolve => pool.queueSize === 0 ? resolve() : pool.once('drain', resolve))
await pool.recycleWorkers()

Copy link
Member Author

@sheremet-va sheremet-va Nov 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, I would like an API that we can expose to reuse the same Tinypool instance in different pools:

function createThreadsPool(ctx, { pool }) {
  function runTests(testFile) {
    const context = { config: ctx.config, files: [testFile], executeFile: resolve('./dist/worker.js') }
    await pool.run(context, { runtime: 'worker_threads', name: 'run' })
  }
}

Maybe instead of having separate worker.js/child.js/vmWorker.js files we have a single file where inside of a context we pass down information on how we want to run it? So, we don't need to change filename option in Tinypool. This should also make it easier for external pools to reuse it

Copy link
Member

@AriPerkkio AriPerkkio Nov 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea. From Tinypool's side I think that kind of API should already be possible - some kind of wrappers around it might be needed though.
There's also some need for refactoring in threads and forks pools to reduce code duplication. While doing that it might be good to look into this kind of API. But all this can be left out of this PR as it's not relevant to these changes.

const [pool, files] = entry as [Pool, WorkspaceSpec[]]

if (!files.length)
return null

const specs = await sortSpecs(files)

if (pool === 'browser') {
pools.browser ??= createBrowserPool(ctx)
return pools.browser.runTests(files, invalidate)
return pools.browser.runTests(specs, invalidate)
}

if (pool === 'vmThreads') {
pools.vmThreads ??= createVmThreadsPool(ctx, options)
return pools.vmThreads.runTests(files, invalidate)
return pools.vmThreads.runTests(specs, invalidate)
}

if (pool === 'threads') {
pools.threads ??= createThreadsPool(ctx, options)
return pools.threads.runTests(files, invalidate)
return pools.threads.runTests(specs, invalidate)
}

if (pool === 'typescript') {
pools.typescript ??= createTypecheckPool(ctx)
return pools.typescript.runTests(files)
return pools.typescript.runTests(specs)
}

if (pool === 'forks') {
pools.forks ??= createChildProcessPool(ctx, options)
return pools.forks.runTests(specs, invalidate)
}

pools.forks ??= createChildProcessPool(ctx, options)
return pools.forks.runTests(files, invalidate)
const poolHandler = await resolveCustomPool(pool)
pools[poolHandler.name] ??= poolHandler
return poolHandler.runTests(specs, invalidate)
}))
}

return {
name: 'default',
runTests,
async close() {
await Promise.all(Object.values(pools).map(p => p?.close()))
await Promise.all(Object.values(pools).map(p => p?.close?.()))
},
}
}
3 changes: 2 additions & 1 deletion packages/vitest/src/node/pools/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool {
if (project.config.browser.isolate) {
for (const path of paths) {
if (isCancelled) {
ctx.state.cancelFiles(files.slice(paths.indexOf(path)), ctx.config.root)
ctx.state.cancelFiles(files.slice(paths.indexOf(path)), ctx.config.root, project.getName())
break
}

Expand Down Expand Up @@ -77,6 +77,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool {
}

return {
name: 'browser',
async close() {
ctx.state.browserTestPromises.clear()
await Promise.all([...providers].map(provider => provider.close()))
Expand Down
Loading
Loading