diff --git a/docs/api/advanced/plugin.md b/docs/api/advanced/plugin.md index dbdc1111f..a176f8303 100644 --- a/docs/api/advanced/plugin.md +++ b/docs/api/advanced/plugin.md @@ -117,11 +117,11 @@ Note that this will only affect projects injected with [`injectTestProjects`](#i ::: ::: tip Referencing the Current Config -If you want to keep the user configuration, you can specify the `extends` property. All other properties will be merged with the user defined config. +Inline configurations inherit the root config by default. If you want to inherit a specific configuration file instead, set the `extends` property to its path. All other properties will be merged with the user defined config. The project's `configFile` can be accessed in Vite's config: `project.vite.config.configFile`. -Note that this will also inherit the `name` - Vitest doesn't allow multiple projects with the same name, so this will throw an error. Make sure you specified a different name. You can access the current name via the `project.name` property and all used names are available in the `vitest.projects` array. +Note that the `name` is never inherited because Vitest doesn't allow multiple projects with the same name. Make sure every project has a unique name. You can access the current name via the `project.name` property and all used names are available in the `vitest.projects` array. ::: ### defineCacheKeyGenerator 5.0.0 {#definecachekeygenerator} diff --git a/docs/guide/advanced/index.md b/docs/guide/advanced/index.md index ac83e0b53..1b7d3cc5a 100644 --- a/docs/guide/advanced/index.md +++ b/docs/guide/advanced/index.md @@ -130,17 +130,20 @@ The root configuration is resolved from three inputs, in ascending priority: Every project then resolves its own Vite config independently: - A project referenced as a config file or a directory resolves only its own file. It does not inherit any options from the root configuration. -- An inline project with the [`extends`](/guide/projects#configuration) option re-executes the extended config file and merges the project's own options on top. Only the config **file** participates in this: `viteOverrides` and CLI options are not part of the file, so `extends` does not carry them into projects. +- An inline project inherits the root configuration by default (see [`extends`](/guide/projects#configuration)): the root config file is re-executed for the project, `viteOverrides` are merged on top of it, and the project's own options are merged last. Inheritance works even when there is no root config file, because `viteOverrides` are part of the effective root configuration. +- With `extends: false`, an inline project resolves only its own options. With `extends: './path'`, the referenced file is re-executed instead of the root config file, and `viteOverrides` are not merged. -Independently of `extends`, several groups of options reach every project: +A few options are excluded from inheritance: -- A fixed subset of CLI options that configure how tests run (`--testTimeout`, `--retry`, `--pool`, and similar) is applied to every project at the highest priority, mirroring the root resolution. -- Run-level options only make sense for the test run as a whole: every project receives the root's resolved `coverage`, `attachmentsDir`, and `mergeReportsLabel` values. -- The root's `fsModuleCache`, `fsModuleCachePath`, `experimental.viteModuleRunner`, `experimental.nodeLoader`, and `experimental.importDurations` values are applied as defaults to every project that doesn't define them. +- `plugins` from `viteOverrides` are never inherited. A config file is re-executed for every project, which creates fresh plugin instances, but plugin instances passed in `viteOverrides` belong to the root Vite server and cannot be shared with project servers. +- `test.browser` and `test.tagsFilter` from `viteOverrides` are never inherited: `browser` describes the instances of a single project, and `tagsFilter` applies to the whole run. +- `name` and `projects` are never inherited; the root `globalSetup` is not inherited because it already runs once per test run. +- The project's own `tags` always replace the `tags` array merged from an extended config instead of being concatenated with it, so the same tag names can be redefined. -The project's own `tags` always replace the `tags` array merged from an extended config instead of being concatenated with it, so the same tag names can be redefined. +Independently of `extends`, two groups of options reach every project: -Note that `plugins` reach a project only through a config file: the file is re-executed for every project, which creates fresh plugin instances. Plugin instances passed in `viteOverrides` belong to the root Vite server and are never shared with project servers. +- A fixed subset of CLI options that configure how tests run (`--testTimeout`, `--retry`, `--pool`, and similar) is applied to every project at the highest priority, mirroring the root resolution. +- Run-level options only make sense for the test run as a whole: every project receives the root's resolved `coverage`, `attachmentsDir`, and `mergeReportsLabel` values. ## parseCLI diff --git a/docs/guide/advanced/pool.md b/docs/guide/advanced/pool.md index cebe4aea9..5ae745c19 100644 --- a/docs/guide/advanced/pool.md +++ b/docs/guide/advanced/pool.md @@ -43,13 +43,11 @@ export default defineConfig({ test: { projects: [ { - extends: true, test: { pool: 'threads', }, }, { - extends: true, test: { pool: customPool({ customProperty: true, diff --git a/docs/guide/browser/visual-regression-testing.md b/docs/guide/browser/visual-regression-testing.md index a2242f584..2156e42cf 100644 --- a/docs/guide/browser/visual-regression-testing.md +++ b/docs/guide/browser/visual-regression-testing.md @@ -82,14 +82,12 @@ export default defineConfig({ // ...other configurations projects: [ { - extends: true, test: { name: 'unit', exclude: [vrtPattern, ...defaultExclude], }, }, { - extends: true, test: { name: 'vrt', browser: { @@ -675,7 +673,6 @@ export default defineConfig({ // ...other configurations projects: [ { - extends: true, test: { name: 'vrt', browser: { diff --git a/docs/guide/migration.md b/docs/guide/migration.md index 972aa5927..015d29fd0 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -56,6 +56,60 @@ export default defineConfig({ }) ``` +### Inline Projects Inherit the Root Config by Default + +The [`extends`](/guide/projects#configuration) option now defaults to `true`: every project defined as an inline configuration in [`test.projects`](/guide/projects) inherits all options from the root configuration, including Vite options like `plugins` or `resolve.alias`. The options are merged with the same rules that applied to an explicit `extends: true` in Vitest 4: + +```ts [vitest.config.ts] +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + projects: [ + { + // v4: this project didn't apply the react plugin + // v5: the plugin is inherited from the root config + test: { + name: 'unit', + include: ['**/*.unit.test.ts'], + }, + }, + ], + }, +}) +``` + +A few options are excluded because they are always scoped to a single project or to the whole test run: + +- `name` and `projects` are never inherited. +- `globalSetup` is not inherited from the root config: the root-level `globalSetup` already runs once per test run, so inheriting it would run the same files again for every project. It is still inherited when extending a non-root config file. +- The project's own `tags` replace the inherited array instead of being merged with it. + +Projects referenced as config files or directories are not affected; they still don't inherit any options from the root config. + +Keep in mind that arrays are merged, not overridden. For example, if the root config defines `setupFiles`, the project's own `setupFiles` are appended to the inherited ones. If you need the previous behavior, set `extends: false` in the project configuration: + +```ts [vitest.config.ts] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + setupFiles: ['./setup.global.ts'], + projects: [ + { + extends: false, // [!code ++] + test: { + name: 'unit', + setupFiles: ['./setup.unit.ts'], + }, + }, + ], + }, +}) +``` + ### Hoisted Mocking Calls Must Be at the Top Level [`vi.mock`](/api/vi#vi-mock), [`vi.unmock`](/api/vi#vi-unmock), and [`vi.hoisted`](/api/vi#vi-hoisted) are hoisted to the top of the file and run before any surrounding code. Calling them inside a function, block, or `describe`/`test` callback previously only logged a warning. Vitest 5.0 now throws, because the call does not execute where it is written: diff --git a/docs/guide/projects.md b/docs/guide/projects.md index 794a41ddd..e2c22b160 100644 --- a/docs/guide/projects.md +++ b/docs/guide/projects.md @@ -126,8 +126,8 @@ export default defineConfig({ // matches every folder and file inside the `packages` folder 'packages/*', { - // add "extends: true" to inherit the options from the root config - extends: true, + // inline projects inherit the options + // from this config file by default test: { include: ['tests/**/*.{browser}.test.{ts,js}'], // it is recommended to define a name when using inline configs @@ -136,6 +136,9 @@ export default defineConfig({ } }, { + // add "extends: false" to ignore + // the options defined in this config file + extends: false, test: { include: ['tests/**/*.{node}.test.{ts,js}'], // color of the name label can be changed @@ -234,23 +237,7 @@ bun run test --project e2e --project unit ## 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: - -```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', - } - }) -) -``` - -Additionally, you can use the `extends` option to inherit from your root-level configuration. All options will be merged. +Projects defined with an inline configuration inherit all options from the root-level configuration. This is controlled by the `extends` option, which is enabled by default since Vitest 5.0: ```ts [vitest.config.ts] import { defineConfig } from 'vitest/config' @@ -262,8 +249,8 @@ export default defineConfig({ pool: 'threads', projects: [ { - // will inherit options from this config like plugins and pool - extends: true, + // inherits options from this config like plugins and pool + // (`extends: true` is the default) test: { name: 'unit', include: ['**/*.unit.test.ts'], @@ -271,7 +258,6 @@ export default defineConfig({ }, { // won't inherit any options from this config - // this is the default behaviour extends: false, test: { name: 'integration', @@ -283,6 +269,50 @@ export default defineConfig({ }) ``` +The `extends` option also accepts a path to another config file if you want to inherit options from a config file other than the root config: + +```ts [vitest.config.ts] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + projects: [ + { + extends: './vitest.shared.ts', + test: { + name: 'unit', + include: ['**/*.unit.test.ts'], + }, + }, + ], + }, +}) +``` + +All options from the extended config are merged with the project's own options. Note that arrays like `setupFiles` are concatenated, not overridden. A few options are treated specially: + +- `name` and `projects` are never inherited. +- `globalSetup` is not inherited from the root config: the root-level `globalSetup` already runs once per test run, so inheriting it would run the same files again for every project. It is still inherited when extending a non-root config file. +- The project's own `tags` replace the inherited array instead of being merged with it. + +If you run Vitest through the [advanced API](/guide/advanced/), see [Project Configuration Resolution](/guide/advanced/#project-configuration-resolution) for how the programmatic configuration participates in inheritance. + +Projects referenced as config files or directories do not inherit any options from the root config. You can create a shared config file and merge it with the project config yourself: + +```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', + } + }) +) +``` + ::: danger Unsupported Options Some of the configuration options are not allowed in a project config. Most notably: diff --git a/packages/vitest/src/node/plugins/workspace.ts b/packages/vitest/src/node/plugins/workspace.ts index 8ff92afd1..0949aaef4 100644 --- a/packages/vitest/src/node/plugins/workspace.ts +++ b/packages/vitest/src/node/plugins/workspace.ts @@ -46,27 +46,6 @@ export function WorkspaceVitestPlugin( }, } - // always inherit the global `fsModuleCache` values even without `extends: true` - if (testConfig.fsModuleCache == null && globalConfig.fsModuleCache != null) { - testConfig.fsModuleCache = globalConfig.fsModuleCache - } - if (testConfig.fsModuleCachePath == null && globalConfig.fsModuleCachePath != null) { - testConfig.fsModuleCachePath = globalConfig.fsModuleCachePath - } - - // TODO: remove this after "extends: false" is flipped - testConfig.experimental ??= {} - - if (testConfig.experimental?.viteModuleRunner == null && globalConfig.experimental?.viteModuleRunner != null) { - testConfig.experimental.viteModuleRunner = globalConfig.experimental.viteModuleRunner - } - if (testConfig.experimental?.nodeLoader == null && globalConfig.experimental?.nodeLoader != null) { - testConfig.experimental.nodeLoader = globalConfig.experimental.nodeLoader - } - if (testConfig.experimental?.importDurations == null && globalConfig.experimental?.importDurations != null) { - testConfig.experimental.importDurations = globalConfig.experimental.importDurations - } - return config }, configResolved(config) { diff --git a/packages/vitest/src/node/projects/resolveProjects.ts b/packages/vitest/src/node/projects/resolveProjects.ts index 262de3eaa..77fde1a0a 100644 --- a/packages/vitest/src/node/projects/resolveProjects.ts +++ b/packages/vitest/src/node/projects/resolveProjects.ts @@ -2,6 +2,7 @@ import type { GlobOptions } from 'tinyglobby' import type { ResolvedConfig as ResolvedViteConfig, InlineConfig as ViteInlineConfig, + Plugin as VitePlugin, } from 'vite' import type { PluginHarness } from '../config/pluginHarness' import type { Vitest } from '../core' @@ -62,6 +63,9 @@ const PROJECT_CLI_OVERRIDES = [ 'fileParallelism', 'tagsFilter', 'browser', + 'experimental', + 'fsModuleCache', + 'fsModuleCachePath', ] as const /** @@ -118,17 +122,19 @@ export async function resolveProjectEntries( } const existing = seenNames.get(name) if (existing) { - const entryFile = entry.viteConfig.configFile + // inline entries carry the configFile they extend, which doesn't say + // where the project is declared, so they are reported without a file + const entryFile = !entry.inline && entry.viteConfig.configFile ? relative(globalConfig.root, entry.viteConfig.configFile) : '' - const existingFile = existing.viteConfig.configFile + const existingFile = !existing.inline && existing.viteConfig.configFile ? relative(globalConfig.root, existing.viteConfig.configFile) : '' const filesError = baseEntries.length > 1 && (entryFile || existingFile) ? [ '\n\nYour config matched these files:\n', baseEntries - .filter(e => e.viteConfig.configFile) + .filter(e => !e.inline && e.viteConfig.configFile) .map(e => ` - ${relative(globalConfig.root, e.viteConfig.configFile as string)}`) .join('\n'), '\n\n', @@ -282,7 +288,7 @@ async function resolveDeclaredProjectEntries( // if extends a config file, resolve the file path const configFile = typeof options.extends === 'string' ? resolve(configRoot, options.extends) - : options.extends === true + : options.extends !== false ? (globalViteConfig.configFile || false) : false // if `root` is configured, resolve it relative to vite root (like other options) @@ -345,11 +351,77 @@ async function resolveDeclaredProjectEntries( return entries } +// `name` must stay unique per project, `projects` would redefine the whole workspace +const NON_INHERITED_OPTIONS = ['name', 'projects'] as const + +// the root `globalSetup` already runs once per test run; a non-root +// config keeps it because nothing else runs it +const NON_INHERITED_ROOT_OPTIONS = [...NON_INHERITED_OPTIONS, 'globalSetup'] as const + +function ProjectInheritancePlugin(options: ViteInlineConfig, extendsRootConfig: boolean): VitePlugin { + const nonInheritedOptions = extendsRootConfig + ? NON_INHERITED_ROOT_OPTIONS + : NON_INHERITED_OPTIONS + return { + name: 'vitest:project-inheritance', + enforce: 'pre', + config: { + // run before other `config` hooks so only the values merged from the + // extended config file are removed, not the values set by plugins + order: 'pre', + handler(config) { + config.test ??= {} + // the project's own `tags` replace the inherited array so tags can be overridden + if (options.test?.tags) { + config.test.tags = options.test.tags + } + for (const key of nonInheritedOptions) { + if (options.test?.[key] !== undefined) { + (config.test as any)[key] = options.test[key] + } + else { + delete config.test[key] + } + } + }, + }, + api: { + vitest: { + ignoreFsModuleCache: true, + }, + }, + } +} + +/** + * Merges the programmatic config passed to `createVitest` into an extending + * project's options. The programmatic config is part of the effective root + * config, so a project inherits it even when the root config file doesn't + * exist. Some options never transfer to a project: + * - `plugins` are live instances owned by the root server + * - `tagsFilter` is CLI-only; `PROJECT_CLI_OVERRIDES` applies it per project + * - `browser` describes the instances of a single project; inheriting it + * would create duplicate instance names (the `--browser` flags have the + * same guard in `CliOverride`) + */ +function inheritRootViteOverrides( + globalConfig: ResolvedConfig, + options: ViteInlineConfig, +): ViteInlineConfig { + const { plugins: _plugins, ...rootViteOverrides } = globalConfig.viteOverrides + // cloned so plugins that mutate inherited arrays in place don't share + // them between the root and every project + const inherited = deepClone(rootViteOverrides) + delete (inherited.test as UserConfig | undefined)?.tagsFilter + delete (inherited.test as UserConfig | undefined)?.browser + return mergeConfig(inherited, options) +} + async function resolveSingleProjectEntry( harness: PluginHarness, globalViteConfig: ResolvedViteConfig, globalConfig: ResolvedConfig, - options: ViteInlineConfig, + options: ViteInlineConfig & { extends?: string | boolean }, workspacePath: string | number, cliOverrides: UserConfig, ): Promise { @@ -357,8 +429,22 @@ async function resolveSingleProjectEntry( const browserHolder: BrowserContributionHolder = {} + // only inline entries (keyed by their index) extend another config; + // file-based projects own all of their values + const isInlineEntry = typeof workspacePath === 'number' + const inheritsRootConfig = isInlineEntry + && options.extends !== false + && typeof options.extends !== 'string' + // `extends: './path'` can still point back to the root config file + const extendsRootConfig = inheritsRootConfig + || (!!configFile && configFile === globalViteConfig.configFile) + + const inlineOptions = inheritsRootConfig + ? inheritRootViteOverrides(globalConfig, restOptions) + : restOptions + const projectInline: ViteInlineConfig = { - ...restOptions, + ...inlineOptions, configFile, configLoader: globalViteConfig.inlineConfig.configLoader, // this will make "mode": "test" inside defineConfig @@ -373,23 +459,9 @@ async function resolveSingleProjectEntry( options, ), ...BrowserLoaderPlugin(browserHolder, harness), - { - name: 'vitest:tags', - config(config) { - // We need to keep the `tags` array untouched if `extends` is `true`, - // Otherwise it gets merged with the top level tags and we don't want that because tags could be overridden - // Setting it to `options.test?.tags` overrides the merged value - if (options.test?.tags) { - config.test ??= {} - config.test.tags = options.test?.tags - } - }, - api: { - vitest: { - ignoreFsModuleCache: true, - }, - }, - }, + ...(isInlineEntry + ? [ProjectInheritancePlugin(options, extendsRootConfig)] + : []), ], } @@ -423,6 +495,7 @@ async function resolveSingleProjectEntry( return { viteConfig: projectViteConfig, projectConfig, + inline: isInlineEntry, } } @@ -732,7 +805,7 @@ async function resolveTestProjectConfigs( projectsDefinition: TestProjectConfiguration[], ) { // project configurations that were specified directly - const projectsOptions: (UserWorkspaceConfig & { extends?: true | string })[] = [] + const projectsOptions: (UserWorkspaceConfig & { extends?: boolean | string })[] = [] // custom config files that were specified directly or resolved from a directory const projectsConfigFiles: string[] = [] diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 5d87d7e4f..2090fe6ee 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -1313,6 +1313,12 @@ export interface ResolvedProjectEntry { * reference it via `_parent`) but is NOT pushed to `vitest.projects`. */ hidden?: boolean + /** + * The project was declared as an inline configuration. Its + * `viteConfig.configFile` is the config it extends (the root config file + * by default), not a file of its own. + */ + inline?: boolean } type NonProjectOptions @@ -1407,9 +1413,11 @@ export type TestProjectInlineConfiguration = (UserWorkspaceConfig & { /** * Relative path to the extendable config. All other options will be merged with this config. * If `true`, the project will inherit all options from the root config. + * Set to `false` to keep the project configuration completely separate from the root config. + * @default true * @example '../vite.config.ts' */ - extends?: string | true + extends?: string | boolean }) export type TestProjectConfiguration diff --git a/test/e2e/test/projects.test.ts b/test/e2e/test/projects.test.ts index 65d554e4a..1b39ff4fa 100644 --- a/test/e2e/test/projects.test.ts +++ b/test/e2e/test/projects.test.ts @@ -1,4 +1,4 @@ -import { runInlineTests, runVitest } from '#test-utils' +import { runInlineTests, runVitest, ts } from '#test-utils' import { resolve } from 'pathe' import { describe, expect, it } from 'vitest' @@ -82,7 +82,6 @@ it('can define inline workspace config programmatically', async () => { }, projects: [ { - extends: true, test: { name: 'project-1', }, @@ -118,6 +117,124 @@ it('correctly inherits the root config', async () => { expect(stdout).toContain('repro.test.js > importing a virtual module') }) +describe('the root config inheritance', () => { + const basicTest = ts` + import { test } from 'vitest' + test('runs', () => {}) + ` + + it('inline projects inherit options from the root config by default', async () => { + const { stderr, ctx } = await runInlineTests({ + 'vitest.config.js': { + test: { + testTimeout: 1234, + projects: [ + { test: { name: 'inherited' } }, + { extends: false, test: { name: 'isolated' } }, + ], + }, + }, + 'basic.test.js': basicTest, + }) + expect(stderr).toBe('') + const timeouts = Object.fromEntries( + ctx!.projects.map(project => [project.name, project.config.testTimeout]), + ) + expect(timeouts).toEqual({ + inherited: 1234, + isolated: 5000, + }) + expect(ctx!.projects.map(project => project.config.projects)).toEqual([ + undefined, + undefined, + ]) + }) + + it('the root name and globalSetup are not inherited by the projects', async () => { + const { stderr, ctx, fs } = await runInlineTests({ + 'globalSetup.js': ts` + import { existsSync, readFileSync, writeFileSync } from 'node:fs' + import { resolve } from 'node:path' + + export default function setup(project) { + const file = resolve(project.config.root, 'setup-runs.txt') + const runs = existsSync(file) ? Number(readFileSync(file, 'utf-8')) : 0 + writeFileSync(file, String(runs + 1)) + } + `, + 'vitest.config.js': { + test: { + name: 'root', + globalSetup: './globalSetup.js', + projects: [ + { test: {} }, + { test: {} }, + ], + }, + }, + 'basic.test.js': basicTest, + }) + expect(stderr).toBe('') + expect(ctx!.projects.map(project => project.name)).toEqual(['0', '1']) + expect(fs.readFile('setup-runs.txt')).toBe('1') + }) + + it('globalSetup from an extended non-root config runs for every project', async () => { + const { stderr, fs } = await runInlineTests({ + 'globalSetup.js': ts` + import { existsSync, readFileSync, writeFileSync } from 'node:fs' + import { resolve } from 'node:path' + + export default function setup(project) { + const file = resolve(project.config.root, 'setup-runs.txt') + const runs = existsSync(file) ? Number(readFileSync(file, 'utf-8')) : 0 + writeFileSync(file, String(runs + 1)) + } + `, + 'vitest.shared.js': { test: { globalSetup: './globalSetup.js' } }, + 'vitest.config.js': { + test: { + projects: [ + { extends: './vitest.shared.js', test: { name: 'a' } }, + { extends: './vitest.shared.js', test: { name: 'b' } }, + ], + }, + }, + 'basic.test.js': basicTest, + }) + expect(stderr).toBe('') + expect(fs.readFile('setup-runs.txt')).toBe('2') + }) + + it('the project tags replace the inherited tags', async () => { + const { stderr, ctx } = await runInlineTests({ + 'vitest.config.js': { + test: { + tags: [{ name: 'shared', retry: 2 }], + projects: [ + // the same tag name would be a duplicate tag error + // if the arrays were merged instead of replaced + { test: { name: 'own-tags', tags: [{ name: 'shared', retry: 5 }] } }, + { test: { name: 'inherited-tags' } }, + ], + }, + }, + 'basic.test.js': basicTest, + }) + expect(stderr).toBe('') + const retries = Object.fromEntries( + ctx!.projects.map(project => [ + project.name, + project.config.tags.find(tag => tag.name === 'shared')!.retry, + ]), + ) + expect(retries).toEqual({ + 'own-tags': 5, + 'inherited-tags': 2, + }) + }) +}) + it('fails if workspace is empty', async () => { const { stderr } = await runVitest({ config: false,