···33outline: deep
44---
5566-# projects <CRoot />
66+# projects
7788- **Type:** `TestProjectConfiguration[]`
99- **Default:** `[]`
10101111An array of [projects](/guide/projects).
1212+1313+A config file that declares `projects` doesn't run tests itself, it only provides the projects that do. This also applies to project config files: a referenced config that declares `projects` becomes a container for [nested projects](/guide/projects#nested-projects). The option is not supported inside an inline project configuration.
+28
docs/guide/migration.md
···110110})
111111```
112112113113+### Referenced Config Files Can Define Their Own Projects
114114+115115+A config file referenced in [`test.projects`](/guide/projects) that declares `projects` itself is now treated like the root config: it doesn't run tests on its own, it only provides the [nested projects](/guide/projects#nested-projects) it declares. Their names are prefixed with the name of the declaring config, e.g. `app (unit)`.
116116+117117+In Vitest 4 the `projects` field of a referenced config was silently ignored and the config ran as a single project. Check that your project configs don't carry a `projects` field unknowingly. The most common way to do that is merging a config that defines it:
118118+119119+```ts [packages/app/vitest.config.ts]
120120+import { defineProject, mergeConfig } from 'vitest/config'
121121+import rootConfig from '../../vitest.config' // [!code --]
122122+import sharedConfig from '../../vitest.shared' // [!code ++]
123123+124124+export default mergeConfig(
125125+ // the root config defines `test.projects`, so merging it
126126+ // would turn this project into a container for those projects
127127+ rootConfig, // [!code --]
128128+ sharedConfig, // [!code ++]
129129+ defineProject({
130130+ test: {
131131+ environment: 'jsdom',
132132+ },
133133+ }),
134134+)
135135+```
136136+137137+Since the inherited `projects` paths resolve relative to the referenced config, this misconfiguration usually fails loudly at startup with `Projects definition references a non-existing file or a directory`, `No projects were found in "..."`, or a circular `projects` definition error.
138138+139139+Inline configurations continue to ignore the `projects` field at runtime, but it is now also excluded from their `ProjectConfig` type.
140140+113141### Hoisted Mocking Calls Must Be at the Top Level
114142115143[`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:
+67
docs/guide/projects.md
···324324325325All configuration options that are not supported inside a project configuration are marked with a <CRoot /> icon next to their name. They can only be defined once in the root config file.
326326:::
327327+328328+## Nested Projects
329329+330330+A project referenced as a config file (or a directory containing one) can declare `projects` itself. Such a config behaves like the root config: it doesn't run any tests on its own, it only provides the projects that do. This makes it possible to reference a workspace that already defines its own projects:
331331+332332+```ts [vitest.config.ts]
333333+import { defineConfig } from 'vitest/config'
334334+335335+export default defineConfig({
336336+ test: {
337337+ projects: ['./packages/app/vitest.config.ts'],
338338+ },
339339+})
340340+```
341341+342342+```ts [packages/app/vitest.config.ts]
343343+import { defineProject } from 'vitest/config'
344344+345345+export default defineProject({
346346+ test: {
347347+ name: 'app',
348348+ projects: [
349349+ {
350350+ test: {
351351+ name: 'unit',
352352+ include: ['**/*.unit.test.ts'],
353353+ },
354354+ },
355355+ {
356356+ test: {
357357+ name: 'e2e',
358358+ include: ['**/*.e2e.test.ts'],
359359+ },
360360+ },
361361+ ],
362362+ },
363363+})
364364+```
365365+366366+Nested projects work the same way as projects defined in the root config: inline configurations extend the config that declares them (the `app` config here, not the root one), `extends` paths are resolved relative to it, and its own `globalSetup` is inherited by the extending projects [like any other non-root config](#configuration).
367367+368368+The names of nested projects are prefixed with the name of the config that declares them, so the example above creates the `app (unit)` and `app (e2e)` projects. The `--project` filter matches the prefix as well: `--project app` runs every project of the `app` config, while `--project "app (unit)"` runs only one of them.
369369+370370+To also run the tests of the config that declares `projects`, reference its own config file:
371371+372372+```ts [packages/app/vitest.config.ts]
373373+import { defineProject } from 'vitest/config'
374374+375375+export default defineProject({
376376+ test: {
377377+ name: 'app',
378378+ include: ['**/*.test.ts'],
379379+ projects: [
380380+ // the "app" project runs its own "include" alongside "app (unit)"
381381+ './vitest.config.ts',
382382+ {
383383+ test: {
384384+ name: 'unit',
385385+ include: ['**/*.unit.test.ts'],
386386+ },
387387+ },
388388+ ],
389389+ },
390390+})
391391+```
392392+393393+Note that only config files can define nested projects. The `projects` option inside an inline configuration is not supported.
···335335 await this._restart()
336336 }
337337338338+ // container configs have no Vite server (and might be outside the root),
339339+ // so their files are watched explicitly
340340+ if (resolved._containerConfigFiles?.length) {
341341+ server.watcher.add(resolved._containerConfigFiles)
342342+ }
343343+338344 // since we set `server.hmr: false`, Vite does not auto restart itself
339345 server.watcher.on('change', async (file) => {
340346 file = normalize(file)
341347 const isConfig = file === server.config.configFile
342348 || this.projects.some(p => p.vite.config.configFile === file)
349349+ || this.config._containerConfigFiles?.includes(file)
343350 if (isConfig) {
344351 await this._restart('config')
345352 }
···1616 UserConfig,
1717 UserWorkspaceConfig,
1818} from '../types/config'
1919-import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
1919+import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
2020import os from 'node:os'
2121import { deepClone } from '@vitest/utils/helpers'
2222import { basename, dirname, relative, resolve } from 'pathe'
2323import { glob, isDynamicPattern } from 'tinyglobby'
2424import { mergeConfig, resolveConfig as viteResolveConfig } from 'vite'
2525import { configFiles as defaultConfigFiles } from '../../constants'
2626+import { wildcardPatternToRegExp } from '../../utils/base'
2627import { limitConcurrency } from '../../utils/limit-concurrency'
2728import { isExcludedByProjectFilter, matchesProjectFilter, resolveTestConfig } from '../config/resolveConfig'
2829import { BrowserLoaderPlugin, createClusterServer } from '../plugins/browserLoader'
···7475 * - If the user declared `test.projects`, each declared project gets its own
7576 * resolved Vite config plus per-project Vitest test config.
7677 * - Otherwise the root config is used as the single base entry.
7878+ * - A file-based project whose config declares `projects` itself is a
7979+ * container: like the root, it doesn't run tests and is replaced by the
8080+ * projects it declares (recursively).
7781 * - Browser instances expand each entry with `browser.enabled` into one entry
7882 * per instance (sharing `viteConfig` with the parent).
7983 * - Benchmarks add a benchmark variant for each entry whose
···96100 // falls through to the default root-project entry.
97101 let baseEntries: ResolvedProjectEntry[]
98102 if (definitions !== undefined) {
9999- baseEntries = await resolveDeclaredProjectEntries(
103103+ const cliOverrides = PROJECT_CLI_OVERRIDES.reduce((acc, name) => {
104104+ if (name in globalConfig.cliOptions) {
105105+ acc[name] = globalConfig.cliOptions[name] as any
106106+ }
107107+ return acc
108108+ }, {} as UserConfig)
109109+ const containerConfigFiles: string[] = []
110110+ const context: ProjectsResolutionContext = {
100111 harness,
101101- globalViteConfig,
102102- globalConfig,
103103- definitions,
104104- )
112112+ rootViteConfig: globalViteConfig,
113113+ rootConfig: globalConfig,
114114+ parentViteConfig: globalViteConfig,
115115+ parentConfig: globalConfig,
116116+ cliOverrides,
117117+ ancestors: [],
118118+ chain: globalViteConfig.configFile
119119+ ? [safeRealpath(globalViteConfig.configFile)]
120120+ : [],
121121+ containerConfigFiles,
122122+ }
123123+ baseEntries = await resolveDeclaredProjectEntries(context, definitions)
124124+ if (containerConfigFiles.length) {
125125+ // appended rather than assigned so `injectTestProjects` containers
126126+ // are also watched
127127+ globalConfig._containerConfigFiles = [
128128+ ...(globalConfig._containerConfigFiles || []),
129129+ ...containerConfigFiles,
130130+ ]
131131+ }
105132 }
106133 else {
107134 baseEntries = [{ viteConfig: globalViteConfig, projectConfig: globalConfig }]
···260287 )
261288}
262289290290+/**
291291+ * The set of values threaded through (possibly recursive) project resolution.
292292+ *
293293+ * The `root*` pair is the true root config: it carries run-wide values (CLI
294294+ * options, the `--project` filter, the programmatic config). The `parent*`
295295+ * pair is the config the current `projects` array is declared in — the root
296296+ * itself or a container config — and provides the default `extends` target,
297297+ * env defaults and the base for resolving relative definitions.
298298+ */
299299+interface ProjectsResolutionContext {
300300+ harness: PluginHarness
301301+ rootViteConfig: ResolvedViteConfig
302302+ rootConfig: ResolvedConfig
303303+ parentViteConfig: ResolvedViteConfig
304304+ parentConfig: ResolvedConfig
305305+ /** CLI options projects may override, computed once from the root's `cliOptions` */
306306+ cliOverrides: UserConfig
307307+ /** names of the containers above this level, outermost first */
308308+ ancestors: string[]
309309+ /** realpaths of the config files above this level, the cycle guard */
310310+ chain: string[]
311311+ /** shared across levels; collects container config files for the watcher */
312312+ containerConfigFiles: string[]
313313+}
314314+263315async function resolveDeclaredProjectEntries(
264264- harness: PluginHarness,
265265- globalViteConfig: ResolvedViteConfig,
266266- globalConfig: ResolvedConfig,
316316+ context: ProjectsResolutionContext,
267317 definitions: TestProjectConfiguration[],
268318): Promise<ResolvedProjectEntry[]> {
319319+ const { parentViteConfig, parentConfig } = context
269320 const { configFiles, projectConfigs, nonConfigDirectories } = await resolveTestProjectConfigs(
270270- globalViteConfig,
271271- globalConfig,
321321+ parentViteConfig,
322322+ parentConfig,
272323 definitions,
273324 )
274325275275- const cliOverrides = PROJECT_CLI_OVERRIDES.reduce((acc, name) => {
276276- if (name in globalConfig.cliOptions) {
277277- acc[name] = globalConfig.cliOptions[name] as any
278278- }
279279- return acc
280280- }, {} as UserConfig)
281326 const concurrent = limitConcurrency(os.availableParallelism?.() || os.cpus().length || 5)
282327 const fileProjects = [...configFiles, ...nonConfigDirectories]
283328284329 const promises: Promise<ResolvedProjectEntry>[] = []
285330286331 projectConfigs.forEach((options, index) => {
287287- const configRoot = globalConfig.root
332332+ const configRoot = parentConfig.root
288333 // if extends a config file, resolve the file path
289334 const configFile = typeof options.extends === 'string'
290335 ? resolve(configRoot, options.extends)
291336 : options.extends !== false
292292- ? (globalViteConfig.configFile || false)
337337+ ? (parentViteConfig.configFile || false)
293338 : false
294294- // if `root` is configured, resolve it relative to vite root (like other options)
295295- // if `root` is not specified, inline configs use the same root as the root project
339339+ // if `root` is configured, resolve it relative to the declaring config's
340340+ // root (like other options); if `root` is not specified, inline configs
341341+ // use the same root as the declaring config
296342 const rawRoot = options.test?.root ?? options.root
297343 const root = rawRoot
298344 ? resolve(configRoot, rawRoot)
299345 : configRoot
300346301301- promises.push(concurrent(() => resolveSingleProjectEntry(harness, globalViteConfig, globalConfig, {
347347+ promises.push(concurrent(() => resolveSingleProjectEntry(context, {
302348 ...options,
303349 root,
304350 configFile,
305305- }, index, cliOverrides)))
351351+ }, index)))
306352 })
307353308354 for (const path of fileProjects) {
309309- // if file leads to the root config, then we can just reuse it because we already initialized it
310310- if (globalViteConfig.configFile === path) {
311311- // The root viteConfig is already resolved; emit it as an entry directly.
355355+ // if the file leads to the declaring config itself, reuse the already
356356+ // resolved pair: the root (or the container) also runs as a regular project
357357+ if (parentViteConfig.configFile === path) {
312358 promises.push(Promise.resolve({
313313- viteConfig: globalViteConfig,
314314- projectConfig: globalConfig,
359359+ viteConfig: parentViteConfig,
360360+ projectConfig: parentConfig,
361361+ ancestors: context.ancestors.length > 1
362362+ ? context.ancestors.slice(0, -1)
363363+ : undefined,
315364 }))
316365 continue
317366 }
···320369 const projectRoot = path.endsWith('/') ? path : dirname(path)
321370322371 promises.push(concurrent(() => resolveSingleProjectEntry(
323323- harness,
324324- globalViteConfig,
325325- globalConfig,
372372+ context,
326373 { root: projectRoot, configFile },
327374 path,
328328- cliOverrides,
329375 )))
330376 }
331377···348394 )
349395 }
350396351351- return entries
397397+ return flattenContainerEntries(context, entries)
398398+}
399399+400400+/**
401401+ * Replace container entries (file-based configs that declare `projects`) with
402402+ * the projects they declare, recursively. A container behaves like the root
403403+ * config: it doesn't run tests and never gets a Vite server; its projects
404404+ * extend it by default and their names are prefixed with the container's name.
405405+ */
406406+async function flattenContainerEntries(
407407+ context: ProjectsResolutionContext,
408408+ entries: ResolvedProjectEntry[],
409409+): Promise<ResolvedProjectEntry[]> {
410410+ const result: ResolvedProjectEntry[] = []
411411+ for (const entry of entries) {
412412+ const definitions = entry.projectConfig.projects
413413+ // inline projects cannot declare `projects`; the declaring config's own
414414+ // entry (emitted when it references its own config file) is kept as-is —
415415+ // its `projects` are the definitions currently being resolved
416416+ if (entry.inline || definitions === undefined || entry.projectConfig === context.parentConfig) {
417417+ result.push(entry)
418418+ continue
419419+ }
420420+421421+ const configFile = entry.viteConfig.configFile
422422+ const relativeFile = configFile
423423+ ? relative(context.rootConfig.root, configFile)
424424+ : entry.projectConfig.name
425425+ let chain = context.chain
426426+ if (configFile) {
427427+ const realConfigFile = safeRealpath(configFile)
428428+ if (chain.includes(realConfigFile)) {
429429+ throw new Error(
430430+ [
431431+ `Found a circular "projects" definition: `,
432432+ [...chain, realConfigFile].map(file => `"${relative(context.rootConfig.root, file)}"`).join(' -> '),
433433+ '. Make sure your configuration is correct.',
434434+ ].join(''),
435435+ )
436436+ }
437437+ chain = [...chain, realConfigFile]
438438+ context.containerConfigFiles.push(configFile)
439439+ }
440440+441441+ const childContext: ProjectsResolutionContext = {
442442+ ...context,
443443+ parentViteConfig: entry.viteConfig,
444444+ parentConfig: entry.projectConfig,
445445+ ancestors: [...context.ancestors, entry.projectConfig.name],
446446+ chain,
447447+ }
448448+ const children = await resolveDeclaredProjectEntries(childContext, definitions)
449449+ if (!children.length) {
450450+ throw new Error(
451451+ `No projects were found in "${relativeFile}". Make sure your configuration is correct.`,
452452+ )
453453+ }
454454+ result.push(...children)
455455+ }
456456+ return result
457457+}
458458+459459+function safeRealpath(path: string): string {
460460+ try {
461461+ return realpathSync(path)
462462+ }
463463+ catch {
464464+ return path
465465+ }
352466}
353467354468// `name` must stay unique per project, `projects` would redefine the whole workspace
355469const NON_INHERITED_OPTIONS = ['name', 'projects'] as const
356470357471// the root `globalSetup` already runs once per test run; a non-root
358358-// config keeps it because nothing else runs it
472472+// config (a shared config or a container) keeps it because nothing else runs it
359473const NON_INHERITED_ROOT_OPTIONS = [...NON_INHERITED_OPTIONS, 'globalSetup'] as const
360474361361-function ProjectInheritancePlugin(options: ViteInlineConfig, extendsRootConfig: boolean): VitePlugin {
362362- const nonInheritedOptions = extendsRootConfig
475475+function ProjectInheritancePlugin(options: ViteInlineConfig, extendsTrueRootConfig: boolean): VitePlugin {
476476+ const nonInheritedOptions = extendsTrueRootConfig
363477 ? NON_INHERITED_ROOT_OPTIONS
364478 : NON_INHERITED_OPTIONS
365479 return {
···405519 * same guard in `CliOverride`)
406520 */
407521function inheritRootViteOverrides(
408408- globalConfig: ResolvedConfig,
522522+ rootConfig: ResolvedConfig,
409523 options: ViteInlineConfig,
410524): ViteInlineConfig {
411411- const { plugins: _plugins, ...rootViteOverrides } = globalConfig.viteOverrides
525525+ const { plugins: _plugins, ...rootViteOverrides } = rootConfig.viteOverrides
412526 // cloned so plugins that mutate inherited arrays in place don't share
413527 // them between the root and every project
414528 const inherited = deepClone(rootViteOverrides)
···418532}
419533420534async function resolveSingleProjectEntry(
421421- harness: PluginHarness,
422422- globalViteConfig: ResolvedViteConfig,
423423- globalConfig: ResolvedConfig,
535535+ context: ProjectsResolutionContext,
424536 options: ViteInlineConfig & { extends?: string | boolean },
425537 workspacePath: string | number,
426426- cliOverrides: UserConfig,
427538): Promise<ResolvedProjectEntry> {
539539+ const { harness, rootViteConfig, rootConfig, parentViteConfig, parentConfig, cliOverrides } = context
428540 const { configFile, ...restOptions } = options
429541430542 const browserHolder: BrowserContributionHolder = {}
···432544 // only inline entries (keyed by their index) extend another config;
433545 // file-based projects own all of their values
434546 const isInlineEntry = typeof workspacePath === 'number'
435435- const inheritsRootConfig = isInlineEntry
547547+ const inheritsParentConfig = isInlineEntry
436548 && options.extends !== false
437549 && typeof options.extends !== 'string'
438438- // `extends: './path'` can still point back to the root config file
439439- const extendsRootConfig = inheritsRootConfig
440440- || (!!configFile && configFile === globalViteConfig.configFile)
550550+ // the root `globalSetup` runs once per test run, so it is stripped from
551551+ // projects extending the root config file (directly or via `extends: true`
552552+ // at the top level); a container's `globalSetup` stays inherited because
553553+ // nothing else runs it, like any other non-root extended config
554554+ const extendsTrueRootConfig = (inheritsParentConfig && parentConfig === rootConfig)
555555+ || (!!configFile && configFile === rootViteConfig.configFile)
441556442442- const inlineOptions = inheritsRootConfig
443443- ? inheritRootViteOverrides(globalConfig, restOptions)
557557+ // the programmatic config is part of the effective root config; a container
558558+ // is fully described by its file, so only root-level projects inherit it
559559+ const inlineOptions = inheritsParentConfig && parentConfig === rootConfig
560560+ ? inheritRootViteOverrides(rootConfig, restOptions)
444561 : restOptions
445562446563 const projectInline: ViteInlineConfig = {
447564 ...inlineOptions,
448565 configFile,
449449- configLoader: globalViteConfig.inlineConfig.configLoader,
566566+ configLoader: parentViteConfig.inlineConfig.configLoader,
450567 // this will make "mode": "test" inside defineConfig
451451- mode: options.test?.mode || options.mode || globalConfig.mode,
568568+ mode: options.test?.mode || options.mode || parentConfig.mode,
452569 plugins: [
453570 CliOverride(cliOverrides),
454571 ...(options.plugins || []),
455572 ...WorkspaceVitestPlugin(
456573 harness,
457457- globalViteConfig,
458458- globalConfig,
574574+ parentViteConfig,
575575+ rootConfig,
459576 options,
460577 ),
461578 ...BrowserLoaderPlugin(browserHolder, harness),
462579 ...(isInlineEntry
463463- ? [ProjectInheritancePlugin(options, extendsRootConfig)]
580580+ ? [ProjectInheritancePlugin(options, extendsTrueRootConfig)]
464581 : []),
465582 ],
466583 }
467584468585 const projectViteConfig = await viteResolveConfig(projectInline, 'serve')
469586470470- // inherit the root's resolved env as defaults; a project's own env wins,
471471- // like every other option a project can override
472472- for (const key in globalViteConfig.env) {
473473- projectViteConfig.env[key] ??= globalViteConfig.env[key]
587587+ // inherit the declaring config's resolved env as defaults; a project's own
588588+ // env wins, like every other option a project can override
589589+ for (const key in parentViteConfig.env) {
590590+ projectViteConfig.env[key] ??= parentViteConfig.env[key]
474591 }
475592476593 const mergedOptions = (projectViteConfig.test ?? {}) as UserConfig
477594478595 // resolved after `viteResolveConfig` so a plugin can still set `test.name`
479479- mergedOptions.name = resolveProjectName(mergedOptions.name, workspacePath)
596596+ mergedOptions.name = resolveProjectName(
597597+ mergedOptions.name,
598598+ workspacePath,
599599+ context.ancestors.at(-1),
600600+ )
480601481602 const projectConfig = resolveTestConfig(
482603 harness.logger,
483604 mergedOptions,
484605 projectViteConfig,
485485- globalConfig,
606606+ parentConfig,
486607 )
487608488609 projectViteConfig.test = projectConfig
···496617 viteConfig: projectViteConfig,
497618 projectConfig,
498619 inline: isInlineEntry,
620620+ ancestors: context.ancestors.length ? [...context.ancestors] : undefined,
499621 }
500622}
501623···533655 const parentName = projectConfig.name
534656535657 const instances = projectConfig.browser.instances ?? []
536536- if (instances.length === 0 || isExcludedByProjectFilter(globalConfig.project, parentName)) {
658658+ if (instances.length === 0 || isEntryExcludedByFilter(globalConfig.project, parentName, entry.ancestors)) {
537659 continue
538660 }
539661540540- const keepAllInstances = matchesProjectFilter(globalConfig.project, parentName)
662662+ const keepAllInstances = matchesEntryFilter(globalConfig.project, parentName, entry.ancestors)
541663 const filteredInstances = keepAllInstances
542664 ? instances
543665 : instances.filter(instance => matchesProjectFilter(globalConfig.project, instance.name!))
···641763 result.push({
642764 viteConfig, // shared with parent
643765 projectConfig: clonedConfig,
766766+ ancestors: entry.ancestors,
644767 })
645768 })
646769 }
···714837 result.push({
715838 viteConfig: entry.viteConfig, // shared with non-benchmark counterpart
716839 projectConfig: benchmarkConfig,
840840+ ancestors: entry.ancestors,
717841 })
718842 }
719843···747871 // Browser instance: already filtered during expansion.
748872 return true
749873 }
750750- return matchesProjectFilter(filter, entry.projectConfig.name)
874874+ return matchesEntryFilter(filter, entry.projectConfig.name, entry.ancestors)
751875 })
752876}
753877···799923 } satisfies ResolvedConfig, overrideConfig) as ResolvedConfig
800924}
801925926926+/**
927927+ * Match an entry against the `--project` filter. In addition to the entry's
928928+ * own name, the names of the containers it is nested under are considered,
929929+ * so a container name selects (or excludes) its whole subtree.
930930+ */
931931+function matchesEntryFilter(
932932+ filter: string[],
933933+ name: string,
934934+ ancestors: string[] | undefined,
935935+): boolean {
936936+ if (!filter.length) {
937937+ return true
938938+ }
939939+ const names = [name, ...(ancestors || [])]
940940+ return filter.some((project) => {
941941+ const regexp = wildcardPatternToRegExp(project)
942942+ // a negated pattern compiles into a negative lookahead: the entry is kept
943943+ // only when neither its name nor any of its containers match the exclusion
944944+ return project.startsWith('!')
945945+ ? names.every(candidate => regexp.test(candidate))
946946+ : names.some(candidate => regexp.test(candidate))
947947+ })
948948+}
949949+950950+function isEntryExcludedByFilter(
951951+ filter: string[],
952952+ name: string,
953953+ ancestors: string[] | undefined,
954954+): boolean {
955955+ return isExcludedByProjectFilter(filter, name)
956956+ || !!ancestors?.some(ancestor => isExcludedByProjectFilter(filter, ancestor))
957957+}
958958+802959async function resolveTestProjectConfigs(
803803- globalViteConfig: ResolvedViteConfig,
804804- globalConfig: ResolvedConfig,
960960+ parentViteConfig: ResolvedViteConfig,
961961+ parentConfig: ResolvedConfig,
805962 projectsDefinition: TestProjectConfiguration[],
806963) {
807964 // project configurations that were specified directly
···818975819976 for (const definition of projectsDefinition) {
820977 if (typeof definition === 'string') {
821821- const stringOption = definition.replace('<rootDir>', globalConfig.root)
978978+ const stringOption = definition.replace('<rootDir>', parentConfig.root)
822979 // if the string doesn't contain a glob, we can resolve it directly
823980 // ['./vitest.config.js']
824981 if (!isDynamicPattern(stringOption)) {
825825- const file = resolve(globalConfig.root, stringOption)
982982+ const file = resolve(parentConfig.root, stringOption)
826983827984 if (!existsSync(file)) {
828985 throw new Error(`Projects definition references a non-existing file or a directory: ${file}`)
···834991 const name = basename(file)
835992 if (!CONFIG_REGEXP.test(name)) {
836993 throw new Error(
837837- `The file "${relative(globalConfig.root, file)}" must start with "vitest.config"/"vite.config" `
994994+ `The file "${relative(parentConfig.root, file)}" must start with "vitest.config"/"vite.config" `
838995 + `or match the pattern "(vitest|vite).*.config.*" to be a valid project config.`,
839996 )
840997 }
···8671024 else if (typeof definition === 'function') {
8681025 projectsOptions.push(await definition({
8691026 command: 'serve',
870870- mode: globalViteConfig.mode,
10271027+ mode: parentViteConfig.mode,
8711028 isPreview: false,
8721029 isSsrBuild: false,
8731030 }))
···8831040 absolute: true,
8841041 dot: true,
8851042 onlyFiles: false,
886886- cwd: globalConfig.root,
10431043+ cwd: parentConfig.root,
8871044 expandDirectories: false,
8881045 ignore: [
8891046 '**/node_modules/**',
···9121069 const name = basename(path)
9131070 if (!CONFIG_REGEXP.test(name)) {
9141071 throw new Error(
915915- `The projects glob matched a file "${relative(globalConfig.root, path)}", `
10721072+ `The projects glob matched a file "${relative(parentConfig.root, path)}", `
9161073 + `but it should also either start with "vitest.config"/"vite.config" `
9171074 + `or match the pattern "(vitest|vite).*.config.*".`,
9181075 )
···9451102/**
9461103 * Resolve a project's name, falling back to the `package.json` name or the
9471104 * directory/index when neither the config nor a plugin provided one.
11051105+ *
11061106+ * Projects declared by a container config are namespaced by the container's
11071107+ * name: the "unit" project of an "app" container is named "app (unit)".
9481108 */
9491109function resolveProjectName(
9501110 name: string | ProjectName | undefined,
9511111 workspacePath: string | number,
11121112+ containerLabel: string | undefined,
9521113): ProjectName {
9531114 let { label, color }: ProjectName = typeof name === 'string'
9541115 ? { label: name }
9551116 : { label: '', ...name }
9561117957957- if (label) {
958958- return { label, color }
11181118+ if (!label) {
11191119+ if (typeof workspacePath === 'number') {
11201120+ label = workspacePath.toString()
11211121+ }
11221122+ else {
11231123+ const dir = workspacePath.endsWith('/')
11241124+ ? workspacePath.slice(0, -1)
11251125+ : dirname(workspacePath)
11261126+ const pkgJsonPath = resolve(dir, 'package.json')
11271127+ if (existsSync(pkgJsonPath)) {
11281128+ label = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')).name
11291129+ }
11301130+ if (typeof label !== 'string' || !label) {
11311131+ label = basename(dir)
11321132+ }
11331133+ }
9591134 }
9601135961961- if (typeof workspacePath === 'number') {
962962- return { label: workspacePath.toString(), color }
11361136+ if (containerLabel) {
11371137+ label = `${containerLabel} (${label})`
9631138 }
9641139965965- const dir = workspacePath.endsWith('/')
966966- ? workspacePath.slice(0, -1)
967967- : dirname(workspacePath)
968968- const pkgJsonPath = resolve(dir, 'package.json')
969969- if (existsSync(pkgJsonPath)) {
970970- label = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')).name
971971- }
972972- if (typeof label !== 'string' || !label) {
973973- label = basename(dir)
974974- }
9751140 return { label, color }
9761141}
9771142
+26-3
packages/vitest/src/node/types/config.ts
···396396 fileParallelism?: boolean
397397398398 /**
399399- * Options for projects
399399+ * Options for projects.
400400+ *
401401+ * When a project is referenced as a config file (or a directory with one) and
402402+ * that config declares `projects` itself, it becomes a container: it doesn't
403403+ * run tests, only provides the projects it declares. Inline configurations
404404+ * cannot declare `projects`.
400405 */
401406 projects?: TestProjectConfiguration[]
402407···12801285 viteOverrides: ViteUserConfig
12811286 resolvedProjects: ResolvedProjectEntry[]
12821287 /**
12881288+ * Config files that declared `projects` and act as containers (the file
12891289+ * itself doesn't run tests). Set only on the root config; used to restart
12901290+ * on change since containers have no Vite server of their own.
12911291+ *
12921292+ * @internal
12931293+ */
12941294+ _containerConfigFiles?: string[]
12951295+ /**
12831296 * Browser server contribution captured by the `vitest:browser:loader` plugin
12841297 * during this config's resolution (set only when `browser.enabled`). Used by
12851298 * server creation to build the single Vite server shared by `project.vite` and
···13191332 * by default), not a file of its own.
13201333 */
13211334 inline?: boolean
13351335+ /**
13361336+ * Names of the container configs this project is nested under, outermost
13371337+ * first. The `--project` filter matches these in addition to the project's
13381338+ * own name, so a container name selects its whole subtree.
13391339+ *
13401340+ * @internal
13411341+ */
13421342+ ancestors?: string[]
13221343}
1323134413241345type NonProjectOptions
···13781399export type ProjectConfig = Omit<
13791400 InlineConfig,
13801401 NonProjectOptions
14021402+ // `projects` is only respected in config files; a container config is
14031403+ // root-like and should be authored with `defineConfig`/`defineProject`
14041404+ | 'projects'
13811405 | 'sequence'
13821406 | 'deps'
13831407> & {
···13971421>
1398142213991423export interface UserWorkspaceConfig extends ViteUserConfig {
14001400- test?: ProjectConfig
14241424+ test?: ProjectConfig & { projects?: TestProjectConfiguration[] }
14011425}
1402142614031403-// TODO: remove types when "workspace" support is removed
14041427export type UserProjectConfigFn = (
14051428 env: ConfigEnv,
14061429) => UserWorkspaceConfig | Promise<UserWorkspaceConfig>