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: add "configureVitest" plugin #7349

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,10 @@ export default ({ mode }: { mode: string }) => {
},
],
},
{
text: 'Plugin API',
link: '/advanced/api/plugin',
},
{
text: 'Runner API',
link: '/advanced/runner',
Expand Down
126 changes: 126 additions & 0 deletions docs/advanced/api/plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
title: Plugin API
outline: deep
---

# Plugin API <Version>3.1.0</Version> {#plugin-api}

::: warning
This guide lists advanced APIs to run tests via a Node.js script. If you just want to [run tests](/guide/), you probably don't need this. It is primarily used by library authors.

This guide assumes you know how to work with [Vite plugins](https://vite.dev/guide/api-plugin.html).
:::

Vitest supports an experimental `configureVitest` [plugin](https://vite.dev/guide/api-plugin.html) hook hook since version 3.1. Any feedback regarding this API is welcome in [GitHub](https://github.com/vitest-dev/vitest/discussions/7104).

::: code-group
```ts [only vitest]
import type { Vite, VitestPluginContext } from 'vitest/node'

export function plugin(): Vite.Plugin {
return {
name: 'vitest:my-plugin',
configureVitest(context: VitestPluginContext) {
// ...
}
}
}
```
```ts [vite and vitest]
/// <reference types="vitest/config" />

import type { Plugin } from 'vite'

export function plugin(): Plugin {
return {
name: 'vitest:my-plugin',
transform() {
// ...
},
configureVitest(context) {
// ...
}
}
}
```
:::

::: tip TypeScript
Vitest re-exports all Vite type-only imports via a `Vite` namespace, which you can use to keep your versions in sync. However, if you are writing a plugin for both Vite and Vitest, you can continue using the `Plugin` type from the `vite` entrypoint. Just make sure you have `vitest/config` referenced somewhere so that `configureVitest` is augmented correctly:

```ts
/// <reference types="vitest/config" />
```
:::

Unlike [`reporter.onInit`](/advanced/api/reporters#oninit), this hooks runs early in Vitest lifecycle allowing you to make changes to configuration like `coverage` and `reporters`. A more notable change is that you can manipulate the global config from a [workspace project](/guide/workspace) if your plugin is defined in the project and not in the global config.

## Context

### project

The current [test project](./test-project) that the plugin belongs to.

::: warning Browser Mode
Note that if you are relying on a browser feature, the `project.browser` field is not set yet. Use [`reporter.onBrowserInit`](./reporters#onbrowserinit) event instead.
:::

### vitest

The global [Vitest](./vitest) instance. You can change the global configuration by directly mutating the `vitest.config` property:

```ts
vitest.config.coverage.enabled = false
vitest.config.reporters.push([['my-reporter', {}]])
```

::: warning Config is Resolved
Note that Vitest already resolved the config, so some types might be different from the usual user configuration.

At this point reporters are not created yet, so modifying `vitest.reporters` will have no effect because it will be overwritten. If you need to inject your own reporter, modify the config instead.
:::

### injectTestProjects

```ts
function injectTestProjects(
config: TestProjectConfiguration | TestProjectConfiguration[]
): Promise<TestProject[]>
```

This methods accepts a config glob pattern, a filepath to the config or an inline configuration. It returns an array of resolved [test projects](./test-project).

```ts
// inject a single project with a custom alias
const newProjects = await injectTestProjects({
// you can inherit the current project config by referencing `configFile`
// note that you cannot have a project with the name that already exists
configFile: project.vite.config.configFile,
test: {
name: 'my-custom-alias',
alias: {
customAlias: resolve('./custom-path.js'),
},
},
})
```

::: warning Projects are Filtered
Vitest filters projects during the config resolution, so if the user defined a filter, injected project might not be resolved unless it [maches the filter](./vitest#matchesprojectfilter) or has `force: true`:

```ts
await injectTestProjects({
// this project will always be included even
// if the `--project` filters it out
force: true,
})
```
:::

::: tip Referencing the Current Config
If you want to keep the user configuration, you can specify the `configFile` property. All other properties will be merged with the user defined config.

The project's `configFile` can be accessed in Vite's config: `project.vite.config.configFile`.

Note that this will also inherit the `name` - Vitest doesn't allow multiple projects with the same name, so this will throw an error. Make sure you specified a different name. You can access the current name via the `project.name` property and all used names are available in the `vitest.projects` array.
:::
10 changes: 10 additions & 0 deletions docs/advanced/api/vitest.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,3 +518,13 @@ vitest.onFilterWatchedSpecification(specification =>
```

Vitest can create different specifications for the same file depending on the `pool` or `locations` options, so do not rely on the reference. Vitest can also return cached specification from [`vitest.getModuleSpecifications`](#getmodulespecifications) - the cache is based on the `moduleId` and `pool`. Note that [`project.createSpecification`](/advanced/api/test-project#createspecification) always returns a new instance.

## matchesProjectFilter <Version>3.1.0</Version> {#matchesprojectfilter}

```ts
function matchesProjectFilter(name: string): boolean
```

Check if the name matches the current [project filter](/guide/cli#project). If there is no project filter, this will always return `true`.

It is not possible to programmatically change the `--project` CLI option.
15 changes: 15 additions & 0 deletions docs/guide/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,21 @@ bun test --project e2e --project unit
```
:::

Since Vitest 3.1, you can define a project that will always run even if you filter it out with a `--project` flag. Specify `force: true` flag when defining the project to always include it in your test run (note that this is only available in inline configs):

```ts
export default defineWorkspace([
{
// this project will always run even if it was filtered out
force: true,
extends: './vitest.config.ts',
test: {
name: 'unit',
},
},
])
```

## Configuration

None of the configuration options are inherited from the root-level config file, even if the workspace is defined inside that config and not in a separate `vitest.workspace` file. You can create a shared config file and merge it with the project config yourself:
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/node/config/resolveConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,7 @@ function isPlaywrightChromiumOnly(vitest: Vitest, config: ResolvedConfig) {
for (const instance of browser.instances) {
const name = instance.name || (config.name ? `${config.name} (${instance.browser})` : instance.browser)
// browser config is filtered out
if (!vitest._matchesProjectFilter(name)) {
if (!vitest.matchesProjectFilter(name)) {
continue
}
if (instance.browser !== 'chromium') {
Expand Down
39 changes: 34 additions & 5 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { SerializedCoverageConfig } from '../runtime/config'
import type { ArgumentsType, ProvidedContext, UserConsoleLog } from '../types/general'
import type { ProcessPool, WorkspaceSpec } from './pool'
import type { TestSpecification } from './spec'
import type { ResolvedConfig, UserConfig, VitestRunMode } from './types/config'
import type { ResolvedConfig, TestProjectConfiguration, UserConfig, VitestRunMode } from './types/config'
import type { CoverageProvider } from './types/coverage'
import type { Reporter } from './types/reporter'
import type { TestRunResult } from './types/tests'
Expand Down Expand Up @@ -98,7 +98,7 @@ export class Vitest {
/** @internal */ _browserLastPort = defaultBrowserPort
/** @internal */ _browserSessions = new BrowserSessions()
/** @internal */ _options: UserConfig = {}
/** @internal */ reporters: Reporter[] = undefined!
/** @internal */ reporters: Reporter[] = []
/** @internal */ vitenode: ViteNodeServer = undefined!
/** @internal */ runner: ViteNodeRunner = undefined!
/** @internal */ _testRun: TestRun = undefined!
Expand Down Expand Up @@ -279,6 +279,12 @@ export class Vitest {
const projects = await this.resolveWorkspace(cliOptions)
this.resolvedProjects = projects
this.projects = projects

await Promise.all(projects.flatMap((project) => {
const hooks = project.vite.config.getSortedPluginHooks('configureVitest')
return hooks.map(hook => hook({ project, vitest: this, injectTestProjects: this.injectTestProject }))
}))

if (!this.projects.length) {
throw new Error(`No projects matched the filter "${toArray(resolved.project).join('", "')}".`)
}
Expand All @@ -297,6 +303,26 @@ export class Vitest {
await Promise.all(this._onSetServer.map(fn => fn()))
}

/**
* Inject new test projects into the workspace.
* @param config Glob, config path or a custom config options.
* @returns New test project or `undefined` if it was filtered out.
*/
private injectTestProject = async (config: TestProjectConfiguration | TestProjectConfiguration[]): Promise<TestProject[]> => {
// TODO: test that it errors when the project is already in the workspace
const currentNames = new Set(this.projects.map(p => p.name))
const workspace = await resolveWorkspace(
this,
this._options,
undefined,
Array.isArray(config) ? config : [config],
currentNames,
)
this.resolvedProjects.push(...workspace)
this.projects.push(...workspace)
return workspace
}

/**
* Provide a value to the test context. This value will be available to all tests with `inject`.
*/
Expand Down Expand Up @@ -385,12 +411,15 @@ export class Vitest {
}

private async resolveWorkspace(cliOptions: UserConfig): Promise<TestProject[]> {
const names = new Set<string>()

if (Array.isArray(this.config.workspace)) {
return resolveWorkspace(
this,
cliOptions,
undefined,
this.config.workspace,
names,
)
}

Expand All @@ -406,7 +435,7 @@ export class Vitest {
if (!project) {
return []
}
return resolveBrowserWorkspace(this, new Set(), [project])
return resolveBrowserWorkspace(this, new Set([project.name]), [project])
}

const workspaceModule = await this.import<{
Expand All @@ -422,6 +451,7 @@ export class Vitest {
cliOptions,
workspaceConfigPath,
workspaceModule.default,
names,
)
}

Expand Down Expand Up @@ -1252,9 +1282,8 @@ export class Vitest {

/**
* Check if the project with a given name should be included.
* @internal
*/
_matchesProjectFilter(name: string): boolean {
matchesProjectFilter(name: string): boolean {
// no filters applied, any project can be included
if (!this._projectFilters.length) {
return true
Expand Down
10 changes: 6 additions & 4 deletions packages/vitest/src/node/plugins/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { UserConfig as ViteConfig, Plugin as VitePlugin } from 'vite'
import type { TestProject } from '../project'
import type { ResolvedConfig, UserWorkspaceConfig } from '../types/config'
import type { ResolvedConfig, TestProjectInlineConfiguration } from '../types/config'
import { existsSync, readFileSync } from 'node:fs'
import { deepMerge } from '@vitest/utils'
import { basename, dirname, relative, resolve } from 'pathe'
Expand All @@ -22,7 +22,7 @@ import {
} from './utils'
import { VitestProjectResolver } from './vitestResolver'

interface WorkspaceOptions extends UserWorkspaceConfig {
interface WorkspaceOptions extends TestProjectInlineConfiguration {
root?: string
workspacePath: string | number
}
Expand Down Expand Up @@ -84,9 +84,11 @@ export function WorkspaceVitestPlugin(
// if there is `--project=...` filter, check if any of the potential projects match
// if projects don't match, we ignore the test project altogether
// if some of them match, they will later be filtered again by `resolveWorkspace`
if (filters.length) {
// ignore if `{ force: true }` is set
// TODO: test for force
if (!options.force && filters.length) {
const hasProject = workspaceNames.some((name) => {
return project.vitest._matchesProjectFilter(name)
return project.vitest.matchesProjectFilter(name)
})
if (!hasProject) {
throw new VitestFilteredOutProjectError()
Expand Down
4 changes: 2 additions & 2 deletions packages/vitest/src/node/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import type { ParentProjectBrowser, ProjectBrowser } from './types/browser'
import type {
ResolvedConfig,
SerializedConfig,
TestProjectInlineConfiguration,
UserConfig,
UserWorkspaceConfig,
} from './types/config'
import { promises as fs, readFileSync } from 'node:fs'
import { rm } from 'node:fs/promises'
Expand Down Expand Up @@ -722,7 +722,7 @@ export interface SerializedTestProject {
context: ProvidedContext
}

interface InitializeProjectOptions extends UserWorkspaceConfig {
interface InitializeProjectOptions extends TestProjectInlineConfiguration {
configFile: string | false
}

Expand Down
12 changes: 11 additions & 1 deletion packages/vitest/src/node/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1123,14 +1123,24 @@ export type UserProjectConfigExport =
| Promise<UserWorkspaceConfig>
| UserProjectConfigFn

export type TestProjectConfiguration = string | (UserProjectConfigExport & {
export type TestProjectInlineConfiguration = (UserWorkspaceConfig & {
/**
* Relative path to the extendable config. All other options will be merged with this config.
* If `true`, the project will inherit all options from the root config.
* @example '../vite.config.ts'
*/
extends?: string | true
/**
* Always include this project in the test run, even if it's filtered out by the `--project` option.
*/
force?: true
})

export type TestProjectConfiguration =
string
| TestProjectInlineConfiguration
| Promise<UserWorkspaceConfig>
| UserProjectConfigFn

/** @deprecated use `TestProjectConfiguration` instead */
export type WorkspaceProjectConfiguration = TestProjectConfiguration
9 changes: 9 additions & 0 deletions packages/vitest/src/node/types/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Vitest } from '../core'
import type { TestProject } from '../project'
import type { TestProjectConfiguration } from './config'

export interface VitestPluginContext {
vitest: Vitest
project: TestProject
injectTestProjects: (config: TestProjectConfiguration | TestProjectConfiguration[]) => Promise<TestProject[]>
}
8 changes: 8 additions & 0 deletions packages/vitest/src/node/types/vite.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
/* eslint-disable unused-imports/no-unused-vars */

import type { HookHandler } from 'vite'
import type { InlineConfig } from './config'
import type { VitestPluginContext } from './plugin'

type VitestInlineConfig = InlineConfig

Expand All @@ -9,6 +13,10 @@ declare module 'vite' {
*/
test?: VitestInlineConfig
}

interface Plugin<A = any> {
configureVitest?: HookHandler<(context: VitestPluginContext) => void>
}
}

export {}
Loading