[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

refactor!: separate config resolution from the server creation (#10554)

authored by

Vladimir and committed by
GitHub
(Jul 7, 2026, 11:24 AM +0200) 1c0ec344 b4dddb1d

+3480 -3138
-8
docs/.vitepress/config.ts
··· 619 619 link: '/config/browser/headless', 620 620 }, 621 621 { 622 - text: 'browser.isolate', 623 - link: '/config/browser/isolate', 624 - }, 625 - { 626 622 text: 'browser.testerHtmlPath', 627 623 link: '/config/browser/testerhtmlpath', 628 - }, 629 - { 630 - text: 'browser.api', 631 - link: '/config/browser/api', 632 624 }, 633 625 { 634 626 text: 'browser.provider',
-1
docs/.vitepress/scripts/cli-generator.ts
··· 39 39 'project', 40 40 'ui', 41 41 'browser.name', 42 - 'browser.fileParallelism', 43 42 'clearCache', 44 43 'tagsFilter', 45 44 'listTags',
+1 -1
docs/api/advanced/artifacts.md
··· 67 67 Extend this interface when creating custom test artifacts. Vitest automatically manages the `attachments` array and injects the `location` property to indicate where the artifact was created in your test code. 68 68 69 69 ::: danger 70 - When running with [`api.allowWrite`](/config/api#api-allowwrite) or [`browser.api.allowWrite`](/config/browser/api#api-allowwrite) disabled, Vitest empties the `attachments` array on every artifact before reporting it. 70 + When running with [`api.allowWrite`](/config/api#api-allowwrite) disabled, Vitest empties the `attachments` array on every artifact before reporting it. 71 71 72 72 If your custom artifact narrows the `attachments` type (e.g. to a tuple), include `| []` in the union so the type reflects what actually happens at runtime. 73 73 :::
+1 -1
docs/api/advanced/test-module.md
··· 153 153 function logs(): ReadonlyArray<UserConsoleLog> 154 154 ``` 155 155 156 - Console logs recorded on top level of the module during test collection.For example: 156 + Console logs recorded on top level of the module during test collection. For example: 157 157 158 158 ```ts 159 159 console.log('included') // [!code highlight]
+3 -3
docs/api/browser/commands.md
··· 18 18 ::: tip 19 19 The built-in file commands follow Vite's [`server.fs`](https://vitejs.dev/config/server-options.html#server-fs-allow) restrictions for security reasons. 20 20 21 - `writeFile` and `removeFile` also require write access through [`browser.api.allowWrite`](/config/browser/api) and [`api.allowWrite`](/config/api#api-allowwrite). 21 + `writeFile` and `removeFile` also require write access through [`api.allowWrite`](/config/api#api-allowwrite). 22 22 ::: 23 23 24 24 ```ts ··· 60 60 ::: warning 61 61 CDP session works only with `playwright` provider and only when using `chromium` browser. You can read more about it in playwright's [`CDPSession`](https://playwright.dev/docs/api/class-cdpsession) documentation. 62 62 63 - CDP is a privileged debugging API. It is available only when browser API write and exec operations are enabled through [`browser.api.allowWrite`](/config/browser/api#api-allowwrite), [`browser.api.allowExec`](/config/browser/api#api-allowexec), [`api.allowWrite`](/config/api#api-allowwrite), and [`api.allowExec`](/config/api#api-allowexec). 63 + CDP is a privileged debugging API. It is available only when browser API write and exec operations are enabled through [`api.allowWrite`](/config/api#api-allowwrite), and [`api.allowExec`](/config/api#api-allowexec). 64 64 ::: 65 65 66 66 ## Custom Commands ··· 131 131 132 132 Vitest's built-in file commands validate paths against Vite's [`server.fs`](https://vite.dev/config/server-options#server-fs-allow) restrictions and separately check whether writes are allowed. Custom commands do not automatically inherit these protections. If a custom command accepts browser-provided input and uses it to read, write, delete, execute, or expose local resources, validate that input before using it. 133 133 134 - For file reads or fixture loading, use `isFileLoadingAllowed` from `vitest/node` or an explicit allowlist. For writes and deletes, also require an explicit mutation policy, such as [`browser.api.allowWrite`](/config/browser/api#api-allowwrite), [`api.allowWrite`](/config/api#api-allowwrite), and a command-specific allowed directory. For commands that execute code, shell commands, or project scripts, also check [`browser.api.allowExec`](/config/browser/api#api-allowexec) and [`api.allowExec`](/config/api#api-allowexec). 134 + For file reads or fixture loading, use `isFileLoadingAllowed` from `vitest/node` or an explicit allowlist. For writes and deletes, also require an explicit mutation policy, such as [`api.allowWrite`](/config/api#api-allowwrite), and a command-specific allowed directory. For commands that execute code, shell commands, or project scripts, also check [`api.allowExec`](/config/api#api-allowexec). 135 135 136 136 For example, if you create your own file-writing command instead of using Vitest's built-in `writeFile`, apply the same checks: 137 137
+1 -1
docs/api/browser/context.md
··· 212 212 ::: warning 213 213 CDP session works only with `playwright` provider and only when using `chromium` browser. You can read more about it in playwright's [`CDPSession`](https://playwright.dev/docs/api/class-cdpsession) documentation. 214 214 215 - CDP is a privileged debugging API. It is available only when browser API write and exec operations are enabled through [`browser.api.allowWrite`](/config/browser/api#api-allowwrite), [`browser.api.allowExec`](/config/browser/api#api-allowexec), [`api.allowWrite`](/config/api#api-allowwrite), and [`api.allowExec`](/config/api#api-allowexec). 215 + CDP is a privileged debugging API. It is available only when browser API write and exec operations are enabled through [`api.allowWrite`](/config/api#api-allowwrite), and [`api.allowExec`](/config/api#api-allowexec). 216 216 ::: 217 217 218 218 ```ts
+5 -2
docs/config/api.md
··· 9 9 - **Default:** `false` 10 10 - **CLI:** `--api`, `--api.port`, `--api.host`, `--api.strictPort` 11 11 12 - Listen to port and serve API for [the UI](/guide/ui) or [browser server](/guide/browser/). When set to `true`, the default port is `51204`. 12 + Listen to port and serve API for [the UI](/guide/ui) or [browser server](/guide/browser/). When set to `true`, the default port is `51204` or `63315` if running in Browser Mode. 13 13 14 14 ## api.allowWrite <Version>4.1.0</Version> {#api-allowwrite} 15 15 ··· 17 17 - **Default:** `true` if not exposed to the network, `false` otherwise 18 18 19 19 Vitest server can save test files or snapshot files via the API. This allows anyone who can connect to the API the ability to run any arbitrary code on your machine. 20 + 21 + In Browser Mode Vitest saves [annotation attachments](/guide/test-annotations), [artifacts](/api/advanced/artifacts) and [snapshots](/guide/snapshot) by receiving a WebSocket connection from the browser. This allows anyone who can connect to the API write any arbitrary code on your machine within the root of your project (configured by [`fs.allow`](https://vite.dev/config/server-options#server-fs-allow)). This option also gates privileged browser APIs that can write files indirectly, such as raw Chrome DevTools Protocol access through [`cdp()`](/api/browser/context#cdp). 20 22 21 23 ::: danger SECURITY ADVICE 22 24 Vitest does not expose the API to the internet by default and only listens on `localhost`. However if `host` is manually exposed to the network, anyone who connects to it can run arbitrary code on your machine, unless `api.allowWrite` and `api.allowExec` are set to `false`. ··· 29 31 - **Type:** `boolean` 30 32 - **Default:** `true` if not exposed to the network, `false` otherwise 31 33 32 - Allows running any test file via the API. See the security advice in [`api.allowWrite`](#api-allowwrite). 34 + Allows running any test file via the UI. This applies to the interactive elements (and the server code behind them) in the [UI](/guide/ui) that can run the code. This option also gates privileged browser APIs that can execute code indirectly, such as raw Chrome DevTools Protocol access through [`cdp()`](/api/browser/context#cdp). 35 +
-28
docs/config/browser/api.md
··· 1 - --- 2 - title: browser.api | Config 3 - outline: deep 4 - --- 5 - 6 - # browser.api 7 - 8 - - **Type:** `number | object` 9 - - **Default:** `63315` 10 - - **CLI:** `--browser.api=63315`, `--browser.api.port=1234, --browser.api.host=example.com` 11 - 12 - Configure options for Vite server that serves code in the browser. Does not affect [`test.api`](/config/api) option. By default, Vitest assigns port `63315` to avoid conflicts with the development server, allowing you to run both in parallel. 13 - 14 - ## api.allowWrite <Version>4.1.0</Version> {#api-allowwrite} 15 - 16 - - **Type:** `boolean` 17 - - **Default:** `true` if not exposed to the network, `false` otherwise 18 - 19 - Vitest saves [annotation attachments](/guide/test-annotations), [artifacts](/api/advanced/artifacts) and [snapshots](/guide/snapshot) by receiving a WebSocket connection from the browser. This allows anyone who can connect to the API write any arbitrary code on your machine within the root of your project (configured by [`fs.allow`](https://vite.dev/config/server-options#server-fs-allow)). This option also gates privileged browser APIs that can write files indirectly, such as raw Chrome DevTools Protocol access through [`cdp()`](/api/browser/context#cdp). 20 - 21 - If browser server is not exposed to the internet (the host is `localhost`), this should not be a problem, so the default value in that case is `true`. If you override the host, Vitest will set `allowWrite` to `false` by default to prevent potentially harmful writes. 22 - 23 - ## api.allowExec <Version>4.1.0</Version> {#api-allowexec} 24 - 25 - - **Type:** `boolean` 26 - - **Default:** `true` if not exposed to the network, `false` otherwise 27 - 28 - Allows running any test file via the UI. This applies to the interactive elements (and the server code behind them) in the [UI](/guide/ui) that can run the code. This option also gates privileged browser APIs that can execute code indirectly, such as raw Chrome DevTools Protocol access through [`cdp()`](/api/browser/context#cdp). See [`api.allowExec`](/config/api#api-allowexec) for more information.
-16
docs/config/browser/isolate.md
··· 1 - --- 2 - title: browser.isolate | Config 3 - outline: deep 4 - --- 5 - 6 - # browser.isolate <Deprecated /> 7 - 8 - - **Type:** `boolean` 9 - - **Default:** the same as [`--isolate`](/config/isolate) 10 - - **CLI:** `--browser.isolate`, `--browser.isolate=false` 11 - 12 - Run every test in a separate iframe. 13 - 14 - ::: danger DEPRECATED 15 - This option is deprecated. Use [`isolate`](/config/isolate) instead. 16 - :::
+59 -15
docs/guide/advanced/index.md
··· 85 85 function resolveConfig( 86 86 options: UserConfig = {}, 87 87 viteOverrides: ViteUserConfig = {}, 88 - ): Promise<{ 89 - vitestConfig: ResolvedConfig 90 - viteConfig: ResolvedViteConfig 91 - }> 88 + harness?: PluginHarness, 89 + ): Promise<ResolvedViteConfig> 92 90 ``` 93 91 94 - This method resolves the config with custom parameters. If no parameters are given, the `root` will be `process.cwd()`. 92 + This method resolves the config with custom parameters, without creating a Vite server. If no parameters are given, the `root` will be `process.cwd()`. 93 + 94 + It returns the resolved Vite config. The fully resolved Vitest config, including every project, lives on its `test` property. 95 95 96 96 ```ts 97 97 import { resolveConfig } from 'vitest/node' 98 98 99 - // vitestConfig only has resolved "test" properties 100 - const { vitestConfig, viteConfig } = await resolveConfig({ 99 + const viteConfig = await resolveConfig({ 101 100 mode: 'custom', 102 101 configFile: false, 103 102 resolve: { ··· 108 107 pool: 'threads', 109 108 }, 110 109 }) 110 + 111 + viteConfig.test.pool // 'threads' 111 112 ``` 112 113 113 114 ::: info 114 - Due to how Vite's `createServer` works, Vitest has to resolve the config during the plugin's `configResolve` hook. Therefore, this method is not actually used internally and is exposed exclusively as a public API. 115 + This is the same method Vitest uses internally to resolve the config before creating the server. If you pass the options down to `startVitest` or `createVitest`, Vitest resolves them again. 115 116 116 - If you pass down the config to the `startVitest` or `createVitest` APIs, Vitest will still resolve the config again. 117 - ::: 118 - 119 - ::: warning 120 - The `resolveConfig` doesn't resolve `projects`. To resolve projects configs, Vitest needs an established Vite server. 121 - 122 - Also note that `viteConfig.test` will not be fully resolved. If you need Vitest config, use `vitestConfig` instead. 117 + You can pass a shared [`PluginHarness`](#pluginharness) as the third argument to reuse a logger and package installer across calls. 123 118 ::: 124 119 125 120 ## parseCLI ··· 147 142 result.filter 148 143 // ['./files.ts'] 149 144 ``` 145 + 146 + ## createCLI 147 + 148 + ```ts 149 + function createCLI(options?: CliParseOptions): CAC 150 + ``` 151 + 152 + Creates the Vitest command-line interface: a [`cac`](https://github.com/cacjs/cac) instance with all of Vitest's commands and options registered. [`parseCLI`](#parsecli) is built on top of it; use `createCLI` directly if you need the raw parser. 153 + 154 + ```ts 155 + import { createCLI } from 'vitest/node' 156 + 157 + const cli = createCLI() 158 + ``` 159 + 160 + ## PluginHarness 161 + 162 + ```ts 163 + class PluginHarness { 164 + vitest?: Vitest 165 + version: string 166 + logger: Logger 167 + packageInstaller: VitestPackageInstaller 168 + getVitest(): Vitest 169 + } 170 + ``` 171 + 172 + A container that Vitest passes to its internal plugins while the config is being resolved, before a [`Vitest`](/api/advanced/vitest) instance exists. It holds the [`Logger`](#logger), the package installer and the resolved version, and exposes the `Vitest` instance via `getVitest()` once it has been created (calling it earlier throws). 173 + 174 + This is an advanced, plugin-facing API. You rarely construct one directly, but you can pass a shared instance to [`resolveConfig`](#resolveconfig) to reuse a logger and package installer. 175 + 176 + ## Logger 177 + 178 + ```ts 179 + class Logger { 180 + constructor( 181 + outputStream?: Writable, 182 + errorStream?: Writable, 183 + ) 184 + } 185 + ``` 186 + 187 + Vitest's terminal logger, exposed as [`vitest.logger`](/api/advanced/vitest). It handles formatted output, the error summary, the run banner and screen clearing. Construct one with custom `stdout`/`stderr` streams to capture or redirect Vitest's output when running it programmatically. 188 + 189 + ```ts 190 + import { Logger } from 'vitest/node' 191 + 192 + const logger = new Logger(process.stdout, process.stderr) 193 + ```
+1 -1
docs/guide/browser/index.md
··· 118 118 ``` 119 119 120 120 ::: info 121 - Vitest assigns port `63315` to avoid conflicts with the development server, allowing you to run both in parallel. You can change that with the [`browser.api`](/config/browser/api) option. 121 + Vitest assigns port `63315` to avoid conflicts with the development server, allowing you to run both in parallel. You can change that with the [`api`](/config/api) option. 122 122 ::: 123 123 124 124 If you have not used Vite before, make sure you have your framework's plugin installed and specified in the config. Some frameworks might require extra configuration to work - check their Vite related documentation to be sure.
-48
docs/guide/cli-generated.md
··· 353 353 354 354 Run the browser in headless mode (i.e. without opening the GUI (Graphical User Interface)). If you are running Vitest in CI, it will be enabled by default (default: `process.env.CI`) 355 355 356 - ### browser.api.port 357 - 358 - - **CLI:** `--browser.api.port [port]` 359 - - **Config:** [browser.api.port](/config/browser/api#api-port) 360 - 361 - Specify server port. Note if the port is already being used, Vite will automatically try the next available port so this may not be the actual port the server ends up listening on. If true will be set to `63315` 362 - 363 - ### browser.api.host 364 - 365 - - **CLI:** `--browser.api.host [host]` 366 - - **Config:** [browser.api.host](/config/browser/api#api-host) 367 - 368 - Specify which IP addresses the server should listen on. Set this to `0.0.0.0` or `true` to listen on all addresses, including LAN and public addresses 369 - 370 - ### browser.api.strictPort 371 - 372 - - **CLI:** `--browser.api.strictPort` 373 - - **Config:** [browser.api.strictPort](/config/browser/api#api-strictport) 374 - 375 - Set to true to exit if port is already in use, instead of automatically trying the next available port 376 - 377 - ### browser.api.allowExec 378 - 379 - - **CLI:** `--browser.api.allowExec` 380 - - **Config:** [browser.api.allowExec](/config/browser/api#api-allowexec) 381 - 382 - Allow API to execute code. (Be careful when enabling this option in untrusted environments) 383 - 384 - ### browser.api.allowWrite 385 - 386 - - **CLI:** `--browser.api.allowWrite` 387 - - **Config:** [browser.api.allowWrite](/config/browser/api#api-allowwrite) 388 - 389 - Allow API to edit files. (Be careful when enabling this option in untrusted environments) 390 - 391 - ### browser.isolate 392 - 393 - - **CLI:** `--browser.isolate` 394 - - **Config:** [browser.isolate](/config/browser/isolate) 395 - 396 - Run every browser test file in isolation. To disable isolation, use `--browser.isolate=false` (default: `true`) 397 - 398 356 ### browser.ui 399 357 400 358 - **CLI:** `--browser.ui` ··· 408 366 - **Config:** [browser.detailsPanelPosition](/config/browser/detailspanelposition) 409 367 410 368 Default position for the details panel in browser mode. Either `right` (horizontal split) or `bottom` (vertical split) (default: `right`) 411 - 412 - ### browser.fileParallelism 413 - 414 - - **CLI:** `--browser.fileParallelism` 415 - 416 - Should browser test files run in parallel. Use `--browser.fileParallelism=false` to disable (default: `true`) 417 369 418 370 ### browser.connectTimeout 419 371
+1 -1
packages/browser/rollup.config.js
··· 102 102 file: 'dist/context.js', 103 103 format: 'esm', 104 104 }, 105 - external: ['vitest/internal/browser'], 105 + external: ['vitest/internal/browser', 'vitest'], 106 106 plugins: [ 107 107 oxc({ 108 108 transform: { target: 'node18' },
+2 -2
packages/browser/src/client/orchestrator.ts
··· 86 86 } 87 87 } 88 88 89 - if (config.browser.isolate === false) { 89 + if (config.isolate === false) { 90 90 await this.runNonIsolatedTests(container, options, startTime, orchestratorSpan.context) 91 91 await endSpan() 92 92 return ··· 119 119 120 120 public async cleanupTesters(): Promise<void> { 121 121 const config = getConfig() 122 - if (config.browser.isolate) { 122 + if (config.isolate) { 123 123 // isolated mode assigns filepaths as ids 124 124 const files = Array.from(this.iframes.keys()) 125 125 // when the run is completed, show the last file in the UI
+2 -2
packages/browser/src/client/tester/runner.ts
··· 65 65 super(options.config) 66 66 this.config = options.config 67 67 this.commands = getBrowserState().commands 68 - this.viteEnvironment = '__browser__' 68 + this.viteEnvironment = 'client' 69 69 this._otel = getBrowserState().traces 70 70 } 71 71 ··· 240 240 await rpc().onAfterSuiteRun({ 241 241 coverage, 242 242 testFiles: files.map(file => file.name), 243 - environment: '__browser__', 243 + environment: 'client', 244 244 projectName: this.config.name, 245 245 }) 246 246 }
+1
packages/browser/src/client/tester/state.ts
··· 14 14 concurrencyId: 1, 15 15 config, 16 16 projectName: config.name || '', 17 + metaEnv: null as any, 17 18 files: [], 18 19 environment: { 19 20 name: 'browser',
+5 -2
packages/browser/src/client/tester/tester.ts
··· 6 6 import { 7 7 collectTests, 8 8 setupCommonEnv, 9 + setupEnv, 9 10 SpyModule, 10 11 startCoverageInsideWorker, 11 12 startTests, ··· 269 270 270 271 debug?.('prepare time', state.durations.prepare, 'ms') 271 272 273 + setupEnv(config.env, state.metaEnv) 274 + 272 275 await Promise.all([ 273 276 setupCommonEnv(config), 274 - startCoverageInsideWorker(config.coverage, moduleRunner, { isolate: config.browser.isolate }), 277 + startCoverageInsideWorker(config.coverage, moduleRunner, { isolate: config.isolate }), 275 278 (async () => { 276 279 const VitestIndex = await import('vitest') 277 280 Object.defineProperty(window, '__vitest_index__', { ··· 316 319 await rpc.wdioSwitchContext('parent') 317 320 .catch(error => unhandledError(error, 'Cleanup Error')) 318 321 } 319 - await stopCoverageInsideWorker(config.coverage, moduleRunner, { isolate: config.browser.isolate }).catch((error) => { 322 + await stopCoverageInsideWorker(config.coverage, moduleRunner, { isolate: config.isolate }).catch((error) => { 320 323 return unhandledError(error, 'Coverage Error') 321 324 }) 322 325 }
+474 -72
packages/browser/src/node/index.ts
··· 1 - import type { BrowserCommand, BrowserProviderOption, BrowserServerFactory } from 'vitest/node' 1 + import type { HtmlTagDescriptor, UserConfig, UserConfig as ViteUserConfig } from 'vite' 2 + import type { BrowserCommand, BrowserProviderOption, BrowserServerContribution, BrowserServerFactory, PluginHarness, ResolvedConfig } from 'vitest/node' 3 + import { createReadStream, readFileSync } from 'node:fs' 4 + import { createRequire } from 'node:module' 2 5 import { MockerRegistry } from '@vitest/mocker' 3 6 import { interceptorPlugin } from '@vitest/mocker/node' 7 + import { distClientRoot as uiClientRoot } from '@vitest/ui' 8 + import { toArray } from '@vitest/utils/helpers' 9 + import { join, resolve } from 'pathe' 10 + import sirv from 'sirv' 4 11 import c from 'tinyrainbow' 5 - import { createViteLogger, createViteServer } from 'vitest/node' 12 + import { isFileServingAllowed, isValidApiRequest, rolldownVersion, distDir as vitestDist } from 'vitest/node' 6 13 import { version } from '../../package.json' 7 14 import { distRoot } from './constants' 15 + import { createOrchestratorMiddleware } from './middlewares/orchestratorMiddleware' 16 + import { createTesterMiddleware } from './middlewares/testerMiddleware' 8 17 import BrowserPlugin from './plugin' 9 18 import { ParentBrowserProject } from './projectParent' 10 19 import { setupBrowserRpc } from './rpc' ··· 25 34 // export type { ProjectBrowser } from './project' 26 35 export { assertBrowserApiWrite, assertBrowserFileAccess, parseKeyDef, resolveScreenshotPath } from './utils' 27 36 28 - export const createBrowserServer: BrowserServerFactory = async (options) => { 29 - const project = options.project 30 - const configFile = project.vite.config.configFile 37 + const versionRegexp = /(?:\?|&)v=\w{8}/ 31 38 32 - if (project.vitest.version !== version) { 33 - project.vitest.logger.warn( 34 - c.yellow( 35 - `Loaded ${c.inverse(c.yellow(` vitest@${project.vitest.version} `))} and ${c.inverse(c.yellow(` @vitest/browser@${version} `))}.` 36 - + '\nRunning mixed versions is not supported and may lead into bugs' 37 - + '\nUpdate your dependencies and make sure the versions match.', 38 - ), 39 - ) 40 - } 39 + /** 40 + * The browser provider's `serverFactory`. Returns a `BrowserServerContribution` 41 + * that Vitest core uses to build the SINGLE Vite server shared by `project.vite` 42 + * and `project.browser.vite`. This factory does NOT create a server (core does); 43 + * it only contributes config, plugins, the parent factory, and the RPC setup. 44 + */ 45 + export const createBrowserServer: BrowserServerFactory = async () => { 46 + const mockerRegistry = new MockerRegistry() 41 47 42 - const server = new ParentBrowserProject(project, '/') 48 + const contribution: BrowserServerContribution = { 49 + async transformIndexHtml(ctx) { 50 + const parentServer = contribution.parent as ParentBrowserProject 51 + const projectBrowser = [...parentServer.children].find((server) => { 52 + return ctx.filename === server.testerFilepath 53 + }) 54 + if (!projectBrowser) { 55 + return 56 + } 43 57 44 - const configPath = typeof configFile === 'string' ? configFile : false 58 + const stateJs = typeof parentServer.stateJs === 'string' 59 + ? parentServer.stateJs 60 + : await parentServer.stateJs 45 61 46 - const logLevel = (process.env.VITEST_BROWSER_DEBUG as 'info') ?? 'info' 62 + const testerTags: HtmlTagDescriptor[] = [] 47 63 48 - const logger = createViteLogger(project.vitest.logger, logLevel, { 49 - allowClearScreen: false, 50 - }) 64 + const isDefaultTemplate = resolve(distRoot, 'client/tester/tester.html') === projectBrowser.testerFilepath 65 + if (!isDefaultTemplate) { 66 + const manifestContent = parentServer.manifest instanceof Promise 67 + ? await parentServer.manifest 68 + : parentServer.manifest 69 + const testerEntry = manifestContent['tester/tester.html'] 51 70 52 - const mockerRegistry = new MockerRegistry() 71 + testerTags.push({ 72 + tag: 'script', 73 + attrs: { 74 + type: 'module', 75 + crossorigin: '', 76 + src: `${parentServer.base}${testerEntry.file}`, 77 + }, 78 + injectTo: 'head', 79 + }) 53 80 54 - let cacheDir: string 55 - const vite = await createViteServer({ 56 - ...project.options, // spread project config inlined in root workspace config 57 - define: project.config.viteDefine, 58 - base: '/', 59 - root: project.config.root, 60 - logLevel, 61 - customLogger: { 62 - ...logger, 63 - info(msg, options) { 64 - logger.info(msg, options) 65 - if (msg.includes('optimized dependencies changed. reloading')) { 66 - logger.warn( 67 - [ 68 - c.yellow(`\n${c.bold('[vitest]')} Vite unexpectedly reloaded a test. This may cause tests to fail, lead to flaky behaviour or duplicated test runs.\n`), 69 - c.yellow(`For a stable experience, please add mentioned dependencies to your config\'s ${c.bold('\`optimizeDeps.include\`')} field manually.\n\n`), 70 - ].join(''), 71 - ) 81 + for (const importName of testerEntry.imports || []) { 82 + const entryManifest = manifestContent[importName] 83 + if (entryManifest) { 84 + testerTags.push( 85 + { 86 + tag: 'link', 87 + attrs: { 88 + href: `${parentServer.base}${entryManifest.file}`, 89 + rel: 'modulepreload', 90 + crossorigin: '', 91 + }, 92 + injectTo: 'head', 93 + }, 94 + ) 95 + } 72 96 } 73 - }, 74 - }, 75 - mode: project.config.mode, 76 - configFile: configPath, 77 - configLoader: project.vite.config.inlineConfig.configLoader, 78 - // watch is handled by Vitest 79 - server: { 80 - ...project.options?.server, 81 - hmr: false, 82 - watch: null, 97 + } 98 + else { 99 + // inject the reset style only in the default template, 100 + // allowing users to customize the style in their own template 101 + testerTags.push({ 102 + tag: 'style', 103 + children: ` 104 + html { 105 + padding: 0; 106 + margin: 0; 107 + } 108 + body { 109 + padding: 0; 110 + margin: 0; 111 + min-height: 100vh; 112 + }`, 113 + injectTo: 'head', 114 + }) 115 + } 116 + 117 + return [ 118 + { 119 + tag: 'script', 120 + children: '{__VITEST_INJECTOR__}', 121 + injectTo: 'head-prepend' as const, 122 + }, 123 + { 124 + tag: 'script', 125 + children: stateJs, 126 + injectTo: 'head-prepend', 127 + } as const, 128 + { 129 + tag: 'script', 130 + attrs: { 131 + type: 'module', 132 + src: parentServer.errorCatcherUrl, 133 + }, 134 + injectTo: 'head' as const, 135 + }, 136 + { 137 + tag: 'script', 138 + attrs: { 139 + type: 'module', 140 + src: parentServer.matchersUrl, 141 + }, 142 + injectTo: 'head' as const, 143 + }, 144 + ...parentServer.initScripts.map(script => ({ 145 + tag: 'script', 146 + attrs: { 147 + type: 'module', 148 + src: join('/@fs/', script), 149 + }, 150 + injectTo: 'head', 151 + } as const)), 152 + ...testerTags, 153 + ].filter(s => s != null) 83 154 }, 84 - cacheDir: project.vite.config.cacheDir, 85 - plugins: [ 86 - { 87 - name: 'vitest-internal:browser-cacheDir', 88 - configResolved(config) { 89 - cacheDir = config.cacheDir 155 + configureServer(server) { 156 + const parentServer = contribution.parent as ParentBrowserProject 157 + parentServer.setServer(server) 158 + 159 + // eslint-disable-next-line prefer-arrow-callback 160 + server.middlewares.use(function vitestHeaders(_req, res, next) { 161 + const headers = server.config.server.headers 162 + if (headers) { 163 + for (const name in headers) { 164 + res.setHeader(name, headers[name]!) 165 + } 166 + } 167 + next() 168 + }) 169 + // strip _vitest_original query added by importActual so that 170 + // the plugin pipeline sees the original import id (e.g. virtual modules's load hook). 171 + server.middlewares.use((req, _res, next) => { 172 + if ( 173 + req.url?.includes('_vitest_original') 174 + && parentServer.config.browser.provider?.name === 'playwright' 175 + ) { 176 + req.url = req.url 177 + .replace(/[?&]_vitest_original(?=[&#]|$)/, '') 178 + .replace(/[?&]ext\b[^&#]*/, '') 179 + .replace(/\?$/, '') 180 + } 181 + next() 182 + }) 183 + server.middlewares.use(createOrchestratorMiddleware(parentServer)) 184 + server.middlewares.use(createTesterMiddleware(parentServer)) 185 + 186 + server.middlewares.use( 187 + `/favicon.svg`, 188 + (_, res) => { 189 + const content = readFileSync(resolve(distRoot, 'client/favicon.svg')) 190 + res.write(content, 'utf-8') 191 + res.end() 90 192 }, 91 - }, 92 - ...options.mocksPlugins({ 93 - filter(id) { 94 - if (id.includes(distRoot) || id.includes(cacheDir)) { 95 - return false 193 + ) 194 + 195 + // Serve coverage HTML at ./coverage if configured 196 + const coverageHtmlDir = parentServer.vitest.config.coverage?.htmlDir 197 + if (coverageHtmlDir) { 198 + server.middlewares.use( 199 + '/__vitest_test__/coverage', 200 + sirv(coverageHtmlDir, { 201 + single: true, 202 + dev: true, 203 + setHeaders: (res) => { 204 + const csp = res.getHeader('Content-Security-Policy') 205 + if (typeof csp === 'string') { 206 + // add frame-ancestors to allow the iframe to be loaded by Vitest, 207 + // but keep the rest of the CSP 208 + res.setHeader( 209 + 'Content-Security-Policy', 210 + csp.replace(/frame-ancestors [^;]+/, 'frame-ancestors *'), 211 + ) 212 + } 213 + res.setHeader( 214 + 'Cache-Control', 215 + 'public,max-age=0,must-revalidate', 216 + ) 217 + }, 218 + }), 219 + ) 220 + } 221 + 222 + server.middlewares.use((req, res, next) => { 223 + // 9000 mega head move 224 + // Vite always caches optimized dependencies, but users might mock 225 + // them in _some_ tests, while keeping original modules in others 226 + // there is no way to configure that in Vite, so we patch it here 227 + // to always ignore the cache-control set by Vite in the next middleware 228 + if (req.url && versionRegexp.test(req.url) && !req.url.includes('chunk-')) { 229 + res.setHeader('Cache-Control', 'no-cache') 230 + const setHeader = res.setHeader.bind(res) 231 + res.setHeader = function (name, value) { 232 + if (name === 'Cache-Control') { 233 + return res 234 + } 235 + return setHeader(name, value) 96 236 } 97 - return true 237 + } 238 + next() 239 + }) 240 + // handle attachments the same way as in packages/ui/node/index.ts 241 + server.middlewares.use((req, res, next) => { 242 + if (!req.url) { 243 + return next() 244 + } 245 + 246 + const url = new URL(req.url, 'http://localhost') 247 + 248 + if (url.pathname !== '/__vitest_attachment__') { 249 + return next() 250 + } 251 + 252 + const path = url.searchParams.get('path') 253 + const contentType = url.searchParams.get('contentType') 254 + 255 + if (!isValidApiRequest(parentServer.config, req) || !contentType || !path) { 256 + return next() 257 + } 258 + 259 + const fsPath = decodeURIComponent(path) 260 + 261 + if (!isFileServingAllowed(parentServer.vite.config, fsPath)) { 262 + return next() 263 + } 264 + 265 + try { 266 + res.setHeader( 267 + 'content-type', 268 + contentType, 269 + ) 270 + 271 + return createReadStream(fsPath) 272 + .pipe(res) 273 + .on('close', () => res.end()) 274 + } 275 + catch (err) { 276 + return next(err) 277 + } 278 + }) 279 + 280 + // When the Vitest UI (`test.ui`) is enabled, the `vitest:ui` plugin owns 281 + // `/__vitest__` (including the token-injected index.html and its assets), 282 + // so registering sirv here would shadow it and serve the page without a token. 283 + if (!parentServer.vitest.config.ui) { 284 + server.middlewares.use( 285 + '/__vitest__', 286 + sirv(uiClientRoot), 287 + ) 288 + } 289 + }, 290 + // Resolution-time config: only what is derivable from the (partial) user 291 + // config. The mocks / coverage / meta-env plugins come from the shared 292 + // workspace plugin set, not from here. 293 + async config(viteConfig) { 294 + const testConfig = viteConfig.test || {} 295 + const config: UserConfig = { 296 + resolve: { 297 + alias: testConfig.alias, 298 + dedupe: ['vitest'], 98 299 }, 99 - }), 100 - options.metaEnvReplacer(), 101 - ...(project.options?.plugins || []), 102 - BrowserPlugin(server), 103 - interceptorPlugin({ registry: mockerRegistry }), 104 - options.coveragePlugin(), 105 - ], 106 - }) 300 + server: { 301 + fs: { 302 + allow: [distRoot], 303 + }, 304 + middlewareMode: false, 305 + watch: null, 306 + // Vitest forwards browser console logs and unhandled errors through its 307 + // own RPC, so Vite's client forwarding (defaults on under agents) would 308 + // double-report them and leak into stderr. 309 + forwardConsole: false, 310 + }, 311 + } 312 + // Enables using the @preserve ignore hint for coverage providers. Only for 313 + // esbuild (rolldown-vite uses oxc instead). 314 + if (!rolldownVersion && viteConfig.esbuild !== false) { 315 + config.esbuild = { legalComments: 'inline' } 316 + } 317 + const define: Record<string, string> = {} 318 + const envVars = testConfig.env || {} 319 + for (const env in envVars) { 320 + define[`import.meta.env.${env}`] = JSON.stringify(envVars[env]) 321 + } 322 + config.define = define 323 + return config 324 + }, 325 + resolveOptimizeDeps(projectConfigs, testFiles, harness) { 326 + return resolveBrowserOptimizeDeps(projectConfigs, testFiles, harness) 327 + }, 328 + plugins: [], 329 + createParent({ config, vitest }) { 330 + if (vitest.version !== version) { 331 + vitest.logger.warn( 332 + c.yellow( 333 + `Loaded ${c.inverse(c.yellow(` vitest@${vitest.version} `))} and ${c.inverse(c.yellow(` @vitest/browser@${version} `))}.` 334 + + '\nRunning mixed versions is not supported and may lead into bugs' 335 + + '\nUpdate your dependencies and make sure the versions match.', 336 + ), 337 + ) 338 + } 339 + return new ParentBrowserProject({ config, vitest }, '/') 340 + }, 341 + setupRpc(parent) { 342 + setupBrowserRpc(parent as ParentBrowserProject, mockerRegistry) 343 + }, 344 + } 345 + 346 + contribution.plugins = [ 347 + ...BrowserPlugin(contribution), 348 + // this plugin's `configureServer` is ignored since it's added through `applyToEnvironment` 349 + interceptorPlugin({ registry: mockerRegistry }), 350 + ] 351 + 352 + return contribution 353 + } 354 + 355 + function resolveBrowserOptimizeDeps( 356 + projectConfigs: ResolvedConfig[], 357 + testFiles: string[], 358 + harness: PluginHarness, 359 + ): NonNullable<ViteUserConfig['optimizeDeps']> { 360 + // `testFiles` are globbed by the core package and aggregated across every 361 + // project that shares this browser Vite server (instance and benchmark 362 + // variants). The remaining options are shared by those projects, so the first 363 + // config is representative. 364 + const testConfig = projectConfigs[0] 365 + const root = testConfig.root || process.cwd() 366 + 367 + const setupFiles = new Set( 368 + projectConfigs.flatMap(config => toArray(config.setupFiles || [])), 369 + ) 370 + 371 + const entries: string[] = [ 372 + ...testFiles, 373 + ...setupFiles, 374 + resolve(vitestDist, 'index.js'), 375 + resolve(vitestDist, 'browser.js'), 376 + resolve(vitestDist, 'runners.js'), 377 + resolve(vitestDist, 'utils.js'), 378 + ...(testConfig.snapshotSerializers || []), 379 + ] 380 + 381 + const exclude = [ 382 + 'vitest', 383 + 'vitest/browser', 384 + 'vitest/internal/browser', 385 + 'vite/module-runner', 386 + '@vitest/browser/utils', 387 + '@vitest/browser/context', 388 + '@vitest/browser/client', 389 + '@vitest/utils', 390 + '@vitest/utils/source-map', 391 + '@vitest/spy', 392 + '@vitest/utils/error', 393 + 'std-env', 394 + 'tinybench', 395 + 'tinyspy', 396 + 'tinyrainbow', 397 + 'pathe', 398 + 'msw', 399 + 'msw/browser', 400 + ] 107 401 108 - await vite.listen() 402 + if (typeof testConfig.diff === 'string') { 403 + entries.push(testConfig.diff) 404 + } 109 405 110 - setupBrowserRpc(server, mockerRegistry) 406 + if (testConfig.coverage?.enabled) { 407 + const provider = testConfig.coverage.provider ?? 'v8' 408 + if (provider === 'v8') { 409 + const path = tryResolve('@vitest/coverage-v8', [root]) 410 + if (path) { 411 + entries.push(path) 412 + exclude.push('@vitest/coverage-v8/browser') 413 + } 414 + } 415 + else if (provider === 'istanbul') { 416 + const path = tryResolve('@vitest/coverage-istanbul', [root]) 417 + if (path) { 418 + entries.push(path) 419 + exclude.push('@vitest/coverage-istanbul') 420 + } 421 + } 422 + else if (provider === 'custom' && testConfig.coverage.customProviderModule) { 423 + entries.push(testConfig.coverage.customProviderModule) 424 + } 425 + } 111 426 112 - return server 427 + const include = [ 428 + 'vitest > expect-type', 429 + 'vitest > magic-string', 430 + 'vitest > chai', 431 + ] 432 + 433 + const provider = testConfig.browser?.provider 434 + if (provider?.name === 'preview') { 435 + include.push( 436 + '@vitest/browser-preview > @testing-library/user-event', 437 + '@vitest/browser-preview > @testing-library/dom', 438 + ) 439 + } 440 + 441 + const isPackageExists = (pkg: string) => { 442 + return harness.packageInstaller.isPackageExists(pkg, { paths: [root] }) 443 + } 444 + 445 + if (isPackageExists('vitest-browser-svelte')) { 446 + exclude.push('vitest-browser-svelte') 447 + } 448 + // since we override the resolution in the esbuild plugin, Vite can no longer optimize it 449 + if (isPackageExists('vitest-browser-vue')) { 450 + include.push( 451 + 'vitest-browser-vue', 452 + 'vitest-browser-vue > @vue/test-utils', 453 + 'vitest-browser-vue > @vue/test-utils > @vue/compiler-core', 454 + ) 455 + } 456 + if (isPackageExists('@vue/test-utils')) { 457 + include.push('@vue/test-utils') 458 + } 459 + 460 + const otelConfig = testConfig.experimental?.openTelemetry 461 + if (otelConfig?.enabled && otelConfig.browserSdkPath) { 462 + entries.push(otelConfig.browserSdkPath) 463 + include.push('@opentelemetry/api') 464 + } 465 + else { 466 + exclude.push('@opentelemetry/api') 467 + } 468 + 469 + // resolve @vue/(test-utils|compiler-core) to CJS instead of the browser build: 470 + // test-utils' browser build expects a global Vue; compiler-core's CJS build is 471 + // the only one that allows slots as strings. 472 + const rolldownPlugin = { 473 + name: 'vue-test-utils-rewrite', 474 + resolveId: { 475 + filter: { id: /^@vue\/(test-utils|compiler-core)$/ }, 476 + handler(source: string, importer: string) { 477 + return getRequire().resolve(source, { paths: [importer!] }) 478 + }, 479 + }, 480 + } 481 + const esbuildPlugin = { 482 + name: 'test-utils-rewrite', 483 + setup(build: any) { 484 + build.onResolve({ filter: /^@vue\/(test-utils|compiler-core)$/ }, (args: any) => { 485 + return { path: getRequire().resolve(args.path, { paths: [args.importer] }) } 486 + }) 487 + }, 488 + } 489 + 490 + return { 491 + entries, 492 + exclude, 493 + include, 494 + ...(rolldownVersion 495 + ? { rolldownOptions: { plugins: [rolldownPlugin] } } 496 + : { esbuildOptions: { plugins: [esbuildPlugin] } }), 497 + } 498 + } 499 + 500 + function tryResolve(path: string, paths: string[]) { 501 + try { 502 + return getRequire().resolve(path, { paths }) 503 + } 504 + catch { 505 + return undefined 506 + } 507 + } 508 + 509 + let _require: ReturnType<typeof createRequire> 510 + function getRequire(): ReturnType<typeof createRequire> { 511 + if (!_require) { 512 + _require = createRequire(import.meta.url) 513 + } 514 + return _require 113 515 } 114 516 115 517 export function defineBrowserProvider<T extends object = object>(options: Omit<
+20 -546
packages/browser/src/node/plugin.ts
··· 1 - import type { HtmlTagDescriptor } from 'vite' 2 1 import type { Plugin } from 'vitest/config' 2 + import type { BrowserServerContribution } from 'vitest/node' 3 3 import type { ParentBrowserProject } from './projectParent' 4 - import { createReadStream, readFileSync } from 'node:fs' 5 - import { createRequire } from 'node:module' 6 4 import { dynamicImportPlugin } from '@vitest/mocker/node' 7 - import { distClientRoot as uiClientRoot } from '@vitest/ui' 8 - import { toArray } from '@vitest/utils/helpers' 9 5 import MagicString from 'magic-string' 10 - import { dirname, join, resolve } from 'pathe' 11 - import sirv from 'sirv' 12 - import { 13 - isFileServingAllowed, 14 - isValidApiRequest, 15 - resolveApiServerConfig, 16 - resolveFsAllow, 17 - rolldownVersion, 18 - distDir as vitestDist, 19 - } from 'vitest/node' 20 - import { API_TOKEN_FILE } from '../../../vitest/src/node/config/apiToken' 6 + import { resolve } from 'pathe' 21 7 import { distRoot } from './constants' 22 - import { createOrchestratorMiddleware } from './middlewares/orchestratorMiddleware' 23 - import { createTesterMiddleware } from './middlewares/testerMiddleware' 24 8 import BrowserContext from './plugins/pluginContext' 25 9 26 10 export type { BrowserCommand } from 'vitest/node' 27 11 28 - const versionRegexp = /(?:\?|&)v=\w{8}/ 29 - 30 - export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => { 31 - function isPackageExists(pkg: string, root: string) { 32 - return parentServer.vitest.packageInstaller.isPackageExists?.(pkg, { 33 - paths: [root], 34 - }) 35 - } 36 - 12 + export default (contribution: BrowserServerContribution): Plugin[] => { 37 13 return [ 38 14 { 15 + name: 'vitest:browser:tests', 39 16 enforce: 'pre', 40 - name: 'vitest:browser', 41 - async configureServer(server) { 42 - parentServer.setServer(server) 43 - 44 - // eslint-disable-next-line prefer-arrow-callback 45 - server.middlewares.use(function vitestHeaders(_req, res, next) { 46 - const headers = server.config.server.headers 47 - if (headers) { 48 - for (const name in headers) { 49 - res.setHeader(name, headers[name]!) 50 - } 51 - } 52 - next() 53 - }) 54 - // strip _vitest_original query added by importActual so that 55 - // the plugin pipeline sees the original import id (e.g. virtual modules's load hook). 56 - server.middlewares.use((req, _res, next) => { 57 - if ( 58 - req.url?.includes('_vitest_original') 59 - && parentServer.project.config.browser.provider?.name === 'playwright' 60 - ) { 61 - req.url = req.url 62 - .replace(/[?&]_vitest_original(?=[&#]|$)/, '') 63 - .replace(/[?&]ext\b[^&#]*/, '') 64 - .replace(/\?$/, '') 65 - } 66 - next() 67 - }) 68 - server.middlewares.use(createOrchestratorMiddleware(parentServer)) 69 - server.middlewares.use(createTesterMiddleware(parentServer)) 70 - 71 - server.middlewares.use( 72 - `${base}favicon.svg`, 73 - (_, res) => { 74 - const content = readFileSync(resolve(distRoot, 'client/favicon.svg')) 75 - res.write(content, 'utf-8') 76 - res.end() 77 - }, 78 - ) 79 - 80 - // Serve coverage HTML at ./coverage if configured 81 - const coverageHtmlDir = parentServer.vitest.config.coverage?.htmlDir 82 - if (coverageHtmlDir) { 83 - server.middlewares.use( 84 - '/__vitest_test__/coverage', 85 - sirv(coverageHtmlDir, { 86 - single: true, 87 - dev: true, 88 - setHeaders: (res) => { 89 - const csp = res.getHeader('Content-Security-Policy') 90 - if (typeof csp === 'string') { 91 - // add frame-ancestors to allow the iframe to be loaded by Vitest, 92 - // but keep the rest of the CSP 93 - res.setHeader( 94 - 'Content-Security-Policy', 95 - csp.replace(/frame-ancestors [^;]+/, 'frame-ancestors *'), 96 - ) 97 - } 98 - res.setHeader( 99 - 'Cache-Control', 100 - 'public,max-age=0,must-revalidate', 101 - ) 102 - }, 103 - }), 104 - ) 105 - } 106 - 107 - server.middlewares.use((req, res, next) => { 108 - // 9000 mega head move 109 - // Vite always caches optimized dependencies, but users might mock 110 - // them in _some_ tests, while keeping original modules in others 111 - // there is no way to configure that in Vite, so we patch it here 112 - // to always ignore the cache-control set by Vite in the next middleware 113 - if (req.url && versionRegexp.test(req.url) && !req.url.includes('chunk-')) { 114 - res.setHeader('Cache-Control', 'no-cache') 115 - const setHeader = res.setHeader.bind(res) 116 - res.setHeader = function (name, value) { 117 - if (name === 'Cache-Control') { 118 - return res 119 - } 120 - return setHeader(name, value) 121 - } 122 - } 123 - next() 124 - }) 125 - // handle attachments the same way as in packages/ui/node/index.ts 126 - server.middlewares.use((req, res, next) => { 127 - if (!req.url) { 128 - return next() 17 + resolveId: { 18 + order: 'pre', 19 + handler(id) { 20 + if (!/\?browserv=\w+$/.test(id)) { 21 + return 129 22 } 130 23 131 - const url = new URL(req.url, 'http://localhost') 132 - 133 - if (url.pathname !== '/__vitest_attachment__') { 134 - return next() 24 + let useId = id.slice(0, id.lastIndexOf('?')) 25 + if (useId.startsWith('/@fs/')) { 26 + useId = useId.slice(5) 135 27 } 136 28 137 - const path = url.searchParams.get('path') 138 - const contentType = url.searchParams.get('contentType') 139 - 140 - if (!isValidApiRequest(parentServer.config, req) || !contentType || !path) { 141 - return next() 29 + if (/^\w:/.test(useId)) { 30 + useId = useId.replace(/\\/g, '/') 142 31 } 143 32 144 - const fsPath = decodeURIComponent(path) 145 - 146 - if (!isFileServingAllowed(parentServer.vite.config, fsPath)) { 147 - return next() 148 - } 149 - 150 - try { 151 - res.setHeader( 152 - 'content-type', 153 - contentType, 154 - ) 155 - 156 - return createReadStream(fsPath) 157 - .pipe(res) 158 - .on('close', () => res.end()) 159 - } 160 - catch (err) { 161 - return next(err) 162 - } 163 - }) 164 - }, 165 - }, 166 - { 167 - name: 'vitest:browser:tests', 168 - enforce: 'pre', 169 - async config() { 170 - // this plugin can be used in different projects, but all of them 171 - // have the same `include` pattern, so it doesn't matter which project we use 172 - const project = parentServer.project 173 - // only glob benchmarks when a browser-enabled bench project exists in 174 - // the workspace — keeps the optimizeDeps entries symmetrical across 175 - // the test/bench project clones so the optimizer doesn't re-scan when 176 - // the user switches modes 177 - const hasBrowserBenchProject = parentServer.vitest.projects.some(p => 178 - p.config.browser.enabled && p.config.benchmark.enabled, 179 - ) 180 - const benchInclude = hasBrowserBenchProject 181 - ? project.config.benchmark.include 182 - : [] 183 - const dir = project.config.dir || project.config.root 184 - const [{ testFiles: browserTestFiles }, browserBenchFiles] = await Promise.all([ 185 - project.globTestFiles(), 186 - benchInclude.length > 0 187 - ? project.globFiles( 188 - benchInclude, 189 - project.config.benchmark.exclude ?? project.config.exclude, 190 - dir, 191 - ) 192 - : [], 193 - ]) 194 - const setupFiles = toArray(project.config.setupFiles) 195 - 196 - // replace env values - cannot be reassign at runtime 197 - const define: Record<string, string> = {} 198 - for (const env in (project.config.env || {})) { 199 - const stringValue = JSON.stringify(project.config.env[env]) 200 - define[`import.meta.env.${env}`] = stringValue 201 - } 202 - 203 - const entries: string[] = [ 204 - ...new Set([...browserTestFiles, ...browserBenchFiles]), 205 - ...setupFiles, 206 - resolve(vitestDist, 'index.js'), 207 - resolve(vitestDist, 'browser.js'), 208 - resolve(vitestDist, 'runners.js'), 209 - resolve(vitestDist, 'utils.js'), 210 - ...(project.config.snapshotSerializers || []), 211 - ] 212 - 213 - const exclude = [ 214 - 'vitest', 215 - 'vitest/browser', 216 - 'vitest/internal/browser', 217 - 'vite/module-runner', 218 - '@vitest/browser/utils', 219 - '@vitest/browser/context', 220 - '@vitest/browser/client', 221 - '@vitest/utils', 222 - '@vitest/utils/source-map', 223 - '@vitest/spy', 224 - '@vitest/utils/error', 225 - 'std-env', 226 - 'tinybench', 227 - 'tinyspy', 228 - 'tinyrainbow', 229 - 'pathe', 230 - 'msw', 231 - 'msw/browser', 232 - ] 233 - 234 - if (typeof project.config.diff === 'string') { 235 - entries.push(project.config.diff) 236 - } 237 - 238 - if (parentServer.vitest.coverageProvider) { 239 - const coverage = parentServer.vitest._coverageOptions 240 - const provider = coverage.provider 241 - if (provider === 'v8') { 242 - const path = tryResolve('@vitest/coverage-v8', [parentServer.config.root]) 243 - if (path) { 244 - entries.push(path) 245 - exclude.push('@vitest/coverage-v8/browser') 246 - } 247 - } 248 - else if (provider === 'istanbul') { 249 - const path = tryResolve('@vitest/coverage-istanbul', [parentServer.config.root]) 250 - if (path) { 251 - entries.push(path) 252 - exclude.push('@vitest/coverage-istanbul') 253 - } 254 - } 255 - else if (provider === 'custom' && coverage.customProviderModule) { 256 - entries.push(coverage.customProviderModule) 257 - } 258 - } 259 - 260 - const include = [ 261 - 'vitest > expect-type', 262 - 'vitest > magic-string', 263 - 'vitest > chai', 264 - ] 265 - 266 - const provider = parentServer.config.browser.provider || [...parentServer.children][0]?.provider 267 - if (provider?.name === 'preview') { 268 - include.push( 269 - '@vitest/browser-preview > @testing-library/user-event', 270 - '@vitest/browser-preview > @testing-library/dom', 271 - ) 272 - } 273 - 274 - const fileRoot = browserTestFiles[0] ? dirname(browserTestFiles[0]) : project.config.root 275 - 276 - const svelte = isPackageExists('vitest-browser-svelte', fileRoot) 277 - if (svelte) { 278 - exclude.push('vitest-browser-svelte') 279 - } 280 - 281 - // since we override the resolution in the esbuild plugin, Vite can no longer optimizer it 282 - const vue = isPackageExists('vitest-browser-vue', fileRoot) 283 - if (vue) { 284 - // we override them in the esbuild plugin so optimizer can no longer intercept it 285 - include.push( 286 - 'vitest-browser-vue', 287 - 'vitest-browser-vue > @vue/test-utils', 288 - 'vitest-browser-vue > @vue/test-utils > @vue/compiler-core', 289 - ) 290 - } 291 - const vueTestUtils = isPackageExists('@vue/test-utils', fileRoot) 292 - if (vueTestUtils) { 293 - include.push('@vue/test-utils') 294 - } 295 - 296 - const otelConfig = project.config.experimental.openTelemetry 297 - if (otelConfig?.enabled && otelConfig.browserSdkPath) { 298 - entries.push(otelConfig.browserSdkPath) 299 - include.push('@opentelemetry/api') 300 - } 301 - else { 302 - exclude.push('@opentelemetry/api') 303 - } 304 - 305 - return { 306 - define, 307 - resolve: { 308 - dedupe: ['vitest'], 309 - }, 310 - optimizeDeps: { 311 - entries, 312 - exclude, 313 - include, 314 - }, 315 - } 316 - }, 317 - async resolveId(id) { 318 - if (!/\?browserv=\w+$/.test(id)) { 319 - return 320 - } 321 - 322 - let useId = id.slice(0, id.lastIndexOf('?')) 323 - if (useId.startsWith('/@fs/')) { 324 - useId = useId.slice(5) 325 - } 326 - 327 - if (/^\w:/.test(useId)) { 328 - useId = useId.replace(/\\/g, '/') 329 - } 330 - 331 - return useId 332 - }, 333 - }, 334 - { 335 - name: 'vitest:browser:resolve-virtual', 336 - async resolveId(rawId) { 337 - if (rawId === '/mockServiceWorker.js') { 338 - return this.resolve('msw/mockServiceWorker.js', distRoot, { 339 - skipSelf: true, 340 - }) 341 - } 33 + return useId 34 + }, 342 35 }, 343 36 }, 344 37 { 345 38 name: 'vitest:browser:assets', 346 - configureServer(server) { 347 - server.middlewares.use( 348 - '/__vitest__', 349 - sirv(uiClientRoot), 350 - ) 351 - }, 352 39 resolveId(id) { 353 40 if (id.startsWith('/__vitest_browser__/')) { 354 41 return resolve(distRoot, 'client', id.slice(1)) 355 42 } 356 43 }, 357 44 transform(code, id) { 45 + const parentServer = contribution.parent as ParentBrowserProject 358 46 if (id.includes(parentServer.vite.config.cacheDir) && id.includes('loupe.js')) { 359 47 // loupe bundle has a nasty require('util') call that leaves a warning in the console 360 48 const utilRequire = 'nodeUtil = require_util();' ··· 362 50 } 363 51 }, 364 52 }, 365 - BrowserContext(parentServer), 53 + BrowserContext(contribution), 366 54 dynamicImportPlugin({ 367 55 globalThisAccessor: '"__vitest_browser_runner__"', 368 - filter(id, environment) { 369 - if (environment.name !== 'client') { 370 - return false 371 - } 56 + // injected only into the `client` environment via the browser contribution 57 + filter(id) { 372 58 if (id.includes(distRoot)) { 373 59 return false 374 60 } ··· 376 62 }, 377 63 }), 378 64 { 379 - name: 'vitest:browser:config', 380 - enforce: 'post', 381 - async config(viteConfig) { 382 - // Enables using ignore hint for coverage providers with @preserve keyword 383 - // Only set esbuild options when not using rolldown-vite (Vite 8+), 384 - // which uses oxc for transformation instead of esbuild 385 - if (!rolldownVersion && viteConfig.esbuild !== false) { 386 - viteConfig.esbuild ||= {} 387 - viteConfig.esbuild.legalComments = 'inline' 388 - } 389 - 390 - const defaultPort = parentServer.vitest.state._data.browserLastPort++ 391 - 392 - const api = resolveApiServerConfig( 393 - viteConfig.test?.browser || {}, 394 - defaultPort, 395 - ) 396 - 397 - viteConfig.server = { 398 - ...viteConfig.server, 399 - port: defaultPort, 400 - ...api, 401 - middlewareMode: false, 402 - open: false, 403 - } 404 - viteConfig.server.fs ??= {} 405 - viteConfig.server.fs.allow = viteConfig.server.fs.allow || [] 406 - viteConfig.server.fs.deny ??= [] 407 - viteConfig.server.fs.deny.push(API_TOKEN_FILE) 408 - viteConfig.server.fs.allow.push( 409 - ...resolveFsAllow( 410 - parentServer.vitest.config.root, 411 - parentServer.vitest.vite.config.configFile, 412 - ), 413 - distRoot, 414 - ) 415 - 416 - return { 417 - resolve: { 418 - alias: viteConfig.test?.alias, 419 - }, 420 - } 421 - }, 422 - }, 423 - { 424 65 name: 'vitest:browser:in-source-tests', 425 66 transform: { 426 67 filter: { ··· 458 99 }, 459 100 }, 460 101 { 461 - name: 'vitest:browser:transform-tester-html', 462 - enforce: 'pre', 463 - async transformIndexHtml(html, ctx) { 464 - const projectBrowser = [...parentServer.children].find((server) => { 465 - return ctx.filename === server.testerFilepath 466 - }) 467 - if (!projectBrowser) { 468 - return 469 - } 470 - 471 - const stateJs = typeof parentServer.stateJs === 'string' 472 - ? parentServer.stateJs 473 - : await parentServer.stateJs 474 - 475 - const testerTags: HtmlTagDescriptor[] = [] 476 - 477 - const isDefaultTemplate = resolve(distRoot, 'client/tester/tester.html') === projectBrowser.testerFilepath 478 - if (!isDefaultTemplate) { 479 - const manifestContent = parentServer.manifest instanceof Promise 480 - ? await parentServer.manifest 481 - : parentServer.manifest 482 - const testerEntry = manifestContent['tester/tester.html'] 483 - 484 - testerTags.push({ 485 - tag: 'script', 486 - attrs: { 487 - type: 'module', 488 - crossorigin: '', 489 - src: `${parentServer.base}${testerEntry.file}`, 490 - }, 491 - injectTo: 'head', 492 - }) 493 - 494 - for (const importName of testerEntry.imports || []) { 495 - const entryManifest = manifestContent[importName] 496 - if (entryManifest) { 497 - testerTags.push( 498 - { 499 - tag: 'link', 500 - attrs: { 501 - href: `${parentServer.base}${entryManifest.file}`, 502 - rel: 'modulepreload', 503 - crossorigin: '', 504 - }, 505 - injectTo: 'head', 506 - }, 507 - ) 508 - } 509 - } 510 - } 511 - else { 512 - // inject the reset style only in the default template, 513 - // allowing users to customize the style in their own template 514 - testerTags.push({ 515 - tag: 'style', 516 - children: ` 517 - html { 518 - padding: 0; 519 - margin: 0; 520 - } 521 - body { 522 - padding: 0; 523 - margin: 0; 524 - min-height: 100vh; 525 - }`, 526 - injectTo: 'head', 527 - }) 528 - } 529 - 530 - return [ 531 - { 532 - tag: 'script', 533 - children: '{__VITEST_INJECTOR__}', 534 - injectTo: 'head-prepend' as const, 535 - }, 536 - { 537 - tag: 'script', 538 - children: stateJs, 539 - injectTo: 'head-prepend', 540 - } as const, 541 - { 542 - tag: 'script', 543 - attrs: { 544 - type: 'module', 545 - src: parentServer.errorCatcherUrl, 546 - }, 547 - injectTo: 'head' as const, 548 - }, 549 - { 550 - tag: 'script', 551 - attrs: { 552 - type: 'module', 553 - src: parentServer.matchersUrl, 554 - }, 555 - injectTo: 'head' as const, 556 - }, 557 - ...parentServer.initScripts.map(script => ({ 558 - tag: 'script', 559 - attrs: { 560 - type: 'module', 561 - src: join('/@fs/', script), 562 - }, 563 - injectTo: 'head', 564 - } as const)), 565 - ...testerTags, 566 - ].filter(s => s != null) 567 - }, 568 - }, 569 - { 570 - name: 'vitest:browser:support-testing-library', 571 - enforce: 'pre', 572 - config() { 573 - const rolldownPlugin = { 574 - name: 'vue-test-utils-rewrite', 575 - resolveId: { 576 - // test-utils: resolve to CJS instead of the browser because the browser version expects a global Vue object 577 - // compiler-core: only CJS version allows slots as strings 578 - filter: { id: /^@vue\/(test-utils|compiler-core)$/ }, 579 - handler(source: string, importer: string) { 580 - const resolved = getRequire().resolve(source, { 581 - paths: [importer!], 582 - }) 583 - return resolved 584 - }, 585 - }, 586 - } 587 - const esbuildPlugin = { 588 - name: 'test-utils-rewrite', 589 - // "any" because vite doesn't expose any types for this 590 - setup(build: any) { 591 - // test-utils: resolve to CJS instead of the browser because the browser version expects a global Vue object 592 - // compiler-core: only CJS version allows slots as strings 593 - build.onResolve({ filter: /^@vue\/(test-utils|compiler-core)$/ }, (args: any) => { 594 - const resolved = getRequire().resolve(args.path, { 595 - paths: [args.importer], 596 - }) 597 - return { path: resolved } 598 - }) 599 - }, 600 - } 601 - 602 - return { 603 - optimizeDeps: rolldownVersion 604 - ? { rolldownOptions: { plugins: [rolldownPlugin] } } 605 - : { esbuildOptions: { plugins: [esbuildPlugin] } }, 606 - } 607 - }, 608 - }, 609 - { 610 102 name: 'vitest:browser:__vitest_browser_import_meta_env_init__', 611 103 transform: { 612 104 handler(code) { ··· 619 111 }, 620 112 }, 621 113 ] 622 - } 623 - 624 - function tryResolve(path: string, paths: string[]) { 625 - try { 626 - const _require = getRequire() 627 - return _require.resolve(path, { paths }) 628 - } 629 - catch { 630 - return undefined 631 - } 632 - } 633 - 634 - let _require: NodeRequire 635 - function getRequire() { 636 - if (!_require) { 637 - _require = createRequire(import.meta.url) 638 - } 639 - return _require 640 114 } 641 115 642 116 const postfixRE = /[?#].*$/
+24 -36
packages/browser/src/node/plugins/pluginContext.ts
··· 1 1 import type { Rollup } from 'vite' 2 2 import type { Plugin } from 'vitest/config' 3 - import type { BrowserProvider } from 'vitest/node' 3 + import type { BrowserProvider, BrowserServerContribution } from 'vitest/node' 4 4 import type { ParentBrowserProject } from '../projectParent' 5 5 import { fileURLToPath } from 'node:url' 6 6 import { slash } from '@vitest/utils/helpers' ··· 8 8 9 9 const VIRTUAL_ID_CONTEXT = '\0vitest/browser' 10 10 const ID_CONTEXT = 'vitest/browser' 11 - // for libraries that use an older import but are not type checked 12 - const DEPRECATED_ID_CONTEXT = '@vitest/browser/context' 13 - 14 - const DEPRECATED_VIRTUAL_ID_UTILS = '\0@vitest/browser/utils' 15 - const DEPRECATED_ID_UTILS = '@vitest/browser/utils' 16 11 17 12 const __dirname = dirname(fileURLToPath(import.meta.url)) 18 13 19 - export default function BrowserContext(globalServer: ParentBrowserProject): Plugin { 14 + export default function BrowserContext(contribution: BrowserServerContribution): Plugin { 20 15 return { 21 16 name: 'vitest:browser:virtual-module:context', 22 17 enforce: 'pre', 23 - resolveId(id, importer) { 24 - if (id === ID_CONTEXT) { 25 - return VIRTUAL_ID_CONTEXT 26 - } 27 - if (id === DEPRECATED_ID_CONTEXT) { 28 - if (importer && !importer.includes('/node_modules/')) { 29 - globalServer.vitest.logger.deprecate( 30 - `${importer} tries to load a deprecated "${id}" module. ` 31 - + `This import will stop working in the next major version. ` 32 - + `Please, use "vitest/browser" instead.`, 33 - ) 18 + resolveId: { 19 + order: 'pre', 20 + handler(id) { 21 + if (id === ID_CONTEXT) { 22 + return VIRTUAL_ID_CONTEXT 34 23 } 35 - return VIRTUAL_ID_CONTEXT 36 - } 37 - if (id === DEPRECATED_ID_UTILS) { 38 - return DEPRECATED_VIRTUAL_ID_UTILS 39 - } 24 + }, 40 25 }, 41 26 load(id) { 27 + const globalServer = contribution.parent as ParentBrowserProject 42 28 if (id === VIRTUAL_ID_CONTEXT) { 43 29 return generateContextFile.call(this, globalServer) 44 30 } 45 - if (id === DEPRECATED_VIRTUAL_ID_UTILS) { 46 - return ` 47 - import { utils } from 'vitest/browser' 48 - export const getElementLocatorSelectors = utils.getElementLocatorSelectors 49 - export const debug = utils.debug 50 - export const prettyDOM = utils.prettyDOM 51 - export const getElementError = utils.getElementError 52 - ` 53 - } 54 31 }, 55 32 } 56 33 } ··· 60 37 globalServer: ParentBrowserProject, 61 38 ) { 62 39 const commands = Object.keys(globalServer.commands) 63 - const provider = [...globalServer.children][0].provider 64 - const providerName = provider?.name || 'preview' 40 + // The provider instance is initialized lazily when a page opens, so reading 41 + // `child.provider` here races and can be `undefined` before the first page is 42 + // opened. The provider name is uniform across a project's instances and is 43 + // already resolved on the config, so read it from there for this shared 44 + // (cached) context module. The instance is still used below for the 45 + // preview-only user-event import. 46 + const child = [...globalServer.children][0] 47 + const provider = child.provider 48 + const providerName = child.config.browser.provider?.name 49 + if (!providerName) { 50 + throw new Error( 51 + `Browser provider is not defined for the project "${child.project.name}". This is a bug in Vitest. Please, open a new issue with a reproduction.`, 52 + ) 53 + } 65 54 66 55 const commandsCode = commands 67 56 .filter(command => !command.startsWith('__vitest')) ··· 106 95 throw new Error(`Failed to resolve user-event package from ${previewDistRoot}`) 107 96 } 108 97 return `\ 109 - import { userEvent as __vitest_user_event__ } from '${slash(`/@fs/${resolved.id}`)}' 110 - const _userEventSetup = __vitest_user_event__ 98 + import { userEvent as _userEventSetup } from '${slash(`/@fs/${resolved.id}`)}' 111 99 ` 112 100 }
+7 -7
packages/browser/src/node/projectParent.ts
··· 51 51 private sourceMapCache = new Map<string, any>() 52 52 53 53 constructor( 54 - public project: TestProject, 54 + ctx: { config: ResolvedConfig; vitest: Vitest }, 55 55 public base: string, 56 56 ) { 57 - this.vitest = project.vitest 58 - this.config = project.config 57 + this.vitest = ctx.vitest 58 + this.config = ctx.config 59 59 this.stackTraceOptions = { 60 - frameFilter: project.config.onStackTrace, 60 + frameFilter: this.config.onStackTrace, 61 61 getSourceMap: (id) => { 62 62 if (this.sourceMapCache.has(id)) { 63 63 return this.sourceMapCache.get(id) ··· 100 100 } 101 101 102 102 // validate names because they can't be used as identifiers 103 - for (const command in project.config.browser.commands) { 103 + for (const command in this.config.browser.commands) { 104 104 if (!/^[a-z_$][\w$]*$/i.test(command)) { 105 105 throw new Error( 106 106 `Invalid command name "${command}". Only alphanumeric characters, $ and _ are allowed.`, 107 107 ) 108 108 } 109 - this.commands[command] = project.config.browser.commands[command] 109 + this.commands[command] = this.config.browser.commands[command] 110 110 } 111 111 112 112 this.prefixTesterUrl = `${base || '/'}` ··· 119 119 ) 120 120 })().then(manifest => (this.manifest = manifest)) 121 121 122 - this.orchestratorHtml = (project.config.browser.ui 122 + this.orchestratorHtml = (this.config.browser.ui 123 123 ? readFile(resolve(uiClientRoot, 'index.html'), 'utf8') 124 124 : readFile(resolve(distRoot, 'client/orchestrator.html'), 'utf8')) 125 125 .then(html => (this.orchestratorHtml = html))
+9 -14
packages/browser/src/node/rpc.ts
··· 1 1 import type { MockerRegistry } from '@vitest/mocker' 2 + import type { IncomingMessage } from 'node:http' 2 3 import type { Duplex } from 'node:stream' 3 4 import type { TestError } from 'vitest' 4 5 import type { BrowserCommandContext, ResolveSnapshotPathHandlerContext, TestProject } from 'vitest/node' ··· 26 27 27 28 const wss = new WebSocketServer({ noServer: true }) 28 29 29 - vite.httpServer?.on('upgrade', (request, socket: Duplex, head: Buffer) => { 30 + vite.httpServer?.on('upgrade', (request: IncomingMessage, socket: Duplex, head: Buffer) => { 30 31 if (!request.url) { 31 32 return 32 33 } ··· 124 125 125 126 function canWrite(project: TestProject) { 126 127 return ( 127 - project.config.browser.api.allowWrite 128 - && project.vitest.config.browser.api.allowWrite 129 - && project.config.api.allowWrite 128 + project.config.api.allowWrite 130 129 && project.vitest.config.api.allowWrite 131 130 ) 132 131 } ··· 134 133 function isCdpAllowed(project: TestProject) { 135 134 return ( 136 135 project.config.api.allowExec 137 - && project.config.browser.api.allowExec 138 136 && project.vitest.config.api.allowExec 139 - && project.vitest.config.browser.api.allowExec 140 137 && project.config.api.allowWrite 141 - && project.config.browser.api.allowWrite 142 138 && project.vitest.config.api.allowWrite 143 - && project.vitest.config.browser.api.allowWrite 144 139 ) 145 140 } 146 141 147 142 function assertCdpAllowed(project: TestProject) { 148 143 if (!isCdpAllowed(project)) { 149 144 throw new Error( 150 - `Cannot use CDP because browser API write or exec operations are disabled. See https://vitest.dev/config/browser/api.`, 145 + `Cannot use CDP because browser API write or exec operations are disabled. See https://vitest.dev/config/api.`, 151 146 ) 152 147 } 153 148 } ··· 199 194 if (artifact.type === 'internal:annotation' && artifact.annotation.attachment) { 200 195 artifact.annotation.attachment = undefined 201 196 vitest.logger.error( 202 - `[vitest] Cannot record annotation attachment because file writing is disabled. See https://vitest.dev/config/browser/api.`, 197 + `[vitest] Cannot record annotation attachment because file writing is disabled. See https://vitest.dev/config/api.`, 203 198 ) 204 199 } 205 200 // remove attachments if cannot write ··· 207 202 const attachments = artifact.attachments.map(n => n.path).filter(r => !!r).join('", "') 208 203 artifact.attachments = [] 209 204 vitest.logger.error( 210 - `[vitest] Cannot record attachments ("${attachments}") because file writing is disabled, removing attachments from artifact "${artifact.type}". See https://vitest.dev/config/browser/api.`, 205 + `[vitest] Cannot record attachments ("${attachments}") because file writing is disabled, removing attachments from artifact "${artifact.type}". See https://vitest.dev/config/api.`, 211 206 ) 212 207 } 213 208 } ··· 237 232 async writeBenchmarkResult(relativePath, data) { 238 233 if (!canWrite(project)) { 239 234 vitest.logger.error( 240 - `[vitest] Cannot write benchmark artifact "${relativePath}" because file writing is disabled. See https://vitest.dev/config/browser/api.`, 235 + `[vitest] Cannot write benchmark artifact "${relativePath}" because file writing is disabled. See https://vitest.dev/config/api.`, 241 236 ) 242 237 return 243 238 } ··· 285 280 checkFileAccess(id) 286 281 if (!canWrite(project)) { 287 282 vitest.logger.error( 288 - `[vitest] Cannot save snapshot file "${id}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/browser/api.`, 283 + `[vitest] Cannot save snapshot file "${id}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/api.`, 289 284 ) 290 285 return 291 286 } ··· 296 291 checkFileAccess(id) 297 292 if (!canWrite(project)) { 298 293 vitest.logger.error( 299 - `[vitest] Cannot remove snapshot file "${id}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/browser/api.`, 294 + `[vitest] Cannot remove snapshot file "${id}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/api.`, 300 295 ) 301 296 return 302 297 }
+1 -1
packages/browser/src/node/utils.ts
··· 110 110 } 111 111 112 112 export function assertBrowserApiWrite(project: TestProject, path: string): void { 113 - if (!project.config.browser.api.allowWrite || !project.vitest.config.api.allowWrite) { 113 + if (!project.config.api.allowWrite || !project.vitest.config.api.allowWrite) { 114 114 throw new Error( 115 115 `Cannot modify file "${path}". File writing is disabled because the server is exposed to the internet, see https://vitest.dev/config/browser/api.`, 116 116 )
+2 -6
packages/coverage-v8/src/provider.ts
··· 407 407 project: TestProject = this.ctx.getRootProject(), 408 408 environment: string, 409 409 ): Promise<CoverageMap> { 410 - if (environment === '__browser__' && !project.browser) { 411 - throw new Error(`Cannot access browser module graph because it was torn down.`) 412 - } 413 - 414 410 const onTransform = async (filepath: string, isExtendedContext: ScriptCoverageWithOffset['isExtendedContext'] = false) => { 415 411 const result = await this.transformFile(filepath, project, environment, !isExtendedContext) 416 - if (result && environment === '__browser__' && project.browser) { 412 + if (result && project.isBrowserEnabled()) { 417 413 return { ...result, code: `${result.code}// <inline-source-map>` } 418 414 } 419 415 return result ··· 422 418 const scriptCoverages = [] 423 419 424 420 for (const result of coverage.result) { 425 - if (environment === '__browser__') { 421 + if (environment === 'client' && project.isBrowserEnabled()) { 426 422 if (result.url.startsWith('/@fs')) { 427 423 result.url = `${FILE_PROTOCOL}${removeStartsWith(result.url, '/@fs')}` 428 424 }
+11 -8
packages/mocker/src/node/dynamicImportPlugin.ts
··· 18 18 return { 19 19 name: 'vitest:browser:esm-injector', 20 20 enforce: 'post', 21 - transform(source, id) { 21 + transform: { 22 + order: 'post', 23 + handler(source, id) { 22 24 // TODO: test is not called for static imports 23 - if (!regexDynamicImport.test(source)) { 24 - return 25 - } 26 - if (options.filter && !options.filter(id, this.environment)) { 27 - return 28 - } 29 - return injectDynamicImport(source, id, this.parse, options) 25 + if (!regexDynamicImport.test(source)) { 26 + return 27 + } 28 + if (options.filter && !options.filter(id, this.environment)) { 29 + return 30 + } 31 + return injectDynamicImport(source, id, this.parse, options) 32 + }, 30 33 }, 31 34 } 32 35 }
+1 -4
packages/mocker/src/node/resolver.ts
··· 59 59 } 60 60 61 61 public async resolveId(id: string, importer?: string): Promise<ServerIdResolution | null> { 62 - const resolved = await this.server.pluginContainer.resolveId( 62 + const resolved = await this.server.environments.client.pluginContainer.resolveId( 63 63 id, 64 64 importer, 65 - { 66 - ssr: false, 67 - }, 68 65 ) 69 66 if (!resolved) { 70 67 return null
+1
packages/ui/client/components/FileDetailsModuleGraph.vue
··· 18 18 () => client.rpc.getModuleGraph( 19 19 props.projectName, 20 20 props.file.filepath, 21 + props.file.viteEnvironment, 21 22 ), 22 23 undefined, 23 24 )
+7 -6
packages/ui/node/index.ts
··· 1 - import type { Vite, Vitest } from 'vitest/node' 1 + import type { PluginHarness, Vite } from 'vitest/node' 2 2 import fs from 'node:fs' 3 3 import { parse as parseCookie, serialize as serializeCookie } from 'cookie' 4 4 import { join, resolve } from 'pathe' ··· 12 12 13 13 const UI_TOKEN_COOKIE = 'vitest-ui-token' 14 14 15 - export default (ctx: Vitest): Vite.Plugin => { 16 - if (ctx.version !== version) { 17 - ctx.logger.warn( 15 + export default (harness: PluginHarness): Vite.Plugin => { 16 + if (harness.version !== version) { 17 + harness.logger.warn( 18 18 c.yellow( 19 - `Loaded ${c.inverse(c.yellow(` vitest@${ctx.version} `))} and ${c.inverse(c.yellow(` @vitest/ui@${version} `))}.` 19 + `Loaded ${c.inverse(c.yellow(` vitest@${harness.version} `))} and ${c.inverse(c.yellow(` @vitest/ui@${version} `))}.` 20 20 + '\nRunning mixed versions is not supported and may lead into bugs' 21 21 + '\nUpdate your dependencies and make sure the versions match.', 22 22 ), ··· 29 29 configureServer: { 30 30 order: 'post', 31 31 handler(server) { 32 + const ctx = harness.getVitest() 32 33 const uiOptions = ctx.config 33 34 const base = uiOptions.uiBase 34 35 ··· 70 71 71 72 const fsPath = decodeURIComponent(path) 72 73 73 - if (!isFileServingAllowed(ctx.vite.config, fsPath)) { 74 + if (!isFileServingAllowed(ctx.viteConfig, fsPath)) { 74 75 return next() 75 76 } 76 77
+1
packages/ui/node/reporter.ts
··· 182 182 ctx, 183 183 projectName, 184 184 testModule.moduleId, 185 + testModule.viteEnvironment?.name, 185 186 ) 186 187 })()) 187 188 }
+1 -1
packages/vitest/LICENSE.md
··· 346 346 347 347 > The MIT License (MIT) 348 348 > 349 - > Copyright (c) 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Simon Lydell 349 + > Copyright (c) 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024 Simon Lydell 350 350 > 351 351 > Permission is hereby granted, free of charge, to any person obtaining a copy 352 352 > of this software and associated documentation files (the "Software"), to deal
+2 -2
packages/vitest/src/api/setup.ts
··· 156 156 catch {} 157 157 return result 158 158 }, 159 - async getModuleGraph(project, id): Promise<ModuleGraphData> { 160 - return getModuleGraph(ctx, project, id) 159 + async getModuleGraph(project, id, viteEnvironment): Promise<ModuleGraphData> { 160 + return getModuleGraph(ctx, project, id, viteEnvironment) 161 161 }, 162 162 async updateSnapshot(file?: File) { 163 163 // silently ignore exec/write attempts if not allowed
+1
packages/vitest/src/api/types.ts
··· 47 47 getModuleGraph: ( 48 48 projectName: string, 49 49 id: string, 50 + viteEnvironment?: string, 50 51 ) => Promise<ModuleGraphData> 51 52 getTransformResult: ( 52 53 projectName: string,
+5 -5
packages/vitest/src/integrations/vi.ts
··· 440 440 */ 441 441 stubEnv: <T extends string>( 442 442 name: T, 443 - value: T extends 'PROD' | 'DEV' | 'SSR' ? boolean : string | undefined, 443 + value: T extends 'PROD' | 'DEV' | 'SSR' ? boolean | undefined : string | undefined, 444 444 ) => VitestUtils 445 445 446 446 /** ··· 779 779 if (!_stubsEnv.has(name)) { 780 780 _stubsEnv.set(name, env[name]) 781 781 } 782 - if (_envBooleans.includes(name)) { 783 - env[name] = value ? '1' : '' 784 - } 785 - else if (value === undefined) { 782 + if (value === undefined) { 786 783 delete env[name] 784 + } 785 + else if (_envBooleans.includes(name)) { 786 + env[name] = value ? '1' : '' 787 787 } 788 788 else { 789 789 env[name] = String(value)
+1 -1
packages/vitest/src/node/cache/fsModuleCache.ts
··· 46 46 >() 47 47 48 48 constructor(private vitest: Vitest) { 49 - const workspaceRoot = searchForWorkspaceRoot(vitest.vite.config.root) 49 + const workspaceRoot = searchForWorkspaceRoot(vitest.viteConfig.root) 50 50 this.rootCache = vitest.config.experimental.fsModuleCachePath 51 51 || join(workspaceRoot, 'node_modules', '.experimental-vitest-cache') 52 52 this.metadataFilePath = join(this.rootCache, METADATA_FILE)
+2 -15
packages/vitest/src/node/cli/cli-config.ts
··· 109 109 }, 110 110 api: { 111 111 argument: '[port]', 112 - description: `Specify server port. Note if the port is already being used, Vite will automatically try the next available port so this may not be the actual port the server ends up listening on. If true will be set to ${defaultPort}`, 112 + description: `Specify server port. Note if the port is already being used, Vite will automatically try the next available port so this may not be the actual port the server ends up listening on. If true will be set to ${defaultPort} or ${defaultBrowserPort} in browser mode`, 113 113 subcommands: apiConfig(defaultPort), 114 114 transform(portOrOptions) { 115 115 if (typeof portOrOptions === 'number') { ··· 138 138 hideSkippedTests: { 139 139 description: 'Hide logs for skipped tests', 140 140 }, 141 + reporter: null, 141 142 reporters: { 142 143 alias: 'reporter', 143 144 description: `Specify reporters (default, agent, minimal, blob, verbose, dot, json, tap, tap-flat, junit, tree, hanging-process, github-actions)`, ··· 380 381 description: 381 382 'Run the browser in headless mode (i.e. without opening the GUI (Graphical User Interface)). If you are running Vitest in CI, it will be enabled by default (default: `process.env.CI`)', 382 383 }, 383 - api: { 384 - description: 385 - 'Specify options for the browser API server. Does not affect the --api option', 386 - argument: '[port]', 387 - subcommands: apiConfig(defaultBrowserPort), 388 - }, 389 - isolate: { 390 - description: 391 - 'Run every browser test file in isolation. To disable isolation, use `--browser.isolate=false` (default: `true`)', 392 - }, 393 384 ui: { 394 385 description: 395 386 'Show Vitest UI when running tests (default: `!process.env.CI`)', ··· 398 389 description: 399 390 'Default position for the details panel in browser mode. Either `right` (horizontal split) or `bottom` (vertical split) (default: `right`)', 400 391 argument: '<position>', 401 - }, 402 - fileParallelism: { 403 - description: 404 - 'Should browser test files run in parallel. Use `--browser.fileParallelism=false` to disable (default: `true`)', 405 392 }, 406 393 connectTimeout: { 407 394 description: 'If connection to the browser takes longer, the test suite will fail (default: `60_000`)',
+33
packages/vitest/src/node/config/pluginHarness.ts
··· 1 + import type { Vitest } from '../core' 2 + import { version } from '../../../package.json' with { type: 'json' } 3 + import { defaultBrowserPort } from '../../constants' 4 + import { Logger } from '../logger' 5 + import { VitestPackageInstaller } from '../packageInstaller' 6 + 7 + export class PluginHarness { 8 + public vitest?: Vitest 9 + 10 + public version: string = version 11 + 12 + /** 13 + * @internal 14 + */ 15 + public _browserLastPort = defaultBrowserPort 16 + 17 + constructor( 18 + public logger: Logger = new Logger(), 19 + public packageInstaller: VitestPackageInstaller = new VitestPackageInstaller(), 20 + ) {} 21 + 22 + setVitest(vitest: Vitest | undefined): this { 23 + this.vitest = vitest 24 + return this 25 + } 26 + 27 + getVitest(): Vitest { 28 + if (!this.vitest) { 29 + throw new Error(`Don't have access to the "vitest" instance yet. This is a bug in Vitest.`) 30 + } 31 + return this.vitest 32 + } 33 + }
+259 -190
packages/vitest/src/node/config/resolveConfig.ts
··· 1 - import type { ResolvedConfig as ResolvedViteConfig } from 'vite' 2 - import type { Vitest } from '../core' 1 + import type { 2 + InlineConfig, 3 + ResolvedConfig as ResolvedViteConfig, 4 + UserConfig as ViteUserConfig, 5 + } from 'vite' 3 6 import type { Logger } from '../logger' 4 - import type { ResolvedBrowserOptions } from '../types/browser' 7 + import type { BrowserContributionHolder } from '../plugins/browserLoader' 5 8 import type { 6 9 ApiConfig, 7 10 ResolvedConfig, ··· 10 13 import type { CoverageOptions, CoverageReporterWithOptions } from '../types/coverage' 11 14 import { existsSync, statSync } from 'node:fs' 12 15 import { pathToFileURL } from 'node:url' 13 - import { slash, toArray } from '@vitest/utils/helpers' 16 + import { deepMerge, slash, toArray } from '@vitest/utils/helpers' 14 17 import { resolveModule } from 'local-pkg' 15 18 import { join, normalize, relative, resolve } from 'pathe' 16 19 import { isDynamicPattern } from 'tinyglobby' 17 20 import c from 'tinyrainbow' 18 - import { mergeConfig } from 'vite' 21 + import { mergeConfig, resolveConfig as viteResolveConfig } from 'vite' 19 22 import { 20 23 configFiles, 21 - defaultBrowserPort, 22 24 defaultInspectPort, 23 - defaultPort, 24 25 } from '../../constants' 25 26 import { benchmarkConfigDefaults, configDefaults } from '../../defaults' 27 + import { wildcardPatternToRegExp } from '../../utils/base' 26 28 import { isAgent, isCI, stdProvider } from '../../utils/env' 27 29 import { getWorkersCountByPercentage } from '../../utils/workers' 30 + import { BrowserLoaderPlugin } from '../plugins/browserLoader' 31 + import { CliOverride } from '../plugins/cliOverride' 32 + import { VitestConfig } from '../plugins/config' 33 + import { VitestCorePlugin } from '../plugins/index' 34 + import { VitestConfigServer } from '../plugins/server' 35 + import { resolveFsAllow } from '../plugins/utils' 36 + import { resolveProjectEntries } from '../projects/resolveProjects' 28 37 import { withLabel } from '../reporters/renderers/utils' 29 38 import { BaseSequencer } from '../sequencers/BaseSequencer' 30 39 import { RandomSequencer } from '../sequencers/RandomSequencer' 31 - import { resolveApiToken } from './apiToken' 40 + import { API_TOKEN_FILE, resolveApiToken } from './apiToken' 41 + import { PluginHarness } from './pluginHarness' 32 42 33 43 function resolvePath(path: string, root: string) { 34 44 // local-pkg (mlly)'s resolveModule("./file", { paths: ["/some/root"] }) tries ··· 76 86 return { host, port: Number(port) || defaultInspectPort } 77 87 } 78 88 79 - /** 80 - * @deprecated Internal function 81 - */ 82 - export function resolveApiServerConfig<Options extends ApiConfig & Omit<UserConfig, 'expect'>>( 83 - options: Options, 89 + export function resolveApiServerConfig( 90 + config: UserConfig, 84 91 defaultPort: number, 85 - parentApi?: ApiConfig, 86 - logger?: Logger, 87 - ): ApiConfig | undefined { 92 + logger: Logger, 93 + ): ApiConfig { 94 + const isBrowserEnabled = !!config.browser?.enabled 95 + 88 96 let api: ApiConfig | undefined 89 97 90 - if (options.ui && !options.api) { 98 + if (config.ui && !config.api) { 91 99 api = { port: defaultPort } 92 100 } 93 - else if (options.api === true) { 101 + else if (config.api === true) { 94 102 api = { port: defaultPort } 95 103 } 96 - else if (typeof options.api === 'number') { 97 - api = { port: options.api } 104 + else if (typeof config.api === 'number') { 105 + api = { port: config.api } 98 106 } 99 107 100 - if (typeof options.api === 'object') { 108 + if (typeof config.api === 'object') { 101 109 if (api) { 102 - if (options.api.port) { 103 - api.port = options.api.port 110 + if (config.api.port) { 111 + api.port = config.api.port 104 112 } 105 - if (options.api.strictPort) { 106 - api.strictPort = options.api.strictPort 113 + if (config.api.strictPort) { 114 + api.strictPort = config.api.strictPort 107 115 } 108 - if (options.api.host) { 109 - api.host = options.api.host 116 + if (config.api.host) { 117 + api.host = config.api.host 110 118 } 111 119 } 112 120 else { 113 - api = { ...options.api } 121 + api = { ...config.api } 114 122 } 115 123 } 116 124 ··· 123 131 api = { middlewareMode: true } 124 132 } 125 133 134 + if (api && isBrowserEnabled) { 135 + // Always force middlewareMode to false in browser mode 136 + api.middlewareMode = false 137 + // The browser server is standalone, so it always needs a port even when the 138 + // user didn't configure `api`/`ui` (in which case `api` defaulted above) 139 + if (!api.port) { 140 + api.port = defaultPort 141 + } 142 + } 143 + 126 144 // if the API server is exposed to network, disable write operations by default 127 145 if (!api.middlewareMode && api.host && api.host !== 'localhost' && api.host !== '127.0.0.1') { 128 - // assigned to browser 129 - if (parentApi) { 130 - if (api.allowWrite == null && api.allowExec == null) { 131 - logger?.error( 132 - c.yellow( 133 - `${c.yellowBright(' WARNING ')} API server is exposed to network, disabling write and exec operations by default for security reasons. This can cause some APIs to not work as expected. Set \`browser.api.allowExec\` manually to hide this warning. See https://vitest.dev/config/browser/api for more details.`, 134 - ), 135 - ) 136 - } 146 + if (api.allowWrite == null && api.allowExec == null) { 147 + logger.error( 148 + c.yellow( 149 + `${c.bgYellow(' WARNING ')} API server is exposed to network, disabling write and exec operations by default for security reasons. This can cause some APIs to not work as expected. Set \`browser.api.allowExec\` manually to hide this warning. See https://vitest.dev/config/api for more details.`, 150 + ), 151 + ) 137 152 } 138 - api.allowWrite ??= parentApi?.allowWrite ?? false 139 - api.allowExec ??= parentApi?.allowExec ?? false 153 + api.allowWrite ??= false 154 + api.allowExec ??= false 140 155 } 141 156 else { 142 - api.allowWrite ??= parentApi?.allowWrite ?? true 143 - api.allowExec ??= parentApi?.allowExec ?? true 157 + api.allowWrite ??= true 158 + api.allowExec ??= true 144 159 } 145 160 146 161 return api ··· 159 174 // that's why it's on a module-level 160 175 let warnedTypeCheck = false 161 176 162 - export function resolveConfig( 163 - vitest: Vitest, 177 + /** 178 + * Resolve Vitest's test config for a single Vite resolved config (root or a single project). 179 + * 180 + * This is the internal single-config resolver. The top-level `resolveConfig` 181 + * orchestrates the full pipeline (root + projects + browser/benchmark expansion). 182 + * 183 + * `globalConfig` is the resolved root config, passed when resolving a project. 184 + */ 185 + export function resolveTestConfig( 186 + logger: Logger, 164 187 options: UserConfig, 165 188 viteConfig: ResolvedViteConfig, 189 + globalConfig?: ResolvedConfig, 166 190 ): ResolvedConfig { 167 - const logger = vitest.logger 168 191 if (options.dom) { 169 192 if ( 170 193 viteConfig.test?.environment != null 171 194 && viteConfig.test!.environment !== 'happy-dom' 172 195 ) { 173 - logger.console.warn( 196 + logger.warn( 174 197 withLabel( 175 198 'yellow', 176 199 'Vitest', ··· 182 205 options.environment = 'happy-dom' 183 206 } 184 207 185 - const resolved = { 186 - ...configDefaults, 187 - ...options, 188 - root: viteConfig.root, 189 - } as any as ResolvedConfig 208 + const resolved = deepMerge({}, configDefaults, options) as ResolvedConfig 209 + resolved.root = viteConfig.root 210 + 211 + // Coverage is collected once for the whole run using the root config, so projects 212 + // share its resolved coverage. Each project's setup/test/config files are then 213 + // appended to the same exclude list below, keeping them out of the report. 214 + if (globalConfig) { 215 + resolved.coverage = globalConfig.coverage 216 + } 190 217 191 218 const rootStats = statSync(resolved.root, { throwIfNoEntry: false }) 192 219 if (!rootStats?.isDirectory()) { ··· 196 223 resolved.mode ??= viteConfig.mode ?? 'test' 197 224 198 225 if (resolved.retry && typeof resolved.retry === 'object' && typeof resolved.retry.condition === 'function') { 199 - logger.console.warn( 226 + logger.warn( 200 227 c.yellow('Warning: retry.condition function cannot be used inside a config file. ' 201 228 + 'Use a RegExp pattern instead, or define the function in your test file.'), 202 229 ) ··· 213 240 } 214 241 215 242 if ('poolOptions' in resolved) { 216 - logger.deprecate('`test.poolOptions` was removed in Vitest 4. All previous `poolOptions` are now top-level options. Please, refer to the migration guide: https://vitest.dev/guide/migration#pool-rework') 243 + logger.deprecate('`test.poolOptions` was removed in Vitest 4. All previous `poolOptions` are now top-level options. Please, refer to the migration guide: https://v4.vitest.dev/guide/migration#pool-rework') 244 + } 245 + 246 + if ('workspace' in resolved) { 247 + throw new Error('The `test.workspace` option was removed in Vitest 4. Please, migrate to `test.projects` instead. See https://vitest.dev/guide/projects for examples.') 217 248 } 218 249 219 250 resolved.pool ??= 'forks' ··· 314 345 resolved.maxWorkers = resolveInlineWorkerOption(resolved.maxWorkers) 315 346 } 316 347 317 - const fileParallelism = options.fileParallelism ?? true 348 + // `browser.fileParallelism` was replaced by the top-level `fileParallelism`. Map 349 + // it (only when browser is enabled, since it was a browser-only option) so 350 + // existing configs keep working instead of being silently ignored. 351 + const browserOptions = options.browser as { enabled?: boolean; fileParallelism?: boolean } | undefined 352 + const browserFileParallelism = browserOptions?.enabled ? browserOptions.fileParallelism : undefined 353 + if (browserFileParallelism !== undefined) { 354 + logger.deprecate('`browser.fileParallelism` is deprecated. Use the top-level `fileParallelism` option instead.') 355 + } 356 + 357 + const fileParallelism = options.fileParallelism ?? browserFileParallelism ?? true 318 358 319 359 if (!fileParallelism) { 320 360 // ignore user config, parallelism cannot be implemented without limiting workers ··· 337 377 } 338 378 } 339 379 340 - // apply browser CLI options only if the config already has the browser config and not disabled manually 341 - if ( 342 - vitest._cliOptions.browser 343 - && resolved.browser 344 - // if enabled is set to `false`, but CLI overrides it, then always override it 345 - && (resolved.browser.enabled !== false || vitest._cliOptions.browser.enabled) 346 - ) { 347 - resolved.browser = mergeConfig( 348 - resolved.browser, 349 - vitest._cliOptions.browser, 350 - ) as ResolvedBrowserOptions 351 - } 352 - 353 380 resolved.browser ??= {} as any 354 381 const browser = resolved.browser 355 382 ··· 359 386 browser.instances = [] 360 387 } 361 388 389 + // The whole project shares a single browser Vite server, so every instance 390 + // must use the same provider. Validate that here and hoist the provider to 391 + // the project level so the cluster resolves one even when it is only set per 392 + // instance (e.g. connect mode). Because the provider is uniform, any 393 + // instance's server factory represents the whole project. 394 + const providerNames = new Set<string>() 395 + // It's possible to provide the same name and sneak in a different factory 396 + const providerFactories = new Set() 397 + if (browser.provider?.name) { 398 + providerNames.add(browser.provider.name) 399 + providerFactories.add(browser.provider.serverFactory) 400 + } 401 + for (const instance of browser.instances) { 402 + if (instance.provider?.name) { 403 + providerNames.add(instance.provider.name) 404 + providerFactories.add(instance.provider.serverFactory) 405 + } 406 + } 407 + if (providerNames.size > 1 || providerFactories.size > 1) { 408 + throw new Error( 409 + `All browser instances within a project must use the same provider, but found: ${[...providerNames].join(', ')}. ` 410 + + `Use a single provider for the project, or move the instances into separate projects.`, 411 + ) 412 + } 413 + 362 414 // use `chromium` by default when the preview provider is specified 363 415 // for a smoother experience. if chromium is not available, it will 364 416 // open the default browser anyway ··· 379 431 ].join('')) 380 432 } 381 433 } 434 + 435 + browser.instances.forEach((instance) => { 436 + instance.name ??= resolved.name 437 + ? `${resolved.name} (${instance.browser})` 438 + : instance.browser 439 + }) 382 440 } 383 441 384 442 if (resolved.coverage.enabled && resolved.coverage.provider === 'istanbul' && resolved.experimental?.viteModuleRunner === false) { ··· 389 447 logger.console.warn(c.yellow('The option "detectAsyncLeaks" is not supported in browser mode and will be ignored.')) 390 448 } 391 449 392 - const containsChromium = hasBrowserChromium(vitest, resolved) 393 - const hasOnlyChromium = hasOnlyBrowserChromium(vitest, resolved) 394 - 395 - // Browser-mode "Chromium" only features: 396 - if (browser.enabled && (!containsChromium || !hasOnlyChromium)) { 397 - const browserConfig = ` 398 - { 399 - browser: { 400 - provider: ${browser.provider?.name || 'preview'}(), 401 - instances: [ 402 - ${(browser.instances || []).map(i => `{ browser: '${i.browser}' }`).join(',\n ')} 403 - ], 404 - }, 405 - } 406 - `.trim() 407 - 408 - const preferredProvider = (!browser.provider?.name || browser.provider.name === 'preview') 409 - ? 'playwright' 410 - : browser.provider.name 411 - const preferredBrowser = preferredProvider === 'playwright' ? 'chromium' : 'chrome' 412 - const correctExample = ` 413 - { 414 - browser: { 415 - provider: ${preferredProvider}(), 416 - instances: [ 417 - { browser: '${preferredBrowser}' } 418 - ], 419 - }, 420 - } 421 - `.trim() 422 - 423 - // requires all projects to be chromium 424 - if (!hasOnlyChromium && resolved.coverage.enabled && resolved.coverage.provider === 'v8') { 425 - const coverageExample = ` 426 - { 427 - coverage: { 428 - provider: 'istanbul', 429 - }, 430 - } 431 - `.trim() 432 - 433 - throw new Error( 434 - `@vitest/coverage-v8 does not work with\n${browserConfig}\n` 435 - + `\nUse either:\n${correctExample}` 436 - + `\n\n...or change your coverage provider to:\n${coverageExample}\n`, 437 - ) 438 - } 439 - 440 - // ignores non-chromium browsers when there is at least one chromium project 441 - if (!containsChromium && (resolved.inspect || resolved.inspectBrk)) { 442 - const inspectOption = `--inspect${resolved.inspectBrk ? '-brk' : ''}` 443 - 444 - throw new Error( 445 - `${inspectOption} does not work with\n${browserConfig}\n` 446 - + `\nUse either:\n${correctExample}` 447 - + `\n\n...or disable ${inspectOption}\n`, 448 - ) 449 - } 450 - } 451 - 452 450 resolved.coverage.reporter = resolveCoverageReporters(resolved.coverage.reporter) 453 451 if (isAgent) { 454 452 // default to `skipFull` and add `text-summary` reporter when `text` reporter is used on agents ··· 648 646 resolved.isolate = false 649 647 } 650 648 649 + // `browser.isolate` was replaced by the top-level `isolate` option. Map it 650 + // (only when browser is enabled, since it was a browser-only option) so 651 + // existing configs keep working instead of silently falling back to the 652 + // isolated default (which is much slower). 653 + const browserIsolate = browser.enabled ? (browser as { isolate?: boolean }).isolate : undefined 654 + if (browserIsolate !== undefined) { 655 + logger.deprecate('`browser.isolate` is deprecated. Use the top-level `isolate` option instead.') 656 + if (options.isolate === undefined) { 657 + resolved.isolate = browserIsolate 658 + } 659 + } 660 + 651 661 if (process.env.VITEST_MAX_WORKERS) { 652 662 resolved.maxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS) 653 663 } ··· 657 667 resolved.forceRerunTriggers.push(resolved.diff) 658 668 } 659 669 660 - // the server has been created, we don't need to override vite.server options 661 - const api = resolveApiServerConfig(options, defaultPort) 662 - const { token, tokenCreated } = resolveApiToken(resolved.root) 663 - resolved.api = { ...api, token, tokenCreated } 664 - 665 670 if (options.related) { 666 671 resolved.related = toArray(options.related).map(file => 667 672 resolve(resolved.root, file), ··· 708 713 } 709 714 } 710 715 } 716 + } 717 + else { 718 + resolved.reporters = [] 711 719 } 712 720 713 - // @ts-expect-error "reporter" is from CLI, should be absolute to the running directory 714 721 // it is passed down as "vitest --reporter ../reporter.js" 715 - const reportersFromCLI = resolved.reporter 722 + const reportersFromCLI = options.reporter 716 723 717 724 const cliReporters = toArray(reportersFromCLI || []).map( 718 725 (reporter: string) => { ··· 757 764 resolved.passWithNoTests ??= true 758 765 } 759 766 767 + if (resolved.browser.enabled) { 768 + // browser mode renders real CSS in the page 769 + resolved.css = true 770 + } 760 771 resolved.css ??= {} 761 772 if (typeof resolved.css === 'object') { 762 773 resolved.css.modules ??= {} ··· 765 776 766 777 if (resolved.cache !== false) { 767 778 if (resolved.cache && typeof resolved.cache.dir === 'string') { 768 - vitest.logger.deprecate( 779 + logger.deprecate( 769 780 `"cache.dir" is deprecated, use Vite's "cacheDir" instead if you want to change the cache director. Note caches will be written to "cacheDir\/vitest"`, 770 781 ) 771 782 } ··· 811 822 812 823 resolved.browser.enabled ??= false 813 824 resolved.browser.headless ??= isCI 814 - if (resolved.browser.isolate) { 815 - logger.console.warn( 816 - c.yellow('`browser.isolate` is deprecated. Use top-level `isolate` instead.'), 817 - ) 818 - } 819 - resolved.browser.isolate ??= resolved.isolate ?? true 820 - resolved.browser.fileParallelism 821 - ??= options.fileParallelism ?? true 822 825 // disable in headless mode by default, and if CI is detected 823 826 resolved.browser.ui ??= resolved.browser.headless === true ? false : !isCI 824 827 resolved.browser.commands ??= {} ··· 873 876 resolved.browser.provider.options = {} 874 877 } 875 878 876 - resolved.browser.api = resolveApiServerConfig( 877 - resolved.browser, 878 - defaultBrowserPort, 879 - resolved.api, 880 - logger, 881 - ) || { 882 - port: defaultBrowserPort, 879 + if ('api' in resolved.browser) { 880 + logger.deprecate('`test.browser.api` was deprecated in Vitest 5. Use `test.api` instead.') 883 881 } 884 882 885 883 // enable includeTaskLocation by default in UI mode ··· 1000 998 return resolved 1001 999 } 1002 1000 1003 - export function isBrowserEnabled(config: ResolvedConfig): boolean { 1004 - return Boolean(config.browser?.enabled) 1001 + function resolveConfigPath(root: string, options: UserConfig) { 1002 + if (options.config === false) { 1003 + return false 1004 + } 1005 + if (options.config) { 1006 + return resolveModule(options.config, { paths: [root] }) ?? resolve(root, options.config) 1007 + } 1008 + return findConfigFile(root) 1009 + } 1010 + 1011 + export async function resolveConfig( 1012 + options: UserConfig = {}, 1013 + viteOverrides: ViteUserConfig = {}, 1014 + pluginsHarness: PluginHarness = new PluginHarness(), 1015 + ): Promise<ResolvedViteConfig> { 1016 + // We clone CLI Options and Vite overrides to reuse when a watch mode is triggered. 1017 + const cliOptionsCopy = deepMerge({}, options) 1018 + const viteOverridesCopy = deepMerge({}, viteOverrides) 1019 + const root = resolve(options.root || process.cwd()) 1020 + const configPath = resolveConfigPath(root, options) 1021 + options.config = configPath 1022 + options.root = root 1023 + 1024 + const rootBrowserHolder: BrowserContributionHolder = {} 1025 + const inlineConfig: InlineConfig = mergeConfig( 1026 + { 1027 + configFile: configPath, 1028 + configLoader: options.configLoader, 1029 + mode: options.mode || 'test', 1030 + plugins: [ 1031 + CliOverride(cliOptionsCopy), 1032 + VitestConfigServer(pluginsHarness), 1033 + ...VitestConfig(pluginsHarness), 1034 + ...VitestCorePlugin(pluginsHarness, options), 1035 + ...BrowserLoaderPlugin(rootBrowserHolder, pluginsHarness), 1036 + ], 1037 + } satisfies InlineConfig, 1038 + mergeConfig(viteOverrides, { root }), 1039 + ) 1040 + 1041 + const rootViteConfig = await viteResolveConfig(inlineConfig, 'serve') 1042 + 1043 + const rootConfig = resolveTestConfig( 1044 + pluginsHarness.logger, 1045 + (rootViteConfig.test as UserConfig | undefined) || {}, 1046 + rootViteConfig, 1047 + ) 1048 + rootViteConfig.test = rootConfig 1049 + 1050 + rootViteConfig.server.fs.allow.push( 1051 + ...resolveFsAllow(rootViteConfig.root, rootViteConfig.configFile), 1052 + ) 1053 + rootViteConfig.server.fs.deny.push(API_TOKEN_FILE) 1054 + 1055 + // the server has been created, we don't need to override vite.server options 1056 + const { token, tokenCreated } = resolveApiToken(rootViteConfig.root) 1057 + rootConfig.api.token = token 1058 + rootConfig.api.tokenCreated = tokenCreated 1059 + 1060 + if (rootConfig.ui && rootConfig.open) { 1061 + // Note: `tokenCreated` is only an approximation of "the browser is not 1062 + // authenticated yet". If the user clears cookies while the token file 1063 + // persists, the clean URL will block until they re-open the `?token=` 1064 + // URL printed in the terminal. 1065 + if (rootConfig.api.tokenCreated) { 1066 + // First run that generated the token: no browser holds the auth 1067 + // cookie yet, so open the authenticated URL to set it. A new tab 1068 + // here is fine since no clean-URL tab exists to reuse. 1069 + const url = new URL(rootConfig.uiBase, 'http://localhost') 1070 + url.searchParams.set('token', rootConfig.api.token) 1071 + rootViteConfig.server.open = `${url.pathname}${url.search}` 1072 + } 1073 + else { 1074 + // Subsequent runs: open the clean UI base URL (without `?token=`) 1075 + // rather than the authenticated URL printed by the logger. On macOS, 1076 + // `openBrowser` reuses an existing tab whose URL matches via substring 1077 + // and reloads it (Vite's `bin/openChrome.js`). Since the 302 redirect 1078 + // strips the token, an already-authenticated tab lives at the clean 1079 + // URL, so opening the clean URL matches and reloads it; opening the 1080 + // token URL would never match and would spawn a new tab on every 1081 + // restart. 1082 + rootViteConfig.server.open = rootConfig.uiBase 1083 + } 1084 + } 1085 + 1086 + rootConfig.cliOptions = cliOptionsCopy 1087 + rootConfig.viteOverrides = viteOverridesCopy 1088 + rootConfig._browserContribution = rootBrowserHolder.contribution 1089 + 1090 + rootConfig.resolvedProjects = await resolveProjectEntries( 1091 + pluginsHarness, 1092 + rootViteConfig, 1093 + rootConfig, 1094 + rootConfig.projects, 1095 + ) 1096 + 1097 + return rootViteConfig 1005 1098 } 1006 1099 1007 1100 export function resolveCoverageReporters(configReporters: NonNullable<CoverageOptions['reporter']>): CoverageReporterWithOptions[] { ··· 1026 1119 return resolvedReporters 1027 1120 } 1028 1121 1029 - function isChromiumName(provider: string, name: string) { 1030 - if (provider === 'playwright') { 1031 - return name === 'chromium' 1032 - } 1033 - return name === 'chrome' || name === 'edge' 1034 - } 1035 - 1036 - function hasBrowserChromium(vitest: Vitest, config: ResolvedConfig) { 1037 - const browser = config.browser 1038 - if (!browser || !browser.provider || browser.provider.name === 'preview' || !browser.enabled) { 1039 - return false 1040 - } 1041 - if (browser.name) { 1042 - return isChromiumName(browser.provider.name, browser.name) 1043 - } 1044 - if (!browser.instances) { 1045 - return false 1122 + export function matchesProjectFilter(projects: string[], name: string): boolean { 1123 + // no filters applied, any project can be included 1124 + if (!projects.length) { 1125 + return true 1046 1126 } 1047 - return browser.instances.some((instance) => { 1048 - const name = instance.name || (config.name ? `${config.name} (${instance.browser})` : instance.browser) 1049 - // browser config is filtered out 1050 - if (!vitest.matchesProjectFilter(name)) { 1051 - return false 1052 - } 1053 - return isChromiumName(browser.provider!.name, instance.browser) 1127 + return projects.some((project) => { 1128 + const regexp = wildcardPatternToRegExp(project) 1129 + return regexp.test(name) 1054 1130 }) 1055 1131 } 1056 1132 1057 - function hasOnlyBrowserChromium(vitest: Vitest, config: ResolvedConfig) { 1058 - const browser = config.browser 1059 - if (!browser || !browser.provider || browser.provider.name === 'preview' || !browser.enabled) { 1060 - return false 1061 - } 1062 - if (browser.name) { 1063 - return isChromiumName(browser.provider.name, browser.name) 1064 - } 1065 - if (!browser.instances) { 1133 + export function isExcludedByProjectFilter(projects: string[], name: string): boolean { 1134 + if (!projects.length) { 1066 1135 return false 1067 1136 } 1068 - return browser.instances.every((instance) => { 1069 - const name = instance.name || (config.name ? `${config.name} (${instance.browser})` : instance.browser) 1070 - // browser config is filtered out 1071 - if (!vitest.matchesProjectFilter(name)) { 1072 - return true // ignore this project 1137 + 1138 + return projects.some((project) => { 1139 + if (!project.startsWith('!')) { 1140 + return false 1073 1141 } 1074 - return isChromiumName(browser.provider!.name, instance.browser) 1142 + const positivePattern = project.slice(1) 1143 + return wildcardPatternToRegExp(positivePattern).test(name) 1075 1144 }) 1076 1145 }
+5 -11
packages/vitest/src/node/config/serializeConfig.ts
··· 1 1 import type { SerializedDiffOptions } from '@vitest/utils/diff' 2 2 import type { SerializedConfig } from '../../runtime/config' 3 3 import type { TestProject } from '../project' 4 - import type { ApiConfig } from '../types/config' 5 4 import { resolve } from 'node:path' 6 5 import { configDefaults } from '../../defaults' 7 6 import { isAgent, isForceColor } from '../../utils/env' 8 7 9 8 export function serializeConfig(project: TestProject): SerializedConfig { 10 - const { config, globalConfig } = project 11 - const viteConfig = project._vite?.config 9 + const { config, globalConfig, viteConfig } = project 12 10 const optimizer = config.deps?.optimizer || {} 13 11 14 12 return { ··· 37 35 pool: config.pool, 38 36 expect: config.expect, 39 37 snapshotSerializers: config.snapshotSerializers, 40 - api: ((api: ApiConfig | undefined) => { 41 - return { 42 - allowExec: api?.allowExec, 43 - allowWrite: api?.allowWrite, 44 - } 45 - })(project.isBrowserEnabled() ? config.browser.api : config.api), 38 + api: { 39 + allowExec: config.api.allowExec, 40 + allowWrite: config.api.allowWrite, 41 + }, 46 42 diff: serializeDiffOptions(config.diff), 47 43 retry: config.retry, 48 44 repeats: config.repeats, ··· 112 108 return { 113 109 name: browser.name, 114 110 headless: browser.headless, 115 - isolate: browser.isolate, 116 - fileParallelism: browser.fileParallelism, 117 111 ui: browser.ui, 118 112 detailsPanelPosition: browser.detailsPanelPosition ?? 'right', 119 113 viewport: browser.viewport,
+171 -215
packages/vitest/src/node/core.ts
··· 1 1 import type { Awaitable } from '@vitest/utils' 2 2 import type { Writable } from 'node:stream' 3 - import type { ViteDevServer } from 'vite' 3 + import type { ResolvedConfig as ResolvedViteConfig, ViteDevServer } from 'vite' 4 4 import type { ModuleRunner } from 'vite/module-runner' 5 5 import type { SerializedCoverageConfig, SerializedRootConfig } from '../runtime/config' 6 6 import type { CancelReason, File } from '../runtime/runner/types' 7 7 import type { ArgumentsType, ProvidedContext, UserConsoleLog } from '../types/general' 8 8 import type { SourceModuleDiagnostic, SourceModuleLocations } from '../types/module-locations' 9 - import type { CliOptions } from './cli/cli-api' 9 + import type { PluginHarness } from './config/pluginHarness' 10 10 import type { VitestFetchFunction } from './environments/fetchModule' 11 + import type { Logger } from './logger' 12 + import type { VitestPackageInstaller } from './packageInstaller' 11 13 import type { ProcessPool } from './pool' 12 14 import type { Report } from './reporters/report' 13 15 import type { TestModule } from './reporters/reported-tasks' 14 16 import type { TestSpecification } from './test-specification' 15 - import type { ResolvedConfig, TestProjectConfiguration, UserConfig, VitestRunMode } from './types/config' 17 + import type { ParentProjectBrowser } from './types/browser' 18 + import type { ResolvedConfig, TestProjectConfiguration } from './types/config' 16 19 import type { CoverageProvider, ResolvedCoverageOptions } from './types/coverage' 17 20 import type { Reporter } from './types/reporter' 18 21 import type { TestRunResult } from './types/tests' ··· 22 25 import { deepClone, deepMerge, nanoid, toArray } from '@vitest/utils/helpers' 23 26 import { serializeValue } from '@vitest/utils/serialize' 24 27 import { join, normalize, relative } from 'pathe' 25 - import { isRunnableDevEnvironment } from 'vite' 26 28 import { version } from '../../package.json' with { type: 'json' } 29 + import { defaultBrowserPort } from '../constants' 27 30 import { distDir } from '../paths' 28 31 import { createTagsFilter } from '../runtime/runner/utils/tags' 29 - import { wildcardPatternToRegExp } from '../utils/base' 30 32 import { limitConcurrency } from '../utils/limit-concurrency' 31 33 import { NativeModuleRunner } from '../utils/nativeModuleRunner' 32 34 import { convertTasksToEvents, getTasks, hasFailed, interpretTaskModes, someTasksAreOnly } from '../utils/tasks' ··· 35 37 import { BrowserSessions } from './browser/sessions' 36 38 import { VitestCache } from './cache' 37 39 import { FileSystemModuleCache } from './cache/fsModuleCache' 38 - import { resolveConfig } from './config/resolveConfig' 40 + import { matchesProjectFilter, resolveConfig } from './config/resolveConfig' 39 41 import { getCoverageProvider } from './coverage' 40 42 import { createFetchModuleFunction } from './environments/fetchModule' 41 43 import { ServerModuleRunner } from './environments/serverRunner' 42 44 import { FilesNotFoundError } from './errors' 43 - import { Logger } from './logger' 44 45 import { collectModuleDurationsDiagnostic, collectSourceModulesLocations } from './module-diagnostic' 45 - import { VitestPackageInstaller } from './packageInstaller' 46 + import { createClusterServer } from './plugins/browserLoader' 46 47 import { createPool } from './pool' 47 48 import { TestProject } from './project' 48 - import { getDefaultTestProject, resolveDefaultProjects, resolveProjects } from './projects/resolveProjects' 49 + import { attachProjectsFromEntries, resolveAndAttachProjects } from './projects/resolveProjects' 49 50 import { BlobReporter, readBlobs } from './reporters/blob' 50 51 import { HangingProcessReporter } from './reporters/hanging-process' 51 52 import { createReport } from './reporters/report' ··· 115 116 */ 116 117 public vcs!: VCSProvider 117 118 119 + // these values are set after the config is resolved, 120 + // but Vitest instance is not accessible anywhere before that 121 + /** 122 + * The global config. 123 + */ 124 + public config!: ResolvedConfig 125 + /** 126 + * Resolved global vite config. 127 + */ 128 + public viteConfig!: ResolvedViteConfig 129 + /** 130 + * Global Vite's dev server instance. 131 + */ 132 + public vite!: ViteDevServer 133 + /** 134 + * The global test state manager. 135 + * @experimental The State API is experimental and not subject to semver. 136 + */ 137 + public state!: StateManager 138 + /** 139 + * The global snapshot manager. You can access the current state on `snapshot.summary`. 140 + */ 141 + public snapshot!: SnapshotManager 142 + /** 143 + * Test results and test file stats cache. Primarily used by the sequencer to sort tests. 144 + */ 145 + public cache!: VitestCache 146 + 118 147 /** @internal */ configOverride: Partial<ResolvedConfig> = {} 119 148 /** @internal */ filenamePattern?: string[] 120 149 /** @internal */ runningPromise?: Promise<TestRunResult> ··· 122 151 /** @internal */ cancelPromise?: Promise<void | void[]> 123 152 /** @internal */ isCancelling = false 124 153 /** @internal */ coreWorkspaceProject: TestProject | undefined 154 + /** 155 + * When the root config is itself browser-enabled (no `projects`), the root 156 + * Vite server is the single browser server and this is its parent browser 157 + * project (assigned to `coreWorkspaceProject._parentBrowser`). 158 + * @internal 159 + */ 160 + _rootBrowserParent: ParentProjectBrowser | undefined 125 161 /** @internal */ _browserSessions = new BrowserSessions() 126 - /** @internal */ _cliOptions: CliOptions = {} 127 162 /** @internal */ reporters: Reporter[] = [] 128 163 /** @internal */ runner!: ModuleRunner 129 - /** @internal */ _testRun: TestRun = undefined! 130 - /** @internal */ _config?: ResolvedConfig 164 + /** @internal */ _testRun: TestRun 131 165 /** @internal */ _resolver!: VitestResolver 132 166 /** @internal */ _fetcher!: VitestFetchFunction 133 167 /** @internal */ _fsCache!: FileSystemModuleCache 134 168 /** @internal */ _tmpDir = join(tmpdir(), nanoid()) 135 169 /** @internal */ _traces!: Traces 170 + /** @internal */ _harness: PluginHarness 136 171 137 172 private isFirstRun = true 138 173 private restartsCount = 0 139 174 140 175 private readonly specifications: VitestSpecifications 141 176 private pool: ProcessPool | undefined 142 - private _vite?: ViteDevServer 143 - private _state?: StateManager 144 - private _cache?: VitestCache 145 - private _snapshot?: SnapshotManager 146 177 private _coverageProvider?: CoverageProvider | null | undefined 147 178 148 179 /** ··· 151 182 public readonly mode = 'test' 152 183 153 184 constructor( 154 - cliOptions: UserConfig, 155 - options?: VitestOptions, 156 - ) 157 - /** 158 - * @deprecated The `mode` argument is no longer used. Use `new Vitest(cliOptions, options)` instead. 159 - */ 160 - constructor( 161 - mode: VitestRunMode, 162 - cliOptions: UserConfig, 163 - options?: VitestOptions, 164 - ) 165 - constructor( 166 - modeOrCliOptions: VitestRunMode | UserConfig, 167 - cliOptionsOrOptions?: UserConfig | VitestOptions, 168 - maybeOptions?: VitestOptions, 185 + harness: PluginHarness, 186 + viteConfig: ResolvedViteConfig, 169 187 ) { 170 - let cliOptions: UserConfig 171 - let options: VitestOptions 172 - if (typeof modeOrCliOptions === 'string') { 173 - cliOptions = cliOptionsOrOptions as UserConfig 174 - options = maybeOptions ?? {} 175 - } 176 - else { 177 - cliOptions = modeOrCliOptions 178 - options = (cliOptionsOrOptions as VitestOptions) ?? {} 179 - } 180 - this._cliOptions = cliOptions 181 - this.logger = new Logger(this, options.stdout, options.stderr) 182 - this.packageInstaller = options.packageInstaller || new VitestPackageInstaller() 188 + this._harness = harness 189 + this.viteConfig = viteConfig 190 + this.config = viteConfig.test 191 + this.logger = harness.logger.setVitest(this) 192 + this.packageInstaller = harness.packageInstaller 183 193 this.specifications = new VitestSpecifications(this) 184 194 this.watcher = new VitestWatcher(this).onWatcherRerun(file => 185 195 this.scheduleRerun(file), // TODO: error handling 186 196 ) 197 + harness.setVitest(this) 198 + this._testRun = new TestRun(this) 187 199 } 188 200 189 201 private _onRestartListeners: OnServerRestartHandler[] = [] ··· 194 206 private _onFilterWatchedSpecification: ((spec: TestSpecification) => boolean)[] = [] 195 207 196 208 /** 197 - * The global config. 198 - */ 199 - get config(): ResolvedConfig { 200 - assert(this._config, 'config') 201 - return this._config 202 - } 203 - 204 - /** 205 - * Global Vite's dev server instance. 206 - */ 207 - get vite(): ViteDevServer { 208 - assert(this._vite, 'vite', 'server') 209 - return this._vite 210 - } 211 - 212 - /** 213 - * The global test state manager. 214 - * @experimental The State API is experimental and not subject to semver. 215 - */ 216 - get state(): StateManager { 217 - assert(this._state, 'state') 218 - return this._state 219 - } 220 - 221 - /** 222 - * The global snapshot manager. You can access the current state on `snapshot.summary`. 209 + * @internal 223 210 */ 224 - get snapshot(): SnapshotManager { 225 - assert(this._snapshot, 'snapshot', 'snapshot manager') 226 - return this._snapshot 211 + async _start(config: ResolvedViteConfig): Promise<void> { 212 + this._setRootConfig(config) 213 + await this._attachRootServer() 214 + await this._attachProjectServers() 227 215 } 228 216 229 217 /** 230 - * Test results and test file stats cache. Primarily used by the sequencer to sort tests. 218 + * @internal 231 219 */ 232 - get cache(): VitestCache { 233 - assert(this._cache, 'cache') 234 - return this._cache 235 - } 236 - 237 - /** @internal */ 238 - async _setServer(options: UserConfig, server: ViteDevServer) { 220 + _setRootConfig(config: ResolvedViteConfig): void { 239 221 this.watcher.unregisterWatcher() 240 222 clearTimeout(this._rerunTimer) 241 223 this.restartsCount += 1 ··· 245 227 this.projects = [] 246 228 this.runningPromise = undefined 247 229 this.coreWorkspaceProject = undefined 230 + this._rootBrowserParent = undefined 248 231 this.specifications.clearCache() 249 232 this._coverageProvider = undefined 250 233 this._onUserTestsRerun = [] 251 234 252 - this._vite = server 253 - 254 - const resolved = resolveConfig(this, options, server.config) 235 + this.viteConfig = config 236 + this.config = config.test 237 + const resolved = config.test 255 238 256 - this._config = resolved 257 - this._state = new StateManager({ 239 + this.state = new StateManager({ 258 240 onUnhandledError: resolved.onUnhandledError, 259 241 }) 260 - this._cache = new VitestCache(this.logger) 261 - this._snapshot = new SnapshotManager({ ...resolved.snapshotOptions }) 262 - this._testRun = new TestRun(this) 263 - const otelSdkPath = resolved.experimental.openTelemetry?.sdkPath 242 + this.cache = new VitestCache(this.logger) 243 + const otelSdkPath = this.config.experimental.openTelemetry?.sdkPath 264 244 this._traces = new Traces({ 265 - enabled: !!resolved.experimental.openTelemetry?.enabled, 245 + enabled: !!this.config.experimental.openTelemetry?.enabled, 266 246 sdkPath: otelSdkPath, 267 - watchMode: resolved.watch, 247 + watchMode: this.config.watch, 268 248 }) 269 - 270 - if (this.config.watch) { 271 - this.watcher.registerWatcher() 272 - } 273 - 274 - this._resolver = new VitestResolver(server.config.cacheDir, resolved) 275 249 this._fsCache = new FileSystemModuleCache(this) 250 + this.snapshot = new SnapshotManager({ ...resolved.snapshotOptions }) 251 + this._resolver = new VitestResolver(this.viteConfig.cacheDir, resolved) 276 252 this._fetcher = createFetchModuleFunction( 277 253 this._resolver, 278 - this._config, 254 + resolved, 279 255 this._fsCache, 280 256 this._traces, 281 257 this._tmpDir, 282 258 ) 259 + } 260 + 261 + private async _restart(reason?: string) { 262 + await Promise.all(this._onRestartListeners.map(fn => fn(reason))) 263 + this.report('onServerRestart', reason) 264 + await this.close() 265 + // reuse the same browser ports as the previous run instead of letting the 266 + // reused harness keep incrementing them 267 + this._harness._browserLastPort = defaultBrowserPort 268 + // harness mimics `vitest` access like in `node/create.ts` 269 + this._harness.setVitest(undefined) 270 + const config = await resolveConfig( 271 + this.config.cliOptions, 272 + this.config.viteOverrides, 273 + this._harness, 274 + ) 275 + this._harness.setVitest(this) 276 + await this._start(config) 277 + } 278 + 279 + /** 280 + * @internal 281 + */ 282 + async _attachRootServer(): Promise<void> { 283 + const resolved = this.config 284 + // For a root-level browser config (no `projects`) this builds the single 285 + // browser server; otherwise it just creates the Vite server. 286 + const { server, parent } = await createClusterServer(this, this.viteConfig, resolved) 287 + this.vite = server 288 + this._rootBrowserParent = parent 289 + 283 290 const environment = server.environments.__vitest__ 284 291 this.runner = resolved.experimental.viteModuleRunner === false 285 292 ? new NativeModuleRunner(resolved.root) ··· 288 295 this._fetcher, 289 296 resolved, 290 297 ) 291 - // patch default ssr runnable environment so third-party usage of `runner.import` 292 - // still works with Vite's external/noExternal configuration. 293 - const ssrEnvironment = server.environments.ssr 294 - if (isRunnableDevEnvironment(ssrEnvironment)) { 295 - const ssrRunner = new ServerModuleRunner( 296 - ssrEnvironment, 297 - this._fetcher, 298 - resolved, 299 - ) 300 - Object.defineProperty(ssrEnvironment, 'runner', { 301 - value: ssrRunner, 302 - writable: true, 303 - configurable: true, 304 - }) 305 - } 306 298 this.vcs = await loadVCSProvider(this.runner, resolved.experimental.vcsProvider) 307 299 308 - if (this.config.watch) { 309 - // hijack server restart 310 - const serverRestart = server.restart 311 - server.restart = async (...args) => { 312 - await Promise.all(this._onRestartListeners.map(fn => fn())) 313 - this.report('onServerRestart') 314 - await this.close() 315 - await serverRestart(...args) 300 + if (resolved.watch) { 301 + this.watcher.registerWatcher() 302 + 303 + // hijack server restart — re-run the full pipeline rather than letting 304 + // Vite recreate the server in isolation, so Vitest's own resolution 305 + // re-runs too. 306 + server.restart = async () => { 307 + await this._restart() 316 308 } 317 309 318 310 // since we set `server.hmr: false`, Vite does not auto restart itself ··· 321 313 const isConfig = file === server.config.configFile 322 314 || this.projects.some(p => p.vite.config.configFile === file) 323 315 if (isConfig) { 324 - await Promise.all(this._onRestartListeners.map(fn => fn('config'))) 325 - this.report('onServerRestart', 'config') 326 - await this.close() 327 - await serverRestart() 316 + await this._restart('config') 328 317 } 329 318 }) 319 + 320 + if (process.env.VITE_TEST_WATCHER_DEBUG) { 321 + server.watcher.on('ready', () => { 322 + // eslint-disable-next-line no-console 323 + console.log('[debug] watcher is ready') 324 + }) 325 + } 326 + } 327 + 328 + // In run mode we don't need the watcher; closing it improves performance (#415). 329 + if (!resolved.watch) { 330 + await server.watcher.close() 330 331 } 331 332 332 333 this.cache.results.setConfig(resolved.root, resolved.cache) ··· 334 335 await this.cache.results.readFromCache() 335 336 } 336 337 catch { } 338 + } 337 339 338 - this.projects = await this.resolveProjects(this._cliOptions) 339 - if (this._cliOptions.benchmarkOnly) { 340 - this.projects = this.projects.filter(c => c.config.benchmark.enabled) 340 + /** 341 + * Phase B (projects) — instantiate `TestProject`s from the resolved entries 342 + * and create their Vite servers, deduping by `viteConfig` identity. Run 343 + * `configureVitest` hooks. Validate filters and project resolution. Set up 344 + * the core workspace project, populate tags, build reporters. 345 + * 346 + * @internal 347 + */ 348 + async _attachProjectServers(): Promise<void> { 349 + const resolved = this.config 350 + const entries = resolved.resolvedProjects || [] 351 + this.projects = await attachProjectsFromEntries(this, entries) 352 + 353 + // `--benchmark` (CLI `benchmarkOnly`) narrows `vitest.projects` to only 354 + // the benchmark variants produced by the benchmark expansion step. 355 + if (resolved.cliOptions.benchmarkOnly) { 356 + this.projects = this.projects.filter(p => p.config.benchmark.enabled) 341 357 } 342 358 343 359 await Promise.all(this.projects.flatMap((project) => { ··· 353 369 })) 354 370 })) 355 371 356 - if (this._cliOptions.browser?.enabled) { 372 + if (resolved.cliOptions.browser?.enabled) { 357 373 const browserProjects = this.projects.filter(p => p.config.browser.enabled) 358 374 if (!browserProjects.length) { 359 375 throw new Error(`Vitest received --browser flag, but no project had a browser configuration.`) ··· 366 382 } 367 383 else { 368 384 let error = `Vitest wasn't able to resolve any project.` 369 - if (this.config.browser.enabled && !this.config.browser.instances?.length) { 385 + if (resolved.browser.enabled && !resolved.browser.instances?.length) { 370 386 error += ` Please, check that you specified the "browser.instances" option.` 371 387 } 372 388 throw new Error(error) ··· 377 393 this.coreWorkspaceProject = TestProject._createBasicProject(this) 378 394 } 379 395 380 - if (this.config.testNamePattern) { 381 - this.configOverride.testNamePattern = this.config.testNamePattern 396 + if (resolved.testNamePattern) { 397 + this.configOverride.testNamePattern = resolved.testNamePattern 382 398 } 383 399 384 400 // populate will merge all configs into every project, 385 401 // we don't want that when just listing tags 386 - if (!this.config.listTags) { 402 + if (!resolved.listTags) { 387 403 populateProjectsTags(this.coreWorkspaceProject, this.projects) 388 404 } 389 405 390 406 this.reporters = await createReporters(resolved.reporters, this) 391 407 408 + // API setup (watch mode only). Must run after the reporters array is built 409 + // above, since `setup()` appends the UI/API WebSocket reporter to it. For a 410 + // root-level browser server the API lives on the same shared httpServer and 411 + // is only needed when a UI (Vitest dashboard or browser orchestrator) is served. 412 + const apiNeeded = !this._rootBrowserParent || resolved.ui || resolved.browser.ui 413 + if (resolved.api && resolved.watch && apiNeeded) { 414 + (await import('../api/setup')).setup(this) 415 + } 416 + 392 417 await this._fsCache.ensureCacheIntegrity() 393 418 394 419 await Promise.all([ ··· 454 479 } 455 480 456 481 private clearAllCachePaths() { 457 - this.projects.forEach(({ vite, browser }) => { 458 - const environments = [ 459 - ...Object.values(vite.environments), 460 - ...Object.values(browser?.vite.environments || {}), 461 - ] 482 + this.projects.forEach(({ vite }) => { 483 + const environments = Object.values(vite.environments) 462 484 environments.forEach(environment => 463 485 this._fsCache.invalidateAllCachePaths(environment), 464 486 ) ··· 489 511 * @returns An array of new test projects. Can be empty if the name was filtered out. 490 512 */ 491 513 private injectTestProject = async (config: TestProjectConfiguration | TestProjectConfiguration[]): Promise<TestProject[]> => { 492 - const currentNames = new Set(this.projects.map(p => p.name)) 493 - const projects = await resolveProjects( 494 - this, 495 - this._cliOptions, 496 - undefined, 514 + const projects = await resolveAndAttachProjects( 515 + this._harness, 497 516 Array.isArray(config) ? config : [config], 498 - currentNames, 499 517 ) 500 518 this.projects.push(...projects) 501 519 return projects ··· 571 589 await coverageProvider.clean(this._coverageOptions.clean) 572 590 } 573 591 return coverageProvider || null 574 - } 575 - 576 - private async resolveProjects(cliOptions: UserConfig): Promise<TestProject[]> { 577 - const names = new Set<string>() 578 - 579 - if (this.config.projects) { 580 - return resolveProjects( 581 - this, 582 - cliOptions, 583 - undefined, 584 - this.config.projects, 585 - names, 586 - ) 587 - } 588 - 589 - if ('workspace' in this.config) { 590 - throw new Error('The `test.workspace` option was removed in Vitest 4. Please, migrate to `test.projects` instead. See https://vitest.dev/guide/projects for examples.') 591 - } 592 - 593 - // user can filter projects with --project flag, `getDefaultTestProject` 594 - // returns the project only if it matches the filter 595 - const project = getDefaultTestProject(this) 596 - if (!project) { 597 - return [] 598 - } 599 - return resolveDefaultProjects(this, new Set([project.name]), [project]) 600 592 } 601 593 602 594 /** ··· 1207 1199 await this.runningPromise 1208 1200 } 1209 1201 1210 - /** @internal */ 1211 - async _initBrowserServers(): Promise<void> { 1212 - await Promise.all(this.projects.map(p => p._initBrowserServer())) 1213 - } 1214 - 1215 1202 private async initializeGlobalSetup(paths: TestSpecification[]): Promise<void> { 1216 1203 const projects = new Set(paths.map(spec => spec.project)) 1217 1204 const coreProject = this.getRootProject() ··· 1276 1263 /** @internal */ 1277 1264 async changeProjectName(pattern: string): Promise<void> { 1278 1265 if (pattern === '') { 1279 - this.configOverride.project = undefined 1266 + this.config.cliOptions.project = undefined 1280 1267 } 1281 1268 else { 1282 - this.configOverride.project = [pattern] 1269 + this.config.cliOptions.project = [pattern] 1283 1270 } 1284 1271 1285 1272 await this.vite.restart() ··· 1461 1448 * Invalidate a file in all projects. 1462 1449 */ 1463 1450 public invalidateFile(filepath: string): void { 1464 - this.projects.forEach(({ vite, browser }) => { 1465 - const environments = [ 1466 - ...Object.values(vite.environments), 1467 - ...Object.values(browser?.vite.environments || {}), 1468 - ] 1451 + this.projects.forEach(({ vite }) => { 1452 + const environments = Object.values(vite.environments) 1469 1453 1470 1454 environments.forEach((environment) => { 1471 1455 const { moduleGraph } = environment ··· 1535 1519 // close the core workspace server only once 1536 1520 // it's possible that it's not initialized at all because it's not running any tests 1537 1521 if (this.coreWorkspaceProject && !this.projects.includes(this.coreWorkspaceProject)) { 1538 - closePromises.push(this.coreWorkspaceProject.close().then(() => this._vite = undefined as any)) 1522 + closePromises.push(this.coreWorkspaceProject.close().then(() => this.vite = undefined as any)) 1539 1523 } 1540 1524 1541 1525 if (this.pool) { ··· 1571 1555 console.warn(`close timed out after ${this.config.teardownTimeout}ms`) 1572 1556 1573 1557 if (!this.pool) { 1574 - const runningServers = [this._vite, ...this.projects.map(p => p._vite)].filter(Boolean).length 1558 + const runningServers = [this.vite, ...this.projects.map(p => p.vite)].filter(Boolean).length 1575 1559 1576 1560 if (runningServers === 1) { 1577 1561 console.warn('Tests closed successfully but something prevents Vite server from exiting') ··· 1670 1654 * Check if the project with a given name should be included. 1671 1655 */ 1672 1656 matchesProjectFilter(name: string): boolean { 1673 - const projects = this._config?.project || this._cliOptions?.project 1674 - // no filters applied, any project can be included 1675 - if (!projects || !projects.length) { 1676 - return true 1677 - } 1678 - return toArray(projects).some((project) => { 1679 - const regexp = wildcardPatternToRegExp(project) 1680 - return regexp.test(name) 1681 - }) 1682 - } 1683 - 1684 - /** @internal */ 1685 - isExcludedByProjectFilter(name: string): boolean { 1686 - const projects = this._config?.project || this._cliOptions?.project 1687 - if (!projects || !projects.length) { 1688 - return false 1689 - } 1690 - return toArray(projects).some((project) => { 1691 - if (!project.startsWith('!')) { 1692 - return false 1693 - } 1694 - const positivePattern = project.slice(1) 1695 - return wildcardPatternToRegExp(positivePattern).test(name) 1696 - }) 1657 + const projects = this.config?.project || this.config.cliOptions?.project 1658 + return matchesProjectFilter(toArray(projects), name) 1697 1659 } 1698 1660 1699 1661 /** ··· 1701 1663 */ 1702 1664 createReport(scope: string): Report { 1703 1665 return createReport(this, scope) 1704 - } 1705 - } 1706 - 1707 - function assert(condition: unknown, property: string, name: string = property): asserts condition { 1708 - if (!condition) { 1709 - throw new Error(`The ${name} was not set. It means that \`vitest.${property}\` was called before the Vite server was established. Await the Vitest promise before accessing \`vitest.${property}\`.`) 1710 1666 } 1711 1667 } 1712 1668
+1 -10
packages/vitest/src/node/coverage.ts
··· 771 771 } 772 772 } 773 773 774 - if (project.isBrowserEnabled() || viteEnvironment === '__browser__') { 775 - const client = project.browser?.vite.environments.client || project.vite.environments.client 776 - const result = await client.transformRequest(url) 777 - 778 - if (result) { 779 - return result 780 - } 781 - } 782 - 783 774 return project.vite.environments[viteEnvironment].transformRequest(url) 784 775 } 785 776 ··· 803 794 804 795 try { 805 796 const environment = project.config.environment 806 - const viteEnvironment = environment === 'jsdom' || environment === 'happy-dom' ? 'client' : 'ssr' 797 + const viteEnvironment = environment === 'jsdom' || environment === 'happy-dom' || project.isBrowserEnabled() ? 'client' : 'ssr' 807 798 return await this.transformFile(filename, project, viteEnvironment) 808 799 } 809 800 catch (err) {
+23 -60
packages/vitest/src/node/create.ts
··· 1 1 import type { 2 - InlineConfig as ViteInlineConfig, 3 2 UserConfig as ViteUserConfig, 4 3 } from 'vite' 5 4 import type { CliOptions } from './cli/cli-api' 6 5 import type { VitestOptions } from './core' 7 6 import type { VitestRunMode } from './types/config' 8 - import { resolve } from 'node:path' 9 - import { deepClone, slash } from '@vitest/utils/helpers' 10 - import { resolveModule } from 'local-pkg' 11 - import { mergeConfig } from 'vite' 12 - import { findConfigFile } from './config/resolveConfig' 7 + import { PluginHarness } from './config/pluginHarness' 8 + import { resolveConfig } from './config/resolveConfig' 13 9 import { Vitest } from './core' 14 - import { VitestPlugin } from './plugins' 15 - import { createViteServer } from './vite' 10 + import { Logger } from './logger' 11 + import { VitestPackageInstaller } from './packageInstaller' 16 12 17 13 export async function createVitest( 18 14 options: CliOptions, ··· 47 43 viteOverrides = optionsOrViteOverrides as ViteUserConfig 48 44 vitestOptions = viteOverridesOrVitestOptions as VitestOptions 49 45 } 50 - const ctx = new Vitest(deepClone(options), vitestOptions) 51 - const root = slash(resolve(options.root || process.cwd())) 52 - 53 - const configPath 54 - = options.config === false 55 - ? false 56 - : options.config 57 - ? (resolveModule(options.config, { paths: [root] }) ?? resolve(root, options.config)) 58 - : findConfigFile(root) 59 46 60 - options.config = configPath 47 + const logger = new Logger(vitestOptions.stdout, vitestOptions.stderr) 48 + const packageInstaller = vitestOptions.packageInstaller ?? new VitestPackageInstaller() 49 + const pluginHarness = new PluginHarness(logger, packageInstaller) 61 50 62 - const { browser: _removeBrowser, ...restOptions } = options 51 + const config = await resolveConfig( 52 + options, 53 + viteOverrides, 54 + pluginHarness, 55 + ) 63 56 64 - const config: ViteInlineConfig = { 65 - configFile: configPath, 66 - configLoader: options.configLoader, 67 - mode: options.mode || 'test', 68 - plugins: await VitestPlugin(restOptions, ctx), 69 - } 57 + const vitest = new Vitest( 58 + pluginHarness, 59 + config, 60 + ) 70 61 71 62 try { 72 - const server = await createViteServer( 73 - mergeConfig(config, mergeConfig(viteOverrides, { root: options.root })), 74 - ) 63 + await vitest._start(config) 75 64 76 - if (ctx.config.api?.port) { 77 - await server.listen() 78 - if (ctx.config.ui && ctx.config.open) { 79 - // Note: `tokenCreated` is only an approximation of "the browser is not 80 - // authenticated yet". If the user clears cookies while the token file 81 - // persists, the clean URL will block until they re-open the `?token=` 82 - // URL printed in the terminal. 83 - if (ctx.config.api.tokenCreated) { 84 - // First run that generated the token: no browser holds the auth 85 - // cookie yet, so open the authenticated URL to set it. A new tab 86 - // here is fine since no clean-URL tab exists to reuse. 87 - const url = new URL(ctx.config.uiBase, 'http://localhost') 88 - url.searchParams.set('token', ctx.config.api.token) 89 - server.config.server.open = `${url.pathname}${url.search}` 90 - } 91 - else { 92 - // Subsequent runs: open the clean UI base URL (without `?token=`) 93 - // rather than the authenticated URL printed by the logger. On macOS, 94 - // `openBrowser` reuses an existing tab whose URL matches via substring 95 - // and reloads it (Vite's `bin/openChrome.js`). Since the 302 redirect 96 - // strips the token, an already-authenticated tab lives at the clean 97 - // URL, so opening the clean URL matches and reloads it; opening the 98 - // token URL would never match and would spawn a new tab on every 99 - // restart. 100 - server.config.server.open = ctx.config.uiBase 101 - } 102 - server.openBrowser() 103 - } 65 + if (vitest.config.api.port && vitest.config.ui && vitest.config.open) { 66 + vitest.vite.openBrowser() 104 67 } 105 68 106 - return ctx 69 + return vitest 107 70 } 108 - // Vitest can fail at any point inside "setServer" or inside a custom plugin 109 - // Then we need to make sure everything was properly closed (like the logger) 71 + // Vitest can fail at any point during setup or inside a custom plugin. 72 + // Make sure everything is properly closed (like the logger). 110 73 catch (error) { 111 - await ctx.close() 74 + await vitest.close() 112 75 throw error 113 76 } 114 77 }
+21 -1
packages/vitest/src/node/environments/serverRunner.ts
··· 1 - import type { DevEnvironment } from 'vite' 1 + import type { DevEnvironment, ViteDevServer } from 'vite' 2 2 import type { ResolvedConfig } from '../types/config' 3 3 import type { VitestFetchFunction } from './fetchModule' 4 4 import { readFile } from 'node:fs/promises' 5 5 import { VitestModuleEvaluator } from '#module-evaluator' 6 + import { isRunnableDevEnvironment } from 'vite' 6 7 import { ModuleRunner } from 'vite/module-runner' 7 8 import { normalizeResolvedIdToUrl } from './normalizeUrl' 8 9 ··· 59 60 return super.import(url) 60 61 } 61 62 } 63 + 64 + // Override the SSR environment's runner so user `configureServer` hooks that 65 + // call `server.environments.ssr.runner.import(...)` get Vitest's module runner. 66 + export function installSsrModuleRunner( 67 + server: ViteDevServer, 68 + fetcher: VitestFetchFunction, 69 + config: ResolvedConfig, 70 + ): void { 71 + const ssrEnvironment = server.environments.ssr 72 + if (!isRunnableDevEnvironment(ssrEnvironment)) { 73 + return 74 + } 75 + const ssrRunner = new ServerModuleRunner(ssrEnvironment, fetcher, config) 76 + Object.defineProperty(ssrEnvironment, 'runner', { 77 + value: ssrRunner, 78 + writable: true, 79 + configurable: true, 80 + }) 81 + }
+8 -3
packages/vitest/src/node/logger.ts
··· 36 36 private _highlights = new Map<string, string>() 37 37 private cleanupListeners: Listener[] = [] 38 38 public console: Console 39 + private ctx!: Vitest 39 40 40 41 constructor( 41 - public ctx: Vitest, 42 42 public outputStream: NodeJS.WriteStream | Writable = process.stdout, 43 43 public errorStream: NodeJS.WriteStream | Writable = process.stderr, 44 44 ) { 45 45 this.console = new Console({ stdout: outputStream, stderr: errorStream }) 46 46 this._highlights.clear() 47 - this.addCleanupListeners() 48 - this.registerUnhandledRejection() 49 47 50 48 if ((this.outputStream as typeof process.stdout).isTTY) { 51 49 (this.outputStream as Writable).write(HIDE_CURSOR) 52 50 } 51 + } 52 + 53 + setVitest(vitest: Vitest): this { 54 + this.ctx = vitest 55 + this.addCleanupListeners() 56 + this.registerUnhandledRejection() 57 + return this 53 58 } 54 59 55 60 log(...args: any[]): void {
+132
packages/vitest/src/node/plugins/browserLoader.ts
··· 1 + import type { 2 + ResolvedConfig as ResolvedViteConfig, 3 + ViteDevServer, 4 + Plugin as VitePlugin, 5 + } from 'vite' 6 + import type { PluginHarness } from '../config/pluginHarness' 7 + import type { Vitest } from '../core' 8 + import type { 9 + BrowserServerContribution, 10 + ParentProjectBrowser, 11 + } from '../types/browser' 12 + import type { ResolvedConfig } from '../types/config' 13 + import { createViteServer } from '../vite' 14 + 15 + export interface BrowserContributionHolder { 16 + contribution?: BrowserServerContribution 17 + } 18 + 19 + function sortPluginsByEnforce(plugins: VitePlugin[]): VitePlugin[] { 20 + const pre: VitePlugin[] = [] 21 + const normal: VitePlugin[] = [] 22 + const post: VitePlugin[] = [] 23 + for (const plugin of plugins) { 24 + if (plugin.enforce === 'pre') { 25 + pre.push(plugin) 26 + } 27 + else if (plugin.enforce === 'post') { 28 + post.push(plugin) 29 + } 30 + else { 31 + normal.push(plugin) 32 + } 33 + } 34 + return [...pre, ...normal, ...post] 35 + } 36 + 37 + export function BrowserLoaderPlugin( 38 + holder: BrowserContributionHolder, 39 + harness: PluginHarness, 40 + ): VitePlugin[] { 41 + return [ 42 + { 43 + name: 'vitest:browser:loader', 44 + // `pre` so the browser plugins injected via `applyToEnvironment` land before 45 + // Vite's internal resolver in the `client` environment (so e.g. the 46 + // `vitest/browser` virtual module wins over the node stub resolution). 47 + enforce: 'pre', 48 + async config(viteConfig) { 49 + const browser = viteConfig.test?.browser 50 + if (!browser?.enabled) { 51 + return 52 + } 53 + // The provider can be configured at the project level or per instance 54 + // (e.g. connect mode). All instances in a project share one provider 55 + // (validated in `resolveTestConfig`), so any instance's server factory 56 + // builds the shared server. 57 + const provider = browser.provider 58 + ?? browser.instances?.find(instance => instance.provider)?.provider 59 + if (!provider || typeof provider.serverFactory !== 'function') { 60 + throw new Error(`Browser Mode was enabled, but provider was not specified anywhere. See https://vitest.dev/guide/browser/#configuration`) 61 + } 62 + const contribution = await provider.serverFactory() 63 + holder.contribution = contribution 64 + const browserConfig = await contribution.config(viteConfig, harness) 65 + return browserConfig 66 + }, 67 + applyToEnvironment(environment) { 68 + const contribution = holder.contribution 69 + if (contribution && environment.name === 'client') { 70 + // `post` browser plugins are injected by `vitest:browser:loader:post` 71 + // instead, so they run after the `post` plugins of the main pipeline 72 + // rather than at this `pre` position. For example, the mocker's 73 + // `vitest:browser:esm-injector` must run after `vitest:mocks`, which is 74 + // added by the main pipeline and is not part of `contribution.plugins`. 75 + return sortPluginsByEnforce( 76 + contribution.plugins.filter(plugin => plugin.enforce !== 'post'), 77 + ) 78 + } 79 + return false 80 + }, 81 + configureServer: { 82 + order: 'pre', 83 + async handler(server) { 84 + await holder.contribution?.configureServer(server) 85 + }, 86 + }, 87 + transformIndexHtml: { 88 + order: 'pre', 89 + async handler(html, ctx) { 90 + return holder.contribution?.transformIndexHtml(ctx) 91 + }, 92 + }, 93 + }, 94 + { 95 + name: 'vitest:browser:loader:post', 96 + enforce: 'post', 97 + applyToEnvironment(environment) { 98 + const contribution = holder.contribution 99 + if (contribution && environment.name === 'client') { 100 + return sortPluginsByEnforce( 101 + contribution.plugins.filter(plugin => plugin.enforce === 'post'), 102 + ) 103 + } 104 + return false 105 + }, 106 + }, 107 + ] 108 + } 109 + 110 + export async function createClusterServer( 111 + vitest: Vitest, 112 + viteConfig: ResolvedViteConfig, 113 + config: ResolvedConfig, 114 + ): Promise<{ server: ViteDevServer; parent?: ParentProjectBrowser }> { 115 + const contribution = config._browserContribution 116 + 117 + if (!contribution) { 118 + const server = await createViteServer(viteConfig) 119 + if (config.api.port) { 120 + await server.listen(config.api.port) 121 + } 122 + return { server } 123 + } 124 + 125 + const parent = contribution.createParent({ config, vitest }) 126 + contribution.parent = parent 127 + 128 + const server = await createViteServer(viteConfig) 129 + await server.listen(config.api.port) 130 + contribution.setupRpc(parent) 131 + return { server, parent } 132 + }
+33
packages/vitest/src/node/plugins/cliOverride.ts
··· 1 + import type { Plugin } from 'vite' 2 + import type { ResolvedBrowserOptions } from '../types/browser' 3 + import type { UserConfig } from '../types/config' 4 + import { deepMerge } from '@vitest/utils/helpers' 5 + import { mergeConfig } from 'vite' 6 + 7 + export function CliOverride(cliOptions: UserConfig): Plugin { 8 + return { 9 + // The CLI plugin overwrites config values with CLI options, making them 10 + // available in the next plugin. We have to do this via plugins because of watch mode. 11 + name: 'vitest:config:cli', 12 + enforce: 'pre', 13 + config: { 14 + order: 'pre', 15 + handler(config) { 16 + const { browser, ...options } = cliOptions 17 + 18 + config.test ??= {} 19 + // We don't want to use Vite's merge because we want to OVERRIDE options 20 + // By default, Vite extends arrays, for example, but CLI options should have the priority 21 + config.test = deepMerge({}, config.test, options) 22 + 23 + // apply browser CLI options only if the config already has the browser config and not disabled manually 24 + if (config.test.browser && browser && (config.test.browser.enabled !== false || browser.enabled)) { 25 + config.test.browser = mergeConfig( 26 + config.test.browser, 27 + browser, 28 + ) as ResolvedBrowserOptions 29 + } 30 + }, 31 + }, 32 + } 33 + }
+131
packages/vitest/src/node/plugins/config.ts
··· 1 + import type { Plugin, UserConfig as ViteConfig } from 'vite' 2 + import type { PluginHarness } from '../config/pluginHarness' 3 + import type { ResolvedConfig } from '../types/config' 4 + import { relative } from 'pathe' 5 + import * as vite from 'vite' 6 + import { generateScopedClassName } from '../../integrations/css/css-modules' 7 + import { createViteLogger, silenceImportViteIgnoreWarning } from '../viteLogger' 8 + import { VitestOptimizer } from './optimizer' 9 + import { ModuleRunnerTransform } from './runnerTransform' 10 + import { deleteDefineConfig, getDefaultResolveOptions } from './utils' 11 + 12 + export function VitestConfig(harness: PluginHarness): Plugin[] { 13 + let root: string 14 + return [ 15 + { 16 + name: 'vitest:config', 17 + enforce: 'pre', 18 + configResolved(config) { 19 + root = config.root 20 + }, 21 + config(viteConfig) { 22 + const testConfig = viteConfig.test || {} 23 + const resolveOptions = getDefaultResolveOptions() 24 + const browserEnabled = !!testConfig.browser?.enabled 25 + 26 + if (viteConfig.define) { 27 + delete viteConfig.define['import.meta.vitest'] 28 + } 29 + 30 + // move `test.alias` to Vite's `resolve.alias` 31 + const alias = testConfig.alias 32 + delete testConfig.alias 33 + 34 + // We inject the defines string in non-browser tests, 35 + // But keep the original behaviour in the browser mode 36 + const defines = browserEnabled 37 + ? viteConfig.define 38 + : deleteDefineConfig(viteConfig) 39 + 40 + const config: ViteConfig = browserEnabled 41 + ? { 42 + resolve: { 43 + alias, 44 + }, 45 + test: {}, 46 + } 47 + : { 48 + define: { 49 + // disable replacing `process.env.NODE_ENV` with static string by vite:client-inject 50 + 'process.env.NODE_ENV': 'process.env.NODE_ENV', 51 + }, 52 + resolve: { 53 + ...resolveOptions, 54 + alias, 55 + }, 56 + test: {}, 57 + } 58 + 59 + config.environments = { 60 + ssr: { 61 + resolve: resolveOptions, 62 + }, 63 + __vitest__: { 64 + dev: {}, 65 + resolve: resolveOptions, 66 + }, 67 + } 68 + 69 + ;(config.test as ResolvedConfig).defines = defines || {} 70 + 71 + if ('rolldownVersion' in vite) { 72 + // eslint-disable-next-line ts/ban-ts-comment 73 + // @ts-ignore rolldown-vite only 74 + config.oxc = viteConfig.oxc === false 75 + ? false 76 + : { 77 + // eslint-disable-next-line ts/ban-ts-comment 78 + // @ts-ignore rolldown-vite only 79 + target: viteConfig.oxc?.target || 'node18', 80 + } 81 + } 82 + else { 83 + config.esbuild = viteConfig.esbuild === false 84 + ? false 85 + : { 86 + // Lowest target Vitest supports is Node18 87 + target: viteConfig.esbuild?.target || 'node18', 88 + sourcemap: 'external', 89 + // Enables using ignore hint for coverage providers with @preserve keyword 90 + legalComments: 'inline', 91 + } 92 + } 93 + 94 + const classNameStrategy 95 + = (typeof testConfig.css !== 'boolean' 96 + && testConfig.css?.modules?.classNameStrategy) 97 + || 'stable' 98 + 99 + if (!browserEnabled && classNameStrategy !== 'scoped') { 100 + config.css ??= {} 101 + config.css.modules ??= {} 102 + if (config.css.modules) { 103 + config.css.modules.generateScopedName = ( 104 + name: string, 105 + filename: string, 106 + ) => { 107 + return generateScopedClassName( 108 + classNameStrategy, 109 + name, 110 + relative(root, filename), 111 + )! 112 + } 113 + } 114 + } 115 + 116 + config.customLogger = createViteLogger( 117 + harness.logger, 118 + viteConfig.logLevel || 'warn', 119 + { 120 + allowClearScreen: false, 121 + }, 122 + ) 123 + config.customLogger = silenceImportViteIgnoreWarning(config.customLogger) 124 + 125 + return config 126 + }, 127 + }, 128 + VitestOptimizer(), 129 + ModuleRunnerTransform(), 130 + ] 131 + }
+3 -3
packages/vitest/src/node/plugins/coverageTransform.ts
··· 1 1 import type { Plugin as VitePlugin } from 'vite' 2 - import type { Vitest } from '../core' 2 + import type { PluginHarness } from '../config/pluginHarness' 3 3 4 - export function CoverageTransform(ctx: Vitest): VitePlugin { 4 + export function CoverageTransform(harness: PluginHarness): VitePlugin { 5 5 return { 6 6 name: 'vitest:coverage-transform', 7 7 enforce: 'post', 8 8 transform(srcCode, id) { 9 - return ctx.coverageProvider?.onFileTransform?.( 9 + return harness.getVitest().coverageProvider?.onFileTransform?.( 10 10 srcCode, 11 11 id, 12 12 this,
+13 -10
packages/vitest/src/node/plugins/cssEnabler.ts
··· 1 - import type { Plugin as VitePlugin } from 'vite' 2 - import type { CSSModuleScopeStrategy, ResolvedConfig } from '../types/config' 1 + import type { Plugin as VitePlugin, ResolvedConfig as ViteResolvedConfig } from 'vite' 2 + import type { CSSModuleScopeStrategy } from '../types/config' 3 3 import { toArray } from '@vitest/utils/helpers' 4 4 import { relative } from 'pathe' 5 5 import { generateCssFilenameHash } from '../../integrations/css/css-modules' ··· 34 34 return `\`_\${style}_${hash}\`` 35 35 } 36 36 37 - export function CSSEnablerPlugin(ctx: { 38 - config: ResolvedConfig 39 - }): VitePlugin[] { 37 + export function CSSEnablerPlugin(): VitePlugin[] { 38 + let viteConfig: ViteResolvedConfig 39 + 40 40 const shouldProcessCSS = (id: string) => { 41 - const { css } = ctx.config 41 + const { css } = viteConfig.test 42 42 if (typeof css === 'boolean') { 43 43 return css 44 44 } ··· 55 55 { 56 56 name: 'vitest:css-disable', 57 57 enforce: 'pre', 58 - transform(code, id) { 58 + transform(_code, id) { 59 59 if (!isCSS(id)) { 60 60 return 61 61 } ··· 69 69 { 70 70 name: 'vitest:css-empty-post', 71 71 enforce: 'post', 72 + configResolved(config) { 73 + viteConfig = config 74 + }, 72 75 transform(_, id) { 73 76 if (!isCSS(id) || shouldProcessCSS(id)) { 74 77 return ··· 79 82 // styles.foo returns a "foo" instead of "undefined" 80 83 // we don't use code content to generate hash for "scoped", because it's empty 81 84 const scopeStrategy 82 - = (typeof ctx.config.css !== 'boolean' 83 - && ctx.config.css.modules?.classNameStrategy) 85 + = (typeof viteConfig.test.css !== 'boolean' 86 + && viteConfig.test.css.modules?.classNameStrategy) 84 87 || 'stable' 85 88 const proxyReturn = getCSSModuleProxyReturn( 86 89 scopeStrategy, 87 - relative(ctx.config.root, id), 90 + relative(viteConfig.test.root, id), 88 91 ) 89 92 const code = `export default new Proxy(Object.create(null), { 90 93 get(_, style) {
+56 -269
packages/vitest/src/node/plugins/index.ts
··· 1 - import type { UserConfig as ViteConfig, Plugin as VitePlugin } from 'vite' 2 - import type { ResolvedConfig, UserConfig } from '../types/config' 3 - import { deepClone, deepMerge, notNullish } from '@vitest/utils/helpers' 4 - import { relative, resolve } from 'pathe' 5 - import * as vite from 'vite' 6 - import { defaultPort } from '../../constants' 1 + import type { Plugin as VitePlugin } from 'vite' 2 + import type { CliOptions } from '../cli/cli-api' 3 + import type { PluginHarness } from '../config/pluginHarness' 4 + import { resolve } from 'pathe' 7 5 import { configDefaults } from '../../defaults' 8 - import { generateScopedClassName } from '../../integrations/css/css-modules' 9 - import { API_TOKEN_FILE } from '../config/apiToken' 10 - import { resolveApiServerConfig } from '../config/resolveConfig' 11 - import { Vitest } from '../core' 12 - import { createViteLogger, silenceImportViteIgnoreWarning } from '../viteLogger' 13 6 import { CoverageTransform } from './coverageTransform' 14 7 import { CSSEnablerPlugin } from './cssEnabler' 15 8 import { MetaEnvReplacerPlugin } from './metaEnvReplacer' 16 9 import { MocksPlugins } from './mocks' 17 10 import { NormalizeURLPlugin } from './normalizeURL' 18 - import { VitestOptimizer } from './optimizer' 19 - import { ModuleRunnerTransform } from './runnerTransform' 20 - import { 21 - deleteDefineConfig, 22 - getDefaultResolveOptions, 23 - resolveFsAllow, 24 - } from './utils' 11 + import { SsrRunnerFixerPlugin } from './ssrRunnerFixer' 25 12 import { VitestCoreResolver } from './vitestResolver' 26 13 27 - export async function VitestPlugin( 28 - options: UserConfig = {}, 29 - vitest: Vitest = new Vitest(deepClone(options)), 30 - ): Promise<VitePlugin[]> { 31 - const userConfig = deepMerge({}, options) as UserConfig 32 - 33 - async function UIPlugin() { 34 - await vitest.packageInstaller.ensureInstalled('@vitest/ui', resolve(options.root || process.cwd()), vitest.version) 35 - return (await import('@vitest/ui')).default(vitest) 36 - } 37 - 14 + export function VitestCorePlugin(harness: PluginHarness, options: CliOptions = {}): VitePlugin[] { 38 15 return [ 39 - <VitePlugin>{ 40 - name: 'vitest', 41 - enforce: 'pre', 16 + { 17 + name: 'vitest:config:append', 18 + enforce: 'post', 42 19 options() { 43 20 this.meta.watchMode = false 44 21 }, 45 - async config(viteConfig) { 46 - if (options.watch) { 47 - // Earlier runs have overwritten values of the `options`. 48 - // Reset it back to initial user config before setting up the server again. 49 - options = deepMerge({}, userConfig) as UserConfig 50 - } 22 + config: { 23 + order: 'post', 24 + handler(viteConfig) { 25 + const root = resolve(options.root || viteConfig.test?.root || viteConfig.root || process.cwd()) 51 26 52 - // preliminary merge of options to be able to create server options for vite 53 - // however to allow vitest plugins to modify vitest config values 54 - // this is repeated in configResolved where the config is final 55 - const testConfig = deepMerge( 56 - {} as UserConfig, 57 - configDefaults, 58 - viteConfig.test ?? {}, 59 - options, 60 - ) 61 - testConfig.api = resolveApiServerConfig(testConfig, defaultPort) 62 - 63 - // store defines for globalThis to make them 64 - // reassignable when running in worker in src/runtime/setup.ts 65 - const originalDefine = { ...viteConfig.define } // stash original defines for browser mode 66 - const defines: Record<string, any> = deleteDefineConfig(viteConfig) 67 - 68 - ;(options as unknown as ResolvedConfig).defines = defines 69 - ;(options as unknown as ResolvedConfig).viteDefine = originalDefine 70 - 71 - const resolveOptions = getDefaultResolveOptions() 72 - 73 - let config: ViteConfig = { 74 - base: '/', 75 - root: viteConfig.test?.root || options.root, 76 - define: { 77 - // disable replacing `process.env.NODE_ENV` with static string by vite:client-inject 78 - 'process.env.NODE_ENV': 'process.env.NODE_ENV', 79 - }, 80 - resolve: { 81 - ...resolveOptions, 82 - alias: testConfig.alias, 83 - }, 84 - server: { 85 - ...testConfig.api, 86 - // auto open UI via `vite.openBrowser` manually later 87 - open: false, 88 - hmr: false, 89 - ws: testConfig.api?.middlewareMode ? false : undefined, 90 - preTransformRequests: false, 91 - fs: { 92 - allow: resolveFsAllow(options.root || process.cwd(), testConfig.config), 93 - deny: [API_TOKEN_FILE], 27 + return { 28 + base: '/', 29 + root, 30 + build: { 31 + // Vitest doesn't use outputDir, but this value affects what folders are watched 32 + // https://github.com/vitejs/vite/pull/16453 33 + emptyOutDir: false, 94 34 }, 95 - }, 96 - build: { 97 - // Vitest doesn't use outputDir, but this value affects what folders are watched 98 - // https://github.com/vitest-dev/vitest/issues/5429 99 - // This works for Vite <5.2.10 100 - outDir: 'dummy-non-existing-folder', 101 - // This works for Vite >=5.2.10 102 - // https://github.com/vitejs/vite/pull/16453 103 - emptyOutDir: false, 104 - }, 105 - // eslint-disable-next-line ts/ban-ts-comment 106 - // @ts-ignore Vite 6 compat 107 - environments: { 108 - ssr: { 109 - resolve: resolveOptions, 110 - }, 111 - __vitest__: { 112 - dev: {}, 113 - }, 114 - }, 115 - test: { 116 - root: testConfig.root ?? viteConfig.test?.root, 117 - deps: testConfig.deps ?? viteConfig.test?.deps, 118 - }, 119 - } 120 - 121 - if ('rolldownVersion' in vite) { 122 - config = { 123 - ...config, 124 - // eslint-disable-next-line ts/ban-ts-comment 125 - // @ts-ignore rolldown-vite only 126 - oxc: viteConfig.oxc === false 127 - ? false 128 - : { 129 - // eslint-disable-next-line ts/ban-ts-comment 130 - // @ts-ignore rolldown-vite only 131 - target: viteConfig.oxc?.target || 'node18', 132 - }, 133 35 } 134 - } 135 - else { 136 - config = { 137 - ...config, 138 - esbuild: viteConfig.esbuild === false 139 - ? false 140 - : { 141 - // Lowest target Vitest supports is Node18 142 - target: viteConfig.esbuild?.target || 'node18', 143 - sourcemap: 'external', 144 - // Enables using ignore hint for coverage providers with @preserve keyword 145 - legalComments: 'inline', 146 - }, 36 + }, 37 + }, 38 + configResolved: { 39 + order: 'post', 40 + handler(viteConfig) { 41 + // During resolution so Vite uses the real fs when watch is off (a cached 42 + // snapshot misses runtime-generated files); the default is applied later 43 + // by `resolveTestConfig`, hence the `??`. 44 + const server = viteConfig.server 45 + const watch = viteConfig.test?.watch ?? configDefaults.watch 46 + if (!watch) { 47 + server.watch = null 147 48 } 148 - } 149 - 150 - if (vitest._cliOptions.benchmarkOnly) { 151 - config.test!.benchmark ??= {} 152 - config.test!.benchmark.enabled = true 153 - } 154 - 155 - // inherit so it's available in VitestOptimizer 156 - // I cannot wait to rewrite all of this in Vitest 4 157 - if (options.cache != null) { 158 - config.test!.cache = options.cache 159 - } 160 - 161 - if (vitest.configOverride.project) { 162 - // project filter was set by the user, so we need to filter the project 163 - options.project = vitest.configOverride.project 164 - } 165 - 166 - config.customLogger = createViteLogger( 167 - vitest.logger, 168 - viteConfig.logLevel || 'warn', 169 - { 170 - allowClearScreen: false, 171 - }, 172 - ) 173 - config.customLogger = silenceImportViteIgnoreWarning(config.customLogger) 174 - 175 - // chokidar fsevents is unstable on macos when emitting "ready" event 176 - if ( 177 - process.platform === 'darwin' 178 - && process.env.VITE_TEST_WATCHER_DEBUG 179 - ) { 180 - const watch = config.server!.watch 181 - if (watch) { 182 - // eslint-disable-next-line ts/ban-ts-comment 183 - // @ts-ignore Vite 6 compat 184 - watch.useFsEvents = false 185 - watch.usePolling = false 186 - } 187 - } 188 - 189 - const classNameStrategy 190 - = (typeof testConfig.css !== 'boolean' 191 - && testConfig.css?.modules?.classNameStrategy) 192 - || 'stable' 193 - 194 - if (classNameStrategy !== 'scoped') { 195 - config.css ??= {} 196 - config.css.modules ??= {} 197 - if (config.css.modules) { 198 - config.css.modules.generateScopedName = ( 199 - name: string, 200 - filename: string, 201 - ) => { 202 - const root = vitest.config.root || options.root || process.cwd() 203 - return generateScopedClassName( 204 - classNameStrategy, 205 - name, 206 - relative(root, filename), 207 - )! 49 + else { 50 + server.watch ??= {} 51 + // chokidar fsevents is unstable on macos when emitting the "ready" event 52 + if (process.platform === 'darwin' && process.env.VITE_TEST_WATCHER_DEBUG) { 53 + server.watch.useFsEvents = false 54 + server.watch.usePolling = false 208 55 } 209 56 } 210 - } 211 - 212 - return config 213 - }, 214 - async configResolved(viteConfig) { 215 - const viteConfigTest = (viteConfig.test as UserConfig) || {} 216 - if (viteConfigTest.watch === false) { 217 - ;(viteConfigTest as any).run = true 218 - } 219 - 220 - if ('alias' in viteConfigTest) { 221 - delete viteConfigTest.alias 222 - } 223 - 224 - // viteConfig.test is final now, merge it for real 225 - options = deepMerge({}, configDefaults, viteConfigTest, options) 226 - options.api = resolveApiServerConfig(options, defaultPort) 227 - 228 - // we replace every "import.meta.env" with "process.env" 229 - // to allow reassigning, so we need to put all envs on process.env 230 - const { PROD, DEV, ...envs } = viteConfig.env 231 - 232 - // process.env can have only string values and will cast string on it if we pass other type, 233 - // so we are making them truthy 234 - process.env.PROD ??= PROD ? '1' : '' 235 - process.env.DEV ??= DEV ? '1' : '' 236 - 237 - for (const name in envs) { 238 - process.env[name] ??= envs[name] 239 - } 240 - 241 - // don't watch files in run mode 242 - if (!options.watch) { 243 - viteConfig.server.watch = null 244 - } 245 - 246 - if (options.ui) { 247 - // @ts-expect-error mutate readonly 248 - viteConfig.plugins.push(await UIPlugin()) 249 - } 250 - 251 - Object.defineProperty(viteConfig, '_vitest', { 252 - value: options, 253 - enumerable: false, 254 - configurable: true, 255 - }) 256 - 257 - const originalName = options.name 258 - if (options.browser?.instances) { 259 - options.browser.instances.forEach((instance) => { 260 - instance.name ??= originalName ? `${originalName} (${instance.browser})` : instance.browser 261 - }) 262 - } 263 - }, 264 - configureServer: { 265 - async handler(server) { 266 - if (options.watch && process.env.VITE_TEST_WATCHER_DEBUG) { 267 - server.watcher.on('ready', () => { 268 - // eslint-disable-next-line no-console 269 - console.log('[debug] watcher is ready') 270 - }) 271 - } 272 - await vitest._setServer(options, server) 273 - if (options.api && options.watch) { 274 - (await import('../../api/setup')).setup(vitest) 275 - } 276 - 277 - // #415, in run mode we don't need the watcher, close it would improve the performance 278 - if (!options.watch) { 279 - await server.watcher.close() 280 - } 281 57 }, 282 58 }, 283 59 }, 284 - MetaEnvReplacerPlugin(), 285 - ...CSSEnablerPlugin(vitest), 286 - CoverageTransform(vitest), 287 - VitestCoreResolver(vitest), 60 + ...CSSEnablerPlugin(), 288 61 ...MocksPlugins(), 289 - VitestOptimizer(), 62 + CoverageTransform(harness), 63 + VitestCoreResolver(), 290 64 NormalizeURLPlugin(), 291 - ModuleRunnerTransform(), 292 - ].filter(notNullish) 65 + MetaEnvReplacerPlugin(), 66 + SsrRunnerFixerPlugin(harness), 67 + { 68 + name: 'vitest:ui-injector', 69 + enforce: 'post', 70 + async configResolved(config) { 71 + if (config.test.ui) { 72 + await harness.packageInstaller.ensureInstalled('@vitest/ui', resolve(config.root), harness.version) 73 + const uiPlugin = (await import('@vitest/ui')).default(harness) 74 + // @ts-expect-error mutate readonly 75 + config.plugins.push(uiPlugin) 76 + } 77 + }, 78 + }, 79 + ] 293 80 }
+1 -1
packages/vitest/src/node/plugins/optimizer.ts
··· 15 15 const label = typeof name === 'string' ? name : (name?.label || '') 16 16 17 17 viteConfig.cacheDir = VitestCache.resolveCacheDir( 18 - resolve(root || process.cwd()), 18 + root, 19 19 testConfig.cache != null && testConfig.cache !== false 20 20 ? testConfig.cache.dir 21 21 : viteConfig.cacheDir,
-54
packages/vitest/src/node/plugins/publicConfig.ts
··· 1 - import type { 2 - ResolvedConfig as ResolvedViteConfig, 3 - UserConfig as ViteUserConfig, 4 - } from 'vite' 5 - import type { ResolvedConfig, UserConfig } from '../types/config' 6 - import { deepClone, slash } from '@vitest/utils/helpers' 7 - import { resolve } from 'pathe' 8 - import { mergeConfig, resolveConfig as resolveViteConfig } from 'vite' 9 - import { findConfigFile, resolveConfig as resolveVitestConfig } from '../config/resolveConfig' 10 - import { Vitest } from '../core' 11 - import { VitestPlugin } from './index' 12 - 13 - // this is only exported as a public function and not used inside vitest 14 - export async function resolveConfig( 15 - options: UserConfig = {}, 16 - viteOverrides: ViteUserConfig = {}, 17 - ): Promise<{ vitestConfig: ResolvedConfig; viteConfig: ResolvedViteConfig }> { 18 - const root = slash(resolve(options.root || process.cwd())) 19 - 20 - const configPath 21 - = options.config === false 22 - ? false 23 - : options.config 24 - ? resolve(root, options.config) 25 - : findConfigFile(root) 26 - options.config = configPath 27 - 28 - const vitest = new Vitest(deepClone(options)) 29 - const config = await resolveViteConfig( 30 - mergeConfig( 31 - { 32 - configFile: configPath, 33 - mode: options.mode || 'test', 34 - plugins: [ 35 - await VitestPlugin(options, vitest), 36 - ], 37 - }, 38 - mergeConfig(viteOverrides, { root: options.root }), 39 - ), 40 - 'serve', 41 - ) 42 - // Reflect just to avoid type error 43 - const updatedOptions = Reflect.get(config, '_vitest') as UserConfig 44 - const vitestConfig = resolveVitestConfig( 45 - vitest, 46 - updatedOptions, 47 - config, 48 - ) 49 - await vitest.close() 50 - return { 51 - viteConfig: config, 52 - vitestConfig, 53 - } 54 - }
+16
packages/vitest/src/node/plugins/runnerTransform.ts
··· 18 18 handler(config) { 19 19 testConfig = config.test || {} 20 20 21 + // In browser mode the `client` environment serves test code to a real 22 + // browser via native ESM, so it must NOT be module-runner-transformed. 23 + // `ssr`/`__vitest__` keep the transform (node-side global setup + watch 24 + // run there). Note: jsdom/happy-dom (node mode) also uses `client` and 25 + // DOES need the transform, hence the `browser.enabled` gate. 26 + const browserEnabled = !!config.test?.browser?.enabled 27 + 21 28 config.environments ??= {} 22 29 23 30 const names = new Set(Object.keys(config.environments)) ··· 67 74 environment.dev.moduleRunnerTransform = false 68 75 environment.consumer = 'client' 69 76 } 77 + else if (name === 'client' && browserEnabled) { 78 + environment.dev.moduleRunnerTransform = false 79 + } 70 80 else { 71 81 environment.dev.moduleRunnerTransform = true 72 82 } ··· 79 89 order: 'post', 80 90 handler(name, config) { 81 91 if (name === '__vitest_vm__' || name === '__vitest__') { 92 + return 93 + } 94 + // In browser mode the `client` environment is browser-managed: don't 95 + // apply node-runner externalization / `optimizeDeps` to it (that would 96 + // discard the browser `optimizeDeps.include`, e.g. `vitest > expect-type`). 97 + if (name === 'client' && testConfig.browser?.enabled) { 82 98 return 83 99 } 84 100
+50
packages/vitest/src/node/plugins/server.ts
··· 1 + import type { Plugin, ServerOptions } from 'vite' 2 + import type { PluginHarness } from '../config/pluginHarness' 3 + import type { ResolvedApiConfig, ResolvedConfig } from '../types/config' 4 + import { defaultPort } from '../../constants' 5 + import { resolveApiServerConfig } from '../config/resolveConfig' 6 + 7 + export function VitestConfigServer(harness: PluginHarness, globalConfig?: ResolvedConfig): Plugin { 8 + return { 9 + name: 'vitest:config:server', 10 + enforce: 'post', 11 + config: { 12 + order: 'post', 13 + handler(viteConfig) { 14 + // Custom user config, this plugin already received CLI overrides 15 + const testConfig = viteConfig.test ?? {} 16 + const isBrowserEnabled = !!testConfig.browser?.enabled 17 + 18 + const api = resolveApiServerConfig( 19 + testConfig, 20 + isBrowserEnabled ? harness._browserLastPort++ : defaultPort, 21 + harness.logger, 22 + ) as ResolvedApiConfig 23 + testConfig.api = api 24 + if (globalConfig) { 25 + api.token = globalConfig.api.token 26 + api.tokenCreated = globalConfig.api.tokenCreated 27 + } 28 + 29 + const server: ServerOptions = { 30 + ...api, 31 + preTransformRequests: false, 32 + hmr: false, 33 + open: false, 34 + } 35 + 36 + // Always disable the websocket server in middlewareMode 37 + if (!isBrowserEnabled && api.middlewareMode) { 38 + server.ws = false 39 + } 40 + else if (viteConfig.server && 'ws' in viteConfig.server) { 41 + viteConfig.server.ws = undefined 42 + } 43 + 44 + return { 45 + server, 46 + } 47 + }, 48 + }, 49 + } 50 + }
+19
packages/vitest/src/node/plugins/ssrRunnerFixer.ts
··· 1 + import type { Plugin } from 'vite' 2 + import type { PluginHarness } from '../config/pluginHarness' 3 + import { installSsrModuleRunner } from '../environments/serverRunner' 4 + 5 + // Runs `pre` so it sits before user plugins, whose `configureServer` hooks may 6 + // rely on `server.environments.ssr.runner` being Vitest's module runner. 7 + export function SsrRunnerFixerPlugin(harness: PluginHarness): Plugin { 8 + return { 9 + name: 'vitest:ssr-module-runner-fixer', 10 + enforce: 'pre', 11 + configureServer: { 12 + order: 'pre', 13 + handler(server) { 14 + const vitest = harness.getVitest() 15 + installSsrModuleRunner(server, vitest._fetcher, vitest.config) 16 + }, 17 + }, 18 + } 19 + }
+2 -3
packages/vitest/src/node/plugins/utils.ts
··· 9 9 import { rootDir } from '../../paths' 10 10 11 11 export function resolveOptimizerConfig( 12 - _testOptions: DepsOptimizationOptions | undefined, 12 + testOptions_: DepsOptimizationOptions | undefined, 13 13 viteOptions: DepOptimizationOptions | undefined, 14 14 ): DepOptimizationOptions { 15 - const testOptions = _testOptions || {} 15 + const testOptions = testOptions_ || {} 16 16 let optimizeDeps: DepOptimizationOptions 17 17 if (testOptions.enabled !== true) { 18 18 testOptions.enabled ??= false ··· 66 66 export function deleteDefineConfig(viteConfig: ViteConfig): Record<string, any> { 67 67 const defines: Record<string, any> = {} 68 68 if (viteConfig.define) { 69 - delete viteConfig.define['import.meta.vitest'] 70 69 delete viteConfig.define['process.env'] 71 70 delete viteConfig.define.process 72 71 delete viteConfig.define.global
+9 -5
packages/vitest/src/node/plugins/vitestResolver.ts
··· 1 1 import type { Plugin } from 'vite' 2 - import type { Vitest } from '../core' 2 + import type { PluginHarness } from '../config/pluginHarness' 3 3 import { join, resolve } from 'pathe' 4 4 import { distDir } from '../../paths' 5 5 6 - export function VitestProjectResolver(ctx: Vitest): Plugin { 6 + export function VitestProjectResolver(harness: PluginHarness): Plugin { 7 7 const plugin: Plugin = { 8 8 name: 'vitest:resolve-root', 9 9 enforce: 'pre', ··· 19 19 if (id === 'vitest' || id.startsWith('@vitest/') || id.startsWith('vitest/')) { 20 20 // always redirect the request to the root vitest plugin since 21 21 // it will be the one used to run Vitest 22 - const resolved = await ctx.vite.pluginContainer.resolveId(id, undefined, { 22 + const resolved = await harness.getVitest().vite.pluginContainer.resolveId(id, undefined, { 23 23 skip: new Set([plugin]), 24 24 ssr, 25 25 }) ··· 30 30 return plugin 31 31 } 32 32 33 - export function VitestCoreResolver(ctx: Vitest): Plugin { 33 + export function VitestCoreResolver(): Plugin { 34 + let root: string 34 35 return { 35 36 name: 'vitest:resolve-core', 36 37 enforce: 'pre', ··· 42 43 } 43 44 }, 44 45 }, 46 + configResolved(config) { 47 + root = config.root 48 + }, 45 49 async resolveId(id) { 46 50 if (id === 'vitest') { 47 51 return resolve(distDir, 'index.js') 48 52 } 49 53 if (id.startsWith('@vitest/') || id.startsWith('vitest/')) { 50 54 // ignore actual importer, we want it to be resolved relative to the root 51 - return this.resolve(id, join(ctx.config.root, 'index.html'), { 55 + return this.resolve(id, join(root, 'index.html'), { 52 56 skipSelf: true, 53 57 }) 54 58 }
+44 -232
packages/vitest/src/node/plugins/workspace.ts
··· 1 + import type * as vite from 'vite' 1 2 import type { UserConfig as ViteConfig, Plugin as VitePlugin } from 'vite' 2 - import type { TestProject } from '../project' 3 - import type { BrowserConfigOptions, ResolvedConfig, TestProjectInlineConfiguration, UserConfig } from '../types/config' 4 - import { existsSync, readFileSync } from 'node:fs' 5 - import { deepMerge } from '@vitest/utils/helpers' 6 - import { basename, dirname, relative, resolve } from 'pathe' 7 - import * as vite from 'vite' 8 - import { configDefaults } from '../../defaults' 9 - import { generateScopedClassName } from '../../integrations/css/css-modules' 3 + import type { PluginHarness } from '../config/pluginHarness' 4 + import type { ResolvedConfig, TestProjectInlineConfiguration } from '../types/config' 10 5 import { API_TOKEN_FILE } from '../config/apiToken' 11 - import { VitestFilteredOutProjectError } from '../errors' 12 - import { createViteLogger, silenceImportViteIgnoreWarning } from '../viteLogger' 6 + import { VitestConfig } from './config' 13 7 import { CoverageTransform } from './coverageTransform' 14 8 import { CSSEnablerPlugin } from './cssEnabler' 15 9 import { MetaEnvReplacerPlugin } from './metaEnvReplacer' 16 10 import { MocksPlugins } from './mocks' 17 11 import { NormalizeURLPlugin } from './normalizeURL' 18 - import { VitestOptimizer } from './optimizer' 19 - import { ModuleRunnerTransform } from './runnerTransform' 20 - import { 21 - deleteDefineConfig, 22 - getDefaultResolveOptions, 23 - resolveFsAllow, 24 - } from './utils' 12 + import { VitestConfigServer } from './server' 13 + import { SsrRunnerFixerPlugin } from './ssrRunnerFixer' 25 14 import { VitestProjectResolver } from './vitestResolver' 26 15 27 16 interface WorkspaceOptions extends TestProjectInlineConfiguration { 28 17 root?: string 29 - workspacePath: string | number 30 18 } 31 19 32 20 export function WorkspaceVitestPlugin( 33 - project: TestProject, 21 + harness: PluginHarness, 22 + globalViteConfig: vite.ResolvedConfig, 23 + globalConfig: ResolvedConfig, 34 24 options: WorkspaceOptions, 35 - ) { 36 - return <VitePlugin[]>[ 37 - { 38 - name: 'vitest:project:name', 39 - enforce: 'post', 40 - config(viteConfig) { 41 - viteConfig.test ??= {} 42 - 43 - const testConfig = viteConfig.test 44 - 45 - let { label: name, color } = typeof testConfig.name === 'string' 46 - ? { label: testConfig.name } 47 - : { label: '', ...testConfig.name } 48 - 49 - if (!name) { 50 - if (typeof options.workspacePath === 'string') { 51 - // if there is a package.json, read the name from it 52 - const dir = options.workspacePath.endsWith('/') 53 - ? options.workspacePath.slice(0, -1) 54 - : dirname(options.workspacePath) 55 - const pkgJsonPath = resolve(dir, 'package.json') 56 - if (existsSync(pkgJsonPath)) { 57 - name = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')).name 58 - } 59 - if (typeof name !== 'string' || !name) { 60 - name = basename(dir) 61 - } 62 - } 63 - else { 64 - name = options.workspacePath.toString() 65 - } 66 - } 67 - 68 - if (project.vitest._cliOptions.benchmarkOnly) { 69 - viteConfig.test.benchmark ??= {} 70 - viteConfig.test.benchmark.enabled = true 71 - } 72 - 73 - const isUserBrowserEnabled = viteConfig.test?.browser?.enabled 74 - const isBrowserEnabled = isUserBrowserEnabled ?? (viteConfig.test?.browser && project.vitest._cliOptions.browser?.enabled) 75 - // keep project names to potentially filter it out 76 - const workspaceNames = [name] 77 - const browser = (viteConfig.test!.browser || {}) as BrowserConfigOptions 78 - if (isBrowserEnabled && browser.name && !browser.instances?.length) { 79 - // vitest injects `instances` in this case later on 80 - workspaceNames.push(name ? `${name} (${browser.name})` : browser.name) 81 - } 82 - 83 - viteConfig.test?.browser?.instances?.forEach((instance) => { 84 - // every instance is a potential project 85 - instance.name ??= name ? `${name} (${instance.browser})` : instance.browser 86 - if (isBrowserEnabled) { 87 - workspaceNames.push(instance.name) 88 - } 89 - }) 90 - if (viteConfig.test?.benchmark?.enabled) { 91 - workspaceNames.push(name ? `${name} (bench)` : 'bench') 92 - } 93 - 94 - const filters = project.vitest.config.project 95 - // if there is `--project=...` filter, check if any of the potential projects match 96 - // if projects don't match, we ignore the test project altogether 97 - // if some of them match, they will later be filtered again by `resolveWorkspace` 98 - if (filters.length) { 99 - const hasProject = workspaceNames.some((name) => { 100 - return project.vitest.matchesProjectFilter(name) 101 - }) 102 - if (!hasProject) { 103 - throw new VitestFilteredOutProjectError() 104 - } 105 - } 106 - 107 - const vitestConfig: UserConfig = { 108 - name: { label: name, color }, 109 - } 110 - 111 - vitestConfig.experimental ??= {} 112 - 113 - // always inherit the global `fsModuleCache` value even without `extends: true` 114 - if (testConfig.experimental?.fsModuleCache == null && project.vitest.config.experimental?.fsModuleCache != null) { 115 - vitestConfig.experimental.fsModuleCache = project.vitest.config.experimental.fsModuleCache 116 - } 117 - if (testConfig.experimental?.fsModuleCachePath == null && project.vitest.config.experimental?.fsModuleCachePath != null) { 118 - vitestConfig.experimental.fsModuleCachePath = project.vitest.config.experimental.fsModuleCachePath 119 - } 120 - if (testConfig.experimental?.viteModuleRunner == null && project.vitest.config.experimental?.viteModuleRunner != null) { 121 - vitestConfig.experimental.viteModuleRunner = project.vitest.config.experimental.viteModuleRunner 122 - } 123 - if (testConfig.experimental?.nodeLoader == null && project.vitest.config.experimental?.nodeLoader != null) { 124 - vitestConfig.experimental.nodeLoader = project.vitest.config.experimental.nodeLoader 125 - } 126 - if (testConfig.experimental?.importDurations == null && project.vitest.config.experimental?.importDurations != null) { 127 - vitestConfig.experimental.importDurations = project.vitest.config.experimental.importDurations 128 - } 129 - 130 - return { 131 - base: '/', 132 - environments: { 133 - __vitest__: { 134 - dev: {}, 135 - }, 136 - }, 137 - test: vitestConfig, 138 - } 139 - }, 140 - }, 25 + ): VitePlugin[] { 26 + return [ 141 27 { 142 28 name: 'vitest:project', 143 - enforce: 'pre', 29 + enforce: 'post', 144 30 options() { 145 31 this.meta.watchMode = false 146 32 }, 147 33 config(viteConfig) { 148 - const originalDefine = { ...viteConfig.define } // stash original defines for browser mode 149 - const defines: Record<string, any> = deleteDefineConfig(viteConfig) 150 - 151 34 const testConfig = viteConfig.test || {} 152 - const root = testConfig.root || viteConfig.root || options.root 35 + const root = options.root || testConfig.root || viteConfig.root 153 36 154 - const resolveOptions = getDefaultResolveOptions() 155 - let config: ViteConfig = { 37 + const config: ViteConfig = { 38 + base: '/', 156 39 root, 157 - define: { 158 - // disable replacing `process.env.NODE_ENV` with static string by vite:client-inject 159 - 'process.env.NODE_ENV': 'process.env.NODE_ENV', 160 - }, 161 - resolve: { 162 - ...resolveOptions, 163 - alias: testConfig.alias, 164 - }, 165 40 server: { 166 - // disable watch mode in workspaces, 167 - // because it is handled by the top-level watcher 168 - watch: null, 169 41 open: false, 170 - hmr: false, 171 - ws: false, 172 - preTransformRequests: false, 173 - middlewareMode: true, 174 42 fs: { 175 - allow: resolveFsAllow( 176 - project.vitest.config.root, 177 - project.vitest.vite.config.configFile, 178 - ), 43 + allow: globalViteConfig.server.fs.allow, 179 44 deny: [API_TOKEN_FILE], 180 45 }, 181 46 }, 182 - // eslint-disable-next-line ts/ban-ts-comment 183 - // @ts-ignore Vite 6 compat 184 - environments: { 185 - ssr: { 186 - resolve: resolveOptions, 187 - }, 188 - }, 189 - test: {}, 190 47 } 191 48 192 - if ('rolldownVersion' in vite) { 193 - config = { 194 - ...config, 195 - // eslint-disable-next-line ts/ban-ts-comment 196 - // @ts-ignore rolldown-vite only 197 - oxc: viteConfig.oxc === false 198 - ? false 199 - : { 200 - // eslint-disable-next-line ts/ban-ts-comment 201 - // @ts-ignore rolldown-vite only 202 - target: viteConfig.oxc?.target || 'node18', 203 - }, 204 - } 49 + // TODO: remove this after "extends: false" is flipped 50 + testConfig.experimental ??= {} 51 + 52 + // always inherit the global `fsModuleCache` value even without `extends: true` 53 + if (testConfig.experimental?.fsModuleCache == null && globalConfig.experimental?.fsModuleCache != null) { 54 + testConfig.experimental.fsModuleCache = globalConfig.experimental.fsModuleCache 205 55 } 206 - else { 207 - config = { 208 - ...config, 209 - esbuild: viteConfig.esbuild === false 210 - ? false 211 - : { 212 - // Lowest target Vitest supports is Node18 213 - target: viteConfig.esbuild?.target || 'node18', 214 - sourcemap: 'external', 215 - // Enables using ignore hint for coverage providers with @preserve keyword 216 - legalComments: 'inline', 217 - }, 218 - } 56 + if (testConfig.experimental?.fsModuleCachePath == null && globalConfig.experimental?.fsModuleCachePath != null) { 57 + testConfig.experimental.fsModuleCachePath = globalConfig.experimental.fsModuleCachePath 219 58 } 220 - 221 - ;(config.test as ResolvedConfig).defines = defines 222 - ;(config.test as ResolvedConfig).viteDefine = originalDefine 223 - 224 - const classNameStrategy 225 - = (typeof testConfig.css !== 'boolean' 226 - && testConfig.css?.modules?.classNameStrategy) 227 - || 'stable' 228 - 229 - if (classNameStrategy !== 'scoped') { 230 - config.css ??= {} 231 - config.css.modules ??= {} 232 - if (config.css.modules) { 233 - config.css.modules.generateScopedName = ( 234 - name: string, 235 - filename: string, 236 - ) => { 237 - const root = project.config.root 238 - return generateScopedClassName( 239 - classNameStrategy, 240 - name, 241 - relative(root, filename), 242 - )! 243 - } 244 - } 59 + if (testConfig.experimental?.viteModuleRunner == null && globalConfig.experimental?.viteModuleRunner != null) { 60 + testConfig.experimental.viteModuleRunner = globalConfig.experimental.viteModuleRunner 61 + } 62 + if (testConfig.experimental?.nodeLoader == null && globalConfig.experimental?.nodeLoader != null) { 63 + testConfig.experimental.nodeLoader = globalConfig.experimental.nodeLoader 64 + } 65 + if (testConfig.experimental?.importDurations == null && globalConfig.experimental?.importDurations != null) { 66 + testConfig.experimental.importDurations = globalConfig.experimental.importDurations 245 67 } 246 - config.customLogger = createViteLogger( 247 - project.vitest.logger, 248 - viteConfig.logLevel || 'warn', 249 - { 250 - allowClearScreen: false, 251 - }, 252 - ) 253 - config.customLogger = silenceImportViteIgnoreWarning(config.customLogger) 254 68 255 69 return config 256 70 }, 257 - }, 258 - { 259 - name: 'vitest:project:server', 260 - enforce: 'pre', 261 - async configureServer(server) { 262 - const options = deepMerge({}, configDefaults, server.config.test || {}) 263 - await project._configureServer(options, server) 264 - 265 - await server.watcher.close() 71 + configResolved(config) { 72 + // Projects always inherit non-project config options 73 + config.test.coverage = globalConfig.coverage 74 + config.test.attachmentsDir = globalConfig.attachmentsDir 75 + // project servers never watch; the top-level server owns the watcher 76 + config.server.watch = null 266 77 }, 267 78 }, 79 + VitestConfigServer(harness, globalConfig), 80 + SsrRunnerFixerPlugin(harness), 268 81 MetaEnvReplacerPlugin(), 269 - ...CSSEnablerPlugin(project), 270 - CoverageTransform(project.vitest), 82 + ...CSSEnablerPlugin(), 83 + CoverageTransform(harness), 84 + ...VitestConfig(harness), 271 85 ...MocksPlugins(), 272 - VitestProjectResolver(project.vitest), 273 - VitestOptimizer(), 86 + VitestProjectResolver(harness), 274 87 NormalizeURLPlugin(), 275 - ModuleRunnerTransform(), 276 88 ] 277 89 }
+1 -2
packages/vitest/src/node/pools/browser.ts
··· 148 148 const config = project.config.browser 149 149 if ( 150 150 !config.headless 151 - || !config.fileParallelism 152 151 || !project.browser!.provider.supportsParallelism 153 152 ) { 154 153 return 1 ··· 350 349 351 350 if (!file) { 352 351 debug?.('[%s] no more tests to run', sessionId) 353 - const isolate = this.project.config.browser.isolate 352 + const isolate = this.project.config.isolate 354 353 // we don't need to cleanup testers if isolation is enabled, 355 354 // because cleanup is done at the end of every test 356 355 if (isolate) {
+1 -4
packages/vitest/src/node/printError.ts
··· 149 149 : stacks.find((stack) => { 150 150 // we are checking that this module was processed by us at one point 151 151 try { 152 - const environments = [ 153 - ...Object.values(project._vite?.environments || {}), 154 - ...Object.values(project.browser?.vite.environments || {}), 155 - ] 152 + const environments = Object.values(project.vite.environments || {}) 156 153 const hasResult = environments.some((environment) => { 157 154 const modules = environment.moduleGraph.getModulesByFile(stack.file) 158 155 return [...modules?.values() || []].some(module => !!module.transformResult)
+80 -263
packages/vitest/src/node/project.ts
··· 1 - import type { GlobOptions } from 'tinyglobby' 2 - import type { DevEnvironment, ViteDevServer, InlineConfig as ViteInlineConfig } from 'vite' 1 + import type { DevEnvironment, ResolvedConfig as ResolvedViteConfig, ViteDevServer } from 'vite' 3 2 import type { ModuleRunner } from 'vite/module-runner' 4 3 import type { Typechecker } from '../typecheck/typechecker' 5 4 import type { ProvidedContext } from '../types/general' ··· 12 11 ProjectName, 13 12 ResolvedConfig, 14 13 SerializedConfig, 15 - TestProjectInlineConfiguration, 16 - UserConfig, 17 14 } from './types/config' 18 15 import crypto from 'node:crypto' 19 - import { promises as fs, readFileSync } from 'node:fs' 16 + import { readFileSync } from 'node:fs' 20 17 import { rm } from 'node:fs/promises' 21 18 import { tmpdir } from 'node:os' 22 - import path from 'node:path' 23 19 import { deepMerge, nanoid, slash } from '@vitest/utils/helpers' 24 20 import { isAbsolute, join, relative } from 'pathe' 25 21 import pm from 'picomatch' 26 - import { glob } from 'tinyglobby' 27 - import { isRunnableDevEnvironment } from 'vite' 28 - import { setup } from '../api/setup' 29 22 import { createDefinesScript } from '../utils/config-helpers' 30 23 import { NativeModuleRunner } from '../utils/nativeModuleRunner' 31 24 import { BenchmarkManager } from './benchmark' 32 - import { isBrowserEnabled, resolveConfig } from './config/resolveConfig' 33 25 import { serializeConfig } from './config/serializeConfig' 34 26 import { createFetchModuleFunction } from './environments/fetchModule' 35 27 import { ServerModuleRunner } from './environments/serverRunner' 36 28 import { loadGlobalSetupFiles } from './globalSetup' 37 - import { CoverageTransform } from './plugins/coverageTransform' 38 - import { MetaEnvReplacerPlugin } from './plugins/metaEnvReplacer' 39 - import { MocksPlugins } from './plugins/mocks' 40 - import { WorkspaceVitestPlugin } from './plugins/workspace' 41 29 import { getFilePoolName } from './pool' 30 + import { globProjectFiles, globProjectTestFiles, isInSourceTestCode } from './projects/globProjectFiles' 42 31 import { VitestResolver } from './resolver' 43 32 import { TestSpecification } from './test-specification' 44 - import { createViteServer } from './vite' 45 33 46 34 export class TestProject { 47 35 /** ··· 66 54 67 55 public readonly benchmark: BenchmarkManager = new BenchmarkManager(this) 68 56 57 + public config: ResolvedConfig 58 + public viteConfig: ResolvedViteConfig 59 + public vite: ViteDevServer 60 + public hash: string 61 + 69 62 /** @internal */ typechecker?: Typechecker 70 - /** @internal */ _config?: ResolvedConfig 71 - /** @internal */ _vite?: ViteDevServer 72 - /** @internal */ _hash?: string 73 63 /** @internal */ _resolver!: VitestResolver 74 64 /** @internal */ _fetcher!: VitestFetchFunction 75 65 /** @internal */ _serializedDefines?: string ··· 87 77 88 78 constructor( 89 79 vitest: Vitest, 90 - public options?: InitializeProjectOptions | undefined, 91 - tmpDir?: string, 80 + server: ViteDevServer, 81 + viteConfig: ResolvedViteConfig, 82 + projectConfig: ResolvedConfig, 92 83 ) { 93 84 this.vitest = vitest 94 85 this.globalConfig = vitest.config 95 - this.tmpDir = tmpDir || join(tmpdir(), nanoid()) 86 + this.tmpDir = join(tmpdir(), nanoid()) 87 + this.vite = server 88 + this.viteConfig = viteConfig 89 + this.config = projectConfig 90 + this.hash = generateHash( 91 + this.config.root + this.config.name, 92 + ) 93 + this._provideObject(projectConfig.provide) 96 94 } 97 95 98 - /** 99 - * The unique hash of this project. This value is consistent between the reruns. 100 - * 101 - * It is based on the root of the project (not consistent between OS) and its name. 102 - */ 103 - public get hash(): string { 104 - if (!this._hash) { 105 - throw new Error('The server was not set. It means that `project.hash` was called before the Vite server was established.') 106 - } 107 - return this._hash 96 + /** @internal */ 97 + _initializeRunners(server: ViteDevServer) { 98 + this._serializedDefines = createDefinesScript(server.config.define) 99 + this._resolver = new VitestResolver(server.config.cacheDir, this.config) 100 + this._fetcher = createFetchModuleFunction( 101 + this._resolver, 102 + this.config, 103 + this.vitest._fsCache, 104 + this.vitest._traces, 105 + this.tmpDir, 106 + ) 107 + 108 + const environment = server.environments.__vitest__ 109 + this.runner = this.config.experimental.viteModuleRunner === false 110 + ? new NativeModuleRunner(this.config.root) 111 + : new ServerModuleRunner( 112 + environment, 113 + this._fetcher, 114 + this.config, 115 + ) 108 116 } 109 117 110 118 // "provide" is a property, not a method to keep the context when destructed in the global setup, ··· 176 184 } 177 185 178 186 /** 179 - * Vite's dev server instance. Every workspace project has its own server. 180 - */ 181 - public get vite(): ViteDevServer { 182 - if (!this._vite) { 183 - throw new Error('The server was not set. It means that `project.vite` was called before the Vite server was established.') 184 - } 185 - // checking it once should be enough 186 - Object.defineProperty(this, 'vite', { 187 - configurable: true, 188 - writable: true, 189 - value: this._vite, 190 - }) 191 - return this._vite 192 - } 193 - 194 - /** 195 - * Resolved project configuration. 196 - */ 197 - public get config(): ResolvedConfig { 198 - if (!this._config) { 199 - throw new Error('The config was not set. It means that `project.config` was called before the Vite server was established.') 200 - } 201 - // checking it once should be enough 202 - // Object.defineProperty(this, 'config', { 203 - // configurable: true, 204 - // writable: true, 205 - // value: this._config, 206 - // }) 207 - return this._config 208 - } 209 - 210 - /** 211 187 * The name of the project or an empty string if not set. 212 188 */ 213 189 public get name(): string { ··· 337 313 return this.testFilesList 338 314 } 339 315 340 - const testFiles = await this.globFiles(include, exclude, cwd) 341 - 342 - if (includeSource?.length) { 343 - const files = await this.globFiles(includeSource, exclude, cwd) 344 - 345 - await Promise.all( 346 - files.map(async (file) => { 347 - try { 348 - const code = await fs.readFile(file, 'utf-8') 349 - if (this.isInSourceTestCode(code)) { 350 - testFiles.push(file) 351 - } 352 - } 353 - catch { 354 - return null 355 - } 356 - }), 357 - ) 358 - } 316 + const testFiles = await globProjectTestFiles(include, exclude, includeSource, cwd) 359 317 360 318 this.testFilesList = testFiles 361 319 ··· 363 321 } 364 322 365 323 isBrowserEnabled(): boolean { 366 - return isBrowserEnabled(this.config) 324 + return !!this.config.browser?.enabled 367 325 } 368 326 369 327 private markTestFile(testPath: string): void { ··· 394 352 } 395 353 396 354 /** @internal */ 397 - async globFiles(include: string[], exclude: string[], cwd: string) { 398 - const globOptions: GlobOptions = { 399 - dot: true, 400 - cwd, 401 - ignore: exclude, 402 - expandDirectories: false, 403 - } 404 - 405 - const files = await glob(include, globOptions) 406 - // keep the slashes consistent with Vite 407 - // we are not using the pathe here because it normalizes the drive letter on Windows 408 - // and we want to keep it the same as working dir 409 - return files.map(file => slash(path.resolve(cwd, file))) 355 + globFiles(include: string[], exclude: string[], cwd: string): Promise<string[]> { 356 + return globProjectFiles(include, exclude, cwd) 410 357 } 411 358 412 359 /** ··· 429 376 && pm.isMatch(relativeId, this.config.includeSource) 430 377 ) { 431 378 const code = source?.() || readFileSync(moduleId, 'utf-8') 432 - if (this.isInSourceTestCode(code)) { 379 + if (isInSourceTestCode(code)) { 433 380 this.markTestFile(moduleId) 434 381 return true 435 382 } 436 383 } 437 384 return false 438 - } 439 - 440 - private isInSourceTestCode(code: string): boolean { 441 - return code.includes('import.meta.vitest') 442 385 } 443 386 444 387 private filterFiles(testFiles: string[], filters: string[], dir: string): string[] { ··· 469 412 return testFiles 470 413 } 471 414 472 - private _parentBrowser?: ParentProjectBrowser 415 + /** 416 + * The parent browser project that owns this cluster's single Vite server. 417 + * Set on the primary (the hidden parent of a browser cluster) at server 418 + * creation; instance siblings get their own `ProjectBrowser` view via 419 + * `_parentBrowser.spawn`. 420 + * @internal 421 + */ 422 + _parentBrowser?: ParentProjectBrowser 473 423 /** @internal */ 474 424 public _parent?: TestProject 475 - /** @internal */ 476 - _initParentBrowser = deduped(async (childProject: TestProject) => { 477 - if (!this.isBrowserEnabled() || this._parentBrowser) { 478 - return 479 - } 480 - const provider = this.config.browser.provider || childProject.config.browser.provider 481 - if (provider == null) { 482 - throw new Error(`Provider was not specified in the "browser.provider" setting. Please, pass down playwright(), webdriverio() or preview() from "@vitest/browser-playwright", "@vitest/browser-webdriverio" or "@vitest/browser-preview" package respectively.`) 483 - } 484 - if (typeof provider.serverFactory !== 'function') { 485 - throw new TypeError(`The browser provider options do not return a "serverFactory" function. Are you using the latest "@vitest/browser-${provider.name}" package?`) 486 - } 487 - const browser = await provider.serverFactory({ 488 - project: this, 489 - mocksPlugins: options => MocksPlugins(options), 490 - metaEnvReplacer: () => MetaEnvReplacerPlugin(), 491 - coveragePlugin: () => CoverageTransform(this.vitest), 492 - }) 493 - this._parentBrowser = browser 494 - if (this.config.browser.ui) { 495 - setup(this.vitest, browser.vite) 496 - } 497 - }) 498 - 499 - /** @internal */ 500 - _initBrowserServer = deduped(async () => { 501 - await this._parent?._initParentBrowser(this) 502 - 503 - if (!this.browser && this._parent?._parentBrowser) { 504 - this.browser = this._parent._parentBrowser.spawn(this) 505 - await this.vitest.report('onBrowserInit', this) 506 - } 507 - }) 508 425 509 426 /** 510 427 * Closes the project and all associated resources. This can only be called once; the closing promise is cached until the server restarts. ··· 514 431 if (!this.closingPromise) { 515 432 this.closingPromise = Promise.all( 516 433 [ 517 - this.vite?.close(), 434 + this.vite.close(), 518 435 this.typechecker?.stop(), 519 - // browser might not be set if it threw an error during initialization 520 - (this.browser || this._parent?._parentBrowser?.vite)?.close(), 521 436 this.clearTmpDir(), 522 437 ].filter(Boolean), 523 438 ).then(() => { ··· 526 441 } 527 442 }).then(() => { 528 443 this._provided = {} as any 529 - this._vite = undefined 530 444 }) 531 445 } 532 446 return this.closingPromise ··· 540 454 return this.runner.import(moduleId) 541 455 } 542 456 543 - private _setHash() { 544 - this._hash = generateHash( 545 - this._config!.root + this._config!.name, 546 - ) 547 - } 548 - 549 - /** @internal */ 550 - async _configureServer(options: UserConfig, server: ViteDevServer): Promise<void> { 551 - this._config = resolveConfig( 552 - this.vitest, 553 - { 554 - ...options, 555 - // root-only configs 556 - coverage: this.vitest.config.coverage, 557 - attachmentsDir: this.vitest.config.attachmentsDir, 558 - }, 559 - server.config, 560 - ) 561 - this._config.api.token = this.vitest.config.api.token 562 - this._config.mergeReportsLabel = this.vitest.config.mergeReportsLabel 563 - this._setHash() 564 - for (const _providedKey in this.config.provide) { 565 - const providedKey = _providedKey as keyof ProvidedContext 566 - // type is very strict here, so we cast it to any 567 - (this.provide as (key: string, value: unknown) => void)( 568 - providedKey, 569 - this.config.provide[providedKey], 570 - ) 571 - } 572 - 573 - this.closingPromise = undefined 574 - 575 - this._resolver = new VitestResolver(server.config.cacheDir, this._config) 576 - this._vite = server 577 - this._serializedDefines = createDefinesScript(server.config.define) 578 - this._fetcher = createFetchModuleFunction( 579 - this._resolver, 580 - this._config, 581 - this.vitest._fsCache, 582 - this.vitest._traces, 583 - this.tmpDir, 584 - ) 585 - 586 - const environment = server.environments.__vitest__ 587 - this.runner = this._config.experimental.viteModuleRunner === false 588 - ? new NativeModuleRunner(this._config.root) 589 - : new ServerModuleRunner( 590 - environment, 591 - this._fetcher, 592 - this._config, 593 - ) 594 - 595 - const ssrEnvironment = server.environments.ssr 596 - if (isRunnableDevEnvironment(ssrEnvironment)) { 597 - const ssrRunner = new ServerModuleRunner( 598 - ssrEnvironment, 599 - this._fetcher, 600 - this._config, 601 - ) 602 - Object.defineProperty(ssrEnvironment, 'runner', { 603 - value: ssrRunner, 604 - writable: true, 605 - configurable: true, 606 - }) 607 - } 608 - } 609 - 610 457 /** @internal */ 611 458 public _getViteEnvironments(): DevEnvironment[] { 612 - return [ 613 - ...Object.values(this.browser?.vite.environments || {}), 614 - ...Object.values(this.vite.environments || {}), 615 - ] 459 + return Object.values(this.vite.environments || {}) 616 460 } 617 461 618 462 /** @internal */ ··· 697 541 if (!this.isBrowserEnabled() || this.browser?.provider) { 698 542 return 699 543 } 700 - if (!this.browser) { 701 - await this._initBrowserServer() 544 + // The browser server is created eagerly with the project, so `this.browser` 545 + // is already set here; we only need to initialize the provider. 546 + if (this.browser) { 547 + await this.vitest.report('onBrowserInit', this) 702 548 } 703 549 await this.browser?.initBrowserProvider(this) 704 550 }) 705 551 706 - /** @internal */ 707 - public _provideObject(context: Partial<ProvidedContext>): void { 552 + private _provideObject(context: Partial<ProvidedContext>): void { 708 553 for (const _providedKey in context) { 709 554 const providedKey = _providedKey as keyof ProvidedContext 710 555 // type is very strict here, so we cast it to any ··· 719 564 static _createBasicProject(vitest: Vitest): TestProject { 720 565 const project = new TestProject( 721 566 vitest, 722 - undefined, 723 - vitest._tmpDir, 567 + vitest.vite, 568 + vitest.viteConfig, 569 + vitest.config, 724 570 ) 725 571 project.runner = vitest.runner 726 - project._vite = vitest.vite 727 - project._config = vitest.config 728 572 project._resolver = vitest._resolver 729 573 project._fetcher = vitest._fetcher 730 574 project._serializedDefines = createDefinesScript(vitest.vite.config.define) 731 - project._setHash() 732 - project._provideObject(vitest.config.provide) 733 575 return project 734 576 } 735 577 736 - /** @internal */ 737 - static _cloneTestProject(parent: TestProject, config: ResolvedConfig): TestProject { 738 - const clone = new TestProject(parent.vitest, undefined, parent.tmpDir) 739 - clone.runner = parent.runner 740 - clone._vite = parent._vite 741 - clone._resolver = parent._resolver 742 - clone._fetcher = parent._fetcher 743 - clone._config = config 744 - clone._setHash() 745 - clone._parent = parent 746 - clone._serializedDefines = parent._serializedDefines 747 - clone._provideObject(config.provide) 748 - return clone 578 + /** 579 + * Create a sibling project that shares server-derived resources (Vite server, 580 + * runner, resolver, fetcher) with a primary project. The sibling has its own 581 + * distinct `projectConfig`, but the same `viteConfig` reference as the primary. 582 + * 583 + * Used for browser-instance and benchmark variants whose entries share a 584 + * `viteConfig` reference with a primary project entry. 585 + * 586 + * @internal 587 + */ 588 + static _spawnSibling(parent: TestProject, config: ResolvedConfig): TestProject { 589 + const sibling = new TestProject(parent.vitest, parent.vite, parent.viteConfig, config) 590 + sibling.runner = parent.runner 591 + sibling._resolver = parent._resolver 592 + sibling._fetcher = parent._fetcher 593 + sibling._parent = parent 594 + sibling._serializedDefines = parent._serializedDefines 595 + return sibling 749 596 } 750 597 } 751 598 ··· 765 612 name: string 766 613 serializedConfig: SerializedConfig 767 614 context: ProvidedContext 768 - } 769 - 770 - interface InitializeProjectOptions extends TestProjectInlineConfiguration { 771 - configFile: string | false 772 - } 773 - 774 - export async function initializeProject( 775 - workspacePath: string | number, 776 - ctx: Vitest, 777 - options: InitializeProjectOptions, 778 - ): Promise<TestProject> { 779 - const project = new TestProject(ctx, options) 780 - 781 - const { configFile, ...restOptions } = options 782 - 783 - const config: ViteInlineConfig = { 784 - ...restOptions, 785 - configFile, 786 - configLoader: ctx.vite.config.inlineConfig.configLoader, 787 - // this will make "mode": "test" | "benchmark" inside defineConfig 788 - mode: options.test?.mode || options.mode || ctx.config.mode, 789 - plugins: [ 790 - ...(options.plugins || []), 791 - WorkspaceVitestPlugin(project, { ...options, workspacePath }), 792 - ], 793 - } 794 - 795 - await createViteServer(config) 796 - 797 - return project 798 615 } 799 616 800 617 function generateHash(str: string): string {
+62
packages/vitest/src/node/projects/globProjectFiles.ts
··· 1 + import { readFile } from 'node:fs/promises' 2 + import path from 'node:path' 3 + import { slash } from '@vitest/utils/helpers' 4 + import { glob } from 'tinyglobby' 5 + 6 + /** 7 + * Glob files inside a project's working directory. 8 + * 9 + * Resolves with `node:path` (not `pathe`) so the Windows drive-letter casing 10 + * stays consistent with Vite's, and keeps slashes normalized. 11 + */ 12 + export async function globProjectFiles( 13 + include: string[], 14 + exclude: string[], 15 + cwd: string, 16 + ): Promise<string[]> { 17 + const files = await glob(include, { 18 + dot: true, 19 + cwd, 20 + ignore: exclude, 21 + expandDirectories: false, 22 + }) 23 + return files.map(file => slash(path.resolve(cwd, file))) 24 + } 25 + 26 + export function isInSourceTestCode(code: string): boolean { 27 + return code.includes('import.meta.vitest') 28 + } 29 + 30 + /** 31 + * Glob a project's test files, including in-source test files from 32 + * `includeSource` that actually contain `import.meta.vitest`. Typecheck test 33 + * files are not included. 34 + */ 35 + export async function globProjectTestFiles( 36 + include: string[], 37 + exclude: string[], 38 + includeSource: string[] | undefined, 39 + cwd: string, 40 + ): Promise<string[]> { 41 + const testFiles = await globProjectFiles(include, exclude, cwd) 42 + 43 + if (includeSource?.length) { 44 + const files = await globProjectFiles(includeSource, exclude, cwd) 45 + 46 + await Promise.all( 47 + files.map(async (file) => { 48 + try { 49 + const code = await readFile(file, 'utf-8') 50 + if (isInSourceTestCode(code)) { 51 + testFiles.push(file) 52 + } 53 + } 54 + catch { 55 + return null 56 + } 57 + }), 58 + ) 59 + } 60 + 61 + return testFiles 62 + }
+739 -287
packages/vitest/src/node/projects/resolveProjects.ts
··· 1 1 import type { GlobOptions } from 'tinyglobby' 2 + import type { 3 + ResolvedConfig as ResolvedViteConfig, 4 + InlineConfig as ViteInlineConfig, 5 + } from 'vite' 6 + import type { PluginHarness } from '../config/pluginHarness' 2 7 import type { Vitest } from '../core' 8 + import type { BrowserContributionHolder } from '../plugins/browserLoader' 3 9 import type { 4 10 BrowserInstanceOption, 11 + ProjectName, 5 12 ResolvedConfig, 13 + ResolvedProjectEntry, 6 14 TestProjectConfiguration, 7 15 UserConfig, 8 16 UserWorkspaceConfig, 9 17 } from '../types/config' 10 - import { existsSync, readdirSync, statSync } from 'node:fs' 18 + import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' 11 19 import os from 'node:os' 12 20 import { deepClone } from '@vitest/utils/helpers' 13 21 import { basename, dirname, relative, resolve } from 'pathe' 14 22 import { glob, isDynamicPattern } from 'tinyglobby' 15 - import { mergeConfig } from 'vite' 23 + import { mergeConfig, resolveConfig as viteResolveConfig } from 'vite' 16 24 import { configFiles as defaultConfigFiles } from '../../constants' 17 25 import { limitConcurrency } from '../../utils/limit-concurrency' 18 - import { VitestFilteredOutProjectError } from '../errors' 19 - import { initializeProject, TestProject } from '../project' 26 + import { isExcludedByProjectFilter, matchesProjectFilter, resolveTestConfig } from '../config/resolveConfig' 27 + import { BrowserLoaderPlugin, createClusterServer } from '../plugins/browserLoader' 28 + import { CliOverride } from '../plugins/cliOverride' 29 + import { WorkspaceVitestPlugin } from '../plugins/workspace' 30 + import { TestProject } from '../project' 31 + import { globProjectTestFiles } from './globProjectFiles' 20 32 21 33 // vitest.config.* 22 34 // vite.config.* ··· 25 37 // vitest.unit-test.config.* 26 38 const CONFIG_REGEXP = /^vite(?:st)?(?:\.[\w-]+)?\.config\./ 27 39 28 - export async function resolveProjects( 29 - vitest: Vitest, 30 - cliOptions: UserConfig, 31 - workspaceConfigPath: string | undefined, 32 - projectsDefinition: TestProjectConfiguration[], 33 - names: Set<string>, 34 - ): Promise<TestProject[]> { 40 + // CLI options that can override per-project test config. 41 + // Not all options are allowed to be overridden. 42 + const PROJECT_CLI_OVERRIDES = [ 43 + 'logHeapUsage', 44 + 'detectAsyncLeaks', 45 + 'allowOnly', 46 + 'sequence', 47 + 'testTimeout', 48 + 'pool', 49 + 'update', 50 + 'globals', 51 + 'expandSnapshotDiff', 52 + 'disableConsoleIntercept', 53 + 'retry', 54 + 'repeats', 55 + 'testNamePattern', 56 + 'passWithNoTests', 57 + 'bail', 58 + 'isolate', 59 + 'printConsoleTrace', 60 + 'inspect', 61 + 'inspectBrk', 62 + 'fileParallelism', 63 + 'tagsFilter', 64 + 'browser', 65 + ] as const 66 + 67 + /** 68 + * Resolve the full list of project entries for the current Vitest run. 69 + * 70 + * - If the user declared `test.projects`, each declared project gets its own 71 + * resolved Vite config plus per-project Vitest test config. 72 + * - Otherwise the root config is used as the single base entry. 73 + * - Browser instances expand each entry with `browser.enabled` into one entry 74 + * per instance (sharing `viteConfig` with the parent). 75 + * - Benchmarks add a benchmark variant for each entry whose 76 + * `benchmark.enabled` is true (sharing `viteConfig` with its non-benchmark 77 + * counterpart). 78 + * - The `--project` filter is applied at the end so error messages can list 79 + * every name that was considered (including instance- and benchmark-derived). 80 + */ 81 + export async function resolveProjectEntries( 82 + harness: PluginHarness, 83 + globalViteConfig: ResolvedViteConfig, 84 + globalConfig: ResolvedConfig, 85 + definitions: TestProjectConfiguration[] | undefined, 86 + options: { throwIfEmpty?: boolean; existingNames?: Set<string> } = {}, 87 + ): Promise<ResolvedProjectEntry[]> { 88 + const throwIfEmpty = options.throwIfEmpty ?? true 89 + const existingNames = options.existingNames 90 + // `definitions: []` is treated as "user declared workspace but it's empty" 91 + // `definitions === undefined` means "no workspace declared" and 92 + // falls through to the default root-project entry. 93 + let baseEntries: ResolvedProjectEntry[] 94 + if (definitions !== undefined) { 95 + baseEntries = await resolveDeclaredProjectEntries( 96 + harness, 97 + globalViteConfig, 98 + globalConfig, 99 + definitions, 100 + ) 101 + } 102 + else { 103 + baseEntries = [{ viteConfig: globalViteConfig, projectConfig: globalConfig }] 104 + } 105 + 106 + // Ensure project names are unique across declared projects (and any 107 + // already-existing projects passed via `existingNames`, which the inject 108 + // path uses to forbid clashes with the active workspace). Include config 109 + // file paths in the error when available (matches the old workspace-mode 110 + // duplicate-name diagnostic). 111 + const seenNames = new Map<string, ResolvedProjectEntry>() 112 + for (const entry of baseEntries) { 113 + const name = entry.projectConfig.name 114 + if (existingNames?.has(name)) { 115 + throw new Error( 116 + `Project name "${name}" is not unique. All projects should have unique names. Make sure your configuration is correct.`, 117 + ) 118 + } 119 + const existing = seenNames.get(name) 120 + if (existing) { 121 + const entryFile = entry.viteConfig.configFile 122 + ? relative(globalConfig.root, entry.viteConfig.configFile) 123 + : '' 124 + const existingFile = existing.viteConfig.configFile 125 + ? relative(globalConfig.root, existing.viteConfig.configFile) 126 + : '' 127 + const filesError = baseEntries.length > 1 && (entryFile || existingFile) 128 + ? [ 129 + '\n\nYour config matched these files:\n', 130 + baseEntries 131 + .filter(e => e.viteConfig.configFile) 132 + .map(e => ` - ${relative(globalConfig.root, e.viteConfig.configFile as string)}`) 133 + .join('\n'), 134 + '\n\n', 135 + ].join('') 136 + : ' ' 137 + throw new Error([ 138 + `Project name "${name}"`, 139 + entryFile ? ` from "${entryFile}"` : '', 140 + ' is not unique.', 141 + existingFile ? ` The project is already defined by "${existingFile}".` : '', 142 + filesError, 143 + 'All projects should have unique names. Make sure your configuration is correct.', 144 + ].join('')) 145 + } 146 + seenNames.set(name, entry) 147 + } 148 + const seenNamesSet = new Set(seenNames.keys()) 149 + 150 + // Browser instance expansion (per-entry config injection). 151 + const afterBrowser = expandBrowserInstancesInEntries(globalConfig, baseEntries, seenNamesSet) 152 + 153 + // Benchmark expansion (per-entry config injection, runs over post-browser list). 154 + // `--benchmark` makes every project run as a benchmark. 155 + const afterBenchmark = expandBenchmarksInEntries(afterBrowser, seenNamesSet, !!globalConfig.cliOptions.benchmarkOnly) 156 + 157 + // --project filter (applied after expansion so all candidate names are known). 158 + const filtered = applyProjectFilter(globalConfig, afterBenchmark) 159 + 160 + // If the user declared `projects` (or workspace files) but the filter 161 + // excluded every candidate, throw with the projects definition included so 162 + // callers see what was tried. Skipped for the runtime `injectTestProjects` 163 + // path where filtering injected projects out is expected. 164 + const filterMatched = filtered.some(entry => !entry.hidden) 165 + if (throwIfEmpty && definitions && !filterMatched) { 166 + throw new Error( 167 + [ 168 + 'No projects were found. Make sure your configuration is correct. ', 169 + globalConfig.project.length ? `The filter matched no projects: ${globalConfig.project.join(', ')}. ` : '', 170 + `The projects definition: ${JSON.stringify( 171 + definitions.map((p, index) => typeof p === 'string' 172 + ? p 173 + : p instanceof Promise 174 + ? 'Promise' 175 + : typeof p === 'function' 176 + ? p.name 177 + : ({ name: p.test?.name ?? index })), 178 + null, 179 + 4, 180 + )}.`, 181 + ].join(''), 182 + ) 183 + } 184 + 185 + // Browser servers must pre-bundle dependencies before they are created, and a 186 + // single server can be shared by several projects (browser instances and 187 + // benchmark variants). Aggregate each shared server's `optimizeDeps` now that 188 + // every project's config is fully resolved. 189 + await applyBrowserOptimizeDeps(harness, filtered) 190 + 191 + return filtered 192 + } 193 + 194 + /** 195 + * Aggregate `optimizeDeps` for each browser Vite server across every project 196 + * that shares it, then merge the result into the resolved Vite config's `client` 197 + * environment. Runs after resolution (so project `include`/`setupFiles` are 198 + * known) and before server creation (so `createViteServer` reuses the mutated 199 + * resolved config). 200 + */ 201 + async function applyBrowserOptimizeDeps( 202 + harness: PluginHarness, 203 + entries: ResolvedProjectEntry[], 204 + ): Promise<void> { 205 + const groups = new Map<ResolvedViteConfig, ResolvedConfig[]>() 206 + for (const { viteConfig, projectConfig } of entries) { 207 + let group = groups.get(viteConfig) 208 + if (!group) { 209 + group = [] 210 + groups.set(viteConfig, group) 211 + } 212 + group.push(projectConfig) 213 + } 214 + 215 + // Most projects in a group share identical glob inputs (the `dir`/`root` is 216 + // always the same and cannot be overridden by an instance option), so cache 217 + // the result per unique input set to avoid re-globbing the same files. 218 + const fileListCache = new Map<string, Promise<string[]>>() 219 + const globTestFiles = (config: ResolvedConfig) => { 220 + const cwd = config.dir || config.root 221 + const key = JSON.stringify([config.include, config.exclude, config.includeSource, cwd]) 222 + let fileList = fileListCache.get(key) 223 + if (!fileList) { 224 + fileList = globProjectTestFiles(config.include, config.exclude, config.includeSource, cwd) 225 + fileListCache.set(key, fileList) 226 + } 227 + return fileList 228 + } 229 + 230 + await Promise.all( 231 + Array.from(groups, async ([viteConfig, projectConfigs]) => { 232 + const contribution = projectConfigs.find(config => config._browserContribution)?._browserContribution 233 + if (!contribution) { 234 + return 235 + } 236 + const fileLists = await Promise.all(projectConfigs.map(globTestFiles)) 237 + const testFiles = [...new Set(fileLists.flat())] 238 + const optimizeDeps = await contribution.resolveOptimizeDeps(projectConfigs, testFiles, harness) 239 + // the browser runs in the `client` environment, but Vite's dep scanner 240 + // reads the top-level `optimizeDeps`, so keep both in sync (`mergeConfig` 241 + // concatenates arrays, preserving user/default values) 242 + const merged = mergeConfig( 243 + { optimizeDeps: viteConfig.optimizeDeps }, 244 + { optimizeDeps }, 245 + ).optimizeDeps 246 + ;(viteConfig as any).optimizeDeps = { ...merged } 247 + viteConfig.environments.client.optimizeDeps = { ...viteConfig.optimizeDeps } 248 + }), 249 + ) 250 + } 251 + 252 + async function resolveDeclaredProjectEntries( 253 + harness: PluginHarness, 254 + globalViteConfig: ResolvedViteConfig, 255 + globalConfig: ResolvedConfig, 256 + definitions: TestProjectConfiguration[], 257 + ): Promise<ResolvedProjectEntry[]> { 35 258 const { configFiles, projectConfigs, nonConfigDirectories } = await resolveTestProjectConfigs( 36 - vitest, 37 - workspaceConfigPath, 38 - projectsDefinition, 259 + globalViteConfig, 260 + globalConfig, 261 + definitions, 39 262 ) 40 263 41 - // cli options that affect the project config, 42 - // not all options are allowed to be overridden 43 - const overridesOptions = [ 44 - 'logHeapUsage', 45 - 'detectAsyncLeaks', 46 - 'allowOnly', 47 - 'sequence', 48 - 'testTimeout', 49 - 'pool', 50 - 'update', 51 - 'globals', 52 - 'expandSnapshotDiff', 53 - 'disableConsoleIntercept', 54 - 'retry', 55 - 'repeats', 56 - 'testNamePattern', 57 - 'passWithNoTests', 58 - 'bail', 59 - 'isolate', 60 - 'printConsoleTrace', 61 - 'inspect', 62 - 'inspectBrk', 63 - 'fileParallelism', 64 - 'tagsFilter', 65 - ] as const 66 - 67 - const cliOverrides = overridesOptions.reduce((acc, name) => { 68 - if (name in cliOptions) { 69 - acc[name] = cliOptions[name] as any 264 + const cliOverrides = PROJECT_CLI_OVERRIDES.reduce((acc, name) => { 265 + if (name in globalConfig.cliOptions) { 266 + acc[name] = globalConfig.cliOptions[name] as any 70 267 } 71 268 return acc 72 269 }, {} as UserConfig) 73 - 74 - const projectPromises: Promise<TestProject>[] = [] 75 - const fileProjects = [...configFiles, ...nonConfigDirectories] 76 270 const concurrent = limitConcurrency(os.availableParallelism?.() || os.cpus().length || 5) 271 + const fileProjects = [...configFiles, ...nonConfigDirectories] 272 + 273 + const promises: Promise<ResolvedProjectEntry>[] = [] 77 274 78 275 projectConfigs.forEach((options, index) => { 79 - const configRoot = workspaceConfigPath ? dirname(workspaceConfigPath) : vitest.config.root 276 + const configRoot = globalConfig.root 80 277 // if extends a config file, resolve the file path 81 278 const configFile = typeof options.extends === 'string' 82 279 ? resolve(configRoot, options.extends) 83 280 : options.extends === true 84 - ? (vitest.vite.config.configFile || false) 281 + ? (globalViteConfig.configFile || false) 85 282 : false 86 - // if `root` is configured, resolve it relative to the workspace file or vite root (like other options) 283 + // if `root` is configured, resolve it relative to vite root (like other options) 87 284 // if `root` is not specified, inline configs use the same root as the root project 88 - const root = options.root 89 - ? resolve(configRoot, options.root) 90 - : vitest.config.root 91 - projectPromises.push(concurrent(() => initializeProject( 92 - index, 93 - vitest, 94 - { 95 - ...options, 96 - root, 97 - configFile, 98 - plugins: [ 99 - { 100 - name: 'vitest:tags', 101 - // don't inherit tags from workspace config, they are merged separately 102 - configResolved(config) { 103 - ;(config as any).test ??= {} 104 - config.test!.tags = options.test?.tags 105 - }, 106 - api: { 107 - vitest: { 108 - experimental: { ignoreFsModuleCache: true }, 109 - }, 110 - }, 111 - }, 112 - ...options.plugins || [], 113 - ], 114 - test: { ...options.test, ...cliOverrides }, 115 - }, 116 - ))) 285 + const rawRoot = options.test?.root ?? options.root 286 + const root = rawRoot 287 + ? resolve(configRoot, rawRoot) 288 + : configRoot 289 + 290 + promises.push(concurrent(() => resolveSingleProjectEntry(harness, globalViteConfig, globalConfig, { 291 + ...options, 292 + root, 293 + configFile, 294 + }, index, cliOverrides))) 117 295 }) 118 296 119 297 for (const path of fileProjects) { 120 298 // if file leads to the root config, then we can just reuse it because we already initialized it 121 - if (vitest.vite.config.configFile === path) { 122 - const project = getDefaultTestProject(vitest) 123 - if (project) { 124 - projectPromises.push(Promise.resolve(project)) 125 - } 299 + if (globalViteConfig.configFile === path) { 300 + // The root viteConfig is already resolved; emit it as an entry directly. 301 + promises.push(Promise.resolve({ 302 + viteConfig: globalViteConfig, 303 + projectConfig: globalConfig, 304 + })) 126 305 continue 127 306 } 128 307 129 308 const configFile = path.endsWith('/') ? false : path 130 - const root = path.endsWith('/') ? path : dirname(path) 131 - 132 - projectPromises.push( 133 - concurrent(() => initializeProject( 134 - path, 135 - vitest, 136 - { root, configFile, test: cliOverrides }, 137 - )), 138 - ) 139 - } 309 + const projectRoot = path.endsWith('/') ? path : dirname(path) 140 310 141 - // pretty rare case - the glob didn't match anything and there are no inline configs 142 - if (!projectPromises.length) { 143 - throw new Error( 144 - [ 145 - 'No projects were found. Make sure your configuration is correct. ', 146 - vitest.config.project.length ? `The filter matched no projects: ${vitest.config.project.join(', ')}. ` : '', 147 - `The projects definition: ${JSON.stringify(projectsDefinition, null, 4)}.`, 148 - ].join(''), 149 - ) 311 + promises.push(concurrent(() => resolveSingleProjectEntry( 312 + harness, 313 + globalViteConfig, 314 + globalConfig, 315 + { root: projectRoot, configFile }, 316 + path, 317 + cliOverrides, 318 + ))) 150 319 } 151 320 152 - const resolvedProjectsPromises = await Promise.allSettled(projectPromises) 153 - 321 + const settled = await Promise.allSettled(promises) 154 322 const errors: Error[] = [] 155 - const resolvedProjects: TestProject[] = [] 156 - 157 - for (const result of resolvedProjectsPromises) { 323 + const entries: ResolvedProjectEntry[] = [] 324 + for (const result of settled) { 158 325 if (result.status === 'rejected') { 159 - if (result.reason instanceof VitestFilteredOutProjectError) { 160 - // filter out filtered out projects 161 - continue 162 - } 163 326 errors.push(result.reason) 164 327 } 165 328 else { 166 - resolvedProjects.push(result.value) 329 + entries.push(result.value) 167 330 } 168 331 } 169 332 ··· 174 337 ) 175 338 } 176 339 177 - // project names are guaranteed to be unique 178 - for (const project of resolvedProjects) { 179 - const name = project.name 180 - if (names.has(name)) { 181 - const duplicate = resolvedProjects.find(p => p.name === name && p !== project)! 182 - const filesError = fileProjects.length 183 - ? [ 184 - '\n\nYour config matched these files:\n', 185 - fileProjects.map(p => ` - ${relative(vitest.config.root, p)}`).join('\n'), 186 - '\n\n', 187 - ].join('') 188 - : ' ' 189 - throw new Error([ 190 - `Project name "${name}"`, 191 - project.vite.config.configFile ? ` from "${relative(vitest.config.root, project.vite.config.configFile)}"` : '', 192 - ' is not unique.', 193 - duplicate?.vite.config.configFile ? ` The project is already defined by "${relative(vitest.config.root, duplicate.vite.config.configFile)}".` : '', 194 - filesError, 195 - 'All projects should have unique names. Make sure your configuration is correct.', 196 - ].join('')) 197 - } 198 - names.add(name) 340 + return entries 341 + } 342 + 343 + async function resolveSingleProjectEntry( 344 + harness: PluginHarness, 345 + globalViteConfig: ResolvedViteConfig, 346 + globalConfig: ResolvedConfig, 347 + options: ViteInlineConfig, 348 + workspacePath: string | number, 349 + cliOverrides: UserConfig, 350 + ): Promise<ResolvedProjectEntry> { 351 + const { configFile, ...restOptions } = options 352 + 353 + const browserHolder: BrowserContributionHolder = {} 354 + 355 + const projectInline: ViteInlineConfig = { 356 + ...restOptions, 357 + configFile, 358 + configLoader: globalViteConfig.inlineConfig.configLoader, 359 + // this will make "mode": "test" inside defineConfig 360 + mode: options.test?.mode || options.mode || globalConfig.mode, 361 + plugins: [ 362 + CliOverride(cliOverrides), 363 + ...(options.plugins || []), 364 + ...WorkspaceVitestPlugin( 365 + harness, 366 + globalViteConfig, 367 + globalConfig, 368 + options, 369 + ), 370 + ...BrowserLoaderPlugin(browserHolder, harness), 371 + { 372 + name: 'vitest:tags', 373 + config(config) { 374 + // We need to keep the `tags` array untouched if `extends` is `true`, 375 + // Otherwise it gets merged with the top level tags and we don't want that because tags could be overridden 376 + // Setting it to `options.test?.tags` overrides the merged value 377 + if (options.test?.tags) { 378 + config.test ??= {} 379 + config.test.tags = options.test?.tags 380 + } 381 + }, 382 + api: { 383 + vitest: { 384 + experimental: { ignoreFsModuleCache: true }, 385 + }, 386 + }, 387 + }, 388 + ], 199 389 } 200 390 201 - return resolveDefaultProjects(vitest, names, resolvedProjects) 202 - } 391 + const projectViteConfig = await viteResolveConfig(projectInline, 'serve') 203 392 204 - export async function resolveDefaultProjects( 205 - vitest: Vitest, 206 - names: Set<string>, 207 - resolvedProjects: TestProject[], 208 - ): Promise<TestProject[]> { 209 - const newProjects = await resolveBrowserProjects(vitest, names, resolvedProjects) 393 + // inherit the root's resolved env as defaults; a project's own env wins, 394 + // like every other option a project can override 395 + for (const key in globalViteConfig.env) { 396 + projectViteConfig.env[key] ??= globalViteConfig.env[key] 397 + } 210 398 211 - let lastGroupOrder = Math.max(0, ...newProjects.map(p => p.config.sequence.groupOrder)) 399 + const mergedOptions = (projectViteConfig.test ?? {}) as UserConfig 212 400 213 - newProjects.forEach((project) => { 214 - const benchmark = project.config.benchmark 215 - if (!benchmark.enabled) { 216 - return 217 - } 401 + // resolved after `viteResolveConfig` so a plugin can still set `test.name` 402 + mergedOptions.name = resolveProjectName(mergedOptions.name, workspacePath) 218 403 219 - if (vitest.isExcludedByProjectFilter(project.config.name)) { 220 - benchmark.enabled = false 221 - return 222 - } 404 + const projectConfig = resolveTestConfig( 405 + harness.logger, 406 + mergedOptions, 407 + projectViteConfig, 408 + globalConfig, 409 + ) 410 + projectConfig.mergeReportsLabel = globalConfig.mergeReportsLabel 223 411 224 - const name = project.config.name ? `${project.config.name} (bench)` : 'bench' 225 - if (!vitest.matchesProjectFilter(name)) { 226 - benchmark.enabled = false 227 - return 228 - } 412 + projectViteConfig.test = projectConfig 229 413 230 - if (names.has(name)) { 231 - throw new Error(`Cannot create a benchmark project because the name "${name}" is already in use.`) 232 - } 233 - names.add(name) 414 + // The browser provider's contribution (captured during this resolution by the 415 + // `vitest:browser:loader` plugin) is carried on the resolved config + entry so 416 + // server creation can build the single shared Vite server. 417 + projectConfig._browserContribution = browserHolder.contribution 234 418 235 - const benchmarkProject = TestProject._cloneTestProject(project, { 236 - ...project.config, 237 - name, 238 - include: benchmark.include, 239 - exclude: benchmark.exclude, 240 - includeSource: benchmark.includeSource, 241 - coverage: { 242 - ...project.config.coverage, 243 - enabled: false, 244 - }, 245 - maxWorkers: 1, 246 - maxConcurrency: 1, 247 - testTimeout: project.config.testTimeout < 60_000 ? 60_000 : project.config.testTimeout, 248 - hookTimeout: project.config.hookTimeout < 120_000 ? 120_000 : project.config.hookTimeout, 249 - // Spread because we disable it in the original project. `projectName` 250 - // carries the parent's name so the runtime can substitute it into 251 - // `${projectName}` placeholders inside `writeResult` / `bench.from()` 252 - // paths — `project.config.name` already excludes the ` (bench)` suffix 253 - // we add to the cloned project's own name above. 254 - benchmark: { ...benchmark, projectName: project.config.name ?? '' }, 255 - sequence: { 256 - ...project.config.sequence, 257 - concurrent: false, 258 - // benchmarks should always run in a separate isolated group 259 - groupOrder: ++lastGroupOrder, 260 - }, 261 - typecheck: { 262 - ...project.config.typecheck, 263 - enabled: false, 264 - }, 265 - // TODO: mark if benchmark project? 266 - }) 267 - // disable benchmark in the original project 268 - benchmark.enabled = false 269 - newProjects.push(benchmarkProject) 270 - }) 271 - return newProjects 419 + return { 420 + viteConfig: projectViteConfig, 421 + projectConfig, 422 + } 272 423 } 273 424 274 - async function resolveBrowserProjects( 275 - vitest: Vitest, 425 + /** 426 + * For each entry with `browser.enabled` and `browser.instances?.length`, 427 + * insert one entry per instance. Each replacement shares the same 428 + * `viteConfig` reference as the original; only the `projectConfig` differs. 429 + * 430 + * The original (parent) entry is kept in the result with `hidden: true` so a 431 + * `TestProject` is still created for it — instances need a parent that owns 432 + * the Vite server and the browser provider (initialized lazily on 433 + * `parent._initParentBrowser`). The hidden parent is not pushed to 434 + * `vitest.projects`. 435 + */ 436 + function expandBrowserInstancesInEntries( 437 + globalConfig: ResolvedConfig, 438 + entries: ResolvedProjectEntry[], 276 439 names: Set<string>, 277 - resolvedProjects: TestProject[], 278 - ): Promise<TestProject[]> { 279 - const removeProjects = new Set<TestProject>() 440 + ): ResolvedProjectEntry[] { 441 + // non-browser entries first, then each browser parent followed by its instances 442 + const result: ResolvedProjectEntry[] = [] 443 + const browserEntries: ResolvedProjectEntry[] = [] 280 444 281 - resolvedProjects.forEach((project) => { 282 - if (!project.config.browser.enabled) { 283 - return 445 + for (const entry of entries) { 446 + if (entry.projectConfig.browser.enabled) { 447 + browserEntries.push(entry) 448 + } 449 + else { 450 + result.push(entry) 284 451 } 285 - const originalName = project.config.name 286 - const instances = project.config.browser.instances || [] 287 - if (instances.length === 0 || vitest.isExcludedByProjectFilter(originalName)) { 288 - removeProjects.add(project) 289 - return 452 + } 453 + 454 + for (const entry of browserEntries) { 455 + const { projectConfig, viteConfig } = entry 456 + const instances = projectConfig.browser.instances ?? [] 457 + const parentName = projectConfig.name 458 + 459 + if (instances.length === 0 || isExcludedByProjectFilter(globalConfig.project, parentName)) { 460 + continue 290 461 } 291 - // if original name matches a positive filter, keep all instances 292 - // otherwise, filter instances individually (user may target a specific instance name) 293 - const filteredInstances = vitest.matchesProjectFilter(originalName) 294 - ? instances 295 - : instances.filter((instance) => { 296 - const newName = instance.name! // name is set in "workspace" plugin 297 - return vitest.matchesProjectFilter(newName) 298 - }) 299 462 300 - // every project was filtered out 463 + const keepAllInstances = matchesProjectFilter(globalConfig.project, parentName) 464 + const filteredInstances = keepAllInstances 465 + ? instances 466 + : instances.filter(instance => matchesProjectFilter(globalConfig.project, instance.name!)) 301 467 if (!filteredInstances.length) { 302 - removeProjects.add(project) 303 - return 468 + continue 304 469 } 305 470 306 - filteredInstances.forEach((config, index) => { 307 - const browser = config.browser 471 + // Keep the parent in the entry list as `hidden` so a `TestProject` is 472 + // created (instances link to it via `_parent` for the browser provider). 473 + // The parent's name is removed from `names` because the instance names 474 + // take its place in the user-facing project list. 475 + names.delete(parentName) 476 + result.push({ ...entry, hidden: true }) 477 + 478 + filteredInstances.forEach((instance, index) => { 479 + const browser = instance.browser 308 480 if (!browser) { 309 481 const nth = index + 1 310 482 const ending = nth === 2 ? 'nd' : nth === 3 ? 'rd' : 'th' 311 - throw new Error(`The browser configuration must have a "browser" property. The ${nth}${ending} item in "browser.instances" doesn't have it. Make sure your${originalName ? ` "${originalName}"` : ''} configuration is correct.`) 483 + throw new Error(`The browser configuration must have a "browser" property. The ${nth}${ending} item in "browser.instances" doesn't have it. Make sure your${projectConfig.name ? ` "${projectConfig.name}"` : ''} configuration is correct.`) 312 484 } 313 - const name = config.name 314 - 485 + const name = instance.name 315 486 if (name == null) { 316 487 throw new Error(`The browser configuration must have a "name" property. This is a bug in Vitest. Please, open a new issue with reproduction`) 317 488 } 318 - if (config.provider?.name != null && project.config.browser.provider?.name != null && config.provider?.name !== project.config.browser.provider?.name) { 319 - throw new Error(`The instance cannot have a different provider from its parent. The "${name}" instance specifies "${config.provider?.name}" provider, but its parent has a "${project.config.browser.provider?.name}" provider.`) 489 + if (instance.provider?.name != null && projectConfig.browser.provider?.name != null && instance.provider?.name !== projectConfig.browser.provider?.name) { 490 + throw new Error(`The instance cannot have a different provider from its parent. The "${name}" instance specifies "${instance.provider?.name}" provider, but its parent has a "${projectConfig.browser.provider?.name}" provider.`) 491 + } 492 + 493 + const provider = instance.provider?.name ?? projectConfig.browser.provider?.name ?? 'preview' 494 + 495 + // Browser-mode CDP only features: 496 + if (provider === 'preview' || !isChromiumName(provider, browser)) { 497 + const browserConfig = ` 498 + { 499 + browser: { 500 + provider: ${provider}(), 501 + instances: [ 502 + ${(filteredInstances || []).map(i => `{ browser: '${i.browser}' }`).join(',\n ')} 503 + ], 504 + }, 505 + } 506 + `.trim() 507 + 508 + const preferredProvider = provider === 'preview' 509 + ? 'playwright' 510 + : provider 511 + const preferredBrowser = preferredProvider === 'playwright' ? 'chromium' : 'chrome' 512 + const correctExample = ` 513 + { 514 + browser: { 515 + provider: ${preferredProvider}(), 516 + instances: [ 517 + { browser: '${preferredBrowser}' } 518 + ], 519 + }, 520 + } 521 + `.trim() 522 + 523 + if (projectConfig.coverage.enabled && projectConfig.coverage.provider === 'v8') { 524 + const coverageExample = ` 525 + { 526 + coverage: { 527 + provider: 'istanbul', 528 + }, 529 + } 530 + `.trim() 531 + 532 + throw new Error( 533 + `@vitest/coverage-v8 does not work with\n${browserConfig}\n` 534 + + `\nUse either:\n${correctExample}` 535 + + `\n\n...or change your coverage provider to:\n${coverageExample}\n`, 536 + ) 537 + } 538 + 539 + if (globalConfig.inspect || globalConfig.inspectBrk) { 540 + const inspectOption = `--inspect${globalConfig.inspectBrk ? '-brk' : ''}` 541 + 542 + throw new Error( 543 + `${inspectOption} does not work with\n${browserConfig}\n` 544 + + `\nUse either:\n${correctExample}` 545 + + `\n\n...or disable ${inspectOption}\n`, 546 + ) 547 + } 320 548 } 321 549 322 550 if (names.has(name)) { ··· 329 557 ) 330 558 } 331 559 names.add(name) 332 - const clonedConfig = cloneConfig(project, config) 560 + 561 + const clonedConfig = cloneProjectConfigForBrowserInstance(projectConfig, instance) 333 562 clonedConfig.name = name 334 - const clone = TestProject._cloneTestProject(project, clonedConfig) 335 - resolvedProjects.push(clone) 563 + 564 + result.push({ 565 + viteConfig, // shared with parent 566 + projectConfig: clonedConfig, 567 + }) 336 568 }) 569 + } 337 570 338 - removeProjects.add(project) 339 - }) 571 + return result 572 + } 573 + 574 + /** 575 + * For each benchmark-enabled entry (or every entry when `benchmarkOnly`), inject 576 + * an additional benchmark variant entry. The new entry shares `viteConfig` with 577 + * its non-benchmark counterpart and carries its own benchmark-shaped 578 + * `projectConfig`. 579 + * 580 + * Iterates the post-browser list, so benchmark variants also spawn from 581 + * browser-instance entries. 582 + */ 583 + function expandBenchmarksInEntries( 584 + entries: ResolvedProjectEntry[], 585 + names: Set<string>, 586 + benchmarkOnly: boolean, 587 + ): ResolvedProjectEntry[] { 588 + let lastGroupOrder = Math.max(0, ...entries.map(e => e.projectConfig.sequence.groupOrder)) 589 + const result = [...entries] 590 + 591 + for (const entry of entries) { 592 + const benchmark = entry.projectConfig.benchmark 593 + if ((!benchmark.enabled && !benchmarkOnly) || entry.hidden) { 594 + continue 595 + } 596 + 597 + const name = entry.projectConfig.name ? `${entry.projectConfig.name} (bench)` : 'bench' 598 + 599 + if (names.has(name)) { 600 + throw new Error(`Cannot create a benchmark project because the name "${name}" is already in use.`) 601 + } 602 + names.add(name) 603 + 604 + const benchmarkConfig: ResolvedConfig = { 605 + ...entry.projectConfig, 606 + name, 607 + include: benchmark.include, 608 + exclude: benchmark.exclude, 609 + includeSource: benchmark.includeSource, 610 + coverage: { 611 + ...entry.projectConfig.coverage, 612 + enabled: false, 613 + }, 614 + maxWorkers: 1, 615 + maxConcurrency: 1, 616 + testTimeout: entry.projectConfig.testTimeout < 60_000 ? 60_000 : entry.projectConfig.testTimeout, 617 + hookTimeout: entry.projectConfig.hookTimeout < 120_000 ? 120_000 : entry.projectConfig.hookTimeout, 618 + // `enabled` because the original entry might not be benchmark-enabled (when 619 + // forced by `--benchmark`); `projectName` carries the parent's name so the 620 + // runtime can substitute it into `${projectName}` placeholders inside 621 + // `writeResult` / `bench.from()` paths. 622 + benchmark: { ...benchmark, enabled: true, projectName: entry.projectConfig.name ?? '' }, 623 + sequence: { 624 + ...entry.projectConfig.sequence, 625 + concurrent: false, 626 + // benchmarks should always run in a separate isolated group 627 + groupOrder: ++lastGroupOrder, 628 + }, 629 + typecheck: { 630 + ...entry.projectConfig.typecheck, 631 + enabled: false, 632 + }, 633 + } 634 + // disable benchmark in the original entry 635 + benchmark.enabled = false 636 + 637 + result.push({ 638 + viteConfig: entry.viteConfig, // shared with non-benchmark counterpart 639 + projectConfig: benchmarkConfig, 640 + }) 641 + } 642 + 643 + return result 644 + } 340 645 341 - return resolvedProjects.filter(project => !removeProjects.has(project)) 646 + /** 647 + * Drop entries that don't match the `--project` CLI filter. Hidden entries 648 + * (browser-instance parents) are always kept so siblings can attach to them. 649 + * Browser-instance entries (those sharing `viteConfig` with a hidden parent) 650 + * are also kept here — they were already vetted by the browser expansion 651 + * step against the parent's name, and their derived names like 652 + * "myproject (chromium)" wouldn't satisfy a literal `myproject` filter. 653 + */ 654 + function applyProjectFilter( 655 + globalConfig: ResolvedConfig, 656 + entries: ResolvedProjectEntry[], 657 + ): ResolvedProjectEntry[] { 658 + const filter = globalConfig.project 659 + if (!filter.length) { 660 + return entries 661 + } 662 + const browserClusterViteConfigs = new Set( 663 + entries.filter(e => e.hidden).map(e => e.viteConfig), 664 + ) 665 + return entries.filter((entry) => { 666 + if (entry.hidden) { 667 + return true 668 + } 669 + if (browserClusterViteConfigs.has(entry.viteConfig)) { 670 + // Browser instance: already filtered during expansion. 671 + return true 672 + } 673 + return matchesProjectFilter(filter, entry.projectConfig.name) 674 + }) 342 675 } 343 676 344 - function cloneConfig(project: TestProject, { browser, ...config }: BrowserInstanceOption) { 677 + function cloneProjectConfigForBrowserInstance( 678 + parentConfig: ResolvedConfig, 679 + { browser, ...config }: BrowserInstanceOption, 680 + ): ResolvedConfig { 345 681 const { 346 682 locators, 347 683 viewport, ··· 356 692 provider, 357 693 ...overrideConfig 358 694 } = config 359 - const currentConfig = project.config.browser 360 - const clonedConfig = deepClone(project.config) 695 + const currentBrowser = parentConfig.browser 696 + const clonedConfig = deepClone(parentConfig) 361 697 return mergeConfig<any, any>({ 362 698 ...clonedConfig, 699 + maxWorkers: config.fileParallelism === false ? 1 : clonedConfig.maxWorkers, 363 700 browser: { 364 - ...project.config.browser, 701 + ...parentConfig.browser, 365 702 locators: locators 366 703 ? { 367 - testIdAttribute: locators.testIdAttribute ?? currentConfig.locators.testIdAttribute, 368 - exact: locators.exact ?? currentConfig.locators.exact, 369 - errorFormat: locators.errorFormat ?? currentConfig.locators.errorFormat, 704 + testIdAttribute: locators.testIdAttribute ?? currentBrowser.locators.testIdAttribute, 705 + exact: locators.exact ?? currentBrowser.locators.exact, 706 + errorFormat: locators.errorFormat ?? currentBrowser.locators.errorFormat, 370 707 } 371 - : project.config.browser.locators, 372 - viewport: viewport ?? currentConfig.viewport, 373 - testerHtmlPath: testerHtmlPath ?? currentConfig.testerHtmlPath, 374 - screenshotDirectory: screenshotDirectory ?? currentConfig.screenshotDirectory, 375 - screenshotFailures: screenshotFailures ?? currentConfig.screenshotFailures, 376 - headless: headless ?? currentConfig.headless, 377 - provider: provider ?? currentConfig.provider, 378 - fileParallelism: fileParallelism ?? currentConfig.fileParallelism, 708 + : parentConfig.browser.locators, 709 + viewport: viewport ?? currentBrowser.viewport, 710 + testerHtmlPath: testerHtmlPath ?? currentBrowser.testerHtmlPath, 711 + screenshotDirectory: screenshotDirectory ?? currentBrowser.screenshotDirectory, 712 + screenshotFailures: screenshotFailures ?? currentBrowser.screenshotFailures, 713 + headless: headless ?? currentBrowser.headless, 714 + provider: provider ?? currentBrowser.provider, 379 715 name: browser, 380 716 instances: [], // projects cannot spawn more configs 381 717 }, ··· 383 719 include: (overrideConfig.include && overrideConfig.include.length > 0) ? [] : clonedConfig.include, 384 720 exclude: (overrideConfig.exclude && overrideConfig.exclude.length > 0) ? [] : clonedConfig.exclude, 385 721 includeSource: (overrideConfig.includeSource && overrideConfig.includeSource.length > 0) ? [] : clonedConfig.includeSource, 386 - // TODO: should resolve, not merge/override 387 722 } satisfies ResolvedConfig, overrideConfig) as ResolvedConfig 388 723 } 389 724 390 725 async function resolveTestProjectConfigs( 391 - vitest: Vitest, 392 - workspaceConfigPath: string | undefined, 726 + globalViteConfig: ResolvedViteConfig, 727 + globalConfig: ResolvedConfig, 393 728 projectsDefinition: TestProjectConfiguration[], 394 729 ) { 395 730 // project configurations that were specified directly ··· 406 741 407 742 for (const definition of projectsDefinition) { 408 743 if (typeof definition === 'string') { 409 - const stringOption = definition.replace('<rootDir>', vitest.config.root) 744 + const stringOption = definition.replace('<rootDir>', globalConfig.root) 410 745 // if the string doesn't contain a glob, we can resolve it directly 411 746 // ['./vitest.config.js'] 412 747 if (!isDynamicPattern(stringOption)) { 413 - const file = resolve(vitest.config.root, stringOption) 748 + const file = resolve(globalConfig.root, stringOption) 414 749 415 750 if (!existsSync(file)) { 416 - const relativeWorkspaceConfigPath = workspaceConfigPath 417 - ? relative(vitest.config.root, workspaceConfigPath) 418 - : undefined 419 - const note = workspaceConfigPath ? `Workspace config file "${relativeWorkspaceConfigPath}"` : 'Projects definition' 420 - throw new Error(`${note} references a non-existing file or a directory: ${file}`) 751 + throw new Error(`Projects definition references a non-existing file or a directory: ${file}`) 421 752 } 422 753 423 754 const stats = statSync(file) ··· 426 757 const name = basename(file) 427 758 if (!CONFIG_REGEXP.test(name)) { 428 759 throw new Error( 429 - `The file "${relative(vitest.config.root, file)}" must start with "vitest.config"/"vite.config" ` 760 + `The file "${relative(globalConfig.root, file)}" must start with "vitest.config"/"vite.config" ` 430 761 + `or match the pattern "(vitest|vite).*.config.*" to be a valid project config.`, 431 762 ) 432 763 } ··· 458 789 // if the config is inlined, we can resolve it immediately 459 790 else if (typeof definition === 'function') { 460 791 projectsOptions.push(await definition({ 461 - command: vitest.vite.config.command, 462 - mode: vitest.vite.config.mode, 792 + command: 'serve', 793 + mode: globalViteConfig.mode, 463 794 isPreview: false, 464 795 isSsrBuild: false, 465 796 })) ··· 475 806 absolute: true, 476 807 dot: true, 477 808 onlyFiles: false, 478 - cwd: vitest.config.root, 809 + cwd: globalConfig.root, 479 810 expandDirectories: false, 480 811 ignore: [ 481 812 '**/node_modules/**', ··· 504 835 const name = basename(path) 505 836 if (!CONFIG_REGEXP.test(name)) { 506 837 throw new Error( 507 - `The projects glob matched a file "${relative(vitest.config.root, path)}", ` 838 + `The projects glob matched a file "${relative(globalConfig.root, path)}", ` 508 839 + `but it should also either start with "vitest.config"/"vite.config" ` 509 840 + `or match the pattern "(vitest|vite).*.config.*".`, 510 841 ) ··· 534 865 return null 535 866 } 536 867 537 - export function getDefaultTestProject(vitest: Vitest): TestProject | null { 538 - const filter = vitest.config.project 539 - const project = vitest._ensureRootProject() 540 - if (!filter.length) { 541 - return project 868 + /** 869 + * Resolve a project's name, falling back to the `package.json` name or the 870 + * directory/index when neither the config nor a plugin provided one. 871 + */ 872 + function resolveProjectName( 873 + name: string | ProjectName | undefined, 874 + workspacePath: string | number, 875 + ): ProjectName { 876 + let { label, color }: ProjectName = typeof name === 'string' 877 + ? { label: name } 878 + : { label: '', ...name } 879 + 880 + if (label) { 881 + return { label, color } 542 882 } 543 - // check for the project name and browser names 544 - const hasProjects = getPotentialProjectNames(project).some(p => 545 - vitest.matchesProjectFilter(p), 546 - ) 547 - if (hasProjects) { 548 - return project 883 + 884 + if (typeof workspacePath === 'number') { 885 + return { label: workspacePath.toString(), color } 549 886 } 550 - return null 887 + 888 + const dir = workspacePath.endsWith('/') 889 + ? workspacePath.slice(0, -1) 890 + : dirname(workspacePath) 891 + const pkgJsonPath = resolve(dir, 'package.json') 892 + if (existsSync(pkgJsonPath)) { 893 + label = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')).name 894 + } 895 + if (typeof label !== 'string' || !label) { 896 + label = basename(dir) 897 + } 898 + return { label, color } 551 899 } 552 900 553 - function getPotentialProjectNames(project: TestProject) { 554 - const names = [project.name] 555 - // TODO: include benchmarks in browsers 556 - if (project.config.browser.instances) { 557 - names.push(...project.config.browser.instances.map(i => i.name!)) 901 + /** 902 + * Create `TestProject` instances from resolved project entries and attach Vite 903 + * servers, deduping by `viteConfig` identity so projects that share a vite 904 + * config also share a server. 905 + * 906 + * Primary projects (first encounter of a given `viteConfig`) create the Vite 907 + * server and own the resolver / fetcher / runner. Sibling projects (later 908 + * entries with the same `viteConfig`) share those resources. 909 + */ 910 + export async function attachProjectsFromEntries( 911 + vitest: Vitest, 912 + entries: ResolvedProjectEntry[], 913 + ): Promise<TestProject[]> { 914 + // For each unique `viteConfig`, the "primary" project owns the server and 915 + // its server-derived resources (runner, resolver, fetcher, browser 916 + // provider). Siblings (browser instance variants, benchmark variants) share 917 + // these resources by linking to the primary via `_parent`. 918 + const primaryByViteConfig = new Map<ResolvedViteConfig, TestProject>() 919 + 920 + // The root Vite config can also serve as a project's `viteConfig` — either 921 + // the default no-`projects` case or browser/benchmark variants of it. 922 + // `coreWorkspaceProject` is the stable "parent" for that cluster, owning 923 + // any browser provider that gets initialized. Set it up unconditionally 924 + // here so siblings can attach to it. 925 + if (!vitest.coreWorkspaceProject && vitest.vite) { 926 + vitest.coreWorkspaceProject = TestProject._createBasicProject(vitest) 927 + // If the root server is itself a browser server (no `projects`, browser 928 + // enabled at the root), it owns the cluster's parent browser project so 929 + // instance siblings can attach to it. 930 + if (vitest._rootBrowserParent) { 931 + vitest.coreWorkspaceProject._parentBrowser = vitest._rootBrowserParent 932 + } 933 + primaryByViteConfig.set(vitest.vite.config, vitest.coreWorkspaceProject) 558 934 } 559 - else if (project.config.browser.name) { 560 - names.push(project.config.browser.name) 935 + 936 + const projects: TestProject[] = [] 937 + 938 + for (const entry of entries) { 939 + const { viteConfig, projectConfig, hidden } = entry 940 + const primary = primaryByViteConfig.get(viteConfig) 941 + if (primary) { 942 + if (hidden) { 943 + continue 944 + } 945 + // Default-project no-browser case: the entry's `projectConfig` IS the 946 + // root's resolved config. Use `coreWorkspaceProject` directly so 947 + // callers that rely on `project === vitest.getRootProject()` work 948 + // (and so we don't have two TestProjects representing the same root). 949 + if (primary === vitest.coreWorkspaceProject && projectConfig === vitest.config) { 950 + projects.push(vitest.coreWorkspaceProject) 951 + continue 952 + } 953 + const sibling = TestProject._spawnSibling(primary, projectConfig) 954 + // Browser-instance siblings share the primary's single (browser) Vite 955 + // server; each gets its own `ProjectBrowser` view onto it. 956 + if (primary._parentBrowser) { 957 + sibling.browser = primary._parentBrowser.spawn(sibling) 958 + } 959 + projects.push(sibling) 960 + continue 961 + } 962 + 963 + // Workspace project with its own `viteConfig`: own a fresh Vite server. For 964 + // a browser cluster this is the single server shared by `project.vite` and 965 + // `project.browser.vite`. 966 + const { server, parent } = await createClusterServer(vitest, viteConfig, projectConfig) 967 + const project = new TestProject(vitest, server, viteConfig, projectConfig) 968 + project._initializeRunners(server) 969 + if (parent) { 970 + project._parentBrowser = parent 971 + } 972 + primaryByViteConfig.set(viteConfig, project) 973 + if (!hidden) { 974 + projects.push(project) 975 + } 561 976 } 562 - if (project.config.benchmark.enabled) { 563 - names.push(project.name ? `${project.name} (bench)` : 'bench') 977 + 978 + return projects 979 + } 980 + 981 + /** 982 + * Public entry point used by `injectTestProject` to add projects at runtime. 983 + * Resolves entries from the given definitions and attaches `TestProject`s with 984 + * their Vite servers. 985 + */ 986 + export async function resolveAndAttachProjects( 987 + harness: PluginHarness, 988 + definitions: TestProjectConfiguration[], 989 + ): Promise<TestProject[]> { 990 + // Use the same per-entry resolution as the main pipeline (no expansion of 991 + // browser instances or benchmarks here — injected projects already pass 992 + // through the regular expansion via `resolveProjectEntries`). 993 + // 994 + // `throwIfEmpty: false` because filtering an injected project out is 995 + // expected at runtime (the user can call `injectTestProjects` with a name 996 + // that doesn't match the active filter; we just return an empty list). 997 + // 998 + // `existingNames` enforces uniqueness against the already-active workspace. 999 + const vitest = harness.getVitest() 1000 + const entries = await resolveProjectEntries( 1001 + harness, 1002 + vitest.viteConfig, 1003 + vitest.config, 1004 + definitions, 1005 + { 1006 + throwIfEmpty: false, 1007 + existingNames: new Set(vitest.projects.map(p => p.name)), 1008 + }, 1009 + ) 1010 + return attachProjectsFromEntries(vitest, entries) 1011 + } 1012 + 1013 + function isChromiumName(provider: string, name: string) { 1014 + if (provider === 'playwright') { 1015 + return name === 'chromium' 564 1016 } 565 - return names 1017 + return name === 'chrome' || name === 'edge' 566 1018 }
-19
packages/vitest/src/node/reporters/blob.ts
··· 60 60 ) 61 61 }) 62 62 63 - if (project.browser?.vite.environments.client) { 64 - serializedProject.browser = serializeEnvironmentModuleGraph( 65 - project.browser.vite.environments.client, 66 - ) 67 - } 68 - 69 63 for (const [id, value] of project._resolver.externalizeCache.entries()) { 70 64 if (typeof value === 'string') { 71 65 serializedProject.external.push([id, value]) ··· 170 164 projectsArray.map(p => [p.name, p]), 171 165 ) 172 166 173 - for (const project of projectsArray) { 174 - if (project.isBrowserEnabled()) { 175 - await project._initBrowserServer() 176 - } 177 - } 178 - 179 167 blobs.forEach((blob) => { 180 168 Object.entries(blob.environmentModules).forEach(([projectName, modulesByProject]) => { 181 169 const project = projects[projectName] ··· 191 179 const environment = project.vite.environments[environmentName] 192 180 deserializeEnvironmentModuleGraph(environment, moduleGraph) 193 181 }) 194 - 195 - const browserModuleGraph = modulesByProject.browser 196 - if (browserModuleGraph) { 197 - const browserEnvironment = project.browser!.vite.environments.client 198 - deserializeEnvironmentModuleGraph(browserEnvironment, browserModuleGraph) 199 - } 200 182 }) 201 183 }) 202 184 ··· 244 226 environments: { 245 227 [environmentName: string]: SerializedEnvironmentModuleGraph 246 228 } 247 - browser?: SerializedEnvironmentModuleGraph 248 229 external: [id: string, externalized: string][] 249 230 } 250 231 }
+1 -4
packages/vitest/src/node/reporters/reported-tasks.ts
··· 516 516 super(task, project) 517 517 this.moduleId = task.filepath 518 518 this.relativeModuleId = task.name 519 - if (task.viteEnvironment === '__browser__') { 520 - this.viteEnvironment = project.browser?.vite.environments.client 521 - } 522 - else if (typeof task.viteEnvironment === 'string') { 519 + if (typeof task.viteEnvironment === 'string') { 523 520 this.viteEnvironment = project.vite.environments[task.viteEnvironment] 524 521 } 525 522 }
-2
packages/vitest/src/node/state.ts
··· 4 4 import type { MergedBlobs } from './reporters/blob' 5 5 import type { OnUnhandledErrorCallback } from './types/config' 6 6 import { relative } from 'pathe' 7 - import { defaultBrowserPort } from '../constants' 8 7 import { createFileTask, generateFileHash } from '../utils/tasks' 9 8 import { TestCase, TestModule, TestSuite } from './reporters/reported-tasks' 10 9 ··· 42 41 43 42 /** @internal */ 44 43 _data = { 45 - browserLastPort: defaultBrowserPort, 46 44 timeoutIncreased: false, 47 45 } 48 46
+48 -37
packages/vitest/src/node/types/browser.ts
··· 1 1 import type { MockedModule } from '@vitest/mocker' 2 2 import type { Awaitable, ParsedStack, TestError } from '@vitest/utils' 3 3 import type { StackTraceParserOptions } from '@vitest/utils/source-map' 4 - import type { Plugin, ViteDevServer } from 'vite' 4 + import type { IndexHtmlTransformContext, IndexHtmlTransformResult, Plugin, ViteDevServer, UserConfig as ViteUserConfig } from 'vite' 5 5 import type { BrowserCommands, CDPSession, MarkOptions } from 'vitest/browser' 6 6 import type { BrowserTraceViewMode } from '../../runtime/config' 7 7 import type { CancelReason } from '../../runtime/runner/types' 8 8 import type { BrowserTesterOptions } from '../../types/browser' 9 9 import type { OTELCarrier } from '../../utils/traces' 10 + import type { PluginHarness } from '../config/pluginHarness' 11 + import type { Vitest } from '../core' 10 12 import type { TestProject } from '../project' 11 - import type { ApiConfig, ProjectConfig } from './config' 13 + import type { ProjectConfig, ResolvedConfig } from './config' 12 14 13 15 export type { CDPSession } 14 16 ··· 26 28 serverFactory: BrowserServerFactory 27 29 } 28 30 29 - export interface BrowserServerOptions { 30 - project: TestProject 31 - coveragePlugin: () => Plugin 32 - mocksPlugins: (options: { filter: (id: string) => boolean }) => Plugin[] 33 - metaEnvReplacer: () => Plugin 34 - } 35 - 36 31 export interface BrowserServerFactory { 37 - (options: BrowserServerOptions): Promise<ParentProjectBrowser> 32 + (): Promise<BrowserServerContribution> 38 33 } 39 34 40 35 export interface BrowserProvider { ··· 170 165 * @default process.env.CI 171 166 */ 172 167 headless?: boolean 173 - 174 - /** 175 - * Serve API options. 176 - * 177 - * The default port is 63315. 178 - */ 179 - api?: ApiConfig | number 180 - 181 - /** 182 - * Isolate test environment after each test 183 - * 184 - * @default true 185 - * @deprecated use top-level `isolate` instead 186 - */ 187 - isolate?: boolean 188 - 189 - /** 190 - * Run test files in parallel if provider supports this option 191 - * This option only has effect in headless mode (enabled in CI by default) 192 - * 193 - * @default // Same as "test.fileParallelism" 194 - * @deprecated use top-level `fileParallelism` instead 195 - */ 196 - fileParallelism?: boolean 197 168 198 169 /** 199 170 * Show Vitest UI ··· 395 366 export interface ParentProjectBrowser { 396 367 spawn: (project: TestProject) => ProjectBrowser 397 368 vite: ViteDevServer 369 + vitest: Vitest 370 + config: ResolvedConfig 371 + } 372 + 373 + export interface BrowserServerContribution { 374 + transformIndexHtml: (ctx: IndexHtmlTransformContext) => Awaitable<IndexHtmlTransformResult | undefined> 375 + configureServer: (server: ViteDevServer) => Awaitable<void> 376 + /** 377 + * Browser-specific Vite config (`resolve.alias`, `define`, esbuild). Applied 378 + * by the core loader plugin's `config` hook during the single project 379 + * resolution, so other plugins observe it (e.g. alias must be baked at 380 + * resolution time). The loader always forces `server.middlewareMode = false` 381 + * on top. `harness` provides the package installer's `isPackageExists` (no 382 + * `Vitest` instance is available during resolution). 383 + */ 384 + config: (config: ViteUserConfig, harness: PluginHarness) => Awaitable<ViteUserConfig> 385 + /** 386 + * Browser `optimizeDeps`, aggregated across every project that shares the 387 + * single browser Vite server (instance and benchmark variants). Called by core 388 + * after all projects are resolved and before the server is created; the result 389 + * is merged into the resolved Vite config's `client` environment 390 + * `optimizeDeps`. `testFiles` is the aggregated, already-globbed set of test 391 + * files for the server (globbing lives in the core package). 392 + */ 393 + resolveOptimizeDeps: (projectConfigs: ResolvedConfig[], testFiles: string[], harness: PluginHarness) => Awaitable<NonNullable<ViteUserConfig['optimizeDeps']>> 394 + /** 395 + * Runtime plugins. Injected into the browser (`client`) environment by the 396 + * loader's `applyToEnvironment`; their `configureServer`/`transformIndexHtml` 397 + * are run by the loader. MUST NOT define `config`/`configResolved` hooks. 398 + */ 399 + plugins: Plugin[] 400 + /** 401 + * Constructs the `ParentBrowserProject`. Called by core at server creation, 402 + * when the `Vitest` instance exists. 403 + */ 404 + createParent: (ctx: { config: ResolvedConfig; vitest: Vitest }) => ParentProjectBrowser 405 + /** Called by core after `server.listen()` to wire up the browser RPC. */ 406 + setupRpc: (parent: ParentProjectBrowser) => void 407 + /** 408 + * Mutable. Filled by core at server creation; the pushed `BrowserPlugin` 409 + * closes over this same object and reads `.parent` in `configureServer`. 410 + */ 411 + parent?: ParentProjectBrowser 398 412 } 399 413 400 414 export interface ProjectBrowser { ··· 456 470 name: string 457 471 enabled: boolean 458 472 headless: boolean 459 - isolate: boolean 460 - fileParallelism: boolean 461 - api: ApiConfig 462 473 ui: boolean 463 474 viewport: { 464 475 width: number
+50 -10
packages/vitest/src/node/types/config.ts
··· 3 3 import type { SnapshotStateOptions } from '@vitest/snapshot' 4 4 import type { Arrayable } from '@vitest/utils' 5 5 import type { SerializedDiffOptions } from '@vitest/utils/diff' 6 - import type { AliasOptions, ConfigEnv, DepOptimizationConfig, ServerOptions, UserConfig as ViteUserConfig } from 'vite' 6 + import type { AliasOptions, ConfigEnv, DepOptimizationConfig, ResolvedConfig as ResolvedViteConfig, ServerOptions, UserConfig as ViteUserConfig } from 'vite' 7 7 import type { ChaiConfig } from '../../integrations/chai/config' 8 8 import type { SerializedConfig } from '../../runtime/config' 9 9 import type { SequenceHooks, SequenceSetupFiles, SerializableRetry, TestTagDefinition } from '../../runtime/runner/types' 10 10 import type { LabelColor, ParsedStack, ProvidedContext, TestError } from '../../types/general' 11 11 import type { HappyDOMOptions } from '../../types/happy-dom-options' 12 12 import type { JSDOMOptions } from '../../types/jsdom-options' 13 + import type { CliOptions } from '../cli/cli-api' 13 14 import type { PoolRunnerInitializer } from '../pools/types' 14 15 import type { 15 16 BuiltinReporterOptions, ··· 20 21 import type { VCSProvider } from '../vcs/vcs' 21 22 import type { WatcherTriggerPattern } from '../watcher' 22 23 import type { BenchmarkUserOptions } from './benchmark' 23 - import type { BrowserConfigOptions, ResolvedBrowserOptions } from './browser' 24 + import type { BrowserConfigOptions, BrowserServerContribution, ResolvedBrowserOptions } from './browser' 24 25 import type { CoverageOptions, ResolvedCoverageOptions } from './coverage' 25 26 import type { Reporter } from './reporter' 26 27 ··· 61 62 */ 62 63 allowExec?: boolean 63 64 } 65 + 66 + export type ResolvedApiConfig = ApiConfig & { token: string; tokenCreated: boolean } 64 67 65 68 export interface EnvironmentOptions { 66 69 /** ··· 683 686 sequence?: SequenceOptions 684 687 685 688 /** 689 + * Overrides Vite mode 690 + * @default 'test' 691 + */ 692 + mode?: string 693 + 694 + /** 686 695 * Specifies an `Object`, or an `Array` of `Object`, 687 696 * which defines aliases used to replace values in `import` or `require` statements. 688 697 * Will be merged with the default aliases inside `resolve.alias`. ··· 1075 1084 related?: string[] | string 1076 1085 1077 1086 /** 1078 - * Overrides Vite mode 1079 - * @default 'test' 1080 - */ 1081 - mode?: string 1082 - 1083 - /** 1084 1087 * Test suite shard to execute in a format of <index>/<count>. 1085 1088 * Will divide tests into a `count` numbers, and run only the `indexed` part. 1086 1089 * Cannot be used with enabled watch. ··· 1125 1128 * Log all available tags instead of running tests. 1126 1129 */ 1127 1130 listTags?: boolean | 'json' 1131 + 1132 + configLoader?: 'bundle' | 'runner' | 'native' 1133 + 1134 + /** 1135 + * The `--reporter` argument from the CLI 1136 + */ 1137 + reporter?: string | string[] 1128 1138 } 1129 1139 1130 1140 export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void ··· 1158 1168 | 'vmMemoryLimit' 1159 1169 | 'fileParallelism' 1160 1170 | 'tagsFilter' 1171 + | 'reporter' 1161 1172 > { 1162 1173 name: ProjectName['label'] 1163 1174 color?: ProjectName['color'] ··· 1183 1194 reporters: (InlineReporter | ReporterWithOptions)[] 1184 1195 1185 1196 defines: Record<string, any> 1186 - viteDefine: Record<string, any> 1187 1197 1188 - api: ApiConfig & { token: string; tokenCreated: boolean } 1198 + api: ResolvedApiConfig 1189 1199 cliExclude?: string[] 1190 1200 1191 1201 project: string[] ··· 1237 1247 } 1238 1248 } 1239 1249 } 1250 + 1251 + cliOptions: CliOptions 1252 + viteOverrides: ViteUserConfig 1253 + resolvedProjects: ResolvedProjectEntry[] 1254 + /** 1255 + * Browser server contribution captured by the `vitest:browser:loader` plugin 1256 + * during this config's resolution (set only when `browser.enabled`). Used by 1257 + * server creation to build the single Vite server shared by `project.vite` and 1258 + * `project.browser.vite`. 1259 + * 1260 + * @internal 1261 + */ 1262 + _browserContribution?: BrowserServerContribution 1263 + } 1264 + 1265 + /** 1266 + * A resolved project entry. `viteConfig` may be shared by reference across multiple 1267 + * entries (e.g. browser instances or benchmark variants of the same parent), while 1268 + * `projectConfig` is always a distinct object per entry. 1269 + */ 1270 + export interface ResolvedProjectEntry { 1271 + viteConfig: ResolvedViteConfig 1272 + projectConfig: ResolvedConfig 1273 + /** 1274 + * When set, this entry exists only so browser-instance siblings can attach 1275 + * to a parent that owns the Vite server and (later) the browser provider. 1276 + * The resulting `TestProject` is created and kept alive (so siblings can 1277 + * reference it via `_parent`) but is NOT pushed to `vitest.projects`. 1278 + */ 1279 + hidden?: boolean 1240 1280 } 1241 1281 1242 1282 type NonProjectOptions
+9 -1
packages/vitest/src/node/types/vite.ts
··· 1 1 /* eslint-disable unused-imports/no-unused-vars */ 2 2 3 3 import type { HookHandler } from 'vite' 4 - import type { InlineConfig } from './config' 4 + import type { InlineConfig, ResolvedConfig } from './config' 5 5 import type { VitestPluginContext } from './plugin' 6 6 7 7 type VitestInlineConfig = InlineConfig 8 + type VitestResolvedConfig = ResolvedConfig 8 9 9 10 declare module 'vite' { 10 11 interface UserConfig { ··· 12 13 * Options for Vitest 13 14 */ 14 15 test?: VitestInlineConfig 16 + } 17 + 18 + interface ResolvedConfig { 19 + /** 20 + * Options for Vitest 21 + */ 22 + test: VitestResolvedConfig 15 23 } 16 24 17 25 interface Plugin<A = any> {
+1 -1
packages/vitest/src/node/vite.ts
··· 2 2 import { cleanUrl } from '@vitest/utils/helpers' 3 3 import { createServer, isFileLoadingAllowed, normalizePath } from 'vite' 4 4 5 - export async function createViteServer(inlineConfig: InlineConfig): Promise<ViteDevServer> { 5 + export async function createViteServer(inlineConfig: InlineConfig | ResolvedConfig): Promise<ViteDevServer> { 6 6 // Vite prints an error (https://github.com/vitejs/vite/issues/14328) 7 7 // But Vitest works correctly either way 8 8 const error = console.error
+1
packages/vitest/src/public/browser.ts
··· 9 9 loadDiffConfig, 10 10 loadSnapshotSerializers, 11 11 setupCommonEnv, 12 + setupEnv, 12 13 } from '../runtime/setup-common' 13 14 export * as SpyModule from '@vitest/spy' 14 15 export type { ParsedStack, StringifyOptions } from '@vitest/utils'
+8 -7
packages/vitest/src/public/node.ts
··· 6 6 export { isValidApiRequest } from '../api/check' 7 7 export { escapeTestName } from '../node/ast-collect' 8 8 export type { CacheKeyIdGenerator, CacheKeyIdGeneratorContext } from '../node/cache/fsModuleCache' 9 - export { parseCLI } from '../node/cli/cac' 9 + export { createCLI, parseCLI } from '../node/cli/cac' 10 10 export type { CliParseOptions } from '../node/cli/cac' 11 11 export type { CliOptions } from '../node/cli/cli-api' 12 12 export { startVitest } from '../node/cli/cli-api' 13 + export { PluginHarness } from '../node/config/pluginHarness' 13 14 export { resolveApiServerConfig } from '../node/config/resolveConfig' 15 + export { resolveConfig } from '../node/config/resolveConfig' 14 16 export type { 15 17 OnServerRestartHandler, 16 18 OnTestsRerunHandler, ··· 20 22 export { BaseCoverageProvider } from '../node/coverage' 21 23 export { createVitest } from '../node/create' 22 24 export { GitNotFoundError, FilesNotFoundError as TestsNotFoundError } from '../node/errors' 25 + export { Logger } from '../node/logger' 23 26 export { VitestPackageInstaller } from '../node/packageInstaller' 24 - export { VitestPlugin } from '../node/plugins' 25 - export { resolveConfig } from '../node/plugins/publicConfig' 26 27 export { resolveFsAllow } from '../node/plugins/utils' 27 28 export type { ProcessPool } from '../node/pool' 28 29 export { getFilePoolName } from '../node/pool' ··· 40 41 export { TypecheckPoolWorker } from '../node/pools/workers/typecheckWorker' 41 42 export { VmForksPoolWorker } from '../node/pools/workers/vmForksWorker' 42 43 export { VmThreadsPoolWorker } from '../node/pools/workers/vmThreadsWorker' 44 + 43 45 export type { SerializedTestProject, TestProject } from '../node/project' 44 - 45 46 export { 46 47 AgentReporter, 47 48 DefaultReporter, ··· 69 70 } from '../node/reporters' 70 71 export type { HTMLOptions } from '../node/reporters/html' 71 72 export type { JsonOptions } from '../node/reporters/json' 72 - export type { JUnitOptions } from '../node/reporters/junit' 73 73 74 + export type { JUnitOptions } from '../node/reporters/junit' 74 75 export type { Report } from '../node/reporters/report' 75 76 export type { 76 77 ModuleDiagnostic, ··· 90 91 TestSuiteState, 91 92 } from '../node/reporters/reported-tasks' 92 93 export { experimental_getRunnerTask } from '../node/reporters/reported-tasks' 94 + 93 95 export { BaseSequencer } from '../node/sequencers/BaseSequencer' 94 - 95 96 export type { 96 97 TestSequencer, 97 98 TestSequencerConstructor, ··· 112 113 BrowserProvider, 113 114 BrowserProviderOption, 114 115 BrowserScript, 116 + BrowserServerContribution, 115 117 BrowserServerFactory, 116 - BrowserServerOptions, 117 118 BrowserServerState, 118 119 BrowserServerStateSession, 119 120 CDPSession,
-2
packages/vitest/src/runtime/config.ts
··· 111 111 browser: { 112 112 name: string 113 113 headless: boolean 114 - isolate: boolean 115 - fileParallelism: boolean 116 114 ui: boolean 117 115 viewport: { 118 116 width: number
+3 -1
packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts
··· 26 26 27 27 export interface VitestModuleEvaluatorOptions { 28 28 evaluatedModules?: VitestEvaluatedModules 29 + metaEnv?: ModuleRunnerImportMeta['env'] 29 30 interopDefault?: boolean | undefined 30 31 moduleExecutionInfo?: ModuleExecutionInfo 31 32 getCurrentTestFilepath?: () => string | undefined ··· 40 41 41 42 export class VitestModuleEvaluator implements ModuleEvaluator { 42 43 public stubs: Record<string, any> = {} 43 - public env: ModuleRunnerImportMeta['env'] = createImportMetaEnvProxy() 44 + public env: ModuleRunnerImportMeta['env'] 44 45 private vm: VitestVmOptions | undefined 45 46 46 47 private compiledFunctionArgumentsNames?: string[] ··· 64 65 private options: VitestModuleEvaluatorOptions = {}, 65 66 ) { 66 67 this._otel = options.traces || new Traces({ enabled: false }) 68 + this.env = options.metaEnv ?? createImportMetaEnvProxy() 67 69 this.vm = vmOptions 68 70 this.stubs = getDefaultRequestStubs(vmOptions?.context) 69 71 this._evaluatedModules = options.evaluatedModules
+2 -3
packages/vitest/src/runtime/moduleRunner/moduleRunner.ts
··· 22 22 const defaultMeta = viteModuleRunner.createDefaultImportMeta(modulePath) 23 23 const href = defaultMeta.url 24 24 25 - const importMetaResolver = createImportMetaResolver() 25 + const importMetaResolver = createImportMetaResolver() ?? defaultMeta.resolve 26 26 27 27 return { 28 28 ...defaultMeta, 29 29 main: false, 30 30 resolve(id: string, parent?: string) { 31 - const resolver = importMetaResolver ?? defaultMeta.resolve 32 - return resolver(id, parent ?? href) 31 + return importMetaResolver(id, parent ?? href) 33 32 }, 34 33 } 35 34 }
+1
packages/vitest/src/runtime/moduleRunner/startVitestModuleRunner.ts
··· 58 58 vm, 59 59 { 60 60 traces, 61 + metaEnv: state().metaEnv, 61 62 evaluatedModules: options.evaluatedModules, 62 63 get moduleExecutionInfo() { 63 64 return state().moduleExecutionInfo
+1 -1
packages/vitest/src/runtime/runner/types.ts
··· 1402 1402 * 1403 1403 * Extend this interface when creating custom test artifacts. Vitest automatically manages the `attachments` array and injects the `location` property to indicate where the artifact was created in your test code. 1404 1404 * 1405 - * **Important**: when running with [`api.allowWrite`](https://vitest.dev/config/api#api-allowwrite) or [`browser.api.allowWrite`](https://vitest.dev/config/browser/api#api-allowwrite) disabled, Vitest empties the `attachments` array on every artifact before reporting it. 1405 + * **Important**: when running with [`api.allowWrite`](https://vitest.dev/config/api#api-allowwrite) disabled, Vitest empties the `attachments` array on every artifact before reporting it. 1406 1406 */ 1407 1407 export interface TestArtifactBase { 1408 1408 /** File or data attachments associated with this artifact */
+3 -10
packages/vitest/src/runtime/setup-common.ts
··· 5 5 import type { PublicModuleRunner } from './moduleRunner/types' 6 6 import { addSerializer } from '@vitest/snapshot' 7 7 import { setSafeTimers } from '@vitest/utils/timers' 8 - import { getWorkerState } from './utils' 9 8 10 9 let globalSetup = false 11 10 export async function setupCommonEnv(config: SerializedConfig): Promise<void> { 12 11 setupDefines(config) 13 - setupEnv(config.env) 14 12 15 13 if (globalSetup) { 16 14 return ··· 30 28 } 31 29 } 32 30 33 - function setupEnv(env: Record<string, any>) { 34 - const state = getWorkerState() 35 - // same boolean-to-string assignment as VitestPlugin.configResolved 36 - const { PROD, DEV, ...restEnvs } = env 37 - state.metaEnv.PROD = PROD 38 - state.metaEnv.DEV = DEV 39 - for (const key in restEnvs) { 40 - state.metaEnv[key] = env[key] 31 + export function setupEnv(env: Record<string, any>, metaEnv: Record<string, any>): void { 32 + for (const key in env) { 33 + metaEnv[key] = env[key] 41 34 } 42 35 } 43 36
+1 -33
packages/vitest/src/runtime/worker.ts
··· 47 47 onFilterStackTrace(stack) { 48 48 return createStackString(parseStacktrace(stack)) 49 49 }, 50 - metaEnv: createImportMetaEnvProxy(), 50 + metaEnv: ctx.metaEnv, 51 51 getterTracker: ctx.config.benchmark.enabled && !ctx.config.benchmark.suppressExportGetterWarnings 52 52 ? new GetterTracker() 53 53 : undefined, ··· 80 80 export async function teardown(): Promise<void> { 81 81 await listeners.cleanup() 82 82 } 83 - 84 - const env = process.env 85 - 86 - function createImportMetaEnvProxy(): WorkerGlobalState['metaEnv'] { 87 - // packages/vitest/src/node/plugins/index.ts:146 88 - const booleanKeys = ['DEV', 'PROD', 'SSR'] 89 - return new Proxy(env, { 90 - get(_, key) { 91 - if (typeof key !== 'string') { 92 - return undefined 93 - } 94 - if (booleanKeys.includes(key)) { 95 - return !!process.env[key] 96 - } 97 - return process.env[key] 98 - }, 99 - set(_, key, value) { 100 - if (typeof key !== 'string') { 101 - return true 102 - } 103 - 104 - if (booleanKeys.includes(key)) { 105 - process.env[key] = value ? '1' : '' 106 - } 107 - else { 108 - process.env[key] = value 109 - } 110 - 111 - return true 112 - }, 113 - }) as WorkerGlobalState['metaEnv'] 114 - }
+3
packages/vitest/src/runtime/workers/base.ts
··· 15 15 import { createNodeImportMeta } from '../moduleRunner/moduleRunner' 16 16 import { startVitestModuleRunner } from '../moduleRunner/startVitestModuleRunner' 17 17 import { run } from '../runBaseTests' 18 + import { setupEnv } from '../setup-common' 18 19 import { getSafeWorkerState, provideWorkerState } from '../utils' 19 20 20 21 let _moduleRunner: TestModuleRunner ··· 81 82 rpc, 82 83 config, 83 84 } = context 85 + 86 + setupEnv(config.env, context.metaEnv) 84 87 85 88 // we could load @vite/env, but it would take ~8ms, while this takes ~0,02ms 86 89 if (context.config.serializedDefines) {
+31 -1
packages/vitest/src/runtime/workers/init.ts
··· 1 1 import type { WorkerRequest, WorkerResponse } from '../../node/pools/types' 2 - import type { WorkerSetupContext } from '../../types/worker' 2 + import type { MetaEnv, WorkerSetupContext } from '../../types/worker' 3 3 import type { FileSpecification } from '../runner/types' 4 4 import type { VitestWorker } from './types' 5 5 import { serializeError } from '@vitest/utils/error' ··· 8 8 import * as listeners from '../listeners' 9 9 import { createRuntimeRpc } from '../rpc' 10 10 import * as entrypoint from '../worker' 11 + 12 + function createImportMetaEnvProxy(): MetaEnv { 13 + const booleanKeys = ['DEV', 'PROD', 'SSR'] 14 + return new Proxy(process.env, { 15 + get(_, key) { 16 + if (typeof key !== 'string') { 17 + return undefined 18 + } 19 + if (booleanKeys.includes(key)) { 20 + return !!process.env[key] 21 + } 22 + return process.env[key] 23 + }, 24 + set(_, key, value) { 25 + if (typeof key !== 'string') { 26 + return true 27 + } 28 + if (booleanKeys.includes(key)) { 29 + process.env[key] = value ? '1' : '' 30 + } 31 + else { 32 + process.env[key] = value 33 + } 34 + return true 35 + }, 36 + }) as MetaEnv 37 + } 38 + 39 + const importMetaEnvProxy = createImportMetaEnvProxy() 11 40 12 41 interface Options extends VitestWorker { 13 42 teardown?: () => void ··· 74 103 config, 75 104 pool, 76 105 rpc, 106 + metaEnv: importMetaEnvProxy, 77 107 projectName: config.name || '', 78 108 traces, 79 109 }
+3
packages/vitest/src/runtime/workers/vm.ts
··· 13 13 import { getDefaultRequestStubs } from '../moduleRunner/moduleEvaluator' 14 14 import { createNodeImportMeta } from '../moduleRunner/moduleRunner' 15 15 import { startVitestModuleRunner, VITEST_VM_CONTEXT_SYMBOL } from '../moduleRunner/startVitestModuleRunner' 16 + import { setupEnv } from '../setup-common' 16 17 import { provideWorkerState } from '../utils' 17 18 import { FileMap } from '../vm/file-map' 18 19 ··· 118 119 writable: false, 119 120 }) 120 121 context.__vitest_mocker__ = moduleRunner.mocker 122 + 123 + setupEnv(ctx.config.env, state.metaEnv) 121 124 122 125 if (ctx.config.serializedDefines) { 123 126 try {
+12 -8
packages/vitest/src/types/worker.ts
··· 31 31 workerId: number 32 32 } 33 33 34 + export interface MetaEnv { 35 + [key: string]: any 36 + BASE_URL: string 37 + MODE: string 38 + DEV: boolean 39 + PROD: boolean 40 + SSR: boolean 41 + } 42 + 34 43 export interface ContextRPC { 35 44 pool: string 36 45 config: SerializedConfig 37 46 projectName: string 38 47 environment: WorkerTestEnvironment 39 48 rpc: WorkerRPC 49 + metaEnv: MetaEnv 40 50 files: FileSpecification[] 41 51 providedContext: Record<string, any> 42 52 invalidates?: string[] ··· 52 62 config: SerializedConfig 53 63 projectName: string 54 64 rpc: WorkerRPC 65 + metaEnv: MetaEnv 55 66 /** 56 67 * @internal 57 68 */ ··· 64 75 rpc: WorkerRPC 65 76 current?: Task 66 77 filepath?: string 67 - metaEnv: { 68 - [key: string]: any 69 - BASE_URL: string 70 - MODE: string 71 - DEV: boolean 72 - PROD: boolean 73 - SSR: boolean 74 - } 78 + metaEnv: MetaEnv 75 79 environment: Environment 76 80 evaluatedModules: EvaluatedModules 77 81 resolvingModules: Set<string>
+1 -1
packages/vitest/src/utils/environments.ts
··· 3 3 4 4 export function getTestFileEnvironment(project: TestProject, testFile: string, browser = false): DevEnvironment | undefined { 5 5 if (browser) { 6 - return project.browser?.vite.environments.client 6 + return project.vite.environments.client 7 7 } 8 8 else { 9 9 for (const name in project.vite.environments) {
+12 -4
packages/vitest/src/utils/graph.ts
··· 1 - import type { EnvironmentModuleNode } from 'vite' 1 + import type { DevEnvironment, EnvironmentModuleNode } from 'vite' 2 2 import type { Vitest } from '../node/core' 3 3 import type { ModuleGraphData } from '../types/general' 4 4 import { getTestFileEnvironment } from './environments' ··· 7 7 ctx: Vitest, 8 8 projectName: string, 9 9 testFilePath: string, 10 + viteEnvironment?: string, 10 11 ): Promise<ModuleGraphData> { 11 12 const graph: Record<string, string[]> = {} 12 13 const externalized = new Set<string>() ··· 15 16 const project = ctx.getProjectByName(projectName) 16 17 const browser = project.config.browser.enabled 17 18 18 - const environment = project.config.experimental.viteModuleRunner === false 19 - ? project.vite.environments.__vitest__ 20 - : getTestFileEnvironment(project, testFilePath, browser) 19 + let environment: DevEnvironment | undefined 20 + 21 + if (viteEnvironment) { 22 + environment = project.vite.environments[viteEnvironment] 23 + } 24 + else { 25 + environment = project.config.experimental.viteModuleRunner === false 26 + ? project.vite.environments.__vitest__ 27 + : getTestFileEnvironment(project, testFilePath, browser) 28 + } 21 29 22 30 if (!environment) { 23 31 throw new Error(`Cannot find environment for ${testFilePath}`)
+6 -3
pnpm-lock.yaml
··· 267 267 version: 6.3.0(rollup@4.59.0)(typescript@5.9.3) 268 268 rollup-plugin-license: 269 269 specifier: ^3.7.0 270 - version: 3.7.0(picomatch@4.0.4)(rollup@4.59.0) 270 + version: 3.7.0(picomatch@4.0.3)(rollup@4.59.0) 271 271 semver: 272 272 specifier: 'catalog:' 273 273 version: 7.8.3 ··· 1367 1367 other-dep: 1368 1368 specifier: file:./deps/vite-ssr-resolve/other-dep 1369 1369 version: file:test/e2e/deps/vite-ssr-resolve/other-dep 1370 + pathe: 1371 + specifier: 'catalog:' 1372 + version: 2.0.3 1370 1373 playwright: 1371 1374 specifier: 'catalog:' 1372 1375 version: 1.61.0 ··· 19024 19027 optionalDependencies: 19025 19028 '@babel/code-frame': 7.27.1 19026 19029 19027 - rollup-plugin-license@3.7.0(picomatch@4.0.4)(rollup@4.59.0): 19030 + rollup-plugin-license@3.7.0(picomatch@4.0.3)(rollup@4.59.0): 19028 19031 dependencies: 19029 19032 commenting: 1.1.0 19030 - fdir: 6.5.0(picomatch@4.0.4) 19033 + fdir: 6.5.0(picomatch@4.0.3) 19031 19034 lodash: 4.17.21 19032 19035 magic-string: 0.30.21 19033 19036 moment: 2.30.1
+1 -1
test/browser/fixtures/expect-dom/vitest.config.js
··· 5 5 export default defineConfig({ 6 6 cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), 7 7 test: { 8 + isolate: false, 8 9 browser: { 9 10 enabled: true, 10 11 provider, 11 12 instances, 12 - isolate: false, 13 13 expect: { 14 14 toMatchScreenshot: { 15 15 comparators: {
+1 -1
test/browser/fixtures/failing/vitest.config.ts
··· 5 5 export default defineConfig({ 6 6 cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), 7 7 test: { 8 + isolate: false, 8 9 browser: { 9 10 enabled: true, 10 11 provider, 11 12 instances, 12 - isolate: false, 13 13 }, 14 14 }, 15 15 })
+1 -1
test/browser/fixtures/isolate-and-setup-file/vitest.config.ts
··· 5 5 export default defineConfig({ 6 6 cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), 7 7 test: { 8 + isolate: false, 8 9 setupFiles: ['./browser-setup.ts'], 9 10 browser: { 10 11 enabled: true, 11 - isolate: false, 12 12 provider, 13 13 instances, 14 14 headless: false,
+1 -1
test/browser/fixtures/mocking-watch/vitest.config.ts
··· 8 8 }, 9 9 cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), 10 10 test: { 11 + fileParallelism: false, 11 12 browser: { 12 - fileParallelism: false, 13 13 enabled: true, 14 14 provider, 15 15 instances,
+1 -1
test/browser/fixtures/print-logs/vitest.config.ts
··· 5 5 export default defineConfig({ 6 6 cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), 7 7 test: { 8 + isolate: false, 8 9 browser: { 9 10 enabled: true, 10 11 provider, 11 12 instances, 12 - isolate: false, 13 13 }, 14 14 }, 15 15 })
+1 -1
test/browser/fixtures/server-url/vitest.config.ts
··· 23 23 !!process.env.TEST_HTTPS && basicSsl(), 24 24 ], 25 25 test: { 26 + api: process.env.TEST_HTTPS ? 51122 : 51133, 26 27 browser: { 27 - api: process.env.TEST_HTTPS ? 51122 : 51133, 28 28 enabled: true, 29 29 provider: configuredProvider, 30 30 instances,
+1 -1
test/browser/specs/concurrency-id.test.ts
··· 26 26 27 27 const { stderr, stdout } = await runInlineBrowserTests(files, { 28 28 maxWorkers, 29 + fileParallelism: true, 29 30 browser: { 30 - fileParallelism: true, 31 31 instances: [firstInstance], 32 32 }, 33 33 reporters: [
+10 -12
test/browser/specs/errors.test.ts
··· 110 110 }) 111 111 `, 112 112 }, { 113 - browser: { 114 - api: { 115 - allowExec: false, 116 - allowWrite: false, 117 - }, 113 + api: { 114 + allowExec: false, 115 + allowWrite: false, 118 116 }, 119 117 $cliOptions: { 120 118 update: true, ··· 147 145 148 146 const projectTree = buildTestProjectTree(results, (testCase) => { 149 147 const result = testCase.result() 150 - return result.errors.map((e) => { 151 - const stacks = e.stacks.map((s) => { 148 + return result.errors?.map((e) => { 149 + const stacks = e.stacks?.map((s) => { 152 150 const normalizedFile = path 153 151 .relative(ctx.config.root, s.file) 154 152 .replace( ··· 230 228 }) 231 229 `, 232 230 }, { 231 + api: { 232 + allowExec: false, 233 + allowWrite: false, 234 + }, 233 235 browser: { 234 236 instances: [{ browser: 'chromium' }], 235 237 screenshotFailures: false, 236 - api: { 237 - allowExec: false, 238 - allowWrite: false, 239 - }, 240 238 }, 241 239 }) 242 240 expect(result.errorTree({ project: true })).toMatchInlineSnapshot(` ··· 244 242 "chromium": { 245 243 "cdp.test.ts": { 246 244 "cdp throws an error": [ 247 - "Cannot use CDP because browser API write or exec operations are disabled. See https://vitest.dev/config/browser/api.", 245 + "Cannot use CDP because browser API write or exec operations are disabled. See https://vitest.dev/config/api.", 248 246 ], 249 247 }, 250 248 },
+1 -1
test/browser/specs/playwright-connect.test.ts
··· 16 16 '--host', 17 17 '127.0.0.1', 18 18 '--unsafe', 19 - ]).process 19 + ]).process! 20 20 const cli = new Cli({ 21 21 stdin: subprocess.stdin, 22 22 stdout: subprocess.stdout,
+2 -2
test/browser/specs/runner.test.ts
··· 66 66 test('tests are actually running', () => { 67 67 expect(stderr).toBe('') 68 68 69 - const testFiles = browserResultJson.testResults.map(t => t.name) 69 + const testFiles = Array.from(new Set(browserResultJson.testResults.map(t => t.name))) 70 70 71 71 vitest.projects.forEach((project) => { 72 72 // the order is non-deterministic ··· 75 75 76 76 // test files are optimized automatically (type-check-only files are excluded) 77 77 const runtimeTestFiles = testFiles.filter(f => !f.endsWith('.test-d.ts')) 78 - expect(vitest.projects.map(p => p.browser?.vite.config.optimizeDeps.entries)) 78 + expect(vitest.projects.map(p => p.vite.config.optimizeDeps.entries)) 79 79 .toEqual(vitest.projects.map(() => expect.arrayContaining(runtimeTestFiles))) 80 80 81 81 const testFilesCount = readdirSync('./test')
+4 -4
test/browser/specs/server-url.test.ts
··· 10 10 root: './fixtures/server-url', 11 11 watch: true, // otherwise the browser is closed before we can get the url 12 12 }) 13 - const url = ctx?.projects[0].browser?.vite.resolvedUrls?.local[0] 13 + const url = ctx?.projects[0].vite.resolvedUrls?.local[0] 14 14 expect(stderr).toBe('') 15 - expect(url).toBeDefined() 15 + expect.assert(url) 16 16 expect(new URL(url).port).toBe('51133') 17 17 }) 18 18 ··· 23 23 watch: true, // otherwise the browser is closed before we can get the url 24 24 }) 25 25 expect(stderr).toBe('') 26 - const url = ctx?.projects[0].browser?.vite.resolvedUrls?.local[0] 27 - expect(url).toBeDefined() 26 + const url = ctx?.projects[0].vite.resolvedUrls?.local[0] 27 + expect.assert(url) 28 28 expect(new URL(url).port).toBe('51122') 29 29 expect(stdout).toReportSummaryTestFiles({ passed: instances.length }) 30 30 })
+6 -6
test/browser/test/browser.bench.ts
··· 11 11 } 12 12 13 13 test('perProject registrations flow through the browser RPC (onTestBenchmark)', async ({ bench }) => { 14 - await bench('1 + 1', { perProject: true, ...fastBenchOptions }, () => { 14 + await bench('1 + 1', { perProject: true }, () => { 15 15 const result = 1 + 1 16 16 expect.assert(result === 2) 17 - }).run() 18 - await bench('1 + 2', { perProject: true, ...fastBenchOptions }, () => { 17 + }).run(fastBenchOptions) 18 + await bench('1 + 2', { perProject: true }, () => { 19 19 const result = 1 + 2 20 20 expect.assert(result === 3) 21 - }).run() 21 + }).run(fastBenchOptions) 22 22 }) 23 23 24 24 test('bench.compare resolves a BenchStorage in the browser', async ({ bench }) => { ··· 36 36 // The browser worker forwards writeResult through the WebSocket RPC to the 37 37 // node side. We don't assert on the file contents here (the spec layer can 38 38 // do that), just that the round-trip completes without throwing. 39 - const result = await bench('with-write', { writeResult: './out/with-write.json', ...fastBenchOptions }, () => { 39 + const result = await bench('with-write', { writeResult: './dist/with-write.json' }, () => { 40 40 const _ = 1 + 1 41 - }).run() 41 + }).run(fastBenchOptions) 42 42 expect.assert(typeof result.latency.mean === 'number') 43 43 })
+1 -1
test/browser/test/viewport.test.ts
··· 5 5 // preview cannot control viewport 6 6 server.provider === 'preview' 7 7 // other tests affect the viewport if they run in a different order 8 - || server.config.browser.isolate === false, 8 + || server.config.isolate === false, 9 9 )('viewport window has been properly initialized', () => { 10 10 it.skipIf(!server.config.browser.headless)('viewport has proper size', () => { 11 11 const { width, height } = server.config.browser.viewport
+3 -3
test/coverage-test/fixtures/configs/vitest.config.workspace.ts
··· 10 10 extends: true, 11 11 test: { 12 12 name: "project1", 13 - root: "fixtures/workspaces/project/project1", 13 + root: "./project1", 14 14 }, 15 15 }, 16 16 { 17 17 extends: true, 18 18 test: { 19 19 name: "project2", 20 - root: "fixtures/workspaces/project/project2", 20 + root: "./project2", 21 21 }, 22 22 }, 23 23 { 24 24 extends: true, 25 25 test: { 26 26 name: 'project-shared', 27 - root: 'fixtures/workspaces/project/shared', 27 + root: './shared', 28 28 } 29 29 } 30 30 ]
-6
test/coverage-test/test/browser-api-permissions.browser.test.ts
··· 7 7 allowExec: false, 8 8 allowWrite: false, 9 9 }, 10 - browser: { 11 - api: { 12 - allowExec: false, 13 - allowWrite: false, 14 - }, 15 - }, 16 10 include: ['fixtures/test/math.test.ts'], 17 11 coverage: { 18 12 reporter: 'json',
+1 -1
test/coverage-test/test/cjs-dependency.test.ts
··· 10 10 }, 11 11 }, undefined, { 12 12 cacheDir: fileURLToPath(new URL('./node_modules/.vite', import.meta.url)), 13 - optimizeDeps: { include: ['@vitest/cjs-lib', '/Users/ari/Git/vitest/test/browser/cjs-lib'] }, 13 + optimizeDeps: { include: ['@vitest/cjs-lib'] }, 14 14 }) 15 15 const coverageMap = await readCoverageMap() 16 16 const files = coverageMap.files()
+2 -2
test/coverage-test/utils.ts
··· 26 26 ? (() => {}) as any as TestAPI 27 27 : vitestTest 28 28 29 - export async function runVitest(config: TestUserConfig, options = { throwOnError: true }, viteOverrides: ViteUserConfig = {}) { 29 + export const runVitest = vi.defineHelper(async (config: TestUserConfig, options = { throwOnError: true }, viteOverrides: ViteUserConfig = {}) => { 30 30 const provider = process.env.COVERAGE_PROVIDER as any 31 31 32 32 const result = await testUtils.runVitest({ ··· 93 93 } 94 94 95 95 return result 96 - } 96 + }) 97 97 98 98 export async function cleanupCoverageJson(name = './coverage/coverage-final.json') { 99 99 if (existsSync(name)) {
+1 -1
test/e2e/fixtures/list/vitest.config.ts
··· 6 6 cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), 7 7 test: { 8 8 include: ['basic.test.ts', 'math.test.ts'], 9 + api: 7523, 9 10 browser: { 10 11 instances: [{ browser: 'chromium' }], 11 12 provider: playwright(), 12 13 headless: true, 13 - api: 7523, 14 14 } 15 15 }, 16 16 })
+1 -1
test/e2e/fixtures/reporters/merge-reports-module-graph/second.test.ts
··· 1 1 import { test, expect } from 'vitest' 2 2 import { hello } from './util' 3 - import * as obug from "obug" 3 + import * as obug from 'obug' 4 4 5 5 test('also passes', () => { 6 6 expect(hello()).toBe('Hello, graph!')
+1
test/e2e/package.json
··· 34 34 "jest-image-snapshot": "^6.5.1", 35 35 "obug": "^2.1.1", 36 36 "other-dep": "file:./deps/vite-ssr-resolve/other-dep", 37 + "pathe": "catalog:", 37 38 "playwright": "catalog:", 38 39 "ssr-no-external-dep": "file:./deps/vite-ssr-resolve/ssr-no-external-dep", 39 40 "test-dep-conditions": "file:./deps/test-dep-conditions",
+3
test/e2e/snapshots/domain-aria-inline.test.ts
··· 1 + /** 2 + * @module-tag browser 3 + */ 1 4 import { join } from 'node:path' 2 5 import { playwright } from '@vitest/browser-playwright' 3 6 import { expect, test } from 'vitest'
+1 -1
test/e2e/snapshots/domain-aria.test.ts
··· 3 3 import { expect, test } from 'vitest' 4 4 import { editFile, runVitest } from '../../test-utils' 5 5 6 - test('aria snapshot', async () => { 6 + test('aria snapshot', { tags: ['browser'] }, async () => { 7 7 const root = join(import.meta.dirname, 'fixtures/domain-aria') 8 8 const testFile = join(root, 'basic.test.ts') 9 9 const snapshotFile = join(root, '__snapshots__/basic.test.ts.snap')
+3 -7
test/e2e/test/config/bail.test.ts
··· 13 13 if (process.platform !== 'win32') { 14 14 pools.push( 15 15 { 16 + fileParallelism: false, 16 17 browser: { 17 18 enabled: true, 18 19 provider: playwright(), 19 20 headless: true, 20 - fileParallelism: false, 21 21 instances: [ 22 22 { browser: 'chromium' }, 23 23 ], 24 24 }, 25 25 }, 26 26 { 27 + fileParallelism: true, 27 28 browser: { 28 29 enabled: true, 29 30 provider: playwright(), 30 31 headless: true, 31 - fileParallelism: true, 32 32 instances: [ 33 33 { browser: 'chromium' }, 34 34 ], ··· 42 42 configs.push({ 43 43 ...pool, 44 44 isolate, 45 - browser: { 46 - ...pool.browser!, 47 - isolate, 48 - }, 49 45 }) 50 46 } 51 47 } ··· 60 56 const isParallel 61 57 = (config.pool === 'threads' && config.fileParallelism !== false) 62 58 || (config.pool === 'forks' && config.fileParallelism !== false) 63 - || (config.browser?.enabled && config.browser.fileParallelism) 59 + || (config.browser?.enabled && config.fileParallelism !== false) 64 60 65 61 // THREADS here means that multiple tests are run parallel 66 62 process.env.THREADS = isParallel ? 'true' : 'false'
+226 -156
test/e2e/test/config/browser-configs.test.ts
··· 1 1 import type { TestFsStructure } from '#test-utils' 2 2 import type { ViteUserConfig } from 'vitest/config' 3 - import type { TestUserConfig, VitestOptions } from 'vitest/node' 4 - import crypto from 'node:crypto' 5 - import { runVitest, runVitestCli, useFS } from '#test-utils' 3 + import type { CliOptions, TestUserConfig, VitestOptions } from 'vitest/node' 4 + import { createConsole, runVitest, runVitestCli, useTmpFS } from '#test-utils' 6 5 import { playwright } from '@vitest/browser-playwright' 7 6 import { preview } from '@vitest/browser-preview' 8 7 import { resolve } from 'pathe' 9 - import { describe, expect, onTestFinished, test } from 'vitest' 10 - import { createVitest } from 'vitest/node' 8 + import { describe, expect, onTestFailed, onTestFinished, test, vi } from 'vitest' 9 + import { createVitest, Logger, PluginHarness, resolveConfig } from 'vitest/node' 10 + import { Cli } from '../../../test-utils/cli' 11 11 12 - async function vitest(cliOptions: TestUserConfig, configValue: TestUserConfig = {}, viteConfig: ViteUserConfig = {}, vitestOptions: VitestOptions = {}) { 13 - const vitest = await createVitest('test', { ...cliOptions, watch: false, config: false }, { ...viteConfig, test: configValue as any }, vitestOptions) 14 - onTestFinished(() => vitest.close()) 12 + const vitest = vi.defineHelper(async (options: TestUserConfig & { $viteConfig?: ViteUserConfig; $cliConfig?: CliOptions }, vitestOptions: VitestOptions = {}) => { 13 + const vitest = await createVitest( 14 + { 15 + ...options.$cliConfig, 16 + watch: false, 17 + config: false, 18 + }, 19 + { 20 + ...options.$viteConfig, 21 + plugins: [ 22 + ...(options.$viteConfig?.plugins || []), 23 + { 24 + name: 'ignore-optimize-deps', 25 + enforce: 'post', 26 + config: { 27 + order: 'post', 28 + handler(config) { 29 + config.optimizeDeps ??= {} 30 + config.optimizeDeps.include = [] 31 + config.optimizeDeps.include = [] 32 + config.optimizeDeps.entries = [] 33 + config.optimizeDeps.noDiscovery = true 34 + config.optimizeDeps.rolldownOptions = {} 35 + config.optimizeDeps.ignoreOutdatedRequests = true 36 + }, 37 + }, 38 + }, 39 + ], 40 + test: options, 41 + }, 42 + vitestOptions, 43 + ) 44 + onTestFinished(async () => { 45 + await vitest.vite.waitForRequestsIdle() 46 + await vitest.close() 47 + }) 15 48 return vitest 49 + }) 50 + 51 + async function config(options?: TestUserConfig & { $viteConfig?: ViteUserConfig; $cliConfig?: CliOptions }, pluginHarness?: PluginHarness) { 52 + const { test: { resolvedProjects } } = await resolveConfig({ config: false, ...options?.$cliConfig }, { 53 + ...options?.$viteConfig, 54 + test: options, 55 + }, pluginHarness) 56 + return resolvedProjects.filter(p => !p.hidden) 16 57 } 17 58 18 59 test('assigns names as browsers', async () => { 19 - const { projects } = await vitest({}, { 60 + const projects = await config({ 20 61 browser: { 21 62 enabled: true, 22 63 headless: true, ··· 28 69 ], 29 70 }, 30 71 }) 31 - expect(projects.map(p => p.name)).toEqual([ 72 + expect(projects.map(p => p.projectConfig.name)).toEqual([ 32 73 'chromium', 33 74 'firefox', 34 75 'webkit', ··· 36 77 }) 37 78 38 79 test('filters projects', async () => { 39 - const { projects } = await vitest({ project: 'chromium' }, { 80 + const projects = await config({ 81 + project: 'chromium', 40 82 browser: { 41 83 enabled: true, 42 84 provider: preview(), ··· 47 89 ], 48 90 }, 49 91 }) 50 - expect(projects.map(p => p.name)).toEqual([ 92 + expect(projects.map(p => p.projectConfig.name)).toEqual([ 51 93 'chromium', 52 94 ]) 53 95 }) 54 96 55 97 test('filters projects with a wildcard', async () => { 56 - const { projects } = await vitest({ project: 'chrom*' }, { 98 + const projects = await config({ 99 + project: 'chrom*', 57 100 browser: { 58 101 enabled: true, 59 102 provider: preview(), ··· 64 107 ], 65 108 }, 66 109 }) 67 - expect(projects.map(p => p.name)).toEqual([ 110 + expect(projects.map(p => p.projectConfig.name)).toEqual([ 68 111 'chromium', 69 112 ]) 70 113 }) 71 114 72 115 test('assigns names as browsers in a custom project', async () => { 73 - const { projects } = await vitest({}, { 116 + const projects = await config({ 74 117 projects: [ 75 118 { 76 119 test: { ··· 90 133 }, 91 134 ], 92 135 }) 93 - expect(projects.map(p => p.name)).toEqual([ 136 + expect(projects.map(p => p.projectConfig.name)).toEqual([ 94 137 'custom (chromium)', 95 138 'custom (firefox)', 96 139 'custom (webkit)', ··· 99 142 }) 100 143 101 144 test('inherits browser options', async () => { 102 - const { projects } = await vitest({}, { 145 + const fs = useTmpFS({ 146 + './custom-path.html': ``, 147 + './custom-overridden-path.html': ``, 148 + }) 149 + const projects = await config({ 103 150 setupFiles: ['/test/setup.ts'], 104 151 provide: { 105 152 browser: true, ··· 109 156 provider: preview(), 110 157 headless: true, 111 158 screenshotFailures: false, 112 - testerHtmlPath: '/custom-path.html', 159 + testerHtmlPath: fs.resolveFile('./custom-path.html'), 113 160 screenshotDirectory: '/custom-directory', 114 - fileParallelism: false, 115 161 viewport: { 116 162 width: 300, 117 163 height: 900, ··· 134 180 width: 900, 135 181 height: 300, 136 182 }, 137 - testerHtmlPath: '/custom-overridden-path.html', 183 + testerHtmlPath: fs.resolveFile('./custom-overridden-path.html'), 138 184 screenshotDirectory: '/custom-overridden-directory', 139 185 }, 140 186 ], 141 187 }, 142 188 }) 143 - expect(projects.map(p => p.config)).toMatchObject([ 189 + expect(projects.map(p => p.projectConfig)).toMatchObject([ 144 190 { 145 191 name: 'chromium', 146 192 setupFiles: ['/test/setup.ts'], ··· 159 205 locators: { 160 206 testIdAttribute: 'data-tid', 161 207 }, 162 - fileParallelism: false, 163 - testerHtmlPath: '/custom-path.html', 208 + testerHtmlPath: fs.resolveFile('./custom-path.html'), 164 209 }, 165 210 }, 166 211 { ··· 181 226 locators: { 182 227 testIdAttribute: 'data-custom', 183 228 }, 184 - testerHtmlPath: '/custom-overridden-path.html', 229 + testerHtmlPath: fs.resolveFile('./custom-overridden-path.html'), 185 230 }, 186 231 }, 187 232 ]) 188 233 }) 189 234 190 235 test('coverage provider v8 works correctly in browser mode if instances are filtered', async () => { 191 - const { projects } = await vitest( 236 + const projects = await config( 192 237 { 193 238 project: 'chromium', 194 - }, 195 - { 196 239 coverage: { 197 240 enabled: true, 198 241 provider: 'v8', ··· 208 251 }, 209 252 }, 210 253 ) 211 - expect(projects.map(p => p.name)).toEqual([ 254 + expect(projects.map(p => p.projectConfig.name)).toEqual([ 212 255 'chromium', 213 256 ]) 214 257 }) 215 258 216 259 test('coverage provider v8 works correctly in workspaced browser mode if instances are filtered', async () => { 217 - const { projects } = await vitest({ project: 'browser (chromium)' }, { 260 + const projects = await config({ 261 + project: 'browser (chromium)', 218 262 projects: [ 219 263 { 220 264 test: { ··· 236 280 provider: 'v8', 237 281 }, 238 282 }) 239 - expect(projects.map(p => p.name)).toEqual([ 283 + expect(projects.map(p => p.projectConfig.name)).toEqual([ 240 284 'browser (chromium)', 241 285 ]) 242 286 }) 243 287 244 288 test('browser instances with include/exclude/includeSource option override parent that patterns', async () => { 245 - const { projects } = await vitest({ config: false }, { 289 + const projects = await config({ 246 290 include: ['**/*.global.test.{js,ts}', '**/*.shared.test.{js,ts}'], 247 291 exclude: ['**/*.skip.test.{js,ts}'], 248 292 includeSource: ['src/**/*.{js,ts}'], ··· 252 296 headless: true, 253 297 instances: [ 254 298 { browser: 'chromium' }, 255 - { browser: 'firefox', include: ['test/firefox-specific.test.ts'], exclude: ['test/webkit-only.test.ts', 'test/webkit-extra.test.ts'], includeSource: ['src/firefox-compat.ts'] }, 256 - { browser: 'webkit', include: ['test/webkit-only.test.ts', 'test/webkit-extra.test.ts'], exclude: ['test/firefox-specific.test.ts'], includeSource: ['src/webkit-compat.ts'] }, 299 + { 300 + browser: 'firefox', 301 + include: ['test/firefox-specific.test.ts'], 302 + exclude: ['test/webkit-only.test.ts', 'test/webkit-extra.test.ts'], 303 + includeSource: ['src/firefox-compat.ts'], 304 + }, 305 + { 306 + browser: 'webkit', 307 + include: ['test/webkit-only.test.ts', 'test/webkit-extra.test.ts'], 308 + exclude: ['test/firefox-specific.test.ts'], 309 + includeSource: ['src/webkit-compat.ts'], 310 + }, 257 311 ], 258 312 }, 259 313 }) 260 314 261 315 // Chromium should inherit parent include/exclude/includeSource patterns (plus default test pattern) 262 - expect(projects[0].name).toEqual('chromium') 263 - expect(projects[0].config.include).toEqual([ 316 + expect(projects[0].projectConfig.name).toEqual('chromium') 317 + expect(projects[0].projectConfig.include).toEqual([ 264 318 '**/*.global.test.{js,ts}', 265 319 '**/*.shared.test.{js,ts}', 266 320 ]) 267 - expect(projects[0].config.exclude).toEqual([ 321 + expect(projects[0].projectConfig.exclude).toEqual([ 268 322 '**/*.skip.test.{js,ts}', 269 323 ]) 270 - expect(projects[0].config.includeSource).toEqual([ 324 + expect(projects[0].projectConfig.includeSource).toEqual([ 271 325 'src/**/*.{js,ts}', 272 326 ]) 273 327 274 328 // Firefox should only have its specific include/exclude/includeSource pattern (not parent patterns) 275 - expect(projects[1].name).toEqual('firefox') 276 - expect(projects[1].config.include).toEqual([ 329 + expect(projects[1].projectConfig.name).toEqual('firefox') 330 + expect(projects[1].projectConfig.include).toEqual([ 277 331 'test/firefox-specific.test.ts', 278 332 ]) 279 - expect(projects[1].config.exclude).toEqual([ 333 + expect(projects[1].projectConfig.exclude).toEqual([ 280 334 'test/webkit-only.test.ts', 281 335 'test/webkit-extra.test.ts', 282 336 ]) 283 - expect(projects[1].config.includeSource).toEqual([ 337 + expect(projects[1].projectConfig.includeSource).toEqual([ 284 338 'src/firefox-compat.ts', 285 339 ]) 286 340 287 341 // Webkit should only have its specific include/exclude/includeSource patterns (not parent patterns) 288 - expect(projects[2].name).toEqual('webkit') 289 - expect(projects[2].config.include).toEqual([ 342 + expect(projects[2].projectConfig.name).toEqual('webkit') 343 + expect(projects[2].projectConfig.include).toEqual([ 290 344 'test/webkit-only.test.ts', 291 345 'test/webkit-extra.test.ts', 292 346 ]) 293 - expect(projects[2].config.exclude).toEqual([ 347 + expect(projects[2].projectConfig.exclude).toEqual([ 294 348 'test/firefox-specific.test.ts', 295 349 ]) 296 - expect(projects[2].config.includeSource).toEqual([ 350 + expect(projects[2].projectConfig.includeSource).toEqual([ 297 351 'src/webkit-compat.ts', 298 352 ]) 299 353 }) 300 354 301 355 test('browser instances with empty include array should get parent include patterns', async () => { 302 - const { projects } = await vitest({ config: false }, { 356 + const projects = await config({ 303 357 include: ['**/*.test.{js,ts}'], 304 358 browser: { 305 359 enabled: true, ··· 313 367 }) 314 368 315 369 // Both instances should inherit parent include patterns when include is empty or not specified 316 - expect(projects[0].config.include).toEqual(['**/*.test.{js,ts}']) 317 - expect(projects[1].config.include).toEqual(['**/*.test.{js,ts}']) 370 + expect(projects[0].projectConfig.include).toEqual(['**/*.test.{js,ts}']) 371 + expect(projects[1].projectConfig.include).toEqual(['**/*.test.{js,ts}']) 318 372 }) 319 373 320 374 test('negation filter excludes all browser instances', async () => { 321 - const { projects } = await vitest({ project: '!myproject' }, { 375 + const projects = await config({ 376 + project: '!myproject', 322 377 projects: [ 323 378 { 324 379 test: { ··· 342 397 }, 343 398 ], 344 399 }) 345 - expect(projects.map(p => p.name)).toEqual([ 400 + expect(projects.map(p => p.projectConfig.name)).toEqual([ 346 401 'other', 347 402 ]) 348 403 }) 349 404 350 405 test('negation wildcard filter excludes all matching browser instances', async () => { 351 - const { projects } = await vitest({ project: '!my*' }, { 406 + const projects = await config({ 407 + project: '!my*', 352 408 projects: [ 353 409 { 354 410 test: { ··· 371 427 }, 372 428 ], 373 429 }) 374 - expect(projects.map(p => p.name)).toEqual([ 430 + expect(projects.map(p => p.projectConfig.name)).toEqual([ 375 431 'other', 376 432 ]) 377 433 }) 378 434 379 435 test('filter for the global browser project includes all browser instances', async () => { 380 - const { projects } = await vitest({ project: 'myproject' }, { 436 + const projects = await config({ 437 + project: 'myproject', 381 438 projects: [ 382 439 { 383 440 test: { ··· 401 458 }, 402 459 ], 403 460 }) 404 - expect(projects.map(p => p.name)).toEqual([ 461 + expect(projects.map(p => p.projectConfig.name)).toEqual([ 405 462 'myproject (chromium)', 406 463 'myproject (firefox)', 407 464 'myproject (webkit)', ··· 409 466 }) 410 467 411 468 test('can enable browser-cli options for multi-project workspace', async () => { 412 - const { projects } = await vitest( 413 - { 414 - browser: { 415 - enabled: true, 416 - provider: preview(), 417 - headless: true, 418 - instances: [], 419 - }, 420 - }, 469 + const projects = await config( 421 470 { 422 471 projects: [ 423 472 { ··· 437 486 }, 438 487 }, 439 488 ], 489 + $cliConfig: { 490 + browser: { 491 + enabled: true, 492 + provider: preview(), 493 + headless: true, 494 + instances: [], 495 + }, 496 + }, 440 497 }, 441 498 ) 442 - expect(projects.map(p => p.name)).toEqual([ 499 + expect(projects.map(p => p.projectConfig.name)).toEqual([ 443 500 'unit', 444 501 'browser', 445 502 ]) 446 503 447 504 // unit config 448 - expect(projects[0].config.browser.enabled).toBe(false) 505 + expect(projects[0].projectConfig.browser.enabled).toBe(false) 449 506 450 507 // browser config 451 - expect(projects[1].config.browser.enabled).toBe(true) 452 - expect(projects[1].config.browser.headless).toBe(true) 508 + expect(projects[1].projectConfig.browser.enabled).toBe(true) 509 + expect(projects[1].projectConfig.browser.headless).toBe(true) 453 510 }) 454 511 455 512 test('core provider has options if `provider` is playwright', async () => { 456 - const v = await vitest({}, { 513 + const v = await config({ 457 514 browser: { 458 515 enabled: true, 459 516 provider: playwright({ actionTimeout: 1000 }), ··· 462 519 ], 463 520 }, 464 521 }) 465 - expect(v.config.browser.provider?.options).toEqual({ 522 + expect(v[0].projectConfig.browser.provider?.options).toEqual({ 466 523 actionTimeout: 1000, 467 524 }) 468 525 }) 469 526 470 527 test('core provider has options if `provider` is preview', async () => { 471 - const v = await vitest({}, { 528 + const v = await config({ 472 529 browser: { 473 530 enabled: true, 474 531 provider: preview(), ··· 477 534 ], 478 535 }, 479 536 }) 480 - expect(v.config.browser.provider?.options).toEqual({}) 537 + expect(v[0].projectConfig.browser.provider?.options).toEqual({}) 481 538 }) 482 539 483 540 test('provider options can be changed dynamically', async () => { 484 - const v = await vitest({}, { 541 + const v = await vitest({ 485 542 browser: { 486 543 enabled: true, 487 544 provider: playwright({ actionTimeout: 1000 }), ··· 489 546 { browser: 'chromium' }, 490 547 ], 491 548 }, 492 - }, { 493 - plugins: [ 494 - { 495 - name: 'update-options', 496 - configureVitest({ project }) { 497 - if (project.config.browser.provider) { 498 - const options = project.config.browser.provider.options as any 499 - options.actionTimeout = 400 500 - options.someDifferentOption = 'test' 501 - } 549 + $viteConfig: { 550 + plugins: [ 551 + { 552 + name: 'update-options', 553 + configureVitest({ project }) { 554 + if (project.config.browser.provider) { 555 + const options = project.config.browser.provider.options as any 556 + options.actionTimeout = 400 557 + options.someDifferentOption = 'test' 558 + } 559 + }, 502 560 }, 503 - }, 504 - ], 561 + ], 562 + }, 505 563 }) 506 564 expect(v.config.browser.provider?.options).toEqual({ 507 565 actionTimeout: 400, ··· 510 568 }) 511 569 512 570 test('provider options can be changed dynamically if no options are specified', async () => { 513 - const v = await vitest({}, { 571 + const v = await vitest({ 514 572 browser: { 515 573 enabled: true, 516 574 provider: playwright(), ··· 518 576 { browser: 'chromium' }, 519 577 ], 520 578 }, 521 - }, { 522 - plugins: [ 523 - { 524 - name: 'update-options', 525 - configureVitest({ project }) { 526 - if (project.config.browser.provider) { 527 - const options = project.config.browser.provider.options as any 528 - options.actionTimeout = 400 529 - options.someDifferentOption = 'test' 530 - } 579 + $viteConfig: { 580 + plugins: [ 581 + { 582 + name: 'update-options', 583 + configureVitest({ project }) { 584 + if (project.config.browser.provider) { 585 + const options = project.config.browser.provider.options as any 586 + options.actionTimeout = 400 587 + options.someDifferentOption = 'test' 588 + } 589 + }, 531 590 }, 532 - }, 533 - ], 591 + ], 592 + }, 534 593 }) 535 594 expect(v.config.browser.provider?.options).toEqual({ 536 595 actionTimeout: 400, ··· 541 600 test('provider options can be changed dynamically in CLI', async () => { 542 601 const v = await vitest({ 543 602 browser: { 544 - provider: { 545 - name: 'playwright', 546 - _cli: true, 547 - } as any, 548 - instances: [], 549 - }, 550 - }, { 551 - browser: { 552 603 enabled: true, 553 604 provider: preview(), 554 605 instances: [ 555 606 { browser: 'chromium' }, 556 607 ], 557 608 }, 558 - }, { 559 - plugins: [ 560 - { 561 - name: 'update-options', 562 - configureVitest({ project }) { 563 - if (project.config.browser.provider) { 564 - const options = project.config.browser.provider.options as any 565 - options.actionTimeout = 400 566 - options.someDifferentOption = 'test' 567 - } 609 + $cliConfig: { 610 + browser: { 611 + provider: { 612 + name: 'playwright', 613 + _cli: true, 614 + } as any, 615 + instances: [], 616 + }, 617 + }, 618 + $viteConfig: { 619 + plugins: [ 620 + { 621 + name: 'update-options', 622 + configureVitest({ project }) { 623 + if (project.config.browser.provider) { 624 + const options = project.config.browser.provider.options as any 625 + options.actionTimeout = 400 626 + options.someDifferentOption = 'test' 627 + } 628 + }, 568 629 }, 569 - }, 570 - ], 630 + ], 631 + }, 571 632 }) 572 633 expect(v.config.browser.provider?.options).toEqual({ 573 634 actionTimeout: 400, ··· 576 637 }) 577 638 578 639 test('fileParallelism on the instance works properly', async () => { 579 - const v = await vitest({}, { 640 + const projects = await config({ 580 641 fileParallelism: true, 581 642 browser: { 582 643 enabled: true, ··· 593 654 ], 594 655 }, 595 656 }) 596 - expect(v.projects).toHaveLength(2) 597 - expect(v.projects[0].config.browser.fileParallelism).toBe(false) 598 - expect(v.projects[1].config.browser.fileParallelism).toBe(true) 657 + expect(projects).toHaveLength(2) 658 + expect(projects[0].projectConfig.maxWorkers).toBe(1) 659 + expect(projects[1].projectConfig.maxWorkers).toBe(undefined) // decided dynamically 599 660 }) 600 661 601 662 test('detailsPanelPosition defaults to right', async () => { 602 - const { projects } = await vitest({}, { 663 + const projects = await config({ 603 664 browser: { 604 665 enabled: true, 605 666 provider: preview(), ··· 608 669 ], 609 670 }, 610 671 }) 611 - expect(projects[0].config.browser.detailsPanelPosition).toBe('right') 672 + expect(projects[0].projectConfig.browser.detailsPanelPosition).toBe('right') 612 673 }) 613 674 614 675 test('detailsPanelPosition from config file is respected', async () => { 615 - const { projects } = await vitest({}, { 676 + const projects = await config({ 616 677 browser: { 617 678 enabled: true, 618 679 provider: preview(), ··· 622 683 ], 623 684 }, 624 685 }) 625 - expect(projects[0].config.browser.detailsPanelPosition).toBe('bottom') 686 + expect(projects[0].projectConfig.browser.detailsPanelPosition).toBe('bottom') 626 687 }) 627 688 628 689 test('CLI option --browser.detailsPanelPosition overrides config', async () => { 629 - const { projects } = await vitest({ 630 - browser: { 631 - detailsPanelPosition: 'bottom', 690 + const projects = await config({ 691 + $cliConfig: { 692 + browser: { 693 + detailsPanelPosition: 'bottom', 694 + }, 632 695 }, 633 - }, { 634 696 browser: { 635 697 enabled: true, 636 698 provider: playwright(), ··· 640 702 ], 641 703 }, 642 704 }) 643 - expect(projects[0].config.browser.detailsPanelPosition).toBe('bottom') 705 + expect(projects[0].projectConfig.browser.detailsPanelPosition).toBe('bottom') 644 706 }) 645 707 646 - function getCliConfig(options: TestUserConfig, cli: string[], fs: TestFsStructure = {}) { 647 - const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`) 648 - useFS(root, { 708 + async function getCliConfig(options: TestUserConfig, cli: string[], fs: TestFsStructure = {}) { 709 + const { root } = useTmpFS({ 649 710 ...fs, 650 711 'basic.test.ts': /* ts */` 651 712 import { test } from 'vitest' ··· 653 714 expect(1).toBe(1) 654 715 }) 655 716 `, 656 - 'vitest.config.ts': /* ts */ ` 717 + 'vitest.config.js': /* ts */ ` 718 + import { playwright } from '@vitest/browser-playwright' 719 + const config = ${JSON.stringify(options)} 720 + if (config.projects) { 721 + config.projects.forEach(p => { 722 + if (p.test?.browser?.provider) { 723 + p.test.browser.provider = playwright() 724 + } 725 + }) 726 + } 727 + if (config.browser?.provider) { 728 + config.browser.provider = playwright() 729 + } 657 730 export default { 658 731 test: { 659 732 reporters: [ ··· 685 758 }, 686 759 }, 687 760 ], 688 - ...${JSON.stringify(options)} 761 + ...config, 689 762 } 690 763 } 691 764 `, 692 765 }) 693 - return runVitestCli( 766 + const result = await runVitestCli( 694 767 { 695 768 nodeOptions: { 696 769 env: { ··· 704 777 '--no-watch', 705 778 ...cli, 706 779 ) 780 + onTestFailed(() => { 781 + console.error(`stdout: ${result.stdout}\nstderr:${result.stderr}`) 782 + }) 783 + return result 707 784 } 708 785 709 786 describe('[e2e] workspace configs are affected by the CLI options', () => { ··· 1067 1144 process.env.BROWSER_DEFINE_TEST_PROJECT = 'false' 1068 1145 const { testTree, stderr } = await runVitest({ 1069 1146 root: './fixtures/config/browser-define', 1070 - browser: { 1071 - headless: true, 1072 - }, 1073 1147 }) 1074 1148 expect(stderr).toBe('') 1075 1149 expect(testTree()).toMatchInlineSnapshot(` ··· 1085 1159 process.env.BROWSER_DEFINE_TEST_PROJECT = 'true' 1086 1160 const { testTree, stderr } = await runVitest({ 1087 1161 root: './fixtures/config/browser-define', 1088 - browser: { 1089 - headless: true, 1090 - }, 1091 1162 }) 1092 1163 expect(stderr).toBe('') 1093 1164 expect(testTree()).toMatchInlineSnapshot(` ··· 1153 1224 }) 1154 1225 1155 1226 test('show a warning if host is exposed', async () => { 1156 - const { stderr } = await runVitest({ 1157 - config: false, 1158 - root: './fixtures/basic', 1159 - reporters: [ 1160 - { 1161 - onInit() { 1162 - throw new Error('stop') 1163 - }, 1164 - }, 1165 - ], 1166 - browser: { 1227 + const { stderr, stdout, stdin } = createConsole() 1228 + const harness = new PluginHarness(new Logger(stdout, stderr)) 1229 + const cli = new Cli({ stderr, stdin, stdout }) 1230 + 1231 + await config( 1232 + { 1233 + config: false, 1234 + root: './fixtures/basic', 1167 1235 api: { 1168 1236 host: 'custom-host', 1169 1237 }, 1170 1238 }, 1171 - }) 1172 - expect(stderr).toContain( 1173 - 'API server is exposed to network, disabling write and exec operations by default for security reasons. This can cause some APIs to not work as expected. Set `browser.api.allowExec` manually to hide this warning. See https://vitest.dev/config/browser/api for more details.', 1239 + harness, 1240 + ) 1241 + 1242 + expect(cli.stderr).toContain( 1243 + 'API server is exposed to network, disabling write and exec operations by default for security reasons. This can cause some APIs to not work as expected. Set `browser.api.allowExec` manually to hide this warning. See https://vitest.dev/config/api for more details.', 1174 1244 ) 1175 1245 })
+9 -9
test/e2e/test/failures.test.ts
··· 263 263 }, { fails: true }) 264 264 expect(stderr).toMatch( 265 265 `Error: @vitest/coverage-v8 does not work with 266 - { 267 - browser: { 268 - provider: playwright(), 269 - instances: [ 270 - { browser: 'webkit' } 271 - ], 272 - }, 273 - }`, 266 + { 267 + browser: { 268 + provider: playwright(), 269 + instances: [ 270 + { browser: 'webkit' } 271 + ], 272 + }, 273 + }`, 274 274 ) 275 275 }) 276 276 ··· 408 408 test('browser.name or browser.instances are required', async () => { 409 409 const { stderr, exitCode } = await runVitestCli('--browser.enabled', '--root=./fixtures/browser-no-config') 410 410 expect(exitCode).toBe(1) 411 - expect(stderr).toMatch('Vitest received --browser flag, but no project had a browser configuration.') 411 + expect(stderr).toMatch('Browser Mode was enabled, but provider was not specified anywhere.') 412 412 }) 413 413 414 414 test('--browser flag without browser configuration throws an error', async () => {
+33 -62
test/e2e/test/override.test.ts
··· 1 - import type { UserConfig as ViteUserConfig } from 'vite' 2 - import type { TestUserConfig } from 'vitest/node' 1 + import type { ResolvedConfig as ResolvedViteConfig, UserConfig as ViteUserConfig } from 'vite' 2 + import type { CliOptions, ResolvedConfig, TestUserConfig } from 'vitest/node' 3 + import { resolveTestConfig } from '#test-utils' 3 4 import { resolve } from 'pathe' 4 - import { describe, expect, it, onTestFinished } from 'vitest' 5 - import { createVitest, parseCLI } from 'vitest/node' 6 - 7 - type VitestOptions = Parameters<typeof createVitest>[3] 8 - 9 - async function vitest(cliOptions: TestUserConfig, configValue: TestUserConfig = {}, viteConfig: ViteUserConfig = {}, vitestOptions: VitestOptions = {}) { 10 - const vitest = await createVitest('test', { ...cliOptions, watch: false, config: false }, { ...viteConfig, test: configValue as any }, vitestOptions) 11 - onTestFinished(() => vitest.close()) 12 - return vitest 13 - } 5 + import { describe, expect, it } from 'vitest' 6 + import { parseCLI } from 'vitest/node' 14 7 15 - async function config(cliOptions: TestUserConfig, configValue: TestUserConfig = {}, viteConfig: ViteUserConfig = {}, vitestOptions: VitestOptions = {}) { 16 - const v = await vitest(cliOptions, configValue, viteConfig, vitestOptions) 17 - return v.config 8 + async function config(options: TestUserConfig & { $cliOptions?: CliOptions; $viteConfig?: ViteUserConfig }) { 9 + const { config } = await resolveTestConfig(options) as any 10 + config.test.$viteConfig = config 11 + return config.test as ResolvedConfig & { $viteConfig: ResolvedViteConfig } 18 12 } 19 13 20 14 describe('correctly defines api flag', () => { 21 15 it('CLI overrides disabling api', async () => { 22 - const c = await vitest({ api: false }, { 16 + const c = await config({ 17 + $cliOptions: { api: false }, 23 18 api: { 24 19 port: 1234, 25 20 }, 26 21 watch: true, 27 22 }) 28 - expect(c.vite.config.server.middlewareMode).toBe(true) 29 - expect(c.config.api).toEqual({ 23 + expect(c.$viteConfig.server.middlewareMode).toBe(true) 24 + expect(c.api).toEqual({ 30 25 allowExec: true, 31 26 allowWrite: true, 32 27 middlewareMode: true, ··· 36 31 }) 37 32 38 33 it('CLI overrides inlined value', async () => { 39 - const c = await vitest({ api: { port: 4321 } }, { 34 + const c = await config({ 35 + $cliOptions: { api: { port: 4321 } }, 40 36 api: { 41 37 port: 1234, 42 38 }, 43 39 watch: true, 44 40 }) 45 - expect(c.vite.config.server.port).toBe(4321) 46 - expect(c.config.api).toEqual({ 41 + expect(c.$viteConfig.server.port).toBe(4321) 42 + expect(c.api).toEqual({ 47 43 port: 4321, 48 44 allowWrite: true, 49 45 allowExec: true, ··· 52 48 }) 53 49 }) 54 50 55 - it('browser.isolate is inherited', async () => { 56 - const c = await vitest({ isolate: false }, {}) 57 - expect(c.config.isolate).toBe(false) 58 - expect(c.config.browser.isolate).toBe(false) 59 - }) 60 - 61 51 it('allowWrite and allowExec default to true when not exposed to network', async () => { 62 - const c = await config({ api: { port: 5555 } }, {}) 52 + const c = await config({ api: { port: 5555 } }) 63 53 expect(c.api.allowWrite).toBe(true) 64 54 expect(c.api.allowExec).toBe(true) 65 55 }) 66 56 67 57 it('allowWrite and allowExec default to true for localhost', async () => { 68 - const c = await config({ api: { port: 5555, host: 'localhost' } }, {}) 58 + const c = await config({ api: { port: 5555, host: 'localhost' } }) 69 59 expect(c.api.allowWrite).toBe(true) 70 60 expect(c.api.allowExec).toBe(true) 71 61 }) 72 62 73 63 it('allowWrite and allowExec default to true for 127.0.0.1', async () => { 74 - const c = await config({ api: { port: 5555, host: '127.0.0.1' } }, {}) 64 + const c = await config({ api: { port: 5555, host: '127.0.0.1' } }) 75 65 expect(c.api.allowWrite).toBe(true) 76 66 expect(c.api.allowExec).toBe(true) 77 67 }) 78 68 79 69 it('allowWrite and allowExec default to false when exposed to network', async () => { 80 - const c = await config({ api: { port: 5555, host: '0.0.0.0' } }, {}) 70 + const c = await config({ api: { port: 5555, host: '0.0.0.0' } }) 81 71 expect(c.api.allowWrite).toBe(false) 82 72 expect(c.api.allowExec).toBe(false) 83 73 }) 84 74 85 75 it('allowWrite and allowExec can be explicitly overridden when exposed to network', async () => { 86 - const c = await config({ api: { port: 5555, host: '0.0.0.0', allowWrite: true, allowExec: true } }, {}) 76 + const c = await config({ api: { port: 5555, host: '0.0.0.0', allowWrite: true, allowExec: true } }) 87 77 expect(c.api.allowWrite).toBe(true) 88 78 expect(c.api.allowExec).toBe(true) 89 79 }) 90 80 91 81 it('allowWrite and allowExec can be explicitly disabled', async () => { 92 - const c = await config({ api: { port: 5555, allowWrite: false, allowExec: false } }, {}) 82 + const c = await config({ api: { port: 5555, allowWrite: false, allowExec: false } }) 93 83 expect(c.api.allowWrite).toBe(false) 94 84 expect(c.api.allowExec).toBe(false) 95 85 }) 96 - 97 - it('browser.api inherits allowWrite and allowExec from api', async () => { 98 - const c = await config({ api: { port: 5555, allowWrite: false, allowExec: false } }, {}) 99 - expect(c.browser.api.allowWrite).toBe(false) 100 - expect(c.browser.api.allowExec).toBe(false) 101 - }) 102 - 103 - it('browser.api can override inherited allowWrite and allowExec', async () => { 104 - const c = await config({ 105 - api: { port: 5555, allowWrite: false, allowExec: false }, 106 - browser: { api: { allowWrite: true, allowExec: true } }, 107 - }, { 108 - browser: {}, 109 - }) 110 - expect(c.api.allowWrite).toBe(false) 111 - expect(c.api.allowExec).toBe(false) 112 - expect(c.browser.api.allowWrite).toBe(true) 113 - expect(c.browser.api.allowExec).toBe(true) 114 - }) 115 86 }) 116 87 117 88 describe.each([ ··· 153 124 }) 154 125 155 126 it('experimental fsModuleCache is inherited in a project', async () => { 156 - const v = await vitest({}, { 127 + const v = await config({ 157 128 experimental: { 158 129 fsModuleCache: true, 159 130 fsModuleCachePath: './node_modules/custom-cache-path', ··· 166 137 }, 167 138 ], 168 139 }) 169 - expect(v.config.experimental.fsModuleCache).toBe(true) 170 - expect(v.projects[0].config.experimental.fsModuleCache).toBe(true) 140 + expect(v.experimental.fsModuleCache).toBe(true) 141 + expect(v.resolvedProjects[0].projectConfig.experimental.fsModuleCache).toBe(true) 171 142 172 - expect(v.config.experimental.fsModuleCachePath).toBe(resolve('./node_modules/custom-cache-path')) 173 - expect(v.projects[0].config.experimental.fsModuleCachePath).toBe(resolve('./node_modules/custom-cache-path')) 143 + expect(v.experimental.fsModuleCachePath).toBe(resolve('./node_modules/custom-cache-path')) 144 + expect(v.resolvedProjects[0].projectConfig.experimental.fsModuleCachePath).toBe(resolve('./node_modules/custom-cache-path')) 174 145 }) 175 146 176 147 it('project overrides experimental fsModuleCache', async () => { 177 - const v = await vitest({}, { 148 + const v = await config({ 178 149 experimental: { 179 150 fsModuleCache: true, 180 151 fsModuleCachePath: './node_modules/custom-cache-path', ··· 191 162 }, 192 163 ], 193 164 }) 194 - expect(v.config.experimental.fsModuleCache).toBe(true) 195 - expect(v.projects[0].config.experimental.fsModuleCache).toBe(false) 165 + expect(v.experimental.fsModuleCache).toBe(true) 166 + expect(v.resolvedProjects[0].projectConfig.experimental.fsModuleCache).toBe(false) 196 167 197 - expect(v.config.experimental.fsModuleCachePath).toBe(resolve('./node_modules/custom-cache-path')) 198 - expect(v.projects[0].config.experimental.fsModuleCachePath).toBe(resolve('./node_modules/project-cache-path')) 168 + expect(v.experimental.fsModuleCachePath).toBe(resolve('./node_modules/custom-cache-path')) 169 + expect(v.resolvedProjects[0].projectConfig.experimental.fsModuleCachePath).toBe(resolve('./node_modules/project-cache-path')) 199 170 })
-10
test/e2e/test/pass-empty-files.test.ts
··· 1 - import { expect, it } from 'vitest' 2 - import { runVitest } from '../../test-utils' 3 - 4 - it('vitest doesnt fail when running empty files', async () => { 5 - const { exitCode } = await runVitest({ 6 - root: './fixtures/pass-empty-files', 7 - passWithNoTests: true, 8 - }) 9 - expect(exitCode).toBe(0) 10 - })
+24 -24
test/e2e/test/public.test.ts
··· 5 5 import { resolveConfig } from 'vitest/node' 6 6 7 7 test('resolves the test config', async () => { 8 - const { viteConfig, vitestConfig } = await resolveConfig() 8 + const viteConfig = await resolveConfig() 9 9 expect(viteConfig.mode).toBe('test') 10 - expect(vitestConfig.mode).toBe('test') 10 + expect(viteConfig.test.mode).toBe('test') 11 11 // inherits the root config 12 12 // TODO: test cwd loading behavior without relying on test/e2e/vitest.config.ts 13 - expect(vitestConfig.reporters.slice(0, 1)).toEqual([[process.env.CI ? 'minimal' : 'verbose', {}]]) 14 - expect(viteConfig.plugins.find(p => p.name === 'vitest')).toBeDefined() 13 + expect(viteConfig.test.reporters.slice(0, 1)).toEqual([[process.env.CI ? 'minimal' : 'verbose', {}]]) 14 + expect(viteConfig.plugins.find(p => p.name === 'vitest:config')).toBeDefined() 15 15 }) 16 16 17 17 test('applies custom options', async () => { 18 - const { viteConfig, vitestConfig } = await resolveConfig({ 18 + const viteConfig = await resolveConfig({ 19 19 mode: 'development', 20 20 setupFiles: ['/test/setup.ts'], 21 21 }) 22 22 expect(viteConfig.mode).toBe('development') 23 - expect(vitestConfig.mode).toBe('development') 24 - expect(vitestConfig.setupFiles).toEqual(['/test/setup.ts']) 25 - expect(viteConfig.plugins.find(p => p.name === 'vitest')).toBeDefined() 23 + expect(viteConfig.test.mode).toBe('development') 24 + expect(viteConfig.test.setupFiles).toEqual(['/test/setup.ts']) 25 + expect(viteConfig.plugins.find(p => p.name === 'vitest:config')).toBeDefined() 26 26 }) 27 27 28 28 test('respects root', async () => { 29 29 process.env.GITHUB_ACTIONS = 'false' 30 30 const configRoot = resolve(import.meta.dirname, '../fixtures/public-config') 31 - const { viteConfig, vitestConfig } = await resolveConfig({ 31 + const viteConfig = await resolveConfig({ 32 32 root: configRoot, 33 33 }) 34 34 expect(viteConfig.configFile).toBe(resolve(configRoot, 'vitest.config.ts')) 35 - expect(vitestConfig.name).toBe('root config') 36 - expect(vitestConfig.reporters).toEqual(configDefaults.reporters.map(v => [v, {}])) 35 + expect(viteConfig.test.name).toBe('root config') 36 + expect(viteConfig.test.reporters).toEqual(configDefaults.reporters.map(v => [v, {}])) 37 37 }) 38 38 39 39 test('respects custom config', async () => { 40 40 process.env.GITHUB_ACTIONS = 'false' 41 41 const config = resolve(import.meta.dirname, '../fixtures/public-config/vitest.custom.config.ts') 42 - const { viteConfig, vitestConfig } = await resolveConfig({ 42 + const viteConfig = await resolveConfig({ 43 43 config, 44 44 }) 45 45 expect(viteConfig.configFile).toBe(config) 46 - expect(vitestConfig.name).toBe('custom config') 47 - expect(vitestConfig.reporters).toEqual(configDefaults.reporters.map(v => [v, {}])) 46 + expect(viteConfig.test.name).toBe('custom config') 47 + expect(viteConfig.test.reporters).toEqual(configDefaults.reporters.map(v => [v, {}])) 48 48 }) 49 49 50 50 test('default value changes of coverage.exclude do not reflect to test.exclude', async () => { 51 51 const exclude = ['**/custom-exclude/**'] 52 52 53 - const { vitestConfig } = await resolveConfig({ 53 + const viteConfig = await resolveConfig({ 54 54 include: ['**/example.test.ts'], 55 55 exclude, 56 56 coverage: { ··· 60 60 61 61 expect(exclude).toStrictEqual(['**/custom-exclude/**']) 62 62 63 - expect(vitestConfig.include).toStrictEqual(['**/example.test.ts']) 64 - expect(vitestConfig.exclude).toStrictEqual(['**/custom-exclude/**']) 63 + expect(viteConfig.test.include).toStrictEqual(['**/example.test.ts']) 64 + expect(viteConfig.test.exclude).toStrictEqual(['**/custom-exclude/**']) 65 65 66 - expect(vitestConfig.coverage.exclude).toContain('**/custom-exclude/**') 67 - expect(vitestConfig.coverage.exclude).toContain('**/example.test.ts') 66 + expect(viteConfig.test.coverage.exclude).toContain('**/custom-exclude/**') 67 + expect(viteConfig.test.coverage.exclude).toContain('**/example.test.ts') 68 68 }) 69 69 70 70 test.for([ ··· 112 112 options: CoverageOptions 113 113 expected?: string 114 114 }[])('coverage.htmlDir inference: $options', async ({ options, expected }) => { 115 - const { vitestConfig } = await resolveConfig({ 115 + const viteConfig = await resolveConfig({ 116 116 config: false, 117 117 coverage: { enabled: true, ...options }, 118 118 }) 119 - expect(vitestConfig.coverage.htmlDir).toBe( 120 - expected && resolve(vitestConfig.root, expected), 119 + expect(viteConfig.test.coverage.htmlDir).toBe( 120 + expected && resolve(viteConfig.test.root, expected), 121 121 ) 122 122 }) 123 123 124 124 test('coverage.changed inherits from test.changed but can be overridden', async () => { 125 - const { vitestConfig: inherited } = await resolveConfig({ 125 + const { test: inherited } = await resolveConfig({ 126 126 changed: 'HEAD', 127 127 coverage: { 128 128 reporter: 'json', ··· 131 131 132 132 expect(inherited.coverage.changed).toBe('HEAD') 133 133 134 - const { vitestConfig: overridden } = await resolveConfig({ 134 + const { test: overridden } = await resolveConfig({ 135 135 changed: 'HEAD', 136 136 coverage: { 137 137 changed: false,
+1 -1
test/e2e/test/reporters/logger.test.ts
··· 16 16 test('cursor is not hidden during test run in non-TTY', async () => { 17 17 const { stdout } = await runVitest({ 18 18 include: ['b1.test.ts'], 19 - root: 'fixtures/default', 19 + root: 'fixtures/reporters/default', 20 20 reporters: 'none', 21 21 watch: false, 22 22 }, [], { preserveAnsi: true })
+29 -26
test/e2e/test/reporters/merge-reports.test.ts
··· 5 5 import { cpSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs' 6 6 import { mkdir, writeFile } from 'node:fs/promises' 7 7 import path from 'node:path' 8 - import { buildTestTree, runVitest, useFS } from '#test-utils' 8 + import { buildTestTree, runVitest, useFS, useTmpFS } from '#test-utils' 9 9 import { playwright } from '@vitest/browser-playwright' 10 10 import { stringify } from 'flatted' 11 11 import { dirname, resolve } from 'pathe' ··· 351 351 const reportsDir = resolve(root, '.vitest/blob') 352 352 rmSync(reportsDir, { force: true, recursive: true }) 353 353 354 - const baseConfig: TestUserConfig = { 355 - root, 356 - } 357 - if (mode === 'browser') { 358 - baseConfig.browser = { 359 - enabled: true, 360 - provider: playwright(), 361 - instances: [ 362 - { 363 - browser: 'chromium', 364 - }, 365 - ], 366 - headless: true, 354 + const baseConfig = () => { 355 + const baseConfig: TestUserConfig = { 356 + root, 357 + } 358 + 359 + if (mode === 'browser') { 360 + baseConfig.browser = { 361 + enabled: true, 362 + provider: playwright(), 363 + instances: [ 364 + { 365 + browser: 'chromium', 366 + }, 367 + ], 368 + headless: true, 369 + } 367 370 } 371 + return baseConfig 368 372 } 369 373 370 374 const result = await runVitest({ 371 - ...baseConfig, 375 + ...baseConfig(), 372 376 reporters: ['blob'], 373 377 }) 374 378 expect.assert(result.ctx) ··· 471 475 } 472 476 473 477 const result2 = await runVitest({ 474 - ...baseConfig, 478 + ...baseConfig(), 475 479 mergeReports: reportsDir, 476 480 }) 477 481 expect(result2.stderr).toMatchInlineSnapshot(`""`) ··· 480 484 expect(restoredModuleGraphJson).toBe(generatedModuleGraphJson) 481 485 482 486 const result3 = await runVitest({ 483 - ...baseConfig, 487 + ...baseConfig(), 484 488 mergeReports: resolve(root, '.vitest/blob'), 485 489 reporters: ['html'], 486 490 }) ··· 502 506 ctx, 503 507 projectName, 504 508 file.filepath, 509 + file.viteEnvironment, 505 510 ) 506 511 return [file.filepath, graph] as const 507 512 }), ··· 755 760 }) 756 761 757 762 test('merge reports with projects and labels', async () => { 758 - const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`) 759 - useFS(root, { 763 + const { root } = useTmpFS({ 760 764 'basic.test.ts': ` 761 765 import { test, expect } from "vitest"; 762 766 ··· 771 775 }) 772 776 `, 773 777 }) 774 - const baseConfig: RunVitestConfig = { 778 + const baseConfig = (): RunVitestConfig => ({ 779 + config: false, 775 780 root, 776 781 projects: [ 777 782 { 778 - extends: true, 779 783 test: { 780 784 name: 'node', 781 785 sequence: { ··· 784 788 }, 785 789 }, 786 790 { 787 - extends: true, 788 791 test: { 789 792 name: 'browser', 790 793 sequence: { ··· 804 807 }, 805 808 }, 806 809 ], 807 - } 810 + }) 808 811 const result1 = await runVitest({ 809 - ...baseConfig, 812 + ...baseConfig(), 810 813 reporters: [['blob', { label: 'linux' }]], 811 814 }) 812 815 expect(result1.stderr).toMatchInlineSnapshot(`""`) ··· 833 836 } 834 837 `) 835 838 const result2 = await runVitest({ 836 - ...baseConfig, 839 + ...baseConfig(), 837 840 reporters: [['blob', { label: 'macos' }]], 838 841 }) 839 842 expect(result2.stderr).toMatchInlineSnapshot(`""`) ··· 860 863 } 861 864 `) 862 865 const result = await runVitest({ 863 - ...baseConfig, 866 + ...baseConfig(), 864 867 mergeReports: resolve(root, '.vitest/blob'), 865 868 }) 866 869 expect(trimReporterOutput(result.stdout)).toMatchInlineSnapshot(`
+1 -1
test/e2e/test/scoped-fixtures.test.ts
··· 1281 1281 }, 1282 1282 'vitest.config.js': { 1283 1283 test: { 1284 + isolate: false, 1284 1285 browser: { 1285 1286 enabled: true, 1286 1287 provider: playwright(), 1287 1288 headless: true, 1288 - isolate: false, 1289 1289 instances: [ 1290 1290 { browser: 'chromium' }, 1291 1291 ],
+48 -32
test/e2e/test/watch/stdin.test.ts
··· 1 - import { rmSync, writeFileSync } from 'node:fs' 2 - import { runVitest } from '#test-utils' 3 - import { describe, expect, onTestFinished, test } from 'vitest' 1 + import { runInlineTests } from '#test-utils' 2 + import { describe, expect, test } from 'vitest' 4 3 5 - const _options = { root: 'fixtures/watch', watch: true } 4 + const ts = String.raw 6 5 7 - describe.each([true, false])('standalone mode is %s', (standalone) => { 8 - const options = { ..._options, standalone } 6 + const fixture = { 7 + 'math.test.ts': ts` 8 + import { expect, test } from 'vitest' 9 + import { sum } from './math' 9 10 11 + test('sum', () => { 12 + expect(sum(1, 2)).toBe(3) 13 + }) 14 + `, 15 + 'math.ts': ts` 16 + export function sum(a: number, b: number) { 17 + return a + b 18 + } 19 + `, 20 + 'basic.test.ts': ts` 21 + import { test } from 'vitest' 22 + 23 + test('basic', () => {}) 24 + `, 25 + } 26 + 27 + describe.each([true, false])('standalone mode is %s', (standalone) => { 10 28 test('quit watch mode', async () => { 11 - const { vitest, waitForClose } = await runVitest(options) 29 + const { vitest, waitForClose } = await runInlineTests(fixture, { watch: true, standalone }) 12 30 13 31 vitest.write('q') 14 32 ··· 16 34 }) 17 35 18 36 test('filter by filename', async () => { 19 - const { vitest } = await runVitest(options) 37 + const { vitest } = await runInlineTests(fixture, { watch: true, standalone }) 20 38 21 39 vitest.write('p') 22 40 ··· 34 52 }) 35 53 36 54 test('filter by filename when multiple projects match same file', async () => { 37 - const { vitest } = await runVitest({ 38 - ...options, 55 + const { vitest } = await runInlineTests(fixture, { 56 + watch: true, 57 + standalone, 39 58 projects: [ 40 59 { 41 60 test: { 42 61 name: 'First', 43 - root: options.root, 44 62 }, 45 63 }, 46 64 { 47 65 test: { 48 66 name: 'Second', 49 - root: options.root, 50 67 }, 51 68 }, 52 69 ], ··· 69 86 }) 70 87 71 88 test('filter by test name', async () => { 72 - const { vitest } = await runVitest(options) 89 + const { vitest } = await runInlineTests(fixture, { watch: true, standalone }) 73 90 74 91 vitest.write('t') 75 92 ··· 91 108 }) 92 109 93 110 test.skipIf(process.env.GITHUB_ACTIONS)('cancel test run', async () => { 94 - const { vitest } = await runVitest(options) 111 + const { fs, vitest } = await runInlineTests(fixture, { watch: true, standalone }) 95 112 96 - const testPath = 'fixtures/watch/cancel.test.ts' 97 - const testCase = `// Dynamic test case 98 - import { afterAll, afterEach, test } from 'vitest' 113 + const testCase = ts` 114 + // Dynamic test case 115 + import { afterAll, afterEach, test } from 'vitest' 99 116 100 - // These should be called even when test is cancelled 101 - afterAll(() => console.log('[cancel-test]: afterAll')) 102 - afterEach(() => console.log('[cancel-test]: afterEach')) 117 + // These should be called even when test is cancelled 118 + afterAll(() => console.log('[cancel-test]: afterAll')) 119 + afterEach(() => console.log('[cancel-test]: afterEach')) 103 120 104 - test('1 - test that finishes', async () => { 105 - console.log('[cancel-test]: test') 121 + test('1 - test that finishes', async () => { 122 + console.log('[cancel-test]: test') 106 123 107 - await new Promise(resolve => setTimeout(resolve, 1000)) 108 - }) 124 + await new Promise(resolve => setTimeout(resolve, 1000)) 125 + }) 109 126 110 - test('2 - test that is cancelled', async () => { 111 - console.log('[cancel-test]: should not run') 112 - }) 113 - ` 127 + test('2 - test that is cancelled', async () => { 128 + console.log('[cancel-test]: should not run') 129 + }) 130 + ` 114 131 115 - onTestFinished(() => rmSync(testPath)) 116 - writeFileSync(testPath, testCase, 'utf8') 132 + fs.createFile('cancel.test.ts', testCase) 117 133 118 134 // Test case is running, cancel it 119 135 await vitest.waitForStdout('[cancel-test]: test') ··· 129 145 }) 130 146 131 147 test('rerun current pattern tests', async () => { 132 - const { vitest } = await runVitest({ ..._options, testNamePattern: 'sum' }) 148 + const { vitest } = await runInlineTests(fixture, { watch: true, testNamePattern: 'sum' }) 133 149 134 150 vitest.write('r') 135 151 ··· 139 155 }) 140 156 141 157 test('cli filter as watch filename pattern', async () => { 142 - const { vitest } = await runVitest(_options, ['math']) 158 + const { vitest } = await runInlineTests(fixture, { watch: true, $cliFilters: ['math'] }) 143 159 144 160 vitest.write('r') 145 161
+161 -79
test/e2e/test/watch/workspaces.test.ts
··· 1 - import { readFileSync, rmSync, writeFileSync } from 'node:fs' 2 - import { fileURLToPath } from 'node:url' 3 - import { runInlineTests, runVitestCli } from '#test-utils' 4 - import { dirname, resolve } from 'pathe' 5 - 6 - import { afterAll, afterEach, expect, it } from 'vitest' 7 - 8 - const file = fileURLToPath(import.meta.url) 9 - const dir = dirname(file) 10 - const root = resolve(dir, '..', '..', '..', 'workspaces') 11 - const config = resolve(root, 'vitest.config.watch.ts') 12 - const cleanups: (() => void)[] = [] 13 - 14 - const srcMathFile = resolve(root, 'src', 'math.ts') 15 - const specSpace2File = resolve(root, 'space_2', 'test', 'node.spec.ts') 1 + import { runInlineTests } from '#test-utils' 2 + import { expect, it } from 'vitest' 16 3 17 - const srcMathContent = readFileSync(srcMathFile, 'utf-8') 18 - const specSpace2Content = readFileSync(specSpace2File, 'utf-8') 19 - 20 - const dynamicTestContent = `// Dynamic test added by test/watch/test/workspaces.test.ts 4 + const dynamicTestContent = `// Dynamic test added by test/watch/workspaces.test.ts 21 5 import { expect, test } from "vitest"; 22 6 23 7 test("dynamic test case", () => { ··· 26 10 }) 27 11 ` 28 12 29 - async function startVitest() { 30 - const { vitest } = await runVitestCli( 31 - { nodeOptions: { cwd: root, env: { TEST_WATCH: 'true', NO_COLOR: 'true' } } }, 32 - '--root', 33 - root, 34 - '--config', 35 - config, 36 - '--watch', 37 - '--no-coverage', 38 - ) 39 - vitest.resetOutput() 40 - return vitest 41 - } 42 - 43 - afterEach(() => { 44 - cleanups.splice(0).forEach(cleanup => cleanup()) 45 - }) 46 - 47 - afterAll(() => { 48 - writeFileSync(srcMathFile, srcMathContent, 'utf8') 49 - writeFileSync(specSpace2File, specSpace2Content, 'utf8') 50 - }) 13 + it('editing a test file in a project reruns its tests', async () => { 14 + const { fs, vitest } = await runInlineTests({ 15 + 'space_2/node.spec.ts': ` 16 + import { expect, test } from 'vitest' 17 + test('window is not defined', () => { 18 + expect(typeof window).toBe('undefined') 19 + }) 20 + `, 21 + 'vitest.config.ts': { 22 + test: { 23 + projects: [ 24 + { test: { name: 'space_2', root: './space_2', environment: 'node' } }, 25 + ], 26 + }, 27 + }, 28 + }, { watch: true }) 51 29 52 - it('editing a test file in a suite with workspaces reruns test', async () => { 53 - const vitest = await startVitest() 30 + await vitest.waitForStdout('Waiting for file changes') 31 + vitest.resetOutput() 54 32 55 - writeFileSync(specSpace2File, `${specSpace2Content}\n`, 'utf8') 33 + fs.editFile('space_2/node.spec.ts', content => `${content}\n`) 56 34 57 - await vitest.waitForStdout('RERUN space_2/test/node.spec.ts x1') 58 - await vitest.waitForStdout('|space_2| test/node.spec.ts') 35 + await vitest.waitForStdout('RERUN ../space_2/node.spec.ts') 36 + await vitest.waitForStdout('|space_2| node.spec.ts') 59 37 await vitest.waitForStdout('Test Files 1 passed') 60 38 }) 61 39 62 - it('editing a file that is imported in different workspaces reruns both files', async () => { 63 - const vitest = await startVitest() 40 + it('editing a file imported in different projects reruns both files', async () => { 41 + const { fs, vitest } = await runInlineTests({ 42 + 'src/math.ts': `export function sum(a, b) { 43 + return a + b 44 + }`, 45 + 'space_1/math.spec.ts': ` 46 + import { expect, test } from 'vitest' 47 + import { sum } from '../src/math' 48 + test('1 + 1 = 2', () => { 49 + expect(sum(1, 1)).toBe(2) 50 + }) 51 + `, 52 + 'space_3/math.space-3-test.ts': ` 53 + import { expect, test } from 'vitest' 54 + import { sum } from '../src/math' 55 + test('2 + 2 = 4', () => { 56 + expect(sum(2, 2)).toBe(4) 57 + }) 58 + `, 59 + 'vitest.config.ts': { 60 + test: { 61 + projects: [ 62 + { test: { name: 'space_1', root: './space_1', environment: 'node' } }, 63 + { 64 + test: { 65 + name: 'space_3', 66 + root: './space_3', 67 + include: ['**/*.space-3-test.ts'], 68 + environment: 'node', 69 + }, 70 + }, 71 + ], 72 + }, 73 + }, 74 + }, { watch: true }) 64 75 65 - writeFileSync(srcMathFile, `${srcMathContent}\n`, 'utf8') 76 + await vitest.waitForStdout('Waiting for file changes') 77 + vitest.resetOutput() 66 78 67 - await vitest.waitForStdout('RERUN src/math.ts') 68 - await vitest.waitForStdout('|@vitest/space_3| math.space-3-test.ts') 69 - await vitest.waitForStdout('|space_1| test/math.spec.ts') 79 + fs.editFile('src/math.ts', content => `${content}\n`) 80 + 81 + await vitest.waitForStdout('RERUN ../src/math.ts') 82 + await vitest.waitForStdout('|space_1| math.spec.ts') 83 + await vitest.waitForStdout('|space_3| math.space-3-test.ts') 70 84 await vitest.waitForStdout('Test Files 2 passed') 71 85 }) 72 86 73 - it('filters by test name inside a workspace', async () => { 74 - const vitest = await startVitest() 87 + it('filters by test name inside a project', async () => { 88 + const { vitest } = await runInlineTests({ 89 + 'space_3/math.space-3-test.ts': ` 90 + import { expect, test } from 'vitest' 91 + test('2 x 2 = 4', () => { 92 + expect(2 * 2).toBe(4) 93 + }) 94 + test('2 + 2 = 4', () => { 95 + expect(2 + 2).toBe(4) 96 + }) 97 + `, 98 + 'vitest.config.ts': { 99 + test: { 100 + projects: [ 101 + { 102 + test: { 103 + name: 'space_3', 104 + root: './space_3', 105 + include: ['**/*.space-3-test.ts'], 106 + environment: 'node', 107 + }, 108 + }, 109 + ], 110 + }, 111 + }, 112 + }, { watch: true }) 113 + 114 + await vitest.waitForStdout('Waiting for file changes') 75 115 76 116 vitest.write('t') 77 - 78 117 await vitest.waitForStdout('Input test name pattern') 79 118 80 119 vitest.write('2 x 2 = 4\n') ··· 83 122 await vitest.waitForStdout('Test Files 1 passed') 84 123 }) 85 124 86 - it('adding a new test file matching core project config triggers re-run', async () => { 87 - const vitest = await startVitest() 125 + it('adding a new test file matching the default project config triggers a re-run', async () => { 126 + const { fs, vitest } = await runInlineTests({ 127 + 'space_2/node.spec.ts': ` 128 + import { test } from 'vitest' 129 + test('window is not defined', () => {}) 130 + `, 131 + 'space_3/math.space-3-test.ts': ` 132 + import { test } from 'vitest' 133 + test('2 + 2 = 4', () => {}) 134 + `, 135 + 'vitest.config.ts': { 136 + test: { 137 + projects: [ 138 + { test: { name: 'space_2', root: './space_2', environment: 'node' } }, 139 + { 140 + test: { 141 + name: 'space_3', 142 + root: './space_3', 143 + include: ['**/*.space-3-test.ts'], 144 + environment: 'node', 145 + }, 146 + }, 147 + ], 148 + }, 149 + }, 150 + }, { watch: true }) 88 151 89 - const testFile = resolve(root, 'space_2', 'test', 'new-dynamic.test.ts') 152 + await vitest.waitForStdout('Waiting for file changes') 153 + vitest.resetOutput() 90 154 91 - cleanups.push(() => rmSync(testFile)) 92 - writeFileSync(testFile, dynamicTestContent, 'utf-8') 155 + fs.createFile('space_2/new-dynamic.test.ts', dynamicTestContent) 93 156 94 157 await vitest.waitForStdout('Running added dynamic test') 95 - await vitest.waitForStdout('RERUN space_2/test/new-dynamic.test.ts') 96 - await vitest.waitForStdout('|space_2| test/new-dynamic.test.ts') 158 + await vitest.waitForStdout('RERUN ../space_2/new-dynamic.test.ts') 159 + await vitest.waitForStdout('|space_2| new-dynamic.test.ts') 97 160 98 161 // Wait for tests to end 99 162 await vitest.waitForStdout('Waiting for file changes') 100 163 101 - // Test case should not be run by other projects 102 - expect(vitest.stdout).not.include('|space_1|') 103 - expect(vitest.stdout).not.include('|@vitest/space_3|') 104 - expect(vitest.stdout).not.include('|node|') 105 - expect(vitest.stdout).not.include('|happy-dom|') 164 + // The new file should not be picked up by the project with a custom include 165 + expect(vitest.stdout).not.include('|space_3|') 106 166 }) 107 167 108 - it('adding a new test file matching project specific config triggers re-run', async () => { 109 - const vitest = await startVitest() 168 + it('adding a new test file matching a project specific config triggers a re-run', async () => { 169 + const { fs, vitest } = await runInlineTests({ 170 + 'space_2/node.spec.ts': ` 171 + import { test } from 'vitest' 172 + test('window is not defined', () => {}) 173 + `, 174 + 'space_3/math.space-3-test.ts': ` 175 + import { test } from 'vitest' 176 + test('2 + 2 = 4', () => {}) 177 + `, 178 + 'vitest.config.ts': { 179 + test: { 180 + projects: [ 181 + { test: { name: 'space_2', root: './space_2', environment: 'node' } }, 182 + { 183 + test: { 184 + name: 'space_3', 185 + root: './space_3', 186 + include: ['**/*.space-3-test.ts'], 187 + environment: 'node', 188 + }, 189 + }, 190 + ], 191 + }, 192 + }, 193 + }, { watch: true }) 110 194 111 - const testFile = resolve(root, 'space_3', 'new-dynamic.space-3-test.ts') 112 - cleanups.push(() => rmSync(testFile)) 113 - writeFileSync(testFile, dynamicTestContent, 'utf-8') 195 + await vitest.waitForStdout('Waiting for file changes') 196 + vitest.resetOutput() 197 + 198 + fs.createFile('space_3/new-dynamic.space-3-test.ts', dynamicTestContent) 114 199 115 200 await vitest.waitForStdout('Running added dynamic test') 116 - await vitest.waitForStdout('RERUN space_3/new-dynamic.space-3-test.ts') 117 - await vitest.waitForStdout('|@vitest/space_3| new-dynamic.space-3-test.ts') 201 + await vitest.waitForStdout('RERUN ../space_3/new-dynamic.space-3-test.ts') 202 + await vitest.waitForStdout('|space_3| new-dynamic.space-3-test.ts') 118 203 119 204 // Wait for tests to end 120 205 await vitest.waitForStdout('Waiting for file changes') 121 206 122 - // Test case should not be run by other projects 123 - expect(vitest.stdout).not.include('|space_1|') 124 - expect(vitest.stdout).not.include('|space_2|') 125 - expect(vitest.stdout).not.include('|node|') 126 - expect(vitest.stdout).not.include('|happy-dom|') 207 + // The new file should not be picked up by the default-include project 208 + expect(vitest.stdout).not.toContain('|space_2|') 127 209 }) 128 210 129 211 it('editing a setup file inside the project reruns tests', async () => {
+3
test/e2e/vitest.config.ts
··· 30 30 return false 31 31 } 32 32 }, 33 + tags: [ 34 + { name: 'browser', timeout: 60_000 }, 35 + ], 33 36 projects: [ 34 37 { 35 38 extends: true,
+106 -34
test/test-utils/index.ts
··· 1 1 import type { Options } from 'tinyexec' 2 - import type { UserConfig as ViteUserConfig } from 'vite' 2 + import type { FSWatcher, UserConfig as ViteUserConfig } from 'vite' 3 3 import type { ParsedStack, SerializedConfig, TestContext, WorkerGlobalState } from 'vitest' 4 4 import type { TestProjectConfiguration } from 'vitest/config' 5 5 import type { ··· 21 21 import { x } from 'tinyexec' 22 22 import * as tinyrainbow from 'tinyrainbow' 23 23 import { afterEach, onTestFinished, TestRunner } from 'vitest' 24 - import { startVitest } from 'vitest/node' 24 + import { Logger, PluginHarness, resolveConfig, startVitest } from 'vitest/node' 25 25 import { Cli } from './cli' 26 26 27 27 // override default colors to disable them in tests ··· 43 43 44 44 const process_ = process 45 45 46 + export function createConsole({ tty, std }: { tty?: boolean; std?: 'inherit' } = {}) { 47 + const stdout = new Writable({ 48 + write(chunk, __, callback) { 49 + if (std === 'inherit') { 50 + process.stdout.write(chunk.toString()) 51 + } 52 + callback() 53 + }, 54 + }) 55 + 56 + if (tty) { 57 + (stdout as typeof process.stdout).isTTY = true 58 + } 59 + 60 + const stderr = new Writable({ 61 + write(chunk, __, callback) { 62 + if (std === 'inherit') { 63 + process.stderr.write(chunk.toString()) 64 + } 65 + callback() 66 + }, 67 + }) 68 + 69 + // "node:tty".ReadStream doesn't work on Github Windows CI, let's simulate it 70 + const stdin = new Readable({ read: () => '' }) as NodeJS.ReadStream 71 + stdin.isTTY = true 72 + stdin.setRawMode = () => stdin 73 + 74 + return { 75 + stdin, 76 + stdout, 77 + stderr, 78 + } 79 + } 80 + 81 + export async function resolveTestConfig( 82 + config: RunVitestConfig, 83 + runnerOptions: VitestRunnerCLIOptions = {}, 84 + ) { 85 + const { stdout, stderr, stdin } = createConsole(runnerOptions) 86 + 87 + const cli = new Cli({ stdin, stdout, stderr, preserveAnsi: runnerOptions.preserveAnsi }) 88 + const { $cliOptions, $viteConfig, ...restConfig } = config 89 + const harness = new PluginHarness(new Logger(stdout, stderr)) 90 + const resolved = await resolveConfig( 91 + { config: false, ...$cliOptions }, 92 + { ...$viteConfig, test: restConfig }, 93 + harness, 94 + ) 95 + return { 96 + config: resolved, 97 + vitest: cli, 98 + stdout: cli.stdout, 99 + stderr: cli.stderr, 100 + } 101 + } 102 + 46 103 /** 47 104 * The config is assumed to be the config on the file system, not CLI options 48 105 * (Note that CLI only options like "standalone" are passed as CLI options, not config options) ··· 78 135 const exit = process.exit 79 136 process.exit = (() => { }) as never 80 137 81 - const stdout = new Writable({ 82 - write(chunk, __, callback) { 83 - if (runnerOptions.std === 'inherit') { 84 - process.stdout.write(chunk.toString()) 85 - } 86 - callback() 87 - }, 88 - }) 89 - 90 - if (runnerOptions?.tty) { 91 - (stdout as typeof process.stdout).isTTY = true 92 - } 138 + const { stdout, stderr, stdin } = createConsole(runnerOptions) 93 139 94 - const stderr = new Writable({ 95 - write(chunk, __, callback) { 96 - if (runnerOptions.std === 'inherit') { 97 - process.stderr.write(chunk.toString()) 98 - } 99 - callback() 100 - }, 101 - }) 102 - 103 - // "node:tty".ReadStream doesn't work on Github Windows CI, let's simulate it 104 - const stdin = new Readable({ read: () => '' }) as NodeJS.ReadStream 105 - stdin.isTTY = true 106 - stdin.setRawMode = () => stdin 107 140 const cli = new Cli({ stdin, stdout, stderr, preserveAnsi: runnerOptions.preserveAnsi }) 108 141 // @ts-expect-error not typed global 109 142 const currentConfig: SerializedConfig = __vitest_worker__.ctx.config ··· 202 235 }, 203 236 ], 204 237 server: { 205 - // we never need a websocket connection for the root config because it doesn't connect to the browser 206 - // browser mode uses a separate config that doesn't inherit CLI overrides 207 - ws: false, 208 238 watch: { 209 239 // During tests we edit the files too fast and sometimes chokidar 210 240 // misses change events, so enforce polling for consistency ··· 249 279 } 250 280 } 251 281 282 + // In watch mode, `startVitest` can resolve before the file watcher finished 283 + // its initial scan (especially in standalone mode, which doesn't run any 284 + // tests on startup). A test file created right after would be folded into the 285 + // watcher's baseline and ignored, so newly added files wouldn't trigger a 286 + // rerun. Wait for the watcher to be ready before handing control back. 287 + if (watch && ctx) { 288 + await waitForWatcherReady(ctx.vite.watcher, ctx.config.root) 289 + } 290 + 252 291 return { 253 292 thrown, 254 293 ctx, ··· 283 322 } 284 323 } 285 324 325 + // In watch mode `startVitest` can resolve before the file watcher has finished 326 + // establishing itself. chokidar's `ready`/`_readyEmitted` fires after the 327 + // initial scan, but with polling the root directory isn't fully watched for new 328 + // children until its parent shows up in `getWatched()`. A test file created 329 + // before that point gets folded into the watcher's baseline and never emits an 330 + // `add` event, so newly added files wouldn't trigger a rerun. Wait for both 331 + // signals (with a timeout safety net) before handing control back. 332 + async function waitForWatcherReady(watcher: FSWatcher, root: string): Promise<void> { 333 + const slash = (p: string) => p.replace(/\\/g, '/') 334 + const parent = slash(dirname(root)) 335 + const deadline = Date.now() + 2000 336 + while (Date.now() < deadline) { 337 + const isReady = (watcher as { _readyEmitted?: boolean })._readyEmitted 338 + const watchesParent = Object.keys(watcher.getWatched()).some(dir => slash(dir) === parent) 339 + if (isReady && watchesParent) { 340 + return 341 + } 342 + await new Promise(resolve => setTimeout(resolve, 10)) 343 + } 344 + } 345 + 286 346 interface CliOptions extends Partial<Options> { 287 347 earlyReturn?: boolean 288 348 preserveAnsi?: boolean ··· 457 517 return `export default ${JSON.stringify(content)}` 458 518 } 459 519 520 + export function useTmpFS<T extends TestFsStructure>(structure: T, ensureConfig = true, task?: TestContext['task']) { 521 + const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`) 522 + return useFS(root, structure, ensureConfig, task) 523 + } 524 + 460 525 export function useFS<T extends TestFsStructure>(root: string, structure: T, ensureConfig = true, task?: TestContext['task']) { 461 526 const files = new Set<string>() 462 527 const hasConfig = Object.keys(structure).some(file => file.includes('.config.')) ··· 475 540 fs.rmSync(root, { recursive: true, force: true }) 476 541 } 477 542 }) 543 + task?.context.signal.addEventListener('abort', () => { 544 + if (process.env.VITEST_FS_CLEANUP !== 'false') { 545 + fs.rmSync(root, { recursive: true, force: true }) 546 + } 547 + }) 478 548 return { 479 549 root, 550 + readdir: (file: string): string[] => { 551 + return fs.readdirSync(resolve(root, file)) 552 + }, 480 553 readFile: (file: string): string => { 481 554 const filepath = resolve(root, file) 482 555 if (relative(root, filepath).startsWith('..')) { ··· 529 602 options?: VitestRunnerCLIOptions, 530 603 task?: TestContext['task'], 531 604 ) { 532 - const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`) 533 - const fs = useFS(root, structure, undefined, task) 605 + const fs = useTmpFS(structure, undefined, task ?? TestRunner.getCurrentTest()) 534 606 const vitest = await runVitest({ 535 - root, 607 + root: fs.root, 536 608 ...config, 537 609 }, config?.$cliFilters ?? [], options) 538 610 return { 539 611 fs, 540 - root, 612 + root: fs.root, 541 613 ...vitest, 542 614 get results() { 543 615 return vitest.ctx?.state.getTestModules() || []
+14 -17
test/unit/test/cli-test.test.ts
··· 1 - import { resolveConfig as viteResolveConfig } from 'vite' 2 1 import { expect, test } from 'vitest' 3 - import { ReportersMap, rolldownVersion } from 'vitest/node' 4 - import { createCLI, parseCLI } from '../../../packages/vitest/src/node/cli/cac.js' 5 - import { resolveConfig } from '../../../packages/vitest/src/node/config/resolveConfig.js' 2 + import { 3 + createCLI, 4 + parseCLI, 5 + ReportersMap, 6 + resolveConfig, 7 + rolldownVersion, 8 + } from 'vitest/node' 6 9 7 10 const vitestCli = createCLI() 8 11 ··· 294 297 ['', true], 295 298 ['', false], 296 299 ] as const 297 - const baseViteConfig = await viteResolveConfig({ configFile: false }, 'serve') 298 - const results = examples.map(([vitestClearScreen, viteClearScreen]) => { 299 - const viteConfig = { 300 - ...baseViteConfig, 301 - clearScreen: viteClearScreen, 302 - } 300 + const results = examples.map(async ([vitestClearScreen, viteClearScreen]) => { 303 301 const vitestConfig = getCLIOptions(vitestClearScreen) 304 - const config = resolveConfig({ 305 - logger: undefined, 306 - mode: 'test', 307 - _cliOptions: {}, 308 - } as any, vitestConfig, viteConfig) 309 - return config.clearScreen 302 + vitestConfig.config = false 303 + const config = await resolveConfig(vitestConfig, { 304 + clearScreen: viteClearScreen, 305 + }) 306 + return config.test.clearScreen 310 307 }) 311 - expect(results).toMatchInlineSnapshot(` 308 + expect(await Promise.all(results)).toMatchInlineSnapshot(` 312 309 [ 313 310 true, 314 311 true,
+4 -1
test/unit/test/exports.test.ts
··· 69 69 "processError": "function", 70 70 "setSafeTimers": "function", 71 71 "setupCommonEnv": "function", 72 + "setupEnv": "function", 72 73 "startCoverageInsideWorker": "function", 73 74 "startTests": "function", 74 75 "stopCoverageInsideWorker": "function", ··· 90 91 "HangingProcessReporter": "function", 91 92 "JUnitReporter": "function", 92 93 "JsonReporter": "function", 94 + "Logger": "function", 93 95 "MinimalReporter": "function", 96 + "PluginHarness": "function", 94 97 "ReportersMap": "object", 95 98 "TapFlatReporter": "function", 96 99 "TapReporter": "function", ··· 99 102 "TypecheckPoolWorker": "function", 100 103 "VerboseReporter": "function", 101 104 "VitestPackageInstaller": "function", 102 - "VitestPlugin": "function", 103 105 "VmForksPoolWorker": "function", 104 106 "VmThreadsPoolWorker": "function", 107 + "createCLI": "function", 105 108 "createDebugger": "function", 106 109 "createMethodsRPC": "function", 107 110 "createViteLogger": "function",
+8
test/unit/test/stubs.test.ts
··· 121 121 vi.stubEnv(name, 'string') 122 122 }) 123 123 124 + it.each(['PROD', 'DEV', 'SSR'] as const)('stubbing boolean env.%s to undefined removes it', (name) => { 125 + vi.stubEnv(name, undefined) 126 + // removed entirely, not set to an empty string like a `false` boolean stub 127 + expect(name in import.meta.env).toBe(false) 128 + expect(process.env[name]).toBeUndefined() 129 + vi.unstubAllEnvs() 130 + }) 131 + 124 132 it('setting boolean casts the value to string', () => { 125 133 // @ts-expect-error value should be a string 126 134 vi.stubEnv('MY_TEST_ENV', true)
+4 -4
test/workspaces/space_1/test/env-injected.spec.ts
··· 10 10 }) 11 11 12 12 test('env variable is assigned', () => { 13 - // we override it with "local" in .env.local, but dotenv prefers the root .env 14 - // this is consistent with how Vite works 15 - expect(import.meta.env.VITE_MY_TEST_VARIABLE).toBe('core') 16 - expect(process.env.VITE_MY_TEST_VARIABLE).toBe('core') 13 + // the project's own .env.local wins over the inherited root value, like every 14 + // other option a project can override 15 + expect(import.meta.env.VITE_MY_TEST_VARIABLE).toBe('local') 16 + expect(process.env.VITE_MY_TEST_VARIABLE).toBe('local') 17 17 expect(import.meta.env.CUSTOM_MY_TEST_VARIABLE).toBe('custom') 18 18 expect(process.env.CUSTOM_MY_TEST_VARIABLE).toBe('custom') 19 19