diff --git a/README.md b/README.md index 6d25c0cd3f1c..3263eb7cfed8 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,12 @@ A blazing fast unit test framework powered by Vite. - Components testing ([Vue](./examples/vue), [React](./examples/react), [Svelte](./examples/svelte), [Lit](./examples/lit), [Vitesse](./examples/vitesse)) - Workers multi-threading via [Tinypool](https://github.com/tinylibs/tinypool) (a lightweight fork of [Piscina](https://github.com/piscinajs/piscina)) - Benchmarking support with [Tinybench](https://github.com/tinylibs/tinybench) +- [Workspace](/guide/workspace) support - ESM first, top level await - Out-of-box TypeScript / JSX support - Filtering, timeouts, concurrent for suite and tests -> Vitest requires Vite >=v3.0.0 and Node >=v14 +> Vitest requires Vite >=v3.0.0 and Node >=v14.18 ```ts diff --git a/docs/.vitepress/components.d.ts b/docs/.vitepress/components.d.ts index 5bd86b9a8c0f..d45cda87c4ec 100644 --- a/docs/.vitepress/components.d.ts +++ b/docs/.vitepress/components.d.ts @@ -1,5 +1,7 @@ -// generated by unplugin-vue-components -// We suggest you to commit this file into source control +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 import '@vue/runtime-core' @@ -12,6 +14,7 @@ declare module '@vue/runtime-core' { FeaturesList: typeof import('./components/FeaturesList.vue')['default'] HomePage: typeof import('./components/HomePage.vue')['default'] ListItem: typeof import('./components/ListItem.vue')['default'] + NonProjectOption: typeof import('./components/NonProjectOption.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/docs/.vitepress/components/FeaturesList.vue b/docs/.vitepress/components/FeaturesList.vue index 99bf99ac7701..add0417bcb08 100644 --- a/docs/.vitepress/components/FeaturesList.vue +++ b/docs/.vitepress/components/FeaturesList.vue @@ -13,6 +13,7 @@ Workers multi-threading via Tinypool Benchmarking support with Tinybench Filtering, timeouts, concurrent for suite and tests + Workspace support Jest-compatible Snapshot diff --git a/docs/.vitepress/components/NonProjectOption.vue b/docs/.vitepress/components/NonProjectOption.vue new file mode 100644 index 000000000000..87e1ae66d1e3 --- /dev/null +++ b/docs/.vitepress/components/NonProjectOption.vue @@ -0,0 +1,10 @@ + diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1ce0d4e9c78c..def85b3f0edb 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -147,6 +147,10 @@ export default withPwa(defineConfig({ text: 'Features', link: '/guide/features', }, + { + text: 'Workspace', + link: '/guide/workspace', + }, { text: 'CLI', link: '/guide/cli', diff --git a/docs/.vitepress/style/vars.css b/docs/.vitepress/style/vars.css index 661553cf903b..0cb6dd4b98ac 100644 --- a/docs/.vitepress/style/vars.css +++ b/docs/.vitepress/style/vars.css @@ -48,6 +48,9 @@ --vp-button-brand-active-border: var(--vp-c-brand-light); --vp-button-brand-active-text: var(--vp-c-text-dark-1); --vp-button-brand-active-bg: var(--vp-button-brand-bg); + --vp-code-tab-active-text-color: var(--vp-c-brand); + --vp-code-tab-active-bar-color: var(--vp-c-brand-darker); + --vp-code-tab-divider: var(--vp-c-brand); } /** diff --git a/docs/api/vi.md b/docs/api/vi.md index b434242258c8..d7d4135bf129 100644 --- a/docs/api/vi.md +++ b/docs/api/vi.md @@ -251,9 +251,9 @@ test('importing the next module imports mocked one', async () => { When `partial` is `true` it will expect a `Partial` as a return value. ```ts - import example from './example' - - vi.mock('./example') + import example from './example.js' + + vi.mock('./example.js') test('1+1 equals 2', async () => { vi.mocked(example.calc).mockRestore() @@ -271,8 +271,8 @@ test('importing the next module imports mocked one', async () => { Imports module, bypassing all checks if it should be mocked. Can be useful if you want to mock module partially. ```ts - vi.mock('./example', async () => { - const axios = await vi.importActual('./example') + vi.mock('./example.js', async () => { + const axios = await vi.importActual('./example.js') return { ...axios, get: vi.fn() } }) diff --git a/docs/config/index.md b/docs/config/index.md index 421f901b6d77..9a16ad9c13f2 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -74,6 +74,10 @@ export default mergeConfig(viteConfig, defineConfig({ In addition to the following options, you can also use any configuration option from [Vite](https://vitejs.dev/config/). For example, `define` to define global variables, or `resolve.alias` to define aliases. ::: +::: tip +All configuration options that are not supported inside a [workspace](/guide/workspace) project config have sign next them. +::: + ### include - **Type:** `string[]` @@ -100,6 +104,10 @@ Handling for dependencies resolution. - **Version:** Since Vitest 0.29.0 - **See also:** [Dep Optimization Options](https://vitejs.dev/config/dep-optimization-options.html) +::: warning +This feature is temporary disabled since Vitest 0.30.0. +::: + Enable dependency optimization. If you have a lot of tests, this might improve their performance. For `jsdom` and `happy-dom` environments, when Vitest will encounter the external library, it will be bundled into a single file using esbuild and imported as a whole module. This is good for several reasons: @@ -141,7 +149,7 @@ When a dependency is a valid ESM package, try to guess the cjs version based on This might potentially cause some misalignment if a package has different logic in ESM and CJS mode. -#### deps.registerNodeLoader +#### deps.registerNodeLoader - **Type:** `boolean` - **Default:** `false` @@ -411,7 +419,7 @@ export default defineConfig({ }) ``` -### update +### update - **Type:** `boolean` - **Default:** `false` @@ -419,7 +427,7 @@ export default defineConfig({ Update snapshot files. This will update all changed snapshots and delete obsolete ones. -### watch +### watch - **Type:** `boolean` - **Default:** `true` @@ -434,7 +442,7 @@ Enable watch mode Project root -### reporters +### reporters - **Type:** `Reporter | Reporter[]` - **Default:** `'default'` @@ -452,7 +460,7 @@ Custom reporters for output. Reporters can be [a Reporter instance](https://gith - `'hanging-process'` - displays a list of hanging processes, if Vitest cannot exit process safely. This might be a heavy operation, enable it only if Vitest consistently cannot exit process - path of a custom reporter (e.g. `'./path/to/reporter.ts'`, `'@scope/reporter'`) -### outputFile +### outputFile - **Type:** `string | Record` - **CLI:** `--outputFile=`, `--outputFile.json=./path` @@ -485,21 +493,21 @@ Even though this option will force tests to run one after another, this option i This might cause all sorts of issues, if you are relying on global state (frontend frameworks usually do) or your code relies on environment to be defined separately for each test. But can be a speed boost for your tests (up to 3 times faster), that don't necessarily rely on global state or can easily bypass that. ::: -### maxThreads +### maxThreads - **Type:** `number` - **Default:** _available CPUs_ Maximum number of threads. You can also use `VITEST_MAX_THREADS` environment variable. -### minThreads +### minThreads - **Type:** `number` - **Default:** _available CPUs_ Minimum number of threads. You can also use `VITEST_MIN_THREADS` environment variable. -### useAtomics +### useAtomics - **Type:** `boolean` - **Default:** `false` @@ -524,14 +532,14 @@ Default timeout of a test in milliseconds Default timeout of a hook in milliseconds -### teardownTimeout +### teardownTimeout - **Type:** `number` - **Default:** `10000` Default timeout to wait for close when Vitest shuts down, in milliseconds -### silent +### silent - **Type:** `boolean` - **Default:** `false` @@ -591,14 +599,14 @@ Beware that the global setup is run in a different global scope, so your tests d ::: -### watchExclude +### watchExclude - **Type:** `string[]` - **Default:** `['**/node_modules/**', '**/dist/**']` Glob pattern of file paths to be ignored from triggering watch rerun. -### forceRerunTriggers +### forceRerunTriggers - **Type**: `string[]` - **Default:** `['**/package.json/**', '**/vitest.config.*/**', '**/vite.config.*/**']` @@ -626,7 +634,7 @@ Make sure that your files are not excluded by `watchExclude`. Isolate environment for each test file. Does not work if you disable [`--threads`](#threads). -### coverage +### coverage You can use [`c8`](https://github.com/bcoe/c8), [`istanbul`](https://istanbul.js.org/) or [a custom coverage solution](/guide/coverage#custom-coverage-provider) for coverage collection. @@ -703,7 +711,7 @@ List of files excluded from coverage as glob patterns. - **Type:** `boolean` - **Default:** `false` - **Available for providers:** `'c8' | 'istanbul'` -- **CLI:** `--coverage.all`, --coverage.all=false` +- **CLI:** `--coverage.all`, `--coverage.all=false` Whether to include all files, including the untested ones into report. @@ -906,7 +914,7 @@ Watermarks for statements, lines, branches and functions. See [istanbul document Specifies the module name or path for the custom coverage provider module. See [Guide - Custom Coverage Provider](/guide/coverage#custom-coverage-provider) for more information. -### testNamePattern +### testNamePattern - **Type** `string | RegExp` - **CLI:** `-t `, `--testNamePattern=`, `--test-name-pattern=` @@ -928,7 +936,7 @@ test('doNotRun', () => { }) ``` -### open +### open - **Type:** `boolean` - **Default:** `false` @@ -1091,13 +1099,13 @@ export default defineConfig({ }) ``` -### snapshotFormat +### snapshotFormat - **Type:** `PrettyFormatOptions` Format options for snapshot testing. These options are passed down to [`pretty-format`](https://www.npmjs.com/package/pretty-format). -### resolveSnapshotPath +### resolveSnapshotPath - **Type**: `(testPath: string, snapExtension: string) => string` - **Default**: stores snapshot files in `__snapshots__` directory @@ -1122,7 +1130,7 @@ export default defineConfig({ Allow tests and suites that are marked as only. -### dangerouslyIgnoreUnhandledErrors +### dangerouslyIgnoreUnhandledErrors - **Type**: `boolean` - **Default**: `false` @@ -1130,7 +1138,7 @@ Allow tests and suites that are marked as only. Ignore any unhandled errors that occur. -### passWithNoTests +### passWithNoTests - **Type**: `boolean` - **Default**: `false` @@ -1199,7 +1207,7 @@ A number of tests that are allowed to run at the same time marked with `test.con Test above this limit will be queued to run when available slot appears. -### cache +### cache - **Type**: `false | { dir? }` @@ -1224,7 +1232,7 @@ You can provide sequence options to CLI with dot notation: npx vitest --sequence.shuffle --sequence.seed=1000 ``` -#### sequence.sequencer +#### sequence.sequencer - **Type**: `TestSequencerConstructor` - **Default**: `BaseSequencer` @@ -1243,7 +1251,7 @@ If you want tests to run randomly, you can enable it with this option, or CLI ar Vitest usually uses cache to sort tests, so long running tests start earlier - this makes tests run faster. If your tests will run in random order you will lose this performance improvement, but it may be useful to track tests that accidentally depend on another run previously. -#### sequence.seed +#### sequence.seed - **Type**: `number` - **Default**: `Date.now()` @@ -1330,7 +1338,7 @@ By default, if Vitest finds source error, it will fail test suite. Path to custom tsconfig, relative to the project root. -### slowTestThreshold +### slowTestThreshold - **Type**: `number` - **Default**: `300` diff --git a/docs/guide/index.md b/docs/guide/index.md index 63436cce3f88..8e32d15c6736 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -32,7 +32,7 @@ pnpm add -D vitest ``` :::tip -Vitest requires Vite >=v3.0.0 and Node >=v14 +Vitest requires Vite >=v3.0.0 and Node >=v14.18 ::: It is recommended that you install a copy of `vitest` in your `package.json`, using one of the methods listed above. However, if you would prefer to run `vitest` directly, you can use `npx vitest` (the `npx` command comes with npm and Node.js). @@ -61,6 +61,40 @@ export default defineConfig({ See the list of config options in the [Config Reference](../config/) +## Workspaces Support + +Run different project configurations inside the same project with [Vitest Workspaces](/guide/workspace). You can define a list of files and folders that define you workspace in `vitest.workspace` file. The file supports `js`/`ts`/`json` extensions. This feature works great with monorepo setups. + +```ts +import { defineWorkspace } from 'vitest/config' + +export default defineWorkspace([ + // you can use a list of glob patterns to define your workspaces + // Vitest expects a list of config files + // or directories where there is a config file + 'packages/*', + 'tests/*/vitest.config.{e2e,unit}.ts', + // you can even run the same tests, + // but with different configs in the same "vitest" process + { + test: { + name: 'happy-dom', + root: './shared_tests', + environment: 'happy-dom', + setupFiles: ['./setup.happy-dom.ts'], + }, + }, + { + test: { + name: 'node', + root: './shared_tests', + environment: 'node', + setupFiles: ['./setup.node.ts'], + }, + }, +]) +``` + ## Command Line Interface In a project where Vitest is installed, you can use the `vitest` binary in your npm scripts, or run it directly with `npx vitest`. Here are the default npm scripts in a scaffolded Vitest project: diff --git a/docs/guide/mocking.md b/docs/guide/mocking.md index fa52a3abb952..4b5fe78915a7 100644 --- a/docs/guide/mocking.md +++ b/docs/guide/mocking.md @@ -216,7 +216,7 @@ vi.mock('pg', () => { return { Client } }) -vi.mock('./handlers', () => { +vi.mock('./handlers.js', () => { return { success: vi.fn(), failure: vi.fn(), diff --git a/docs/guide/workspace.md b/docs/guide/workspace.md new file mode 100644 index 000000000000..173435f9696d --- /dev/null +++ b/docs/guide/workspace.md @@ -0,0 +1,137 @@ +# Workspace + +Vitest provides built-in support for monorepositories through a workspace configuration file. You can create a workspace to define your project's setups. + +## Defining a workspace + +A workspace should have a `vitest.workspace` or `vitest.projects` file in its root (in the same folder with your config file, if you have one). Vitest supports `ts`/`js`/`json` extensions for this file. + +Workspace configuration file should have a default export with a list of files or glob patterns referencing your projects. For example, if you have a folder with your projects named `packages`, you can define a workspace with this config file: + +:::code-group +```ts [vitest.workspace.ts] +export default [ + 'packages/*' +] +``` +::: + +Vitest will consider every folder in `packages` as a separate project even if it doesn't have a config file inside. + +::: warning +Vitest will not consider the root config as a workspace project (so it will not run tests specified in `include`) unless it is specified in this config. +::: + +You can also reference projects with their config files: + +:::code-group +```ts [vitest.workspace.ts] +export default [ + 'packages/*/vitest.config.{e2e,unit}.ts' +] +``` +::: + +This pattern will only include projects with `vitest.config` file that includes `e2e` and `unit` before the extension. + +::: warning +If you are referencing filenames with glob pattern, make sure your config file starts with `vite.config` or `vitest.config`. Otherwise Vitest will skip it. +::: + +You can also define projects with inline config. Workspace file supports using both syntaxes at the same time. + +:::code-group +```ts [vitest.workspace.ts] +import { defineWorkspace } from 'vitest/config' + +// defineWorkspace provides a nice type hinting DX +export default defineWorkspace([ + 'packages/*', + { + // add "extends" to merge two configs together + extends: './vite.config.js', + test: { + include: ['tests/**/*.{browser}.test.{ts,js}'], + // it is recommended to define a name when using inline configs + name: 'happy-dom', + environment: 'happy-dom', + } + }, + { + test: { + include: ['tests/**/*.{node}.test.{ts,js}'], + name: 'node', + environment: 'node', + } + } +]) +``` +::: + +::: warning +All projects should have unique names. Otherwise Vitest will throw an error. If you do not provide a name inside inline config, Vitest will assign a number. If you don't provide a name inside a project config defined with glob syntax, Vitest will use the directory name by default. +::: + +If you don't rely on inline configs, you can just create a small json file in your root directory: + +:::code-group +```json [vitest.workspace.json] +[ + "packages/*" +] +``` +::: + +Workspace projects don't support all configuration properties. For better type safety, use `defineProject` instead of `defineConfig` method inside project configuration files: + +:::code-group +```ts [packages/a/vitest.config.ts] +import { defineProject } from 'vitest/config' + +export default defineProject({ + test: { + environment: 'jsdom', + // "reporters" is not supported in a project config, + // so it will show an error + reporters: ['json'] + } +}) +``` +::: + +## Configuration + +None of the configuration options are inherited from the root-level config file. You can create a shared config file and merge it with the project config yourself: + +:::code-group +```ts [packages/a/vitest.config.ts] +import { defineProject, mergeConfig } from 'vitest/config' +import configShared from '../vitest.shared.js' + +export default mergeConfig( + configShared, + defineProject({ + test: { + environment: 'jsdom', + } + }) +) +``` +::: + +Also some of the configuration options are not allowed in a project config. Most notably: + +- `coverage`: coverage is done for the whole workspace +- `reporters`: only root-level reporters can be supported +- `resolveSnapshotPath`: only root-level resolver is respected +- all other options that don't affect test runners + +::: tip +All configuration options that are not supported inside a project config have sign next them in ["Config"](/config/) page. +::: + +## Coverage + +Coverage for workspace projects works out of the box. But if you have [`all`](/config/#coverage-all) option enabled and use non-conventional extensions in some of you projects, you will need to have a plugin that handles this extension in your root configuration file. + +For example, if you have a package that uses Vue files and it has its own config file, but some of the files are not imported in your tests, coverage will fail trying to analyze the usage of unused files, because it relies on the root configuration rather than project configuration. diff --git a/packages/browser/src/client/main.ts b/packages/browser/src/client/main.ts index 640da0ecb20e..50a636cb7ca5 100644 --- a/packages/browser/src/client/main.ts +++ b/packages/browser/src/client/main.ts @@ -66,6 +66,10 @@ ws.addEventListener('open', async () => { moduleCache: new Map(), rpc: client.rpc, safeRpc, + durations: { + environment: 0, + prepare: 0, + }, } const paths = getQueryPaths() diff --git a/packages/coverage-c8/rollup.config.js b/packages/coverage-c8/rollup.config.js index c08ba5f99b21..05acfa29bcfc 100644 --- a/packages/coverage-c8/rollup.config.js +++ b/packages/coverage-c8/rollup.config.js @@ -17,6 +17,7 @@ const external = [ ...builtinModules, ...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {}), + 'node:inspector', 'c8/lib/report.js', 'c8/lib/commands/check-coverage.js', 'vitest', diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index e363fe319fe0..273aa0ee95d6 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -18,7 +18,7 @@ export async function collectTests(paths: string[], runner: VitestRunner): Promi for (const filepath of paths) { const path = relative(config.root, filepath) const file: File = { - id: generateHash(path), + id: generateHash(`${path}${config.name || ''}`), name: path, type: 'suite', mode: 'run', diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 04ee02dc3cd2..afff7a4bc439 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -10,10 +10,13 @@ import { API_PATH } from '../constants' import type { Vitest } from '../node' import type { File, ModuleGraphData, Reporter, TaskResultPack, UserConsoleLog } from '../types' import { getModuleGraph, isPrimitive } from '../utils' +import type { WorkspaceProject } from '../node/workspace' import { parseErrorStacktrace } from '../utils/source-map' import type { TransformResultWithSource, WebSocketEvents, WebSocketHandlers } from './types' -export function setup(ctx: Vitest, server?: ViteDevServer) { +export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, server?: ViteDevServer) { + const ctx = 'ctx' in vitestOrWorkspace ? vitestOrWorkspace.ctx : vitestOrWorkspace + const wss = new WebSocketServer({ noServer: true }) const clients = new Map>() diff --git a/packages/vitest/src/config.ts b/packages/vitest/src/config.ts index b2ef089549c9..541d610a56ab 100644 --- a/packages/vitest/src/config.ts +++ b/packages/vitest/src/config.ts @@ -1,9 +1,15 @@ import type { ConfigEnv, UserConfig as ViteUserConfig } from 'vite' +import type { ProjectConfig } from './types' export interface UserConfig extends ViteUserConfig { test?: ViteUserConfig['test'] } +export interface UserWorkspaceConfig extends ViteUserConfig { + extends?: string + test?: ProjectConfig +} + // will import vitest declare test in module 'vite' export { configDefaults, defaultInclude, defaultExclude, coverageConfigDefaults } from './defaults' export { mergeConfig } from 'vite' @@ -12,6 +18,17 @@ export type { ConfigEnv } export type UserConfigFn = (env: ConfigEnv) => UserConfig | Promise export type UserConfigExport = UserConfig | Promise | UserConfigFn +export type UserProjectConfigFn = (env: ConfigEnv) => UserWorkspaceConfig | Promise +export type UserProjectConfigExport = UserWorkspaceConfig | Promise | UserProjectConfigFn + export function defineConfig(config: UserConfigExport) { return config } + +export function defineProject(config: UserProjectConfigExport) { + return config +} + +export function defineWorkspace(config: (string | UserProjectConfigExport)[]) { + return config +} diff --git a/packages/vitest/src/constants.ts b/packages/vitest/src/constants.ts index a97d0c48fc4c..45be55753db8 100644 --- a/packages/vitest/src/constants.ts +++ b/packages/vitest/src/constants.ts @@ -1,25 +1,43 @@ // if changed, update also jsdocs and docs export const defaultPort = 51204 +export const defaultBrowserPort = 63315 export const EXIT_CODE_RESTART = 43 export const API_PATH = '/__vitest_api__' -export const configFiles = [ - 'vitest.config.ts', - 'vitest.config.mts', - 'vitest.config.cts', - 'vitest.config.js', - 'vitest.config.mjs', - 'vitest.config.cjs', - 'vite.config.ts', - 'vite.config.mts', - 'vite.config.cts', - 'vite.config.js', - 'vite.config.mjs', - 'vite.config.cjs', +export const CONFIG_NAMES = [ + 'vitest.config', + 'vite.config', ] +const WORKSPACES_NAMES = [ + 'vitest.workspace', + 'vitest.projects', +] + +const CONFIG_EXTENSIONS = [ + '.ts', + '.mts', + '.cts', + '.js', + '.mjs', + '.cjs', +] + +export const configFiles = CONFIG_NAMES.flatMap(name => + CONFIG_EXTENSIONS.map(ext => name + ext), +) + +const WORKSPACES_EXTENSIONS = [ + ...CONFIG_EXTENSIONS, + '.json', +] + +export const workspacesFiles = WORKSPACES_NAMES.flatMap(name => + WORKSPACES_EXTENSIONS.map(ext => name + ext), +) + export const globalApis = [ // suite 'suite', diff --git a/packages/vitest/src/integrations/browser/server.ts b/packages/vitest/src/integrations/browser/server.ts index 67ebd48f772f..ada77fb26364 100644 --- a/packages/vitest/src/integrations/browser/server.ts +++ b/packages/vitest/src/integrations/browser/server.ts @@ -1,15 +1,15 @@ import { createServer } from 'vite' import { resolve } from 'pathe' import { findUp } from 'find-up' -import { configFiles } from '../../constants' -import type { Vitest } from '../../node' +import { configFiles, defaultBrowserPort } from '../../constants' import type { UserConfig } from '../../types/config' import { ensurePackageInstalled } from '../../node/pkg' import { resolveApiServerConfig } from '../../node/config' import { CoverageTransform } from '../../node/plugins/coverageTransform' +import type { WorkspaceProject } from '../../node/workspace' -export async function createBrowserServer(ctx: Vitest, options: UserConfig) { - const root = ctx.config.root +export async function createBrowserServer(project: WorkspaceProject, options: UserConfig) { + const root = project.config.root await ensurePackageInstalled('@vitest/browser', root) @@ -21,7 +21,7 @@ export async function createBrowserServer(ctx: Vitest, options: UserConfig) { const server = await createServer({ logLevel: 'error', - mode: ctx.config.mode, + mode: project.config.mode, configFile: configPath, // watch is handled by Vitest server: { @@ -32,29 +32,18 @@ export async function createBrowserServer(ctx: Vitest, options: UserConfig) { }, plugins: [ (await import('@vitest/browser')).default('/'), - CoverageTransform(ctx), + CoverageTransform(project.ctx), { enforce: 'post', name: 'vitest:browser:config', async config(config) { const server = resolveApiServerConfig(config.test?.browser || {}) || { - port: 63315, + port: defaultBrowserPort, } config.server = server config.server.fs = { strict: false } - config.optimizeDeps ??= {} - config.optimizeDeps.entries ??= [] - - const [...entries] = await ctx.globAllTestFiles(ctx.config, ctx.config.dir || root) - entries.push(...ctx.config.setupFiles) - - if (typeof config.optimizeDeps.entries === 'string') - config.optimizeDeps.entries = [config.optimizeDeps.entries] - - config.optimizeDeps.entries.push(...entries) - return { resolve: { alias: config.test?.alias, @@ -68,7 +57,7 @@ export async function createBrowserServer(ctx: Vitest, options: UserConfig) { await server.listen() await server.watcher.close() - ;(await import('../../api/setup')).setup(ctx, server) + ;(await import('../../api/setup')).setup(project, server) return server } diff --git a/packages/vitest/src/node/browser/playwright.ts b/packages/vitest/src/node/browser/playwright.ts index ca31c4e04dac..7bda5ed11f70 100644 --- a/packages/vitest/src/node/browser/playwright.ts +++ b/packages/vitest/src/node/browser/playwright.ts @@ -1,7 +1,7 @@ import type { Page } from 'playwright' import type { BrowserProvider, BrowserProviderOptions } from '../../types/browser' import { ensurePackageInstalled } from '../pkg' -import type { Vitest } from '../core' +import type { WorkspaceProject } from '../workspace' export const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const export type PlaywrightBrowser = typeof playwrightBrowsers[number] @@ -15,13 +15,13 @@ export class PlaywrightBrowserProvider implements BrowserProvider { private cachedBrowser: Page | null = null private browser!: PlaywrightBrowser - private ctx!: Vitest + private ctx!: WorkspaceProject getSupportedBrowsers() { return playwrightBrowsers } - async initialize(ctx: Vitest, { browser }: PlaywrightProviderOptions) { + async initialize(ctx: WorkspaceProject, { browser }: PlaywrightProviderOptions) { this.ctx = ctx this.browser = browser diff --git a/packages/vitest/src/node/browser/webdriver.ts b/packages/vitest/src/node/browser/webdriver.ts index 4b4747a920f6..7c8634dd2765 100644 --- a/packages/vitest/src/node/browser/webdriver.ts +++ b/packages/vitest/src/node/browser/webdriver.ts @@ -1,7 +1,7 @@ import type { Browser } from 'webdriverio' import type { BrowserProvider, BrowserProviderOptions } from '../../types/browser' import { ensurePackageInstalled } from '../pkg' -import type { Vitest } from '../core' +import type { WorkspaceProject } from '../workspace' export const webdriverBrowsers = ['firefox', 'chrome', 'edge', 'safari'] as const export type WebdriverBrowser = typeof webdriverBrowsers[number] @@ -16,13 +16,13 @@ export class WebdriverBrowserProvider implements BrowserProvider { private cachedBrowser: Browser | null = null private stopSafari: () => void = () => {} private browser!: WebdriverBrowser - private ctx!: Vitest + private ctx!: WorkspaceProject getSupportedBrowsers() { return webdriverBrowsers } - async initialize(ctx: Vitest, { browser }: WebdriverProviderOptions) { + async initialize(ctx: WorkspaceProject, { browser }: WebdriverProviderOptions) { this.ctx = ctx this.browser = browser diff --git a/packages/vitest/src/node/cache/files.ts b/packages/vitest/src/node/cache/files.ts index e3495f3b296b..b41e10f3de61 100644 --- a/packages/vitest/src/node/cache/files.ts +++ b/packages/vitest/src/node/cache/files.ts @@ -1,23 +1,36 @@ import fs from 'node:fs' import type { Stats } from 'node:fs' +import { relative } from 'pathe' +import type { WorkspaceSpec } from '../pool' type FileStatsCache = Pick export class FilesStatsCache { public cache = new Map() - public getStats(fsPath: string): FileStatsCache | undefined { - return this.cache.get(fsPath) + public getStats(key: string): FileStatsCache | undefined { + return this.cache.get(key) } - public async updateStats(fsPath: string) { + public async populateStats(root: string, specs: WorkspaceSpec[]) { + const promises = specs.map((spec) => { + const key = `${spec[0].getName()}:${relative(root, spec[1])}` + return this.updateStats(spec[1], key) + }) + await Promise.all(promises) + } + + public async updateStats(fsPath: string, key: string) { if (!fs.existsSync(fsPath)) return const stats = await fs.promises.stat(fsPath) - this.cache.set(fsPath, { size: stats.size }) + this.cache.set(key, { size: stats.size }) } public removeStats(fsPath: string) { - this.cache.delete(fsPath) + this.cache.forEach((_, key) => { + if (key.endsWith(fsPath)) + this.cache.delete(key) + }) } } diff --git a/packages/vitest/src/node/cache/index.ts b/packages/vitest/src/node/cache/index.ts index 88f43e9bd2e0..98877d8d0d0f 100644 --- a/packages/vitest/src/node/cache/index.ts +++ b/packages/vitest/src/node/cache/index.ts @@ -12,12 +12,12 @@ export class VitestCache { results = new ResultsCache() stats = new FilesStatsCache() - getFileTestResults(id: string) { - return this.results.getResults(id) + getFileTestResults(key: string) { + return this.results.getResults(key) } - getFileStats(id: string) { - return this.stats.getStats(id) + getFileStats(key: string) { + return this.stats.getStats(key) } static resolveCacheDir(root: string, dir: string | undefined) { diff --git a/packages/vitest/src/node/cache/results.ts b/packages/vitest/src/node/cache/results.ts index 3e9fb8ba3d84..9f570a37ad26 100644 --- a/packages/vitest/src/node/cache/results.ts +++ b/packages/vitest/src/node/cache/results.ts @@ -1,5 +1,5 @@ import fs from 'node:fs' -import { dirname, resolve } from 'pathe' +import { dirname, relative, resolve } from 'pathe' import type { File, ResolvedConfig } from '../../types' import { version } from '../../../package.json' @@ -10,6 +10,7 @@ export interface SuiteResultCache { export class ResultsCache { private cache = new Map() + private workspacesKeyMap = new Map() private cachePath: string | null = null private version: string = version private root = '/' @@ -24,19 +25,29 @@ export class ResultsCache { this.cachePath = resolve(config.dir, 'results.json') } - getResults(fsPath: string) { - return this.cache.get(fsPath?.slice(this.root.length)) + getResults(key: string) { + return this.cache.get(key) } async readFromCache() { if (!this.cachePath) return - if (fs.existsSync(this.cachePath)) { - const resultsCache = await fs.promises.readFile(this.cachePath, 'utf8') - const { results, version } = JSON.parse(resultsCache) + if (!fs.existsSync(this.cachePath)) + return + + const resultsCache = await fs.promises.readFile(this.cachePath, 'utf8') + const { results, version } = JSON.parse(resultsCache || '[]') + // handling changed in 0.30.0 + if (Number(version.split('.')[1]) >= 30) { this.cache = new Map(results) this.version = version + results.forEach(([spec]: [string]) => { + const [projectName, relativePath] = spec.split(':') + const keyMap = this.workspacesKeyMap.get(relativePath) || [] + keyMap.push(projectName) + this.workspacesKeyMap.set(relativePath, keyMap) + }) } } @@ -47,8 +58,8 @@ export class ResultsCache { return const duration = result.duration || 0 // store as relative, so cache would be the same in CI and locally - const relativePath = file.filepath?.slice(this.root.length) - this.cache.set(relativePath, { + const relativePath = relative(this.root, file.filepath) + this.cache.set(`${file.projectName || ''}:${relativePath}`, { duration: duration >= 0 ? duration : 0, failed: result.state === 'fail', }) @@ -56,7 +67,10 @@ export class ResultsCache { } removeFromCache(filepath: string) { - this.cache.delete(filepath) + this.cache.forEach((_, key) => { + if (key.endsWith(filepath)) + this.cache.delete(key) + }) } async writeToCache() { diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index 2f564eba5726..dedbf05be139 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -4,7 +4,7 @@ import c from 'picocolors' import type { ResolvedConfig as ResolvedViteConfig } from 'vite' import type { ApiConfig, ResolvedConfig, UserConfig, VitestRunMode } from '../types' -import { defaultPort } from '../constants' +import { defaultBrowserPort, defaultPort } from '../constants' import { benchmarkConfigDefaults, configDefaults } from '../defaults' import { isCI, toArray } from '../utils' import { VitestCache } from './cache' @@ -274,7 +274,7 @@ export function resolveConfig( resolved.browser.headless ??= isCI resolved.browser.api = resolveApiServerConfig(resolved.browser) || { - port: 63315, + port: defaultBrowserPort, } return resolved diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 091582eac76a..add6e417f39e 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -1,35 +1,34 @@ import { existsSync, promises as fs } from 'node:fs' import type { ViteDevServer } from 'vite' -import { normalize, relative, toNamespacedPath } from 'pathe' +import { mergeConfig } from 'vite' +import { basename, dirname, join, normalize, relative } from 'pathe' import fg from 'fast-glob' import mm from 'micromatch' import c from 'picocolors' import { normalizeRequestId } from 'vite-node/utils' import { ViteNodeRunner } from 'vite-node/client' import { SnapshotManager } from '@vitest/snapshot/manager' -import type { ArgumentsType, CoverageProvider, OnServerRestartHandler, Reporter, ResolvedConfig, UserConfig, VitestRunMode } from '../types' -import { deepMerge, hasFailed, noop, slash, toArray } from '../utils' +import type { ArgumentsType, CoverageProvider, OnServerRestartHandler, Reporter, ResolvedConfig, UserConfig, UserWorkspaceConfig, VitestRunMode } from '../types' +import { hasFailed, noop, slash, toArray } from '../utils' import { getCoverageProvider } from '../integrations/coverage' -import { Typechecker } from '../typecheck/typechecker' import type { BrowserProvider } from '../types/browser' -import { getBrowserProvider } from '../integrations/browser' -import { createBrowserServer } from '../integrations/browser/server' +import { CONFIG_NAMES, configFiles, workspacesFiles as workspaceFiles } from '../constants' import { createPool } from './pool' -import type { ProcessPool } from './pool' +import type { ProcessPool, WorkspaceSpec } from './pool' import { createBenchmarkReporters, createReporters } from './reporters/utils' import { StateManager } from './state' -import { isBrowserEnabled, resolveConfig } from './config' +import { resolveConfig } from './config' import { Logger } from './logger' import { VitestCache } from './cache' +import { WorkspaceProject, initializeProject } from './workspace' import { VitestServer } from './server' const WATCHER_DEBOUNCE = 100 export class Vitest { config: ResolvedConfig = undefined! - configOverride: Partial | undefined + configOverride: Partial = {} - browser: ViteDevServer = undefined! server: ViteDevServer = undefined! state: StateManager = undefined! snapshot: SnapshotManager = undefined! @@ -39,7 +38,6 @@ export class Vitest { browserProvider: BrowserProvider | undefined logger: Logger pool: ProcessPool | undefined - typechecker: Typechecker | undefined vitenode: VitestServer = undefined! @@ -53,6 +51,11 @@ export class Vitest { restartsCount = 0 runner: ViteNodeRunner = undefined! + private coreWorkspace!: WorkspaceProject + + public projects: WorkspaceProject[] = [] + private projectsTestFiles = new Map>() + constructor( public readonly mode: VitestRunMode, ) { @@ -62,14 +65,7 @@ export class Vitest { private _onRestartListeners: OnServerRestartHandler[] = [] private _onSetServer: OnServerRestartHandler[] = [] - async initBrowserServer(options: UserConfig) { - if (!this.isBrowserEnabled()) - return - await this.browser?.close() - this.browser = await createBrowserServer(this, options) - } - - async setServer(options: UserConfig, server: ViteDevServer) { + async setServer(options: UserConfig, server: ViteDevServer, cliOptions: UserConfig) { this.unregisterWatcher?.() clearTimeout(this._rerunTimer) this.restartsCount += 1 @@ -129,12 +125,138 @@ export class Vitest { try { await this.cache.results.readFromCache() } - catch {} + catch { } await Promise.all(this._onSetServer.map(fn => fn())) + + this.projects = await this.resolveWorkspace(options, cliOptions) + + if (this.config.testNamePattern) + this.configOverride.testNamePattern = this.config.testNamePattern + } + + private async createCoreWorkspace(options: UserConfig) { + const coreWorkspace = new WorkspaceProject(this.config.root, this) + await coreWorkspace.setServer(options, this.server, { + runner: this.runner, + server: this.vitenode, + }) + this.coreWorkspace = coreWorkspace + return coreWorkspace + } + + public getCoreWorkspaceProject() { + if (!this.coreWorkspace) + throw new Error('Core workspace project is not initialized') + return this.coreWorkspace + } + + private async resolveWorkspace(options: UserConfig, cliOptions: UserConfig) { + const configDir = dirname(this.server.config.configFile || this.config.root) + const rootFiles = await fs.readdir(configDir) + const workspaceConfigName = workspaceFiles.find((configFile) => { + return rootFiles.includes(configFile) + }) + + if (!workspaceConfigName) + return [await this.createCoreWorkspace(options)] + + const workspacesConfigPath = join(configDir, workspaceConfigName) + + const workspacesModule = await this.runner.executeFile(workspacesConfigPath) as { + default: (string | UserWorkspaceConfig)[] + } + + if (!workspacesModule.default || !Array.isArray(workspacesModule.default)) + throw new Error(`Workspace config file ${workspacesConfigPath} must export a default array of project paths.`) + + const workspacesGlobMatches: string[] = [] + const projectsOptions: UserWorkspaceConfig[] = [] + + for (const project of workspacesModule.default) { + if (typeof project === 'string') + workspacesGlobMatches.push(project.replace('', this.config.root)) + else + projectsOptions.push(project) + } + + const globOptions: fg.Options = { + absolute: true, + dot: true, + onlyFiles: false, + markDirectories: true, + cwd: this.config.root, + ignore: ['**/node_modules/**'], + } + + const workspacesFs = await fg(workspacesGlobMatches, globOptions) + const resolvedWorkspacesPaths = await Promise.all(workspacesFs.filter((file) => { + if (file.endsWith('/')) { + // if it's a directory, check that we don't already have a workspace with a config inside + const hasWorkspaceWithConfig = workspacesFs.some((file2) => { + return file2 !== file && `${dirname(file2)}/` === file + }) + return !hasWorkspaceWithConfig + } + const filename = basename(file) + return CONFIG_NAMES.some(configName => filename.startsWith(configName)) + }).map(async (filepath) => { + if (filepath.endsWith('/')) { + const filesInside = await fs.readdir(filepath) + const configFile = configFiles.find(config => filesInside.includes(config)) + return configFile || filepath + } + return filepath + })) + + const overridesOptions = [ + 'logHeapUsage', + 'allowOnly', + 'sequence', + 'testTimeout', + 'threads', + 'singleThread', + 'isolate', + 'globals', + 'mode', + ] as const + + const cliOverrides = overridesOptions.reduce((acc, name) => { + if (name in cliOptions) + acc[name] = cliOptions[name] as any + return acc + }, {} as UserConfig) + + const projects = resolvedWorkspacesPaths.map(async (workspacePath) => { + // don't start a new server, but reuse existing one + if ( + this.server.config.configFile === workspacePath + ) + return this.createCoreWorkspace(options) + return initializeProject(workspacePath, this, { test: cliOverrides }) + }) + + projectsOptions.forEach((options, index) => { + projects.push(initializeProject(index, this, mergeConfig(options, { test: cliOverrides }))) + }) + + if (!projects.length) + return [await this.createCoreWorkspace(options)] + + const resolvedProjects = await Promise.all(projects) + const names = new Set() + + for (const project of resolvedProjects) { + const name = project.getName() + if (names.has(name)) + throw new Error(`Project name "${name}" is not unique. All projects in a workspace should have unique names.`) + names.add(name) + } + + return resolvedProjects } - async initCoverageProvider() { + private async initCoverageProvider() { if (this.coverageProvider !== undefined) return this.coverageProvider = await getCoverageProvider(this.config.coverage, this.runner) @@ -145,98 +267,12 @@ export class Vitest { return this.coverageProvider } - async initBrowserProvider() { - if (this.browserProvider) - return this.browserProvider - const Provider = await getBrowserProvider(this.config.browser, this.runner) - this.browserProvider = new Provider() - const browser = this.config.browser.name - const supportedBrowsers = this.browserProvider.getSupportedBrowsers() - if (!browser) - throw new Error('Browser name is required. Please, set `test.browser.name` option manually.') - if (!supportedBrowsers.includes(browser)) - throw new Error(`Browser "${browser}" is not supported by the browser provider "${this.browserProvider.name}". Supported browsers: ${supportedBrowsers.join(', ')}.`) - await this.browserProvider.initialize(this, { browser }) - return this.browserProvider - } - - getSerializableConfig() { - return deepMerge({ - ...this.config, - reporters: [], - deps: { - ...this.config.deps, - experimentalOptimizer: { - enabled: this.config.deps?.experimentalOptimizer?.enabled ?? false, - }, - }, - snapshotOptions: { - ...this.config.snapshotOptions, - resolveSnapshotPath: undefined, - }, - onConsoleLog: undefined!, - sequence: { - ...this.config.sequence, - sequencer: undefined!, - }, - benchmark: { - ...this.config.benchmark, - reporters: [], - }, - }, - this.configOverride || {} as any, - ) as ResolvedConfig - } - - async typecheck(filters: string[] = []) { - const { dir, root } = this.config - const { include, exclude } = this.config.typecheck - const testsFilesList = this.filterFiles(await this.globFiles(include, exclude, dir || root), filters) - const checker = new Typechecker(this, testsFilesList) - this.typechecker = checker - checker.onParseEnd(async ({ files, sourceErrors }) => { - this.state.collectFiles(checker.getTestFiles()) - await this.report('onTaskUpdate', checker.getTestPacks()) - await this.report('onCollected') - if (!files.length) { - this.logger.printNoTestFound() - } - else { - if (hasFailed(files)) - process.exitCode = 1 - await this.report('onFinished', files) - } - if (sourceErrors.length && !this.config.typecheck.ignoreSourceErrors) { - process.exitCode = 1 - await this.logger.printSourceTypeErrors(sourceErrors) - } - // if there are source errors, we are showing it, and then terminating process - if (!files.length) { - const exitCode = this.config.passWithNoTests ? (process.exitCode ?? 0) : 1 - process.exit(exitCode) - } - if (this.config.watch) { - await this.report('onWatcherStart', files, [ - ...(this.config.typecheck.ignoreSourceErrors ? [] : sourceErrors), - ...this.state.getUnhandledErrors(), - ]) - } - }) - checker.onParseStart(async () => { - await this.report('onInit', this) - this.state.collectFiles(checker.getTestFiles()) - await this.report('onCollected') - }) - checker.onWatcherRerun(async () => { - await this.report('onWatcherRerun', testsFilesList, 'File change detected. Triggering rerun.') - await checker.collectTests() - this.state.collectFiles(checker.getTestFiles()) - await this.report('onTaskUpdate', checker.getTestPacks()) - await this.report('onCollected') - }) - await checker.prepare() - await checker.collectTests() - await checker.start() + private async initBrowserProviders() { + return Promise.all(this.projects.map(w => w.initBrowserProvider())) + } + + typecheck(filters?: string[]) { + return Promise.all(this.projects.map(project => project.typecheck(filters))) } async start(filters?: string[]) { @@ -248,9 +284,7 @@ export class Vitest { try { await this.initCoverageProvider() await this.coverageProvider?.clean(this.config.coverage.clean) - - if (this.isBrowserEnabled()) - await this.initBrowserProvider() + await this.initBrowserProviders() } catch (e) { this.logger.error(e) @@ -273,7 +307,7 @@ export class Vitest { } // populate once, update cache on watch - await Promise.all(files.map(file => this.cache.stats.updateStats(file))) + await this.cache.stats.populateStats(this.config.root, files) await this.runFiles(files) @@ -283,11 +317,11 @@ export class Vitest { await this.report('onWatcherStart') } - private async getTestDependencies(filepath: string) { + private async getTestDependencies(filepath: WorkspaceSpec) { const deps = new Set() - const addImports = async (filepath: string) => { - const transformed = await this.vitenode.transformRequest(filepath) + const addImports = async ([project, filepath]: WorkspaceSpec) => { + const transformed = await project.vitenode.transformRequest(filepath) if (!transformed) return const dependencies = [...transformed.deps || [], ...transformed.dynamicDeps || []] @@ -297,7 +331,7 @@ export class Vitest { if (fsPath && !fsPath.includes('node_modules') && !deps.has(fsPath) && existsSync(fsPath)) { deps.add(fsPath) - await addImports(fsPath) + await addImports([project, fsPath]) } } } @@ -307,7 +341,7 @@ export class Vitest { return deps } - async filterTestsBySource(tests: string[]) { + async filterTestsBySource(specs: WorkspaceSpec[]) { if (this.config.changed && !this.config.related) { const { VitestGit } = await import('./git') const vitestGit = new VitestGit(this.config.root) @@ -323,20 +357,20 @@ export class Vitest { const related = this.config.related if (!related) - return tests + return specs const forceRerunTriggers = this.config.forceRerunTriggers if (forceRerunTriggers.length && mm(related, forceRerunTriggers).length) - return tests + return specs // don't run anything if no related sources are found if (!related.length) return [] const testGraphs = await Promise.all( - tests.map(async (filepath) => { - const deps = await this.getTestDependencies(filepath) - return [filepath, deps] as const + specs.map(async (spec) => { + const deps = await this.getTestDependencies(spec) + return [spec, deps] as const }), ) @@ -344,19 +378,26 @@ export class Vitest { for (const [filepath, deps] of testGraphs) { // if deps or the test itself were changed - if (related.some(path => path === filepath || deps.has(path))) + if (related.some(path => path === filepath[1] || deps.has(path))) runningTests.push(filepath) } return runningTests } - async runFiles(paths: string[]) { - paths = Array.from(new Set(paths)) + getProjectsByTestFile(file: string) { + const projects = this.projectsTestFiles.get(file) + if (!projects) + return [] + return Array.from(projects).map(project => [project, file] as WorkspaceSpec) + } + + async runFiles(paths: WorkspaceSpec[]) { + const filepaths = paths.map(([, file]) => file) - this.state.collectPaths(paths) + this.state.collectPaths(filepaths) - await this.report('onPathsCollected', paths) + await this.report('onPathsCollected', filepaths) // previous run await this.runningPromise @@ -386,7 +427,9 @@ export class Vitest { await this.cache.results.writeToCache() })() .finally(async () => { - await this.report('onFinished', this.state.getFiles(paths), this.state.getUnhandledErrors()) + // can be duplicate files if different projects are using the same file + const specs = Array.from(new Set(paths.map(([, p]) => p))) + await this.report('onFinished', this.state.getFiles(specs), this.state.getUnhandledErrors()) this.runningPromise = undefined }) @@ -396,14 +439,14 @@ export class Vitest { async rerunFiles(files: string[] = this.state.getFilepaths(), trigger?: string) { if (this.filenamePattern) { const filteredFiles = await this.globTestFiles([this.filenamePattern]) - files = files.filter(file => filteredFiles.includes(file)) + files = files.filter(file => filteredFiles.some(f => f[1] === file)) } if (this.coverageProvider && this.config.coverage.cleanOnRerun) await this.coverageProvider.clean() await this.report('onWatcherRerun', files, trigger) - await this.runFiles(files) + await this.runFiles(files.flatMap(file => this.getProjectsByTestFile(file))) await this.reportCoverage(!trigger) @@ -415,7 +458,7 @@ export class Vitest { if (pattern === '') this.filenamePattern = undefined - this.config.testNamePattern = pattern ? new RegExp(pattern) : undefined + this.configOverride.testNamePattern = pattern ? new RegExp(pattern) : undefined await this.rerunFiles(files, trigger) } @@ -439,24 +482,22 @@ export class Vitest { ...this.snapshot.summary.uncheckedKeysByFile.map(s => s.filePath), ] - this.configOverride = { - snapshotOptions: { - updateSnapshot: 'all', - // environment is resolved inside a worker thread - snapshotEnvironment: null as any, - }, + this.configOverride.snapshotOptions = { + updateSnapshot: 'all', + // environment is resolved inside a worker thread + snapshotEnvironment: null as any, } try { await this.rerunFiles(files, 'update snapshot') } finally { - this.configOverride = undefined + delete this.configOverride.snapshotOptions } } private _rerunTimer: any - private async scheduleRerun(triggerId: string) { + private async scheduleRerun(triggerId: string[]) { const currentCount = this.restartsCount clearTimeout(this._rerunTimer) await this.runningPromise @@ -483,7 +524,7 @@ export class Vitest { if (this.filenamePattern) { const filteredFiles = await this.globTestFiles([this.filenamePattern]) - files = files.filter(file => filteredFiles.includes(file)) + files = files.filter(file => filteredFiles.some(f => f[1] === file)) // A file that does not match the current filename pattern was changed if (files.length === 0) @@ -495,9 +536,11 @@ export class Vitest { if (this.coverageProvider && this.config.coverage.cleanOnRerun) await this.coverageProvider.clean() - await this.report('onWatcherRerun', files, triggerId) + const triggerIds = new Set(triggerId.map(id => relative(this.config.root, id))) + const triggerLabel = Array.from(triggerIds).join(', ') + await this.report('onWatcherRerun', files, triggerLabel) - await this.runFiles(files) + await this.runFiles(files.flatMap(file => this.getProjectsByTestFile(file))) await this.reportCoverage(false) @@ -505,20 +548,31 @@ export class Vitest { }, WATCHER_DEBOUNCE) } + public getModuleProjects(id: string) { + return this.projects.filter((project) => { + return project.server.moduleGraph.getModuleById(id) + || project.browser?.moduleGraph.getModuleById(id) + || project.browser?.moduleGraph.getModulesByFile(id)?.size + }) + } + private unregisterWatcher = noop private registerWatcher() { const updateLastChanged = (id: string) => { - const mod = this.server.moduleGraph.getModuleById(id) || this.browser?.moduleGraph.getModuleById(id) - if (mod) - mod.lastHMRTimestamp = Date.now() + const projects = this.getModuleProjects(id) + projects.forEach(({ server, browser }) => { + const mod = server.moduleGraph.getModuleById(id) || browser?.moduleGraph.getModuleById(id) + if (mod) + server.moduleGraph.invalidateModule(mod) + }) } const onChange = (id: string) => { id = slash(id) updateLastChanged(id) const needsRerun = this.handleFileChanged(id) - if (needsRerun) - this.scheduleRerun(id) + if (needsRerun.length) + this.scheduleRerun(needsRerun) } const onUnlink = (id: string) => { id = slash(id) @@ -537,8 +591,7 @@ export class Vitest { updateLastChanged(id) if (await this.isTargetFile(id)) { this.changedTests.add(id) - await this.cache.stats.updateStats(id) - this.scheduleRerun(id) + this.scheduleRerun([id]) } } const watcher = this.server.watcher @@ -563,50 +616,64 @@ export class Vitest { /** * @returns A value indicating whether rerun is needed (changedTests was mutated) */ - private handleFileChanged(id: string): boolean { + private handleFileChanged(id: string): string[] { if (this.changedTests.has(id) || this.invalidates.has(id)) - return false + return [] if (mm.isMatch(id, this.config.forceRerunTriggers)) { this.state.getFilepaths().forEach(file => this.changedTests.add(file)) - return true + return [] } - const mod = this.server.moduleGraph.getModuleById(id) || this.browser?.moduleGraph.getModuleById(id) - if (!mod) { - // files with `?v=` query from the browser - const mods = this.browser?.moduleGraph.getModulesByFile(id) - if (!mods?.size) - return false - let rerun = false - mods.forEach((m) => { - if (m.id && this.handleFileChanged(m.id)) - rerun = true - }) - return rerun - } + const projects = this.getModuleProjects(id) + if (!projects.length) + return [] + + const files: string[] = [] + + for (const { server, browser } of projects) { + const mod = server.moduleGraph.getModuleById(id) || browser?.moduleGraph.getModuleById(id) + if (!mod) { + // files with `?v=` query from the browser + const mods = browser?.moduleGraph.getModulesByFile(id) + if (!mods?.size) + return [] + let rerun = false + mods.forEach((m) => { + if (m.id && this.handleFileChanged(m.id)) + rerun = true + }) + if (rerun) + files.push(id) + continue + } - // remove queries from id - id = normalizeRequestId(id, this.server.config.base) + // remove queries from id + id = normalizeRequestId(id, server.config.base) - this.invalidates.add(id) + this.invalidates.add(id) - if (this.state.filesMap.has(id)) { - this.changedTests.add(id) - return true - } + if (this.state.filesMap.has(id)) { + this.changedTests.add(id) + files.push(id) + continue + } - let rerun = false - mod.importers.forEach((i) => { - if (!i.id) - return + let rerun = false + mod.importers.forEach((i) => { + if (!i.id) + return - const heedsRerun = this.handleFileChanged(i.id) - if (heedsRerun) - rerun = true - }) + const heedsRerun = this.handleFileChanged(i.id) + if (heedsRerun) + rerun = true + }) - return rerun + if (rerun) + files.push(id) + } + + return files } private async reportCoverage(allTestsRun: boolean) { @@ -620,9 +687,8 @@ export class Vitest { if (!this.closingPromise) { this.closingPromise = Promise.allSettled([ this.pool?.close(), - this.browser?.close(), this.server.close(), - this.typechecker?.stop(), + ...this.projects.map(w => w.close()), ].filter(Boolean)).then((results) => { results.filter(r => r.status === 'rejected').forEach((err) => { this.logger.error('error during close', (err as PromiseRejectedResult).reason) @@ -656,65 +722,21 @@ export class Vitest { ))) } - async globFiles(include: string[], exclude: string[], cwd: string) { - const globOptions: fg.Options = { - absolute: true, - dot: true, - cwd, - ignore: exclude, - } - - return fg(include, globOptions) - } - - private _allTestsCache: string[] | null = null - - async globAllTestFiles(config: ResolvedConfig, cwd: string) { - const { include, exclude, includeSource } = config - - const testFiles = await this.globFiles(include, exclude, cwd) - - if (includeSource) { - const files = await this.globFiles(includeSource, exclude, cwd) - - await Promise.all(files.map(async (file) => { - try { - const code = await fs.readFile(file, 'utf-8') - if (this.isInSourceTestFile(code)) - testFiles.push(file) - } - catch { - return null - } - })) - } - - this._allTestsCache = testFiles - - return testFiles - } - - filterFiles(testFiles: string[], filters: string[] = []) { - if (filters.length && process.platform === 'win32') - filters = filters.map(f => toNamespacedPath(f)) - - if (filters.length) - return testFiles.filter(i => filters.some(f => i.includes(f))) - - return testFiles - } - - async globTestFiles(filters: string[] = []) { - const { dir, root } = this.config - - const testFiles = this._allTestsCache ?? await this.globAllTestFiles(this.config, dir || root) - - this._allTestsCache = null - - return this.filterFiles(testFiles, filters) + public async globTestFiles(filters: string[] = []) { + const files: WorkspaceSpec[] = [] + await Promise.all(this.projects.map(async (project) => { + const specs = await project.globTestFiles(filters) + specs.forEach((file) => { + files.push([project, file]) + const projects = this.projectsTestFiles.get(file) || new Set() + projects.add(project) + this.projectsTestFiles.set(file, projects) + }) + })) + return files } - async isTargetFile(id: string, source?: string): Promise { + private async isTargetFile(id: string, source?: string): Promise { const relativeId = relative(this.config.dir || this.config.root, id) if (mm.isMatch(relativeId, this.config.exclude)) return false @@ -727,10 +749,6 @@ export class Vitest { return false } - isBrowserEnabled() { - return isBrowserEnabled(this.config) - } - // The server needs to be running for communication shouldKeepServer() { return !!this.config?.watch diff --git a/packages/vitest/src/node/error.ts b/packages/vitest/src/node/error.ts index 20c5460e942b..86a977f8a525 100644 --- a/packages/vitest/src/node/error.ts +++ b/packages/vitest/src/node/error.ts @@ -47,7 +47,7 @@ export async function printError(error: unknown, ctx: Vitest, options: PrintErro const nearest = error instanceof TypeCheckError ? error.stacks[0] : stacks.find(stack => - ctx.server.moduleGraph.getModuleById(stack.file) + ctx.getModuleProjects(stack.file).length && existsSync(stack.file), ) diff --git a/packages/vitest/src/node/index.ts b/packages/vitest/src/node/index.ts index 804c24f70293..27e26e3663c7 100644 --- a/packages/vitest/src/node/index.ts +++ b/packages/vitest/src/node/index.ts @@ -1,7 +1,9 @@ export type { Vitest } from './core' +export type { WorkspaceProject as VitestWorkspace } from './workspace' export { createVitest } from './create' export { VitestPlugin } from './plugins' export { startVitest } from './cli-api' +export type { WorkspaceSpec } from './pool' export { VitestExecutor } from '../runtime/execute' export type { ExecuteOptions } from '../runtime/execute' diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index fe9d7406c998..e576210de3f4 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -109,8 +109,15 @@ export class Logger { if (this.ctx.config.sequence.sequencer === RandomSequencer) this.log(c.gray(` Running tests with seed "${this.ctx.config.sequence.seed}"`)) - if (this.ctx.config.browser.enabled) - this.log(c.dim(c.green(` Browser runner started at http://${this.ctx.config.browser.api?.host || 'localhost'}:${c.bold(`${this.ctx.browser.config.server.port}`)}`))) + this.ctx.projects.forEach((project) => { + if (!project.browser) + return + const name = project.getName() + const output = project.isCore() ? '' : ` [${name}]` + + this.log(c.dim(c.green(` ${output} Browser runner started at http://${project.config.browser.api?.host || 'localhost'}:${c.bold(`${project.browser.config.server.port}`)}`))) + }) + if (this.ctx.config.ui) this.log(c.dim(c.green(` UI started at http://${this.ctx.config.api?.host || 'localhost'}:${c.bold(`${this.ctx.server.config.server.port}`)}${this.ctx.config.uiBase}`))) else if (this.ctx.config.api) diff --git a/packages/vitest/src/node/plugins/cssEnabler.ts b/packages/vitest/src/node/plugins/cssEnabler.ts index c77aeaa2352e..8a85f1783acd 100644 --- a/packages/vitest/src/node/plugins/cssEnabler.ts +++ b/packages/vitest/src/node/plugins/cssEnabler.ts @@ -1,9 +1,8 @@ import { relative } from 'pathe' import type { Plugin as VitePlugin } from 'vite' import { generateCssFilenameHash } from '../../integrations/css/css-modules' -import type { CSSModuleScopeStrategy } from '../../types' +import type { CSSModuleScopeStrategy, ResolvedConfig } from '../../types' import { toArray } from '../../utils' -import type { Vitest } from '../core' const cssLangs = '\\.(css|less|sass|scss|styl|stylus|pcss|postcss)($|\\?)' const cssLangRE = new RegExp(cssLangs) @@ -24,7 +23,7 @@ function getCSSModuleProxyReturn(strategy: CSSModuleScopeStrategy, filename: str return `\`_\${style}_${hash}\`` } -export function CSSEnablerPlugin(ctx: Vitest): VitePlugin[] { +export function CSSEnablerPlugin(ctx: { config: ResolvedConfig }): VitePlugin[] { const shouldProcessCSS = (id: string) => { const { css } = ctx.config if (typeof css === 'boolean') diff --git a/packages/vitest/src/node/plugins/globalSetup.ts b/packages/vitest/src/node/plugins/globalSetup.ts index c658933df930..7910590aff8c 100644 --- a/packages/vitest/src/node/plugins/globalSetup.ts +++ b/packages/vitest/src/node/plugins/globalSetup.ts @@ -1,9 +1,10 @@ import type { Plugin } from 'vite' import type { ViteNodeRunner } from 'vite-node/client' import c from 'picocolors' -import type { Vitest } from '../core' import { toArray } from '../../utils' import { divider } from '../reporters/renderers/utils' +import type { Vitest } from '../core' +import type { Logger } from '../logger' interface GlobalSetupFile { file: string @@ -11,9 +12,11 @@ interface GlobalSetupFile { teardown?: Function } -async function loadGlobalSetupFiles(ctx: Vitest): Promise { - const server = ctx.server - const runner = ctx.runner +type SetupInstance = Pick + +async function loadGlobalSetupFiles(project: SetupInstance): Promise { + const server = project.server + const runner = project.runner const globalSetupFiles = toArray(server.config.test?.globalSetup) return Promise.all(globalSetupFiles.map(file => loadGlobalSetupFile(file, runner))) } @@ -42,17 +45,17 @@ async function loadGlobalSetupFile(file: string, runner: ViteNodeRunner): Promis } } -export function GlobalSetupPlugin(ctx: Vitest): Plugin { +export function GlobalSetupPlugin(project: SetupInstance, logger: Logger): Plugin { let globalSetupFiles: GlobalSetupFile[] return { name: 'vitest:global-setup-plugin', enforce: 'pre', async buildStart() { - if (!ctx.server.config.test?.globalSetup) + if (!project.server.config.test?.globalSetup) return - globalSetupFiles = await loadGlobalSetupFiles(ctx) + globalSetupFiles = await loadGlobalSetupFiles(project) try { for (const globalSetupFile of globalSetupFiles) { @@ -65,8 +68,8 @@ export function GlobalSetupPlugin(ctx: Vitest): Plugin { } } catch (e) { - ctx.logger.error(`\n${c.red(divider(c.bold(c.inverse(' Error during global setup '))))}`) - await ctx.logger.printError(e) + logger.error(`\n${c.red(divider(c.bold(c.inverse(' Error during global setup '))))}`) + await logger.printError(e) process.exit(1) } }, @@ -78,7 +81,7 @@ export function GlobalSetupPlugin(ctx: Vitest): Plugin { await globalSetupFile.teardown?.() } catch (error) { - ctx.logger.error(`error during global teardown of ${globalSetupFile.file}`, error) + logger.error(`error during global teardown of ${globalSetupFile.file}`, error) } } } diff --git a/packages/vitest/src/node/plugins/index.ts b/packages/vitest/src/node/plugins/index.ts index 62ec78c217e0..02655dc17a4c 100644 --- a/packages/vitest/src/node/plugins/index.ts +++ b/packages/vitest/src/node/plugins/index.ts @@ -1,8 +1,5 @@ -import { builtinModules } from 'node:module' import type { UserConfig as ViteConfig, Plugin as VitePlugin } from 'vite' -import { normalize, relative, resolve } from 'pathe' -import { toArray } from '@vitest/utils' -import { resolveModule } from 'local-pkg' +import { relative } from 'pathe' import { configDefaults } from '../../defaults' import type { ResolvedConfig, UserConfig } from '../../types' import { deepMerge, notNullish, removeUndefinedValues } from '../../utils' @@ -136,46 +133,49 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t } const optimizeConfig: Partial = {} - const optimizer = preOptions.deps?.experimentalOptimizer - if (!optimizer?.enabled) { - optimizeConfig.cacheDir = undefined - optimizeConfig.optimizeDeps = { - // experimental in Vite >2.9.2, entries remains to help with older versions - disabled: true, - entries: [], - } - } - else { - const root = config.root || process.cwd() - const [...entries] = await ctx.globAllTestFiles(preOptions as ResolvedConfig, preOptions.dir || root) - if (preOptions?.setupFiles) { - const setupFiles = toArray(preOptions.setupFiles).map((file: string) => - normalize( - resolveModule(file, { paths: [root] }) - ?? resolve(root, file), - ), - ) - entries.push(...setupFiles) - } - const cacheDir = preOptions.cache !== false ? preOptions.cache?.dir : null - optimizeConfig.cacheDir = cacheDir ?? 'node_modules/.vitest' - optimizeConfig.optimizeDeps = { - ...viteConfig.optimizeDeps, - ...optimizer, - disabled: false, - entries: [...(viteConfig.optimizeDeps?.entries || []), ...entries], - exclude: ['vitest', ...builtinModules, ...(optimizer.exclude || viteConfig.optimizeDeps?.exclude || [])], - include: (optimizer.include || viteConfig.optimizeDeps?.include || []).filter((n: string) => n !== 'vitest'), - } - // Vite throws an error that it cannot rename "deps_temp", but optimization still works - // let's not show this error to users - const { error: logError } = console - console.error = (...args) => { - if (typeof args[0] === 'string' && args[0].includes('/deps_temp')) - return - return logError(...args) - } + // TODO: optimizer is temporary disabled, until Vite provides "optimzier.byDefault" option + // const optimizer = preOptions.deps?.experimentalOptimizer + // if (!optimizer?.enabled) { + optimizeConfig.cacheDir = undefined + optimizeConfig.optimizeDeps = { + // experimental in Vite >2.9.2, entries remains to help with older versions + disabled: true, + entries: [], } + // } + // else { + // const root = config.root || process.cwd() + // // TODO: add support for experimental optimizer + // const entries = [] + // // const [...entries] = await ctx.globAllTestFiles(preOptions as ResolvedConfig, preOptions.dir || root) + // if (preOptions?.setupFiles) { + // const setupFiles = toArray(preOptions.setupFiles).map((file: string) => + // normalize( + // resolveModule(file, { paths: [root] }) + // ?? resolve(root, file), + // ), + // ) + // entries.push(...setupFiles) + // } + // const cacheDir = preOptions.cache !== false ? preOptions.cache?.dir : null + // optimizeConfig.cacheDir = cacheDir ?? 'node_modules/.vitest' + // optimizeConfig.optimizeDeps = { + // ...viteConfig.optimizeDeps, + // ...optimizer, + // disabled: false, + // entries: [...(viteConfig.optimizeDeps?.entries || []), ...entries], + // exclude: ['vitest', ...builtinModules, ...(optimizer.exclude || viteConfig.optimizeDeps?.exclude || [])], + // include: (optimizer.include || viteConfig.optimizeDeps?.include || []).filter((n: string) => n !== 'vitest'), + // } + // // Vite throws an error that it cannot rename "deps_temp", but optimization still works + // // let's not show this error to users + // const { error: logError } = console + // console.error = (...args) => { + // if (typeof args[0] === 'string' && args[0].includes('/deps_temp')) + // return + // return logError(...args) + // } + // } Object.assign(config, optimizeConfig) return config @@ -221,8 +221,7 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t }, async configureServer(server) { try { - await ctx.setServer(options, server) - await ctx.initBrowserServer(options) + await ctx.setServer(options, server, userConfig) if (options.api && options.watch) (await import('../../api/setup')).setup(ctx) } @@ -237,7 +236,7 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t }, }, EnvReplacerPlugin(), - GlobalSetupPlugin(ctx), + GlobalSetupPlugin(ctx, ctx.logger), ...CSSEnablerPlugin(ctx), CoverageTransform(ctx), options.ui diff --git a/packages/vitest/src/node/plugins/workspace.ts b/packages/vitest/src/node/plugins/workspace.ts new file mode 100644 index 000000000000..250d8040cc77 --- /dev/null +++ b/packages/vitest/src/node/plugins/workspace.ts @@ -0,0 +1,142 @@ +import { dirname, relative } from 'pathe' +import type { UserConfig as ViteConfig, Plugin as VitePlugin } from 'vite' +import { configDefaults } from '../../defaults' +import { generateScopedClassName } from '../../integrations/css/css-modules' +import { deepMerge } from '../../utils/base' +import type { WorkspaceProject } from '../workspace' +import type { UserWorkspaceConfig } from '../../types' +import { CoverageTransform } from './coverageTransform' +import { CSSEnablerPlugin } from './cssEnabler' +import { EnvReplacerPlugin } from './envReplacer' +import { GlobalSetupPlugin } from './globalSetup' + +interface WorkspaceOptions extends UserWorkspaceConfig { + root?: string + workspacePath: string | number +} + +export function WorkspaceVitestPlugin(project: WorkspaceProject, options: WorkspaceOptions) { + return [ + { + name: 'vitest:project', + enforce: 'pre', + options() { + this.meta.watchMode = false + }, + // TODO: refactor so we don't have the same code here and in plugins/index.ts + config(viteConfig) { + if (viteConfig.define) { + delete viteConfig.define['import.meta.vitest'] + delete viteConfig.define['process.env'] + } + + const env: Record = {} + + for (const key in viteConfig.define) { + const val = viteConfig.define[key] + let replacement: any + try { + replacement = typeof val === 'string' ? JSON.parse(val) : val + } + catch { + // probably means it contains reference to some variable, + // like this: "__VAR__": "process.env.VAR" + continue + } + if (key.startsWith('import.meta.env.')) { + const envKey = key.slice('import.meta.env.'.length) + env[envKey] = replacement + delete viteConfig.define[key] + } + else if (key.startsWith('process.env.')) { + const envKey = key.slice('process.env.'.length) + env[envKey] = replacement + delete viteConfig.define[key] + } + } + + const testConfig = viteConfig.test || {} + + const root = testConfig.root || viteConfig.root || options.root + let name = testConfig.name + if (!name) { + if (typeof options.workspacePath === 'string') + name = dirname(options.workspacePath).split('/').pop() + else + name = options.workspacePath.toString() + } + + const config: ViteConfig = { + root, + resolve: { + // by default Vite resolves `module` field, which not always a native ESM module + // setting this option can bypass that and fallback to cjs version + mainFields: [], + alias: testConfig.alias, + conditions: ['node'], + // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error + // @ts-ignore we support Vite ^3.0, but browserField is available in Vite ^3.2 + browserField: false, + }, + esbuild: { + sourcemap: 'external', + + // Enables using ignore hint for coverage providers with @preserve keyword + legalComments: 'inline', + }, + server: { + // disable watch mode in workspaces, + // because it is handled by the top-level watcher + watch: { + ignored: ['**/*'], + depth: 0, + persistent: false, + }, + open: false, + hmr: false, + preTransformRequests: false, + }, + test: { + env, + name, + }, + } + + const classNameStrategy = (typeof testConfig.css !== 'boolean' && testConfig.css?.modules?.classNameStrategy) || 'stable' + + if (classNameStrategy !== 'scoped') { + config.css ??= {} + config.css.modules ??= {} + if (config.css.modules) { + config.css.modules.generateScopedName = (name: string, filename: string) => { + const root = project.config.root + return generateScopedClassName(classNameStrategy, name, relative(root, filename))! + } + } + } + + return config + }, + async configureServer(server) { + try { + const options = deepMerge( + {}, + configDefaults, + server.config.test || {}, + ) + await project.setServer(options, server) + } + catch (err) { + await project.ctx.logger.printError(err, true) + process.exit(1) + } + + await server.watcher.close() + }, + }, + EnvReplacerPlugin(), + ...CSSEnablerPlugin(project), + CoverageTransform(project.ctx), + GlobalSetupPlugin(project, project.ctx.logger), + ] +} diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index 016bf66352e0..35854f4d9fe1 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -7,8 +7,10 @@ import type { Vitest } from './core' import { createChildProcessPool } from './pools/child' import { createThreadsPool } from './pools/threads' import { createBrowserPool } from './pools/browser' +import type { WorkspaceProject } from './workspace' -export type RunWithFiles = (files: string[], invalidates?: string[]) => Promise +export type WorkspaceSpec = [project: WorkspaceProject, testFile: string] +export type RunWithFiles = (files: WorkspaceSpec[], invalidates?: string[]) => Promise export interface ProcessPool { runTests: RunWithFiles @@ -30,23 +32,23 @@ export function createPool(ctx: Vitest): ProcessPool { browser: null, } - function getDefaultPoolName() { - if (ctx.config.browser.enabled) + function getDefaultPoolName(project: WorkspaceProject) { + if (project.config.browser.enabled) return 'browser' - if (ctx.config.threads) + if (project.config.threads) return 'threads' return 'child_process' } - function getPoolName(file: string) { - for (const [glob, pool] of ctx.config.poolMatchGlobs || []) { - if (mm.isMatch(file, glob, { cwd: ctx.server.config.root })) + function getPoolName([project, file]: WorkspaceSpec) { + for (const [glob, pool] of project.config.poolMatchGlobs || []) { + if (mm.isMatch(file, glob, { cwd: project.config.root })) return pool } - return getDefaultPoolName() + return getDefaultPoolName(project) } - async function runTests(files: string[], invalidate?: string[]) { + async function runTests(files: WorkspaceSpec[], invalidate?: string[]) { const conditions = ctx.server.config.resolve.conditions?.flatMap(c => ['--conditions', c]) || [] // Instead of passing whole process.execArgv to the workers, pick allowed options. @@ -79,27 +81,21 @@ export function createPool(ctx: Vitest): ProcessPool { } const filesByPool = { - child_process: [] as string[], - threads: [] as string[], - browser: [] as string[], + child_process: [] as WorkspaceSpec[], + threads: [] as WorkspaceSpec[], + browser: [] as WorkspaceSpec[], } - if (!ctx.config.poolMatchGlobs) { - const name = getDefaultPoolName() - filesByPool[name] = files - } - else { - for (const file of files) { - const pool = getPoolName(file) - filesByPool[pool].push(file) - } + for (const spec of files) { + const pool = getPoolName(spec) + filesByPool[pool].push(spec) } await Promise.all(Object.entries(filesByPool).map(([pool, files]) => { if (!files.length) return null - if (ctx.browserProvider && pool === 'browser') { + if (pool === 'browser') { pools.browser ??= createBrowserPool(ctx) return pools.browser.runTests(files, invalidate) } diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 96782031ebd0..fcdef6b87d8f 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -2,10 +2,11 @@ import { createDefer } from '@vitest/utils' import { relative } from 'pathe' import type { Vitest } from '../core' import type { ProcessPool } from '../pool' +import type { WorkspaceProject } from '../workspace' +import type { BrowserProvider } from '../../types/browser' export function createBrowserPool(ctx: Vitest): ProcessPool { - const provider = ctx.browserProvider! - const origin = `http://${ctx.config.browser.api?.host || 'localhost'}:${ctx.browser.config.server.port}` + const providers = new Set() const waitForTest = (id: string) => { const defer = createDefer() @@ -13,10 +14,14 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { return defer } - const runTests = async (files: string[]) => { - const paths = files.map(file => relative(ctx.config.root, file)) + const runTests = async (project: WorkspaceProject, files: string[]) => { + const provider = project.browserProvider! + providers.add(provider) - const isolate = ctx.config.isolate + const origin = `http://${ctx.config.browser.api?.host || 'localhost'}:${project.browser.config.server.port}` + const paths = files.map(file => relative(project.config.root, file)) + + const isolate = project.config.isolate if (isolate) { for (const path of paths) { const url = new URL('/', origin) @@ -35,11 +40,24 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { } } + const runWorkspaceTests = async (specs: [WorkspaceProject, string][]) => { + const groupedFiles = new Map() + for (const [project, file] of specs) { + const files = groupedFiles.get(project) || [] + files.push(file) + groupedFiles.set(project, files) + } + + for (const [project, files] of groupedFiles.entries()) + await runTests(project, files) + } + return { async close() { ctx.state.browserTestPromises.clear() - await provider.close() + await Promise.all([...providers].map(provider => provider.close())) + providers.clear() }, - runTests, + runTests: runWorkspaceTests, } } diff --git a/packages/vitest/src/node/pools/child.ts b/packages/vitest/src/node/pools/child.ts index b6be56a0cfb8..33fac5f414c3 100644 --- a/packages/vitest/src/node/pools/child.ts +++ b/packages/vitest/src/node/pools/child.ts @@ -4,20 +4,20 @@ import { fork } from 'node:child_process' import { fileURLToPath, pathToFileURL } from 'node:url' import { createBirpc } from 'birpc' import { resolve } from 'pathe' -import type { ContextTestEnvironment, ResolvedConfig, RuntimeRPC } from '../../types' -import type { Vitest } from '../core' +import type { ContextTestEnvironment, ResolvedConfig, RuntimeRPC, Vitest } from '../../types' import type { ChildContext } from '../../types/child' -import type { PoolProcessOptions, ProcessPool } from '../pool' +import type { PoolProcessOptions, ProcessPool, WorkspaceSpec } from '../pool' import { distDir } from '../../paths' import { groupBy } from '../../utils/base' import { envsOrder, groupFilesByEnv } from '../../utils/test-helpers' +import type { WorkspaceProject } from '../workspace' import { createMethodsRPC } from './rpc' const childPath = fileURLToPath(pathToFileURL(resolve(distDir, './child.js')).href) -function setupChildProcessChannel(ctx: Vitest, fork: ChildProcess): void { +function setupChildProcessChannel(project: WorkspaceProject, fork: ChildProcess): void { createBirpc<{}, RuntimeRPC>( - createMethodsRPC(ctx), + createMethodsRPC(project), { serialize: v8.serialize, deserialize: v => v8.deserialize(Buffer.from(v)), @@ -37,7 +37,7 @@ function stringifyRegex(input: RegExp | string): string { return `$$vitest:${input.toString()}` } -function getTestConfig(ctx: Vitest): ResolvedConfig { +function getTestConfig(ctx: WorkspaceProject): ResolvedConfig { const config = ctx.getSerializableConfig() // v8 serialize does not support regex return { @@ -51,7 +51,13 @@ function getTestConfig(ctx: Vitest): ResolvedConfig { export function createChildProcessPool(ctx: Vitest, { execArgv, env }: PoolProcessOptions): ProcessPool { const children = new Set() - function runFiles(config: ResolvedConfig, files: string[], environment: ContextTestEnvironment, invalidates: string[] = []) { + const Sequencer = ctx.config.sequence.sequencer + const sequencer = new Sequencer(ctx) + + function runFiles(project: WorkspaceProject, files: string[], environment: ContextTestEnvironment, invalidates: string[] = []) { + const config = getTestConfig(project) + ctx.state.clearFiles(project, files) + const data: ChildContext = { command: 'start', config, @@ -65,7 +71,7 @@ export function createChildProcessPool(ctx: Vitest, { execArgv, env }: PoolProce env, }) children.add(child) - setupChildProcessChannel(ctx, child) + setupChildProcessChannel(project, child) return new Promise((resolve, reject) => { child.send(data, (err) => { @@ -83,11 +89,15 @@ export function createChildProcessPool(ctx: Vitest, { execArgv, env }: PoolProce }) } - async function runWithFiles(files: string[], invalidates: string[] = []): Promise { - ctx.state.clearFiles(files) - const config = getTestConfig(ctx) + async function runTests(specs: WorkspaceSpec[], invalidates: string[] = []): Promise { + const { shard } = ctx.config + + if (shard) + specs = await sequencer.shard(specs) + + specs = await sequencer.sort(specs) - const filesByEnv = await groupFilesByEnv(files, config) + const filesByEnv = await groupFilesByEnv(specs) const envs = envsOrder.concat( Object.keys(filesByEnv).filter(env => !envsOrder.includes(env)), ) @@ -99,21 +109,21 @@ export function createChildProcessPool(ctx: Vitest, { execArgv, env }: PoolProce if (!files?.length) continue - const filesByOptions = groupBy(files, ({ environment }) => JSON.stringify(environment.options)) + const filesByOptions = groupBy(files, ({ project, environment }) => project.getName() + JSON.stringify(environment.options)) for (const option in filesByOptions) { const files = filesByOptions[option] if (files?.length) { const filenames = files.map(f => f.file) - await runFiles(config, filenames, files[0].environment, invalidates) + await runFiles(files[0].project, filenames, files[0].environment, invalidates) } } } } return { - runTests: runWithFiles, + runTests, async close() { children.forEach((child) => { if (!child.killed) diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts index 34a0a93d2bf7..1e295f9a6576 100644 --- a/packages/vitest/src/node/pools/rpc.ts +++ b/packages/vitest/src/node/pools/rpc.ts @@ -1,9 +1,10 @@ import type { RawSourceMap } from 'vite-node' import type { RuntimeRPC } from '../../types' import { getEnvironmentTransformMode } from '../../utils/base' -import type { Vitest } from '../core' +import type { WorkspaceProject } from '../workspace' -export function createMethodsRPC(ctx: Vitest): RuntimeRPC { +export function createMethodsRPC(project: WorkspaceProject): RuntimeRPC { + const ctx = project.ctx return { async onWorkerExit(error, code) { await ctx.logger.printError(error, false, 'Unexpected Exit') @@ -17,45 +18,45 @@ export function createMethodsRPC(ctx: Vitest): RuntimeRPC { }, async getSourceMap(id, force) { if (force) { - const mod = ctx.server.moduleGraph.getModuleById(id) + const mod = project.server.moduleGraph.getModuleById(id) if (mod) - ctx.server.moduleGraph.invalidateModule(mod) + project.server.moduleGraph.invalidateModule(mod) } - const r = await ctx.vitenode.transformRequest(id) + const r = await project.vitenode.transformRequest(id) return r?.map as RawSourceMap | undefined }, fetch(id, environment) { - const transformMode = getEnvironmentTransformMode(ctx.config, environment) - return ctx.vitenode.fetchModule(id, transformMode) + const transformMode = getEnvironmentTransformMode(project.config, environment) + return project.vitenode.fetchModule(id, transformMode) }, resolveId(id, importer, environment) { - const transformMode = getEnvironmentTransformMode(ctx.config, environment) - return ctx.vitenode.resolveId(id, importer, transformMode) + const transformMode = getEnvironmentTransformMode(project.config, environment) + return project.vitenode.resolveId(id, importer, transformMode) }, onPathsCollected(paths) { ctx.state.collectPaths(paths) - ctx.report('onPathsCollected', paths) + project.report('onPathsCollected', paths) }, onCollected(files) { ctx.state.collectFiles(files) - ctx.report('onCollected', files) + project.report('onCollected', files) }, onAfterSuiteRun(meta) { ctx.coverageProvider?.onAfterSuiteRun(meta) }, onTaskUpdate(packs) { ctx.state.updateTasks(packs) - ctx.report('onTaskUpdate', packs) + project.report('onTaskUpdate', packs) }, onUserConsoleLog(log) { ctx.state.updateUserLog(log) - ctx.report('onUserConsoleLog', log) + project.report('onUserConsoleLog', log) }, onUnhandledError(err, type) { ctx.state.catchError(err, type) }, onFinished(files) { - ctx.report('onFinished', files, ctx.state.getUnhandledErrors()) + project.report('onFinished', files, ctx.state.getUnhandledErrors()) }, } } diff --git a/packages/vitest/src/node/pools/threads.ts b/packages/vitest/src/node/pools/threads.ts index 8ac53377ecdc..4961b15c3070 100644 --- a/packages/vitest/src/node/pools/threads.ts +++ b/packages/vitest/src/node/pools/threads.ts @@ -6,22 +6,22 @@ import { resolve } from 'pathe' import type { Options as TinypoolOptions } from 'tinypool' import Tinypool from 'tinypool' import { distDir } from '../../paths' -import type { ContextTestEnvironment, ResolvedConfig, RuntimeRPC, WorkerContext } from '../../types' -import type { Vitest } from '../core' +import type { ContextTestEnvironment, ResolvedConfig, RuntimeRPC, Vitest, WorkerContext } from '../../types' import type { PoolProcessOptions, ProcessPool, RunWithFiles } from '../pool' import { envsOrder, groupFilesByEnv } from '../../utils/test-helpers' import { groupBy } from '../../utils/base' +import type { WorkspaceProject } from '../workspace' import { createMethodsRPC } from './rpc' const workerPath = pathToFileURL(resolve(distDir, './worker.js')).href -function createWorkerChannel(ctx: Vitest) { +function createWorkerChannel(project: WorkspaceProject) { const channel = new MessageChannel() const port = channel.port2 const workerPort = channel.port1 createBirpc<{}, RuntimeRPC>( - createMethodsRPC(ctx), + createMethodsRPC(project), { post(v) { port.postMessage(v) @@ -74,9 +74,9 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env }: PoolProcessOpt const runWithFiles = (name: string): RunWithFiles => { let id = 0 - async function runFiles(config: ResolvedConfig, files: string[], environment: ContextTestEnvironment, invalidates: string[] = []) { - ctx.state.clearFiles(files) - const { workerPort, port } = createWorkerChannel(ctx) + async function runFiles(project: WorkspaceProject, config: ResolvedConfig, files: string[], environment: ContextTestEnvironment, invalidates: string[] = []) { + ctx.state.clearFiles(project, files) + const { workerPort, port } = createWorkerChannel(project) const workerId = ++id const data: WorkerContext = { port: workerPort, @@ -105,20 +105,52 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env }: PoolProcessOpt const Sequencer = ctx.config.sequence.sequencer const sequencer = new Sequencer(ctx) - return async (files, invalidates) => { - const config = ctx.getSerializableConfig() + return async (specs, invalidates) => { + const configs = new Map() + const getConfig = (project: WorkspaceProject): ResolvedConfig => { + if (configs.has(project)) + return configs.get(project)! - if (config.shard) - files = await sequencer.shard(files) + const config = project.getSerializableConfig() + configs.set(project, config) + return config + } + + const workspaceMap = new Map() + for (const [project, file] of specs) { + const workspaceFiles = workspaceMap.get(file) ?? [] + workspaceFiles.push(project) + workspaceMap.set(file, workspaceFiles) + } - files = await sequencer.sort(files) + // it's possible that project defines a file that is also defined by another project + const { shard } = ctx.config - const filesByEnv = await groupFilesByEnv(files, config) - const envs = envsOrder.concat( - Object.keys(filesByEnv).filter(env => !envsOrder.includes(env)), - ) + if (shard) + specs = await sequencer.shard(specs) + + specs = await sequencer.sort(specs) + + const singleThreads = specs.filter(([project]) => project.config.singleThread) + const multipleThreads = specs.filter(([project]) => !project.config.singleThread) + + if (multipleThreads.length) { + const filesByEnv = await groupFilesByEnv(multipleThreads) + const promises = Object.values(filesByEnv).flat() + const results = await Promise.allSettled(promises + .map(({ file, environment, project }) => runFiles(project, getConfig(project), [file], environment, invalidates))) + + const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason) + if (errors.length > 0) + throw new AggregateError(errors, 'Errors occurred while running tests. For more information, see serialized error.') + } + + if (singleThreads.length) { + const filesByEnv = await groupFilesByEnv(singleThreads) + const envs = envsOrder.concat( + Object.keys(filesByEnv).filter(env => !envsOrder.includes(env)), + ) - if (ctx.config.singleThread) { // always run environments isolated between each other for (const env of envs) { const files = filesByEnv[env] @@ -126,27 +158,16 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env }: PoolProcessOpt if (!files?.length) continue - const filesByOptions = groupBy(files, ({ environment }) => JSON.stringify(environment.options)) + const filesByOptions = groupBy(files, ({ project, environment }) => project.getName() + JSON.stringify(environment.options)) - for (const option in filesByOptions) { - const files = filesByOptions[option] + const promises = Object.values(filesByOptions).map(async (files) => { + const filenames = files.map(f => f.file) + await runFiles(files[0].project, getConfig(files[0].project), filenames, files[0].environment, invalidates) + }) - if (files?.length) { - const filenames = files.map(f => f.file) - await runFiles(config, filenames, files[0].environment, invalidates) - } - } + await Promise.all(promises) } } - else { - const promises = Object.values(filesByEnv).flat() - const results = await Promise.allSettled(promises - .map(({ file, environment }) => runFiles(config, [file], environment, invalidates))) - - const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason) - if (errors.length > 0) - throw new AggregateError(errors, 'Errors occurred while running tests. For more information, see serialized error.') - } } } diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index 53b5ebcae99c..15087eec1229 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -80,7 +80,11 @@ export abstract class BaseReporter implements Reporter { if (this.ctx.config.logHeapUsage && task.result.heap != null) suffix += c.magenta(` ${Math.floor(task.result.heap / 1024 / 1024)} MB heap used`) - logger.log(` ${getStateSymbol(task)} ${task.name} ${suffix}`) + let title = ` ${getStateSymbol(task)} ` + if (task.projectName) + title += formatProjectName(task.projectName) + title += `${task.name} ${suffix}` + logger.log(title) // print short errors, full errors will be at the end in summary for (const test of failed) { @@ -156,7 +160,7 @@ export abstract class BaseReporter implements Reporter { const BADGE = c.inverse(c.bold(c.blue(' RERUN '))) const TRIGGER = trigger ? c.dim(` ${this.relative(trigger)}`) : '' const FILENAME_PATTERN = this.ctx.filenamePattern ? `${BADGE_PADDING} ${c.dim('Filename pattern: ')}${c.blue(this.ctx.filenamePattern)}\n` : '' - const TESTNAME_PATTERN = this.ctx.config.testNamePattern ? `${BADGE_PADDING} ${c.dim('Test name pattern: ')}${c.blue(String(this.ctx.config.testNamePattern))}\n` : '' + const TESTNAME_PATTERN = this.ctx.configOverride.testNamePattern ? `${BADGE_PADDING} ${c.dim('Test name pattern: ')}${c.blue(String(this.ctx.configOverride.testNamePattern))}\n` : '' if (files.length > 1) { // we need to figure out how to handle rerun all from stdin @@ -214,7 +218,11 @@ export abstract class BaseReporter implements Reporter { const collectTime = files.reduce((acc, test) => acc + Math.max(0, test.collectDuration || 0), 0) const setupTime = files.reduce((acc, test) => acc + Math.max(0, test.setupDuration || 0), 0) const testsTime = files.reduce((acc, test) => acc + Math.max(0, test.result?.duration || 0), 0) - const transformTime = Array.from(this.ctx.vitenode.fetchCache.values()).reduce((a, b) => a + (b?.duration || 0), 0) + const transformTime = this.ctx.projects + .flatMap(w => Array.from(w.vitenode.fetchCache.values()).map(i => i.duration || 0)) + .reduce((a, b) => a + b, 0) + const environmentTime = files.reduce((acc, file) => acc + Math.max(0, file.environmentLoad || 0), 0) + const prepareTime = files.reduce((acc, file) => acc + Math.max(0, file.prepareDuration || 0), 0) const threadTime = collectTime + testsTime + setupTime const padTitle = (str: string) => c.dim(`${str.padStart(11)} `) @@ -254,7 +262,7 @@ export abstract class BaseReporter implements Reporter { else if (this.mode === 'typecheck') logger.log(padTitle('Duration'), time(executionTime)) else - logger.log(padTitle('Duration'), time(executionTime) + c.dim(` (transform ${time(transformTime)}, setup ${time(setupTime)}, collect ${time(collectTime)}, tests ${time(testsTime)})`)) + logger.log(padTitle('Duration'), time(executionTime) + c.dim(` (transform ${time(transformTime)}, setup ${time(setupTime)}, collect ${time(collectTime)}, tests ${time(testsTime)}, environment ${time(environmentTime)}, prepare ${time(prepareTime)})`)) logger.log() } diff --git a/packages/vitest/src/node/reporters/verbose.ts b/packages/vitest/src/node/reporters/verbose.ts index 9791cbc0175b..dd17b8d4b0f2 100644 --- a/packages/vitest/src/node/reporters/verbose.ts +++ b/packages/vitest/src/node/reporters/verbose.ts @@ -3,7 +3,7 @@ import type { TaskResultPack } from '../../types' import { getFullName } from '../../utils' import { F_RIGHT } from '../../utils/figures' import { DefaultReporter } from './default' -import { getStateSymbol } from './renderers/utils' +import { formatProjectName, getStateSymbol } from './renderers/utils' export class VerboseReporter extends DefaultReporter { constructor() { @@ -17,7 +17,10 @@ export class VerboseReporter extends DefaultReporter { for (const pack of packs) { const task = this.ctx.state.idMap.get(pack[0]) if (task && task.type === 'test' && task.result?.state && task.result?.state !== 'run') { - let title = ` ${getStateSymbol(task)} ${getFullName(task, c.dim(' > '))}` + let title = ` ${getStateSymbol(task)} ` + if (task.suite?.projectName) + title += formatProjectName(task.suite.projectName) + title += getFullName(task, c.dim(' > ')) if (this.ctx.config.logHeapUsage && task.result.heap != null) title += c.magenta(` ${Math.floor(task.result.heap / 1024 / 1024)} MB heap used`) this.ctx.logger.log(title) diff --git a/packages/vitest/src/node/sequencers/BaseSequencer.ts b/packages/vitest/src/node/sequencers/BaseSequencer.ts index 6b5b7f476ca9..75e07412da58 100644 --- a/packages/vitest/src/node/sequencers/BaseSequencer.ts +++ b/packages/vitest/src/node/sequencers/BaseSequencer.ts @@ -1,7 +1,8 @@ import { createHash } from 'node:crypto' -import { resolve } from 'pathe' +import { relative, resolve } from 'pathe' import { slash } from 'vite-node/utils' import type { Vitest } from '../core' +import type { WorkspaceSpec } from '../pool' import type { TestSequencer } from './types' export class BaseSequencer implements TestSequencer { @@ -12,18 +13,18 @@ export class BaseSequencer implements TestSequencer { } // async so it can be extended by other sequelizers - public async shard(files: string[]): Promise { + public async shard(files: WorkspaceSpec[]): Promise { const { config } = this.ctx const { index, count } = config.shard! const shardSize = Math.ceil(files.length / count) const shardStart = shardSize * (index - 1) const shardEnd = shardSize * index return [...files] - .map((file) => { - const fullPath = resolve(slash(config.root), slash(file)) + .map((spec) => { + const fullPath = resolve(slash(config.root), slash(spec[1])) const specPath = fullPath?.slice(config.root.length) return { - file, + spec, hash: createHash('sha1') .update(specPath) .digest('hex'), @@ -31,19 +32,22 @@ export class BaseSequencer implements TestSequencer { }) .sort((a, b) => (a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0)) .slice(shardStart, shardEnd) - .map(({ file }) => file) + .map(({ spec }) => spec) } // async so it can be extended by other sequelizers - public async sort(files: string[]): Promise { + public async sort(files: WorkspaceSpec[]): Promise { const cache = this.ctx.cache return [...files].sort((a, b) => { - const aState = cache.getFileTestResults(a) - const bState = cache.getFileTestResults(b) + const keyA = `${a[0].getName()}:${relative(this.ctx.config.root, a[1])}` + const keyB = `${b[0].getName()}:${relative(this.ctx.config.root, b[1])}` + + const aState = cache.getFileTestResults(keyA) + const bState = cache.getFileTestResults(keyB) if (!aState || !bState) { - const statsA = cache.getFileStats(a) - const statsB = cache.getFileStats(b) + const statsA = cache.getFileStats(keyA) + const statsB = cache.getFileStats(keyB) // run unknown first if (!statsA || !statsB) diff --git a/packages/vitest/src/node/sequencers/RandomSequencer.ts b/packages/vitest/src/node/sequencers/RandomSequencer.ts index d3b3bd1d54d0..42608a53b81a 100644 --- a/packages/vitest/src/node/sequencers/RandomSequencer.ts +++ b/packages/vitest/src/node/sequencers/RandomSequencer.ts @@ -1,8 +1,9 @@ import { shuffle } from '@vitest/utils' +import type { WorkspaceSpec } from '../pool' import { BaseSequencer } from './BaseSequencer' export class RandomSequencer extends BaseSequencer { - public async sort(files: string[]) { + public async sort(files: WorkspaceSpec[]) { const { sequence } = this.ctx.config return shuffle(files, sequence.seed) diff --git a/packages/vitest/src/node/sequencers/types.ts b/packages/vitest/src/node/sequencers/types.ts index d4419fd261b3..12cf43ef4819 100644 --- a/packages/vitest/src/node/sequencers/types.ts +++ b/packages/vitest/src/node/sequencers/types.ts @@ -1,13 +1,14 @@ import type { Awaitable } from '../../types' import type { Vitest } from '../core' +import type { WorkspaceSpec } from '../pool' export interface TestSequencer { /** * Slicing tests into shards. Will be run before `sort`. * Only run, if `shard` is defined. */ - shard(files: string[]): Awaitable - sort(files: string[]): Awaitable + shard(files: WorkspaceSpec[]): Awaitable + sort(files: WorkspaceSpec[]): Awaitable } export interface TestSequencerConstructor { diff --git a/packages/vitest/src/node/state.ts b/packages/vitest/src/node/state.ts index 28f38e2a6c4f..b956317262dd 100644 --- a/packages/vitest/src/node/state.ts +++ b/packages/vitest/src/node/state.ts @@ -1,6 +1,7 @@ import type { ErrorWithDiff, File, Task, TaskResultPack, UserConsoleLog } from '../types' // can't import actual functions from utils, because it's incompatible with @vitest/browsers import type { AggregateError as AggregateErrorPonyfill } from '../utils' +import type { WorkspaceProject } from './workspace' interface CollectingPromise { promise: Promise @@ -16,7 +17,7 @@ export function isAggregateError(err: unknown): err is AggregateErrorPonyfill { // Note this file is shared for both node and browser, be aware to avoid node specific logic export class StateManager { - filesMap = new Map() + filesMap = new Map() pathsSet: Set = new Set() collectingPromise: CollectingPromise | undefined = undefined browserTestPromises = new Map void; reject: (v: unknown) => void }>() @@ -55,8 +56,8 @@ export class StateManager { getFiles(keys?: string[]): File[] { if (keys) - return keys.map(key => this.filesMap.get(key)!).filter(Boolean) - return Array.from(this.filesMap.values()) + return keys.map(key => this.filesMap.get(key)!).filter(Boolean).flat() + return Array.from(this.filesMap.values()).flat() } getFilepaths(): string[] { @@ -77,14 +78,24 @@ export class StateManager { collectFiles(files: File[] = []) { files.forEach((file) => { - this.filesMap.set(file.filepath, file) + const existing = (this.filesMap.get(file.filepath) || []) + const otherProject = existing.filter(i => i.projectName !== file.projectName) + otherProject.push(file) + this.filesMap.set(file.filepath, otherProject) this.updateId(file) }) } - clearFiles(paths: string[] = []) { + clearFiles(project: WorkspaceProject, paths: string[] = []) { paths.forEach((path) => { - this.filesMap.delete(path) + const files = this.filesMap.get(path) + if (!files) + return + const filtered = files.filter(file => file.projectName !== project.config.name) + if (!filtered.length) + this.filesMap.delete(path) + else + this.filesMap.set(path, filtered) }) } diff --git a/packages/vitest/src/node/stdin.ts b/packages/vitest/src/node/stdin.ts index dbdb240e1351..dfcc5d4824e4 100644 --- a/packages/vitest/src/node/stdin.ts +++ b/packages/vitest/src/node/stdin.ts @@ -81,7 +81,7 @@ export function registerConsoleShortcuts(ctx: Vitest) { name: 'filter', type: 'text', message: 'Input test name pattern (RegExp)', - initial: ctx.config.testNamePattern?.source || '', + initial: ctx.configOverride.testNamePattern?.source || '', }]) await ctx.changeNamePattern(filter.trim(), undefined, 'change pattern') on() diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts new file mode 100644 index 000000000000..895d4e79f280 --- /dev/null +++ b/packages/vitest/src/node/workspace.ts @@ -0,0 +1,281 @@ +import { promises as fs } from 'node:fs' +import fg from 'fast-glob' +import { dirname, resolve, toNamespacedPath } from 'pathe' +import { createServer } from 'vite' +import type { ViteDevServer, InlineConfig as ViteInlineConfig } from 'vite' +import { ViteNodeRunner } from 'vite-node/client' +import { createBrowserServer } from '../integrations/browser/server' +import type { ArgumentsType, Reporter, ResolvedConfig, UserConfig, UserWorkspaceConfig, Vitest } from '../types' +import { deepMerge, hasFailed } from '../utils' +import { Typechecker } from '../typecheck/typechecker' +import type { BrowserProvider } from '../types/browser' +import { getBrowserProvider } from '../integrations/browser' +import { isBrowserEnabled, resolveConfig } from './config' +import { WorkspaceVitestPlugin } from './plugins/workspace' +import { VitestServer } from './server' + +interface InitializeOptions { + server?: VitestServer + runner?: ViteNodeRunner +} + +export async function initializeProject(workspacePath: string | number, ctx: Vitest, options: UserWorkspaceConfig = {}) { + const project = new WorkspaceProject(workspacePath, ctx) + + const configFile = options.extends + ? resolve(ctx.config.root, options.extends) + : (typeof workspacePath === 'number' || workspacePath.endsWith('/')) + ? false + : workspacePath + + const root = options.root || (typeof workspacePath === 'number' ? undefined : dirname(workspacePath)) + + const config: ViteInlineConfig = { + ...options, + root, + logLevel: 'error', + configFile, + // this will make "mode" = "test" inside defineConfig + mode: options.mode || ctx.config.mode || process.env.NODE_ENV, + plugins: [ + ...options.plugins || [], + WorkspaceVitestPlugin(project, { ...options, root, workspacePath }), + ], + } + + const server = await createServer(config) + + // optimizer needs .listen() to be called + if (ctx.config.api?.port || project.config.deps?.experimentalOptimizer?.enabled) + await server.listen() + else + await server.pluginContainer.buildStart({}) + + return project +} + +export class WorkspaceProject { + configOverride: Partial | undefined + + config!: ResolvedConfig + server!: ViteDevServer + vitenode!: VitestServer + runner!: ViteNodeRunner + browser: ViteDevServer = undefined! + typechecker?: Typechecker + + closingPromise: Promise | undefined + browserProvider: BrowserProvider | undefined + + constructor( + public path: string | number, + public ctx: Vitest, + ) { } + + getName(): string { + return this.config.name || '' + } + + isCore() { + return this.ctx.getCoreWorkspaceProject() === this + } + + get reporters() { + return this.ctx.reporters + } + + async globTestFiles(filters: string[] = []) { + const { dir, root } = this.config + + const testFiles = await this.globAllTestFiles(this.config, dir || root) + + return this.filterFiles(testFiles, filters) + } + + async globAllTestFiles(config: ResolvedConfig, cwd: string) { + const { include, exclude, includeSource } = config + + const testFiles = await this.globFiles(include, exclude, cwd) + + if (includeSource) { + const files = await this.globFiles(includeSource, exclude, cwd) + + await Promise.all(files.map(async (file) => { + try { + const code = await fs.readFile(file, 'utf-8') + if (this.ctx.isInSourceTestFile(code)) + testFiles.push(file) + } + catch { + return null + } + })) + } + + return testFiles + } + + async globFiles(include: string[], exclude: string[], cwd: string) { + const globOptions: fg.Options = { + absolute: true, + dot: true, + cwd, + ignore: exclude, + } + + return fg(include, globOptions) + } + + filterFiles(testFiles: string[], filters: string[] = []) { + if (filters.length && process.platform === 'win32') + filters = filters.map(f => toNamespacedPath(f)) + + if (filters.length) + return testFiles.filter(i => filters.some(f => i.includes(f))) + + return testFiles + } + + async initBrowserServer(options: UserConfig) { + if (!this.isBrowserEnabled()) + return + await this.browser?.close() + this.browser = await createBrowserServer(this, options) + } + + async setServer(options: UserConfig, server: ViteDevServer, params: InitializeOptions = {}) { + this.config = resolveConfig(this.ctx.mode, options, server.config) + this.server = server + + this.vitenode = params.server ?? new VitestServer(server, this.config) + const node = this.vitenode + this.runner = params.runner ?? new ViteNodeRunner({ + root: server.config.root, + base: server.config.base, + fetchModule(id: string) { + return node.fetchModule(id) + }, + resolveId(id: string, importer?: string) { + return node.resolveId(id, importer) + }, + }) + + await this.initBrowserServer(options) + } + + async report(name: T, ...args: ArgumentsType) { + return this.ctx.report(name, ...args) + } + + async typecheck(filters: string[] = []) { + const { dir, root } = this.config + const { include, exclude } = this.config.typecheck + const testsFilesList = this.filterFiles(await this.globFiles(include, exclude, dir || root), filters) + const checker = new Typechecker(this, testsFilesList) + this.typechecker = checker + checker.onParseEnd(async ({ files, sourceErrors }) => { + this.ctx.state.collectFiles(checker.getTestFiles()) + await this.report('onTaskUpdate', checker.getTestPacks()) + await this.report('onCollected') + if (!files.length) { + this.ctx.logger.printNoTestFound() + } + else { + if (hasFailed(files)) + process.exitCode = 1 + await this.report('onFinished', files) + } + if (sourceErrors.length && !this.config.typecheck.ignoreSourceErrors) { + process.exitCode = 1 + await this.ctx.logger.printSourceTypeErrors(sourceErrors) + } + // if there are source errors, we are showing it, and then terminating process + if (!files.length) { + const exitCode = this.config.passWithNoTests ? (process.exitCode ?? 0) : 1 + process.exit(exitCode) + } + if (this.config.watch) { + await this.report('onWatcherStart', files, [ + ...(this.config.typecheck.ignoreSourceErrors ? [] : sourceErrors), + ...this.ctx.state.getUnhandledErrors(), + ]) + } + }) + checker.onParseStart(async () => { + await this.report('onInit', this.ctx) + this.ctx.state.collectFiles(checker.getTestFiles()) + await this.report('onCollected') + }) + checker.onWatcherRerun(async () => { + await this.report('onWatcherRerun', testsFilesList, 'File change detected. Triggering rerun.') + await checker.collectTests() + this.ctx.state.collectFiles(checker.getTestFiles()) + await this.report('onTaskUpdate', checker.getTestPacks()) + await this.report('onCollected') + }) + await checker.prepare() + await checker.collectTests() + await checker.start() + } + + isBrowserEnabled() { + return isBrowserEnabled(this.config) + } + + getSerializableConfig() { + return deepMerge({ + ...this.config, + coverage: this.ctx.config.coverage, + reporters: [], + deps: { + ...this.config.deps, + experimentalOptimizer: { + enabled: this.config.deps?.experimentalOptimizer?.enabled ?? false, + }, + }, + snapshotOptions: { + ...this.config.snapshotOptions, + resolveSnapshotPath: undefined, + }, + onConsoleLog: undefined!, + sequence: { + ...this.ctx.config.sequence, + sequencer: undefined!, + }, + benchmark: { + ...this.config.benchmark, + reporters: [], + }, + inspect: this.ctx.config.inspect, + inspectBrk: this.ctx.config.inspectBrk, + }, this.ctx.configOverride || {} as any, + ) as ResolvedConfig + } + + close() { + if (!this.closingPromise) { + this.closingPromise = Promise.all([ + this.server.close(), + this.typechecker?.stop(), + this.browser?.close(), + ].filter(Boolean)) + } + return this.closingPromise + } + + async initBrowserProvider() { + if (!this.isBrowserEnabled()) + return + if (this.browserProvider) + return + const Provider = await getBrowserProvider(this.config.browser, this.runner) + this.browserProvider = new Provider() + const browser = this.config.browser.name + const supportedBrowsers = this.browserProvider.getSupportedBrowsers() + if (!browser) + throw new Error(`[${this.getName()}] Browser name is required. Please, set \`test.browser.name\` option manually.`) + if (!supportedBrowsers.includes(browser)) + throw new Error(`[${this.getName()}] Browser "${browser}" is not supported by the browser provider "${this.browserProvider.name}". Supported browsers: ${supportedBrowsers.join(', ')}.`) + await this.browserProvider.initialize(this, { browser }) + } +} diff --git a/packages/vitest/src/runtime/child.ts b/packages/vitest/src/runtime/child.ts index b16bf03bee41..29a6d690333b 100644 --- a/packages/vitest/src/runtime/child.ts +++ b/packages/vitest/src/runtime/child.ts @@ -22,6 +22,10 @@ function init(ctx: ChildContext) { moduleCache, config, mockMap, + durations: { + environment: 0, + prepare: performance.now(), + }, rpc: createBirpc( {}, { diff --git a/packages/vitest/src/runtime/entry.ts b/packages/vitest/src/runtime/entry.ts index 25136db1101a..e16f75285214 100644 --- a/packages/vitest/src/runtime/entry.ts +++ b/packages/vitest/src/runtime/entry.ts @@ -51,6 +51,14 @@ async function getTestRunner(config: ResolvedConfig, executor: VitestExecutor): const originalOnCollected = testRunner.onCollected testRunner.onCollected = async (files) => { + const state = getWorkerState() + files.forEach((file) => { + file.prepareDuration = state.durations.prepare + file.environmentLoad = state.durations.environment + // should be collected only for a single test file in a batch + state.durations.prepare = 0 + state.durations.environment = 0 + }) rpc().onCollected(files) await originalOnCollected?.call(testRunner, files) } @@ -67,20 +75,26 @@ async function getTestRunner(config: ResolvedConfig, executor: VitestExecutor): // browser shouldn't call this! export async function run(files: string[], config: ResolvedConfig, environment: ContextTestEnvironment, executor: VitestExecutor): Promise { + const workerState = getWorkerState() + await setupGlobalEnv(config) await startCoverageInsideWorker(config.coverage, executor) - const workerState = getWorkerState() - if (config.chaiConfig) setupChaiConfig(config.chaiConfig) const runner = await getTestRunner(config, executor) + workerState.durations.prepare = performance.now() - workerState.durations.prepare + // @ts-expect-error untyped global globalThis.__vitest_environment__ = environment + workerState.durations.environment = performance.now() + await withEnv(environment.name, environment.options || config.environmentOptions || {}, executor, async () => { + workerState.durations.environment = performance.now() - workerState.durations.environment + for (const file of files) { // it doesn't matter if running with --threads // if running with --no-threads, we usually want to reset everything before running a test diff --git a/packages/vitest/src/runtime/execute.ts b/packages/vitest/src/runtime/execute.ts index 199a236ad3f1..7fb8f2622cad 100644 --- a/packages/vitest/src/runtime/execute.ts +++ b/packages/vitest/src/runtime/execute.ts @@ -48,12 +48,12 @@ export async function startViteNode(ctx: ContextRPC) { function catchError(err: unknown, type: string) { const worker = getWorkerState() const error = processError(err) - if (worker.filepath && !isPrimitive(error)) { + if (!isPrimitive(error)) { error.VITEST_TEST_NAME = worker.current?.name - error.VITEST_TEST_PATH = relative(config.root, worker.filepath) + if (worker.filepath) + error.VITEST_TEST_PATH = relative(config.root, worker.filepath) + error.VITEST_AFTER_ENV_TEARDOWN = worker.environmentTeardownRun } - error.VITEST_AFTER_ENV_TEARDOWN = worker.environmentTeardownRun - rpc().onUnhandledError(error, type) } diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index 305eca7fb220..586d9390067c 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -24,6 +24,10 @@ function init(ctx: WorkerContext) { moduleCache, config, mockMap, + durations: { + environment: 0, + prepare: performance.now(), + }, rpc: createBirpc( {}, { diff --git a/packages/vitest/src/typecheck/collect.ts b/packages/vitest/src/typecheck/collect.ts index 73b13ef1dbcf..eae6d4a73af0 100644 --- a/packages/vitest/src/typecheck/collect.ts +++ b/packages/vitest/src/typecheck/collect.ts @@ -4,7 +4,8 @@ import { ancestor as walkAst } from 'acorn-walk' import type { RawSourceMap } from 'vite-node' import { calculateSuiteHash, generateHash, interpretTaskModes, someTasksAreOnly } from '@vitest/runner/utils' -import type { File, Suite, Test, Vitest } from '../types' +import type { File, Suite, Test } from '../types' +import type { WorkspaceProject } from '../node/workspace' interface ParsedFile extends File { start: number @@ -38,7 +39,7 @@ export interface FileInformation { definitions: LocalCallDefinition[] } -export async function collectTests(ctx: Vitest, filepath: string): Promise { +export async function collectTests(ctx: WorkspaceProject, filepath: string): Promise { const request = await ctx.vitenode.transformRequest(filepath) if (!request) return null @@ -50,7 +51,7 @@ export async function collectTests(ctx: Vitest, filepath: string): Promise + initialize(ctx: WorkspaceProject, options: BrowserProviderOptions): Awaitable openPage(url: string): Awaitable close(): Awaitable } diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index d25f901fd939..4b6464c0c933 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -35,6 +35,89 @@ export interface EnvironmentOptions { export type VitestRunMode = 'test' | 'benchmark' | 'typecheck' +interface SequenceOptions { + /** + * Class that handles sorting and sharding algorithm. + * If you only need to change sorting, you can extend + * your custom sequencer from `BaseSequencer` from `vitest/node`. + * @default BaseSequencer + */ + sequencer?: TestSequencerConstructor + /** + * Should tests run in random order. + * @default false + */ + shuffle?: boolean + /** + * Defines how setup files should be ordered + * - 'parallel' will run all setup files in parallel + * - 'list' will run all setup files in the order they are defined in the config file + * @default 'parallel' + */ + setupFiles?: SequenceSetupFiles + /** + * Seed for the random number generator. + * @default Date.now() + */ + seed?: number + /** + * Defines how hooks should be ordered + * - `stack` will order "after" hooks in reverse order, "before" hooks will run sequentially + * - `list` will order hooks in the order they are defined + * - `parallel` will run hooks in a single group in parallel + * @default 'parallel' + */ + hooks?: SequenceHooks +} + +interface DepsOptions { + /** + * Enable dependency optimization. This can improve the performance of your tests. + */ + experimentalOptimizer?: Omit & { + enabled: boolean + } + /** + * Externalize means that Vite will bypass the package to native Node. + * + * Externalized dependencies will not be applied Vite's transformers and resolvers. + * And does not support HMR on reload. + * + * Typically, packages under `node_modules` are externalized. + */ + external?: (string | RegExp)[] + /** + * Vite will process inlined modules. + * + * This could be helpful to handle packages that ship `.js` in ESM format (that Node can't handle). + * + * If `true`, every dependency will be inlined + */ + inline?: (string | RegExp)[] | true + + /** + * Interpret CJS module's default as named exports + * + * @default true + */ + interopDefault?: boolean + + /** + * When a dependency is a valid ESM package, try to guess the cjs version based on the path. + * This will significantly improve the performance in huge repo, but might potentially + * cause some misalignment if a package have different logic in ESM and CJS mode. + * + * @default false + */ + fallbackCJS?: boolean + + /** + * Use experimental Node loader to resolve imports inside node_modules using Vite resolve algorithm. + * @default false + */ + registerNodeLoader?: boolean +} + export interface InlineConfig { /** * Name of the project. Will be used to display in the reporter. @@ -71,53 +154,7 @@ export interface InlineConfig { /** * Handling for dependencies inlining or externalizing */ - deps?: { - /** - * Enable dependency optimization. This can improve the performance of your tests. - */ - experimentalOptimizer?: Omit & { - enabled: boolean - } - /** - * Externalize means that Vite will bypass the package to native Node. - * - * Externalized dependencies will not be applied Vite's transformers and resolvers. - * And does not support HMR on reload. - * - * Typically, packages under `node_modules` are externalized. - */ - external?: (string | RegExp)[] - /** - * Vite will process inlined modules. - * - * This could be helpful to handle packages that ship `.js` in ESM format (that Node can't handle). - * - * If `true`, every dependency will be inlined - */ - inline?: (string | RegExp)[] | true - - /** - * Interpret CJS module's default as named exports - * - * @default true - */ - interopDefault?: boolean - - /** - * When a dependency is a valid ESM package, try to guess the cjs version based on the path. - * This will significantly improve the performance in huge repo, but might potentially - * cause some misalignment if a package have different logic in ESM and CJS mode. - * - * @default false - */ - fallbackCJS?: boolean - - /** - * Use experimental Node loader to resolve imports inside node_modules using Vite resolve algorithm. - * @default false - */ - registerNodeLoader?: boolean - } + deps?: DepsOptions /** * Base directory to scan for the test files @@ -480,40 +517,7 @@ export interface InlineConfig { /** * Options for configuring the order of running tests. */ - sequence?: { - /** - * Class that handles sorting and sharding algorithm. - * If you only need to change sorting, you can extend - * your custom sequencer from `BaseSequencer` from `vitest/node`. - * @default BaseSequencer - */ - sequencer?: TestSequencerConstructor - /** - * Should tests run in random order. - * @default false - */ - shuffle?: boolean - /** - * Defines how setup files should be ordered - * - 'parallel' will run all setup files in parallel - * - 'list' will run all setup files in the order they are defined in the config file - * @default 'parallel' - */ - setupFiles?: SequenceSetupFiles - /** - * Seed for the random number generator. - * @default Date.now() - */ - seed?: number - /** - * Defines how hooks should be ordered - * - `stack` will order "after" hooks in reverse order, "before" hooks will run sequentially - * - `list` will order hooks in the order they are defined - * - `parallel` will run hooks in a single group in parallel - * @default 'parallel' - */ - hooks?: SequenceHooks - } + sequence?: SequenceOptions /** * Specifies an `Object`, or an `Array` of `Object`, @@ -681,6 +685,43 @@ export interface ResolvedConfig extends Omit, 'config' | 'f runner?: string } +export type ProjectConfig = Omit< + UserConfig, + | 'sequencer' + | 'shard' + | 'watch' + | 'run' + | 'cache' + | 'update' + | 'reporters' + | 'outputFile' + | 'maxThreads' + | 'minThreads' + | 'useAtomics' + | 'teardownTimeout' + | 'silent' + | 'watchExclude' + | 'forceRerunTriggers' + | 'testNamePattern' + | 'ui' + | 'open' + | 'uiBase' + // TODO: allow snapshot options + | 'snapshotFormat' + | 'resolveSnapshotPath' + | 'passWithNoTests' + | 'onConsoleLog' + | 'dangerouslyIgnoreUnhandledErrors' + | 'slowTestThreshold' + | 'inspect' + | 'inspectBrk' + | 'deps' + | 'coverage' +> & { + sequencer?: Omit + deps?: Omit +} + export type RuntimeConfig = Pick< UserConfig, | 'allowOnly' @@ -692,3 +733,5 @@ export type RuntimeConfig = Pick< | 'fakeTimers' | 'maxConcurrency' > & { sequence?: { hooks?: SequenceHooks } } + +export type { UserWorkspaceConfig } from '../config' diff --git a/packages/vitest/src/types/global.ts b/packages/vitest/src/types/global.ts index 1ec39b66d412..7caa840b565d 100644 --- a/packages/vitest/src/types/global.ts +++ b/packages/vitest/src/types/global.ts @@ -26,6 +26,11 @@ declare module '@vitest/runner' { expect: Vi.ExpectStatic } + interface File { + prepareDuration?: number + environmentLoad?: number + } + interface TaskBase { logs?: UserConsoleLog[] } diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index a90c214fb26f..cc0face52dbe 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -26,4 +26,8 @@ export interface WorkerGlobalState { environmentTeardownRun?: boolean moduleCache: ModuleCacheMap mockMap: MockMap + durations: { + environment: number + prepare: number + } } diff --git a/packages/vitest/src/utils/graph.ts b/packages/vitest/src/utils/graph.ts index 962adeb6d151..aa8f795c6a8f 100644 --- a/packages/vitest/src/utils/graph.ts +++ b/packages/vitest/src/utils/graph.ts @@ -29,7 +29,7 @@ export async function getModuleGraph(ctx: Vitest, id: string): Promise get(m, seen)))).filter(Boolean) as string[] return id } - await get(ctx.server.moduleGraph.getModuleById(id) || ctx.browser?.moduleGraph.getModuleById(id)) + await get(ctx.server.moduleGraph.getModuleById(id)) return { graph, externalized: Array.from(externalized), diff --git a/packages/vitest/src/utils/test-helpers.ts b/packages/vitest/src/utils/test-helpers.ts index 392cc34a7bcd..3b7d0f0651b5 100644 --- a/packages/vitest/src/utils/test-helpers.ts +++ b/packages/vitest/src/utils/test-helpers.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'node:fs' import mm from 'micromatch' -import type { EnvironmentOptions, ResolvedConfig, VitestEnvironment } from '../types' +import type { EnvironmentOptions, VitestEnvironment } from '../types' +import type { WorkspaceProject } from '../node/workspace' import { groupBy } from './base' export const envsOrder = [ @@ -16,27 +17,28 @@ export interface FileByEnv { envOptions: EnvironmentOptions | null } -export async function groupFilesByEnv(files: string[], config: ResolvedConfig) { - const filesWithEnv = await Promise.all(files.map(async (file) => { +export async function groupFilesByEnv(files: (readonly [WorkspaceProject, string])[]) { + const filesWithEnv = await Promise.all(files.map(async ([project, file]) => { const code = await fs.readFile(file, 'utf-8') // 1. Check for control comments in the file let env = code.match(/@(?:vitest|jest)-environment\s+?([\w-]+)\b/)?.[1] // 2. Check for globals if (!env) { - for (const [glob, target] of config.environmentMatchGlobs || []) { - if (mm.isMatch(file, glob, { cwd: config.root })) { + for (const [glob, target] of project.config.environmentMatchGlobs || []) { + if (mm.isMatch(file, glob, { cwd: project.config.root })) { env = target break } } } // 3. Fallback to global env - env ||= config.environment || 'node' + env ||= project.config.environment || 'node' const envOptions = JSON.parse(code.match(/@(?:vitest|jest)-environment-options\s+?(.+)/)?.[1] || 'null') return { file, + project, environment: { name: env as VitestEnvironment, options: envOptions ? { [env]: envOptions } as EnvironmentOptions : null, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d37e8b4a026e..fb5c5f6e3a4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,7 +290,7 @@ importers: '@types/react-test-renderer': 17.0.2 '@vitejs/plugin-react': 3.1.0_vite@4.2.1 '@vitest/ui': link:../../packages/ui - happy-dom: 9.1.7 + happy-dom: 9.1.9 jsdom: 21.1.1 react-test-renderer: 17.0.2_react@17.0.2 vite: 4.2.1 @@ -1089,7 +1089,7 @@ importers: '@vitejs/plugin-vue': 4.1.0_vite@4.2.1+vue@3.2.47 '@vitest/browser': link:../../packages/browser '@vue/test-utils': 2.3.2_vue@3.2.47 - happy-dom: 9.1.7 + happy-dom: 9.1.9 vite: 4.2.1 vitest: link:../../packages/vitest vue: 3.2.47 @@ -1326,6 +1326,14 @@ importers: '@vitest/web-worker': link:../../packages/web-worker vitest: link:../../packages/vitest + test/workspaces: + specifiers: + jsdom: latest + vitest: workspace:* + devDependencies: + jsdom: 21.1.1 + vitest: link:../../packages/vitest + packages: /@adobe/css-tools/4.0.1: @@ -14627,8 +14635,8 @@ packages: - encoding dev: true - /happy-dom/9.1.7: - resolution: {integrity: sha512-tLkzW0w9EclIsV75hlCFStJa7CYSEUe+OVU8vK+3wzSvzFeXrGnCujuMcYQAPUXDl1CXoQ2ySaTZcqt3ZBJbSw==} + /happy-dom/9.1.9: + resolution: {integrity: sha512-OMbnoknA7iNNG/5fwt1JckCKc53QLLFo2ljzit1pCV9SC1TYwcQj0obq0QUTeqIf2p2skbFG69bo19YoSj/1DA==} dependencies: css.escape: 1.5.1 he: 1.2.0 diff --git a/test/core/test/sequencers.test.ts b/test/core/test/sequencers.test.ts index c920b142b91f..a6e5627e0a62 100644 --- a/test/core/test/sequencers.test.ts +++ b/test/core/test/sequencers.test.ts @@ -2,6 +2,8 @@ import type { Vitest } from 'vitest' import { describe, expect, test, vi } from 'vitest' import { RandomSequencer } from 'vitest/src/node/sequencers/RandomSequencer' import { BaseSequencer } from 'vitest/src/node/sequencers/BaseSequencer' +import type { VitestWorkspace } from 'vitest/node' +import type { WorkspaceSpec } from 'vitest/src/node/pool' function buildCtx() { return { @@ -15,10 +17,22 @@ function buildCtx() { } as unknown as Vitest } +function buildWorkspace() { + return { + getName: () => 'test', + } as any as VitestWorkspace +} + +const workspace = buildWorkspace() + +function workspaced(files: string[]) { + return files.map(file => [workspace, file] as WorkspaceSpec) +} + describe('base sequencer', () => { test('sorting when no info is available', async () => { const sequencer = new BaseSequencer(buildCtx()) - const files = ['a', 'b', 'c'] + const files = workspaced(['a', 'b', 'c']) const sorted = await sequencer.sort(files) expect(sorted).toStrictEqual(files) }) @@ -26,77 +40,77 @@ describe('base sequencer', () => { test('prioritize unknown files', async () => { const ctx = buildCtx() vi.spyOn(ctx.cache, 'getFileStats').mockImplementation((file) => { - if (file === 'b') + if (file === 'test:b') return { size: 2 } }) const sequencer = new BaseSequencer(ctx) - const files = ['b', 'a', 'c'] + const files = workspaced(['b', 'a', 'c']) const sorted = await sequencer.sort(files) - expect(sorted).toStrictEqual(['a', 'c', 'b']) + expect(sorted).toStrictEqual(workspaced(['a', 'c', 'b'])) }) test('sort by size, larger first', async () => { const ctx = buildCtx() vi.spyOn(ctx.cache, 'getFileStats').mockImplementation((file) => { - if (file === 'a') + if (file === 'test:a') return { size: 1 } - if (file === 'b') + if (file === 'test:b') return { size: 2 } - if (file === 'c') + if (file === 'test:c') return { size: 3 } }) const sequencer = new BaseSequencer(ctx) - const files = ['b', 'a', 'c'] + const files = workspaced(['b', 'a', 'c']) const sorted = await sequencer.sort(files) - expect(sorted).toStrictEqual(['c', 'b', 'a']) + expect(sorted).toStrictEqual(workspaced(['c', 'b', 'a'])) }) test('sort by results, failed first', async () => { const ctx = buildCtx() vi.spyOn(ctx.cache, 'getFileTestResults').mockImplementation((file) => { - if (file === 'a') + if (file === 'test:a') return { failed: false, duration: 1 } - if (file === 'b') + if (file === 'test:b') return { failed: true, duration: 1 } - if (file === 'c') + if (file === 'test:c') return { failed: true, duration: 1 } }) const sequencer = new BaseSequencer(ctx) - const files = ['b', 'a', 'c'] + const files = workspaced(['b', 'a', 'c']) const sorted = await sequencer.sort(files) - expect(sorted).toStrictEqual(['b', 'c', 'a']) + expect(sorted).toStrictEqual(workspaced(['b', 'c', 'a'])) }) test('sort by results, long first', async () => { const ctx = buildCtx() vi.spyOn(ctx.cache, 'getFileTestResults').mockImplementation((file) => { - if (file === 'a') + if (file === 'test:a') return { failed: true, duration: 1 } - if (file === 'b') + if (file === 'test:b') return { failed: true, duration: 2 } - if (file === 'c') + if (file === 'test:c') return { failed: true, duration: 3 } }) const sequencer = new BaseSequencer(ctx) - const files = ['b', 'a', 'c'] + const files = workspaced(['b', 'a', 'c']) const sorted = await sequencer.sort(files) - expect(sorted).toStrictEqual(['c', 'b', 'a']) + expect(sorted).toStrictEqual(workspaced(['c', 'b', 'a'])) }) test('sort by results, long and failed first', async () => { const ctx = buildCtx() vi.spyOn(ctx.cache, 'getFileTestResults').mockImplementation((file) => { - if (file === 'a') + if (file === 'test:a') return { failed: false, duration: 1 } - if (file === 'b') + if (file === 'test:b') return { failed: false, duration: 6 } - if (file === 'c') + if (file === 'test:c') return { failed: true, duration: 3 } }) const sequencer = new BaseSequencer(ctx) - const files = ['b', 'a', 'c'] + const files = workspaced(['b', 'a', 'c']) const sorted = await sequencer.sort(files) - expect(sorted).toStrictEqual(['c', 'b', 'a']) + expect(sorted).toStrictEqual(workspaced(['c', 'b', 'a'])) }) }) @@ -105,8 +119,8 @@ describe('random sequencer', () => { const ctx = buildCtx() ctx.config.sequence.seed = 101 const sequencer = new RandomSequencer(ctx) - const files = ['b', 'a', 'c'] + const files = workspaced(['b', 'a', 'c']) const sorted = await sequencer.sort(files) - expect(sorted).toStrictEqual(['a', 'c', 'b']) + expect(sorted).toStrictEqual(workspaced(['a', 'c', 'b'])) }) }) diff --git a/test/reporters/src/context.ts b/test/reporters/src/context.ts index 69276ca7e2e8..ba7be09b6e0b 100644 --- a/test/reporters/src/context.ts +++ b/test/reporters/src/context.ts @@ -25,7 +25,7 @@ export function getContext(): Context { } const state: Partial = { - filesMap: new Map(), + filesMap: new Map(), } const context: Partial = { diff --git a/test/reporters/tests/__snapshots__/html.test.ts.snap b/test/reporters/tests/__snapshots__/html.test.ts.snap index 113fe6e92fa4..a0373aab6af9 100644 --- a/test/reporters/tests/__snapshots__/html.test.ts.snap +++ b/test/reporters/tests/__snapshots__/html.test.ts.snap @@ -6,10 +6,12 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail" "files": [ { "collectDuration": 0, + "environmentLoad": 0, "filepath": "/test/reporters/fixtures/json-fail.test.ts", "id": 0, "mode": "run", "name": "json-fail.test.ts", + "prepareDuration": 0, "result": { "duration": 0, "hooks": { @@ -125,10 +127,12 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing "files": [ { "collectDuration": 0, + "environmentLoad": 0, "filepath": "/test/reporters/fixtures/all-passing-or-skipped.test.ts", "id": 0, "mode": "run", "name": "all-passing-or-skipped.test.ts", + "prepareDuration": 0, "result": { "duration": 0, "hooks": { diff --git a/test/reporters/tests/html.test.ts b/test/reporters/tests/html.test.ts index 3fdd7d2e0cd9..be05c6524642 100644 --- a/test/reporters/tests/html.test.ts +++ b/test/reporters/tests/html.test.ts @@ -30,6 +30,8 @@ describe.skipIf(skip)('html reporter', async () => { const file = resultJson.files[0] file.id = 0 file.collectDuration = 0 + file.environmentLoad = 0 + file.prepareDuration = 0 file.setupDuration = 0 file.result.duration = 0 file.result.startTime = 0 @@ -62,6 +64,8 @@ describe.skipIf(skip)('html reporter', async () => { const file = resultJson.files[0] file.id = 0 file.collectDuration = 0 + file.environmentLoad = 0 + file.prepareDuration = 0 file.setupDuration = 0 file.result.duration = 0 file.result.startTime = 0 diff --git a/test/single-thread/vitest.config.ts b/test/single-thread/vitest.config.ts index 686b625d344b..12a6bc2f6a87 100644 --- a/test/single-thread/vitest.config.ts +++ b/test/single-thread/vitest.config.ts @@ -1,12 +1,12 @@ import { defineConfig } from 'vite' -import { BaseSequencer } from 'vitest/node' +import { BaseSequencer, type WorkspaceSpec } from 'vitest/node' export default defineConfig({ test: { threads: false, sequence: { sequencer: class Sequences extends BaseSequencer { - public async sort(files: string[]): Promise { + public async sort(files: WorkspaceSpec[]): Promise { return files.sort() } }, diff --git a/test/watch/test/file-watching.test.ts b/test/watch/test/file-watching.test.ts index 5c173524d2e6..1d01e0ac2330 100644 --- a/test/watch/test/file-watching.test.ts +++ b/test/watch/test/file-watching.test.ts @@ -31,7 +31,7 @@ test('editing source file triggers re-run', async () => { writeFileSync(sourceFile, editFile(sourceFileContent), 'utf8') await vitest.waitForOutput('New code running') - await vitest.waitForOutput('RERUN math.ts') + await vitest.waitForOutput('RERUN ../math.ts') await vitest.waitForOutput('1 passed') }) @@ -41,7 +41,7 @@ test('editing test file triggers re-run', async () => { writeFileSync(testFile, editFile(testFileContent), 'utf8') await vitest.waitForOutput('New code running') - await vitest.waitForOutput('RERUN math.test.ts') + await vitest.waitForOutput('RERUN ../math.test.ts') await vitest.waitForOutput('1 passed') }) @@ -71,7 +71,7 @@ describe('browser', () => { writeFileSync(sourceFile, editFile(sourceFileContent), 'utf8') await vitest.waitForOutput('New code running') - await vitest.waitForOutput('RERUN math.ts') + await vitest.waitForOutput('RERUN ../math.ts') await vitest.waitForOutput('1 passed') vitest.write('q') diff --git a/test/watch/test/utils.ts b/test/watch/test/utils.ts index e3cdb0724337..4f3b12954daf 100644 --- a/test/watch/test/utils.ts +++ b/test/watch/test/utils.ts @@ -1,9 +1,12 @@ import { afterEach } from 'vitest' -import { execa } from 'execa' +import { type Options, execa } from 'execa' import stripAnsi from 'strip-ansi' -export async function startWatchMode(...args: string[]) { - const subprocess = execa('vitest', ['--root', 'fixtures', ...args]) +export async function startWatchMode(options?: Options | string, ...args: string[]) { + if (typeof options === 'string') + args.unshift(options) + const argsWithRoot = args.includes('--root') ? args : ['--root', 'fixtures', ...args] + const subprocess = execa('vitest', argsWithRoot, typeof options === 'string' ? undefined : options) let setDone: (value?: unknown) => void const isDone = new Promise(resolve => (setDone = resolve)) @@ -23,7 +26,7 @@ export async function startWatchMode(...args: string[]) { const timeout = setTimeout(() => { reject(new Error(`Timeout when waiting for output "${expected}".\nReceived:\n${this.output}`)) - }, 20_000) + }, process.env.CI ? 20_000 : 4_000) const listener = () => { if (this.output.includes(expected)) { diff --git a/test/watch/test/workspaces.test.ts b/test/watch/test/workspaces.test.ts new file mode 100644 index 000000000000..a9bc61f9f749 --- /dev/null +++ b/test/watch/test/workspaces.test.ts @@ -0,0 +1,67 @@ +import { fileURLToPath } from 'node:url' +import { readFileSync, writeFileSync } from 'node:fs' +import { afterAll, it } from 'vitest' +import { dirname, resolve } from 'pathe' +import { startWatchMode } from './utils' + +const file = fileURLToPath(import.meta.url) +const dir = dirname(file) +const root = resolve(dir, '..', '..', 'workspaces') +const config = resolve(root, 'vitest.config.ts') + +const srcMathFile = resolve(root, 'src', 'math.ts') +const specSpace2File = resolve(root, 'space_2', 'test', 'node.spec.ts') + +const srcMathContent = readFileSync(srcMathFile, 'utf-8') +const specSpace2Content = readFileSync(specSpace2File, 'utf-8') + +function startVitest() { + return startWatchMode( + { cwd: root, env: { TEST_WATCH: 'true' } }, + '--root', + root, + '--config', + config, + '--watch', + '--no-coverage', + ) +} + +afterAll(() => { + writeFileSync(srcMathFile, srcMathContent, 'utf8') + writeFileSync(specSpace2File, specSpace2Content, 'utf8') +}) + +it('editing a test file in a suite with workspaces reruns test', async () => { + const vitest = await startVitest() + + writeFileSync(specSpace2File, `${specSpace2Content}\n`, 'utf8') + + await vitest.waitForOutput('RERUN space_2/test/node.spec.ts x1') + await vitest.waitForOutput('|space_2| test/node.spec.ts') + await vitest.waitForOutput('Test Files 1 passed') +}) + +it('editing a file that is imported in different workspaces reruns both files', async () => { + const vitest = await startVitest() + + writeFileSync(srcMathFile, `${srcMathContent}\n`, 'utf8') + + await vitest.waitForOutput('RERUN src/math.ts') + await vitest.waitForOutput('|space_3| math.space-test.ts') + await vitest.waitForOutput('|space_1| test/math.spec.ts') + await vitest.waitForOutput('Test Files 2 passed') +}) + +it('filters by test name inside a workspace', async () => { + const vitest = await startVitest() + + vitest.write('t') + + await vitest.waitForOutput('Input test name pattern') + + vitest.write('2 x 2 = 4\n') + + await vitest.waitForOutput('Test name pattern: /2 x 2 = 4/') + await vitest.waitForOutput('Test Files 1 passed') +}) diff --git a/test/watch/vitest.config.ts b/test/watch/vitest.config.ts index 9ef05a07f993..48120e0f368d 100644 --- a/test/watch/vitest.config.ts +++ b/test/watch/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ include: ['test/**/*.test.*'], // For Windows CI mostly - testTimeout: 30_000, + testTimeout: process.env.CI ? 30_000 : 10_000, // Test cases may have side effects, e.g. files under fixtures/ are modified on the fly to trigger file watchers singleThread: true, diff --git a/test/workspaces/.gitignore b/test/workspaces/.gitignore new file mode 100644 index 000000000000..4e711a62f292 --- /dev/null +++ b/test/workspaces/.gitignore @@ -0,0 +1 @@ +results.json \ No newline at end of file diff --git a/test/workspaces/globalTest.ts b/test/workspaces/globalTest.ts new file mode 100644 index 000000000000..14b129fb4b27 --- /dev/null +++ b/test/workspaces/globalTest.ts @@ -0,0 +1,21 @@ +import { readFile } from 'node:fs/promises' +import assert from 'node:assert/strict' + +export async function teardown() { + const results = JSON.parse(await readFile('./results.json', 'utf-8')) + + try { + assert.ok(results.success) + assert.equal(results.numTotalTestSuites, 6) + assert.equal(results.numTotalTests, 7) + assert.equal(results.numPassedTests, 7) + + const shared = results.testResults.filter((r: any) => r.name.includes('space_shared/test.spec.ts')) + + assert.equal(shared.length, 2) + } + catch (err) { + console.error(err) + process.exit(1) + } +} diff --git a/test/workspaces/package.json b/test/workspaces/package.json new file mode 100644 index 000000000000..0b411b9d6126 --- /dev/null +++ b/test/workspaces/package.json @@ -0,0 +1,13 @@ +{ + "name": "@vitest/test-workspaces", + "type": "module", + "private": true, + "scripts": { + "test": "vitest", + "coverage": "vitest run --coverage" + }, + "devDependencies": { + "jsdom": "latest", + "vitest": "workspace:*" + } +} diff --git a/test/workspaces/space_1/test/happy-dom.spec.ts b/test/workspaces/space_1/test/happy-dom.spec.ts new file mode 100644 index 000000000000..354988050d80 --- /dev/null +++ b/test/workspaces/space_1/test/happy-dom.spec.ts @@ -0,0 +1,5 @@ +import { expect, test } from 'vitest' + +test('window is defined', () => { + expect(window).toBeDefined() +}) diff --git a/test/workspaces/space_1/test/math.spec.ts b/test/workspaces/space_1/test/math.spec.ts new file mode 100644 index 000000000000..89170fcbbddd --- /dev/null +++ b/test/workspaces/space_1/test/math.spec.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { sum } from '../../src/math' + +test('3 + 3 = 6', () => { + expect(sum(3, 3)).toBe(6) +}) diff --git a/test/workspaces/space_1/vite.config.ts b/test/workspaces/space_1/vite.config.ts new file mode 100644 index 000000000000..23a2b17accb3 --- /dev/null +++ b/test/workspaces/space_1/vite.config.ts @@ -0,0 +1,8 @@ +import { defineProject } from 'vitest/config' + +export default defineProject({ + test: { + name: 'space_1', + environment: 'happy-dom', + }, +}) diff --git a/test/workspaces/space_2/test/node.spec.ts b/test/workspaces/space_2/test/node.spec.ts new file mode 100644 index 000000000000..cb2b4e16801e --- /dev/null +++ b/test/workspaces/space_2/test/node.spec.ts @@ -0,0 +1,5 @@ +import { expect, test } from 'vitest' + +test('window is not defined', () => { + expect(typeof window).toBe('undefined') +}) diff --git a/test/workspaces/space_3/math.space-test.ts b/test/workspaces/space_3/math.space-test.ts new file mode 100644 index 000000000000..0036634657d8 --- /dev/null +++ b/test/workspaces/space_3/math.space-test.ts @@ -0,0 +1,11 @@ +import { expect, test } from 'vitest' +import { sum } from '../src/math' +import { multiple } from './src/multiply' + +test('2 x 2 = 4', () => { + expect(multiple(2, 2)).toBe(4) +}) + +test('2 + 2 = 4', () => { + expect(sum(2, 2)).toBe(4) +}) diff --git a/test/workspaces/space_3/src/multiply.ts b/test/workspaces/space_3/src/multiply.ts new file mode 100644 index 000000000000..14df4d3cc86e --- /dev/null +++ b/test/workspaces/space_3/src/multiply.ts @@ -0,0 +1,3 @@ +export function multiple(a: number, b: number) { + return a * b +} diff --git a/test/workspaces/space_3/vitest.config.ts b/test/workspaces/space_3/vitest.config.ts new file mode 100644 index 000000000000..a36bb28d0a0b --- /dev/null +++ b/test/workspaces/space_3/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config' + +export default defineProject({ + test: { + include: ['**/*.space-test.ts'], + name: 'space_3', + environment: 'node', + }, +}) diff --git a/test/workspaces/space_shared/setup.jsdom.ts b/test/workspaces/space_shared/setup.jsdom.ts new file mode 100644 index 000000000000..c3ac6eeb2dc9 --- /dev/null +++ b/test/workspaces/space_shared/setup.jsdom.ts @@ -0,0 +1 @@ +Object.defineProperty(global, 'testValue', { value: 'jsdom' }) diff --git a/test/workspaces/space_shared/setup.node.ts b/test/workspaces/space_shared/setup.node.ts new file mode 100644 index 000000000000..5250360056cb --- /dev/null +++ b/test/workspaces/space_shared/setup.node.ts @@ -0,0 +1 @@ +Object.defineProperty(global, 'testValue', { value: 'node' }) diff --git a/test/workspaces/space_shared/test.spec.ts b/test/workspaces/space_shared/test.spec.ts new file mode 100644 index 000000000000..c152931a1345 --- /dev/null +++ b/test/workspaces/space_shared/test.spec.ts @@ -0,0 +1,9 @@ +import { expect, it } from 'vitest' + +declare global { + const testValue: string +} + +it('the same file works with different projects', () => { + expect(testValue).toBe(expect.getState().environment === 'node' ? 'node' : 'jsdom') +}) diff --git a/test/workspaces/src/math.ts b/test/workspaces/src/math.ts new file mode 100644 index 000000000000..5d8550bb9d4d --- /dev/null +++ b/test/workspaces/src/math.ts @@ -0,0 +1,3 @@ +export function sum(a: number, b: number) { + return a + b +} diff --git a/test/workspaces/vitest.config.ts b/test/workspaces/vitest.config.ts new file mode 100644 index 000000000000..977555a2c478 --- /dev/null +++ b/test/workspaces/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' + +if (process.env.TEST_WATCH) { + // Patch stdin on the process so that we can fake it to seem like a real interactive terminal and pass the TTY checks + process.stdin.isTTY = true + process.stdin.setRawMode = () => process.stdin +} + +export default defineConfig({ + test: { + coverage: { + all: true, + }, + reporters: ['default', 'json'], + outputFile: './results.json', + globalSetup: './globalTest.ts', + }, +}) diff --git a/test/workspaces/vitest.workspace.ts b/test/workspaces/vitest.workspace.ts new file mode 100644 index 000000000000..4c31ef3957cb --- /dev/null +++ b/test/workspaces/vitest.workspace.ts @@ -0,0 +1,22 @@ +import { defineWorkspace } from 'vitest/config' + +export default defineWorkspace([ + './space_2/*', + './space_*/*.config.ts', + { + test: { + name: 'happy-dom', + root: './space_shared', + environment: 'happy-dom', + setupFiles: ['./setup.jsdom.ts'], + }, + }, + { + test: { + name: 'node', + root: './space_shared', + environment: 'node', + setupFiles: ['./setup.node.ts'], + }, + }, +])