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

Configure Feed

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

feat!: support nested projects (#10846)

authored by

Vladimir and committed by
GitHub
(Jul 30, 2026, 12:44 PM +0200) ec367cf2 202e27f3

+783 -88
+3 -1
docs/config/projects.md
··· 3 3 outline: deep 4 4 --- 5 5 6 - # projects <CRoot /> 6 + # projects 7 7 8 8 - **Type:** `TestProjectConfiguration[]` 9 9 - **Default:** `[]` 10 10 11 11 An array of [projects](/guide/projects). 12 + 13 + 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
··· 110 110 }) 111 111 ``` 112 112 113 + ### Referenced Config Files Can Define Their Own Projects 114 + 115 + 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)`. 116 + 117 + 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: 118 + 119 + ```ts [packages/app/vitest.config.ts] 120 + import { defineProject, mergeConfig } from 'vitest/config' 121 + import rootConfig from '../../vitest.config' // [!code --] 122 + import sharedConfig from '../../vitest.shared' // [!code ++] 123 + 124 + export default mergeConfig( 125 + // the root config defines `test.projects`, so merging it 126 + // would turn this project into a container for those projects 127 + rootConfig, // [!code --] 128 + sharedConfig, // [!code ++] 129 + defineProject({ 130 + test: { 131 + environment: 'jsdom', 132 + }, 133 + }), 134 + ) 135 + ``` 136 + 137 + 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. 138 + 139 + Inline configurations continue to ignore the `projects` field at runtime, but it is now also excluded from their `ProjectConfig` type. 140 + 113 141 ### Hoisted Mocking Calls Must Be at the Top Level 114 142 115 143 [`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
··· 324 324 325 325 All 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. 326 326 ::: 327 + 328 + ## Nested Projects 329 + 330 + 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: 331 + 332 + ```ts [vitest.config.ts] 333 + import { defineConfig } from 'vitest/config' 334 + 335 + export default defineConfig({ 336 + test: { 337 + projects: ['./packages/app/vitest.config.ts'], 338 + }, 339 + }) 340 + ``` 341 + 342 + ```ts [packages/app/vitest.config.ts] 343 + import { defineProject } from 'vitest/config' 344 + 345 + export default defineProject({ 346 + test: { 347 + name: 'app', 348 + projects: [ 349 + { 350 + test: { 351 + name: 'unit', 352 + include: ['**/*.unit.test.ts'], 353 + }, 354 + }, 355 + { 356 + test: { 357 + name: 'e2e', 358 + include: ['**/*.e2e.test.ts'], 359 + }, 360 + }, 361 + ], 362 + }, 363 + }) 364 + ``` 365 + 366 + 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). 367 + 368 + 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. 369 + 370 + To also run the tests of the config that declares `projects`, reference its own config file: 371 + 372 + ```ts [packages/app/vitest.config.ts] 373 + import { defineProject } from 'vitest/config' 374 + 375 + export default defineProject({ 376 + test: { 377 + name: 'app', 378 + include: ['**/*.test.ts'], 379 + projects: [ 380 + // the "app" project runs its own "include" alongside "app (unit)" 381 + './vitest.config.ts', 382 + { 383 + test: { 384 + name: 'unit', 385 + include: ['**/*.unit.test.ts'], 386 + }, 387 + }, 388 + ], 389 + }, 390 + }) 391 + ``` 392 + 393 + Note that only config files can define nested projects. The `projects` option inside an inline configuration is not supported.
+403
test/e2e/test/projects.test.ts
··· 342 342 }) 343 343 }) 344 344 345 + describe('nested projects', () => { 346 + const basicTest = ts` 347 + import { test } from 'vitest' 348 + test('runs', () => {}) 349 + ` 350 + 351 + it('a file-based project with `projects` becomes a container and only its projects run', async () => { 352 + const { stderr, ctx, results } = await runInlineTests({ 353 + 'vitest.config.js': { 354 + test: { 355 + projects: ['./app/vitest.config.js'], 356 + }, 357 + }, 358 + 'app/vitest.config.js': { 359 + test: { 360 + name: 'app', 361 + projects: [ 362 + { test: { name: 'unit', include: ['**/*.unit.test.js'] } }, 363 + { test: { name: 'e2e', include: ['**/*.e2e.test.js'] } }, 364 + ], 365 + }, 366 + }, 367 + 'app/basic.unit.test.js': basicTest, 368 + 'app/basic.e2e.test.js': basicTest, 369 + }) 370 + expect(stderr).toBe('') 371 + expect(ctx!.projects.map(project => project.name)).toEqual([ 372 + 'app (unit)', 373 + 'app (e2e)', 374 + ]) 375 + // the container's own include doesn't run anything 376 + expect(results).toHaveLength(2) 377 + }) 378 + 379 + it('a directory reference with a config declaring `projects` becomes a container', async () => { 380 + const { stderr, ctx } = await runInlineTests({ 381 + 'vitest.config.js': { 382 + test: { 383 + projects: ['./app'], 384 + }, 385 + }, 386 + 'app/vitest.config.js': { 387 + test: { 388 + name: 'app', 389 + projects: [ 390 + { test: { name: 'unit' } }, 391 + ], 392 + }, 393 + }, 394 + 'app/basic.test.js': basicTest, 395 + }) 396 + expect(stderr).toBe('') 397 + expect(ctx!.projects.map(project => project.name)).toEqual(['app (unit)']) 398 + }) 399 + 400 + it('file-based projects of a container are namespaced with derived names', async () => { 401 + const { stderr, ctx } = await runInlineTests({ 402 + 'vitest.config.js': { 403 + test: { 404 + projects: ['./app/vitest.config.js'], 405 + }, 406 + }, 407 + 'app/vitest.config.js': { 408 + test: { 409 + name: 'app', 410 + projects: ['./unit/vitest.config.js'], 411 + }, 412 + }, 413 + 'app/unit/vitest.config.js': {}, 414 + 'app/unit/basic.test.js': basicTest, 415 + }) 416 + expect(stderr).toBe('') 417 + expect(ctx!.projects.map(project => project.name)).toEqual(['app (unit)']) 418 + }) 419 + 420 + it('nested containers namespace their projects recursively', async () => { 421 + const { stderr, ctx } = await runInlineTests({ 422 + 'vitest.config.js': { 423 + test: { 424 + projects: ['./app/vitest.config.js'], 425 + }, 426 + }, 427 + 'app/vitest.config.js': { 428 + test: { 429 + name: 'app', 430 + projects: ['./sub/vitest.config.js'], 431 + }, 432 + }, 433 + 'app/sub/vitest.config.js': { 434 + test: { 435 + name: 'sub', 436 + projects: [ 437 + { test: { name: 'leaf' } }, 438 + ], 439 + }, 440 + }, 441 + 'app/sub/basic.test.js': basicTest, 442 + }) 443 + expect(stderr).toBe('') 444 + expect(ctx!.projects.map(project => project.name)).toEqual(['app (sub) (leaf)']) 445 + }) 446 + 447 + it('inline projects ignore the `projects` field', async () => { 448 + const { stderr, ctx } = await runInlineTests({ 449 + 'vitest.config.js': { 450 + test: { 451 + projects: [ 452 + { 453 + test: { 454 + name: 'main', 455 + // inline projects cannot spawn other projects 456 + projects: [{ test: { name: 'ignored' } }], 457 + } as import('vitest/node').ProjectConfig, 458 + }, 459 + ], 460 + }, 461 + }, 462 + 'basic.test.js': basicTest, 463 + }) 464 + expect(stderr).toBe('') 465 + expect(ctx!.projects.map(project => project.name)).toEqual(['main']) 466 + }) 467 + 468 + it('a container can list its own config file to also run its tests', async () => { 469 + const { stderr, ctx } = await runInlineTests({ 470 + 'vitest.config.js': { 471 + test: { 472 + projects: ['./app/vitest.config.js'], 473 + }, 474 + }, 475 + 'app/vitest.config.js': { 476 + test: { 477 + name: 'app', 478 + include: ['self.test.js'], 479 + projects: [ 480 + './vitest.config.js', 481 + { test: { name: 'unit', include: ['unit.test.js'] } }, 482 + ], 483 + }, 484 + }, 485 + 'app/self.test.js': basicTest, 486 + 'app/unit.test.js': basicTest, 487 + }) 488 + expect(stderr).toBe('') 489 + // inline projects always come before file-based projects 490 + expect(ctx!.projects.map(project => project.name)).toEqual([ 491 + 'app (unit)', 492 + 'app', 493 + ]) 494 + }) 495 + 496 + it('fails on a circular projects definition', async () => { 497 + const { stderr } = await runInlineTests({ 498 + 'vitest.config.js': { 499 + test: { 500 + projects: ['./vitest.a.config.js'], 501 + }, 502 + }, 503 + 'vitest.a.config.js': { 504 + test: { name: 'a', projects: ['./vitest.b.config.js'] }, 505 + }, 506 + 'vitest.b.config.js': { 507 + test: { name: 'b', projects: ['./vitest.a.config.js'] }, 508 + }, 509 + }, {}, { fails: true }) 510 + expect(stderr).toContain( 511 + 'Found a circular "projects" definition: "vitest.config.js" -> "vitest.a.config.js" -> "vitest.b.config.js" -> "vitest.a.config.js". Make sure your configuration is correct.', 512 + ) 513 + }) 514 + 515 + it('fails when a container has no projects', async () => { 516 + const { stderr } = await runInlineTests({ 517 + 'vitest.config.js': { 518 + test: { 519 + projects: ['./app/vitest.config.js'], 520 + }, 521 + }, 522 + 'app/vitest.config.js': { 523 + test: { name: 'app', projects: [] }, 524 + }, 525 + }, {}, { fails: true }) 526 + expect(stderr).toContain('No projects were found in "app/vitest.config.js". Make sure your configuration is correct.') 527 + }) 528 + 529 + it('fails when nested project names collide', async () => { 530 + const { stderr } = await runInlineTests({ 531 + 'vitest.config.js': { 532 + test: { 533 + projects: ['./app/vitest.config.js'], 534 + }, 535 + }, 536 + 'app/vitest.config.js': { 537 + test: { 538 + name: 'app', 539 + projects: [ 540 + { test: { name: 'unit' } }, 541 + { test: { name: 'unit' } }, 542 + ], 543 + }, 544 + }, 545 + 'app/basic.test.js': basicTest, 546 + }, {}, { fails: true }) 547 + expect(stderr).toContain('Project name "app (unit)" is not unique.') 548 + }) 549 + 550 + describe('the --project filter', () => { 551 + const structure = { 552 + 'vitest.config.js': { 553 + test: { 554 + projects: [ 555 + { test: { name: 'main', include: ['main.test.js'] } }, 556 + './app/vitest.config.js', 557 + ], 558 + }, 559 + }, 560 + 'app/vitest.config.js': { 561 + test: { 562 + name: 'app', 563 + projects: [ 564 + { test: { name: 'unit', include: ['**/*.unit.test.js'] } }, 565 + { test: { name: 'e2e', include: ['**/*.e2e.test.js'] } }, 566 + ], 567 + }, 568 + }, 569 + 'main.test.js': basicTest, 570 + 'app/basic.unit.test.js': basicTest, 571 + 'app/basic.e2e.test.js': basicTest, 572 + } satisfies Parameters<typeof runInlineTests>[0] 573 + 574 + it('the container name selects the whole subtree', async () => { 575 + const { stderr, ctx } = await runInlineTests(structure, { project: 'app' }) 576 + expect(stderr).toBe('') 577 + expect(ctx!.projects.map(project => project.name)).toEqual([ 578 + 'app (unit)', 579 + 'app (e2e)', 580 + ]) 581 + }) 582 + 583 + it('the qualified name selects a single project', async () => { 584 + const { stderr, ctx } = await runInlineTests(structure, { project: 'app (unit)' }) 585 + expect(stderr).toBe('') 586 + expect(ctx!.projects.map(project => project.name)).toEqual(['app (unit)']) 587 + }) 588 + 589 + it('excluding the container name excludes the whole subtree', async () => { 590 + const { stderr, ctx } = await runInlineTests(structure, { project: '!app' }) 591 + expect(stderr).toBe('') 592 + expect(ctx!.projects.map(project => project.name)).toEqual(['main']) 593 + }) 594 + }) 595 + 596 + it('projects extend the container config by default, not the root', async () => { 597 + const { stderr, ctx } = await runInlineTests({ 598 + 'vitest.config.js': { 599 + test: { 600 + testTimeout: 1111, 601 + projects: ['./app/vitest.config.js'], 602 + }, 603 + }, 604 + 'app/vitest.config.js': { 605 + test: { 606 + name: 'app', 607 + testTimeout: 2222, 608 + projects: [ 609 + { test: { name: 'inherited' } }, 610 + { extends: false, test: { name: 'isolated' } }, 611 + { extends: './vitest.shared.js', test: { name: 'shared' } }, 612 + ], 613 + }, 614 + }, 615 + 'app/vitest.shared.js': { 616 + test: { testTimeout: 3333 }, 617 + }, 618 + 'app/basic.test.js': basicTest, 619 + }) 620 + expect(stderr).toBe('') 621 + const timeouts = Object.fromEntries( 622 + ctx!.projects.map(project => [project.name, project.config.testTimeout]), 623 + ) 624 + expect(timeouts).toEqual({ 625 + 'app (inherited)': 2222, 626 + 'app (isolated)': 5000, 627 + 'app (shared)': 3333, 628 + }) 629 + }) 630 + 631 + it('the container globalSetup runs for every project extending it', async () => { 632 + const { stderr, fs } = await runInlineTests({ 633 + 'vitest.config.js': { 634 + test: { 635 + projects: ['./app/vitest.config.js'], 636 + }, 637 + }, 638 + 'app/globalSetup.js': ts` 639 + import { existsSync, readFileSync, writeFileSync } from 'node:fs' 640 + import { resolve } from 'node:path' 641 + 642 + export default function setup(project) { 643 + const file = resolve(project.config.root, 'setup-runs.txt') 644 + const runs = existsSync(file) ? Number(readFileSync(file, 'utf-8')) : 0 645 + writeFileSync(file, String(runs + 1)) 646 + } 647 + `, 648 + 'app/vitest.config.js': { 649 + test: { 650 + name: 'app', 651 + globalSetup: './globalSetup.js', 652 + projects: [ 653 + { test: { name: 'a' } }, 654 + { test: { name: 'b' } }, 655 + ], 656 + }, 657 + }, 658 + 'app/basic.test.js': basicTest, 659 + }) 660 + expect(stderr).toBe('') 661 + expect(fs.readFile('app/setup-runs.txt')).toBe('2') 662 + }) 663 + 664 + it('cli overrides reach nested projects', async () => { 665 + const { stderr, ctx } = await runInlineTests({ 666 + 'vitest.config.js': { 667 + test: { 668 + projects: ['./app/vitest.config.js'], 669 + }, 670 + }, 671 + 'app/vitest.config.js': { 672 + test: { 673 + name: 'app', 674 + testTimeout: 2222, 675 + projects: [ 676 + { test: { name: 'unit' } }, 677 + ], 678 + }, 679 + }, 680 + 'app/basic.test.js': basicTest, 681 + }, { $cliOptions: { testTimeout: 999 } }) 682 + expect(stderr).toBe('') 683 + expect(ctx!.projects.map(project => project.config.testTimeout)).toEqual([999]) 684 + }) 685 + 686 + it('benchmark projects are created for nested projects', async () => { 687 + const { stderr, ctx } = await runInlineTests({ 688 + 'vitest.config.js': { 689 + test: { 690 + passWithNoTests: true, 691 + projects: ['./app/vitest.config.js'], 692 + }, 693 + }, 694 + 'app/vitest.config.js': { 695 + test: { 696 + name: 'app', 697 + projects: [ 698 + { test: { name: 'unit', benchmark: { enabled: true } } }, 699 + ], 700 + }, 701 + }, 702 + 'app/basic.test.js': basicTest, 703 + }) 704 + expect(stderr).toBe('') 705 + expect(ctx!.projects.map(project => project.name)).toEqual([ 706 + 'app (unit)', 707 + 'app (unit) (bench)', 708 + ]) 709 + }) 710 + 711 + it('editing a container config restarts and picks up new projects', async () => { 712 + const { vitest, fs } = await runInlineTests({ 713 + 'vitest.config.js': { 714 + test: { 715 + projects: ['./app/vitest.config.js'], 716 + }, 717 + }, 718 + 'app/vitest.config.js': ts` 719 + export default { 720 + test: { 721 + name: 'app', 722 + projects: [ 723 + { test: { name: 'one', include: ['one.test.js'] } }, 724 + // extra 725 + ], 726 + }, 727 + } 728 + `, 729 + 'app/one.test.js': basicTest, 730 + 'app/two.test.js': basicTest, 731 + }, { watch: true }) 732 + 733 + await vitest.waitForStdout('Waiting for file changes') 734 + expect(vitest.stdout).toContain('app (one)') 735 + expect(vitest.stdout).not.toContain('app (two)') 736 + vitest.resetOutput() 737 + 738 + fs.editFile('app/vitest.config.js', content => content.replace( 739 + '// extra', 740 + `{ test: { name: 'two', include: ['two.test.js'] } },`, 741 + )) 742 + 743 + await vitest.waitForStdout('Waiting for file changes') 744 + expect(vitest.stdout).toContain('app (two)') 745 + }) 746 + }) 747 + 345 748 describe('project filtering', () => { 346 749 const allProjects = ['project_1', 'project_2', 'space_1'] 347 750
+7
packages/vitest/src/node/core.ts
··· 335 335 await this._restart() 336 336 } 337 337 338 + // container configs have no Vite server (and might be outside the root), 339 + // so their files are watched explicitly 340 + if (resolved._containerConfigFiles?.length) { 341 + server.watcher.add(resolved._containerConfigFiles) 342 + } 343 + 338 344 // since we set `server.hmr: false`, Vite does not auto restart itself 339 345 server.watcher.on('change', async (file) => { 340 346 file = normalize(file) 341 347 const isConfig = file === server.config.configFile 342 348 || this.projects.some(p => p.vite.config.configFile === file) 349 + || this.config._containerConfigFiles?.includes(file) 343 350 if (isConfig) { 344 351 await this._restart('config') 345 352 }
+249 -84
packages/vitest/src/node/projects/resolveProjects.ts
··· 16 16 UserConfig, 17 17 UserWorkspaceConfig, 18 18 } from '../types/config' 19 - import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' 19 + import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' 20 20 import os from 'node:os' 21 21 import { deepClone } from '@vitest/utils/helpers' 22 22 import { basename, dirname, relative, resolve } from 'pathe' 23 23 import { glob, isDynamicPattern } from 'tinyglobby' 24 24 import { mergeConfig, resolveConfig as viteResolveConfig } from 'vite' 25 25 import { configFiles as defaultConfigFiles } from '../../constants' 26 + import { wildcardPatternToRegExp } from '../../utils/base' 26 27 import { limitConcurrency } from '../../utils/limit-concurrency' 27 28 import { isExcludedByProjectFilter, matchesProjectFilter, resolveTestConfig } from '../config/resolveConfig' 28 29 import { BrowserLoaderPlugin, createClusterServer } from '../plugins/browserLoader' ··· 74 75 * - If the user declared `test.projects`, each declared project gets its own 75 76 * resolved Vite config plus per-project Vitest test config. 76 77 * - Otherwise the root config is used as the single base entry. 78 + * - A file-based project whose config declares `projects` itself is a 79 + * container: like the root, it doesn't run tests and is replaced by the 80 + * projects it declares (recursively). 77 81 * - Browser instances expand each entry with `browser.enabled` into one entry 78 82 * per instance (sharing `viteConfig` with the parent). 79 83 * - Benchmarks add a benchmark variant for each entry whose ··· 96 100 // falls through to the default root-project entry. 97 101 let baseEntries: ResolvedProjectEntry[] 98 102 if (definitions !== undefined) { 99 - baseEntries = await resolveDeclaredProjectEntries( 103 + const cliOverrides = PROJECT_CLI_OVERRIDES.reduce((acc, name) => { 104 + if (name in globalConfig.cliOptions) { 105 + acc[name] = globalConfig.cliOptions[name] as any 106 + } 107 + return acc 108 + }, {} as UserConfig) 109 + const containerConfigFiles: string[] = [] 110 + const context: ProjectsResolutionContext = { 100 111 harness, 101 - globalViteConfig, 102 - globalConfig, 103 - definitions, 104 - ) 112 + rootViteConfig: globalViteConfig, 113 + rootConfig: globalConfig, 114 + parentViteConfig: globalViteConfig, 115 + parentConfig: globalConfig, 116 + cliOverrides, 117 + ancestors: [], 118 + chain: globalViteConfig.configFile 119 + ? [safeRealpath(globalViteConfig.configFile)] 120 + : [], 121 + containerConfigFiles, 122 + } 123 + baseEntries = await resolveDeclaredProjectEntries(context, definitions) 124 + if (containerConfigFiles.length) { 125 + // appended rather than assigned so `injectTestProjects` containers 126 + // are also watched 127 + globalConfig._containerConfigFiles = [ 128 + ...(globalConfig._containerConfigFiles || []), 129 + ...containerConfigFiles, 130 + ] 131 + } 105 132 } 106 133 else { 107 134 baseEntries = [{ viteConfig: globalViteConfig, projectConfig: globalConfig }] ··· 260 287 ) 261 288 } 262 289 290 + /** 291 + * The set of values threaded through (possibly recursive) project resolution. 292 + * 293 + * The `root*` pair is the true root config: it carries run-wide values (CLI 294 + * options, the `--project` filter, the programmatic config). The `parent*` 295 + * pair is the config the current `projects` array is declared in — the root 296 + * itself or a container config — and provides the default `extends` target, 297 + * env defaults and the base for resolving relative definitions. 298 + */ 299 + interface ProjectsResolutionContext { 300 + harness: PluginHarness 301 + rootViteConfig: ResolvedViteConfig 302 + rootConfig: ResolvedConfig 303 + parentViteConfig: ResolvedViteConfig 304 + parentConfig: ResolvedConfig 305 + /** CLI options projects may override, computed once from the root's `cliOptions` */ 306 + cliOverrides: UserConfig 307 + /** names of the containers above this level, outermost first */ 308 + ancestors: string[] 309 + /** realpaths of the config files above this level, the cycle guard */ 310 + chain: string[] 311 + /** shared across levels; collects container config files for the watcher */ 312 + containerConfigFiles: string[] 313 + } 314 + 263 315 async function resolveDeclaredProjectEntries( 264 - harness: PluginHarness, 265 - globalViteConfig: ResolvedViteConfig, 266 - globalConfig: ResolvedConfig, 316 + context: ProjectsResolutionContext, 267 317 definitions: TestProjectConfiguration[], 268 318 ): Promise<ResolvedProjectEntry[]> { 319 + const { parentViteConfig, parentConfig } = context 269 320 const { configFiles, projectConfigs, nonConfigDirectories } = await resolveTestProjectConfigs( 270 - globalViteConfig, 271 - globalConfig, 321 + parentViteConfig, 322 + parentConfig, 272 323 definitions, 273 324 ) 274 325 275 - const cliOverrides = PROJECT_CLI_OVERRIDES.reduce((acc, name) => { 276 - if (name in globalConfig.cliOptions) { 277 - acc[name] = globalConfig.cliOptions[name] as any 278 - } 279 - return acc 280 - }, {} as UserConfig) 281 326 const concurrent = limitConcurrency(os.availableParallelism?.() || os.cpus().length || 5) 282 327 const fileProjects = [...configFiles, ...nonConfigDirectories] 283 328 284 329 const promises: Promise<ResolvedProjectEntry>[] = [] 285 330 286 331 projectConfigs.forEach((options, index) => { 287 - const configRoot = globalConfig.root 332 + const configRoot = parentConfig.root 288 333 // if extends a config file, resolve the file path 289 334 const configFile = typeof options.extends === 'string' 290 335 ? resolve(configRoot, options.extends) 291 336 : options.extends !== false 292 - ? (globalViteConfig.configFile || false) 337 + ? (parentViteConfig.configFile || false) 293 338 : false 294 - // if `root` is configured, resolve it relative to vite root (like other options) 295 - // if `root` is not specified, inline configs use the same root as the root project 339 + // if `root` is configured, resolve it relative to the declaring config's 340 + // root (like other options); if `root` is not specified, inline configs 341 + // use the same root as the declaring config 296 342 const rawRoot = options.test?.root ?? options.root 297 343 const root = rawRoot 298 344 ? resolve(configRoot, rawRoot) 299 345 : configRoot 300 346 301 - promises.push(concurrent(() => resolveSingleProjectEntry(harness, globalViteConfig, globalConfig, { 347 + promises.push(concurrent(() => resolveSingleProjectEntry(context, { 302 348 ...options, 303 349 root, 304 350 configFile, 305 - }, index, cliOverrides))) 351 + }, index))) 306 352 }) 307 353 308 354 for (const path of fileProjects) { 309 - // if file leads to the root config, then we can just reuse it because we already initialized it 310 - if (globalViteConfig.configFile === path) { 311 - // The root viteConfig is already resolved; emit it as an entry directly. 355 + // if the file leads to the declaring config itself, reuse the already 356 + // resolved pair: the root (or the container) also runs as a regular project 357 + if (parentViteConfig.configFile === path) { 312 358 promises.push(Promise.resolve({ 313 - viteConfig: globalViteConfig, 314 - projectConfig: globalConfig, 359 + viteConfig: parentViteConfig, 360 + projectConfig: parentConfig, 361 + ancestors: context.ancestors.length > 1 362 + ? context.ancestors.slice(0, -1) 363 + : undefined, 315 364 })) 316 365 continue 317 366 } ··· 320 369 const projectRoot = path.endsWith('/') ? path : dirname(path) 321 370 322 371 promises.push(concurrent(() => resolveSingleProjectEntry( 323 - harness, 324 - globalViteConfig, 325 - globalConfig, 372 + context, 326 373 { root: projectRoot, configFile }, 327 374 path, 328 - cliOverrides, 329 375 ))) 330 376 } 331 377 ··· 348 394 ) 349 395 } 350 396 351 - return entries 397 + return flattenContainerEntries(context, entries) 398 + } 399 + 400 + /** 401 + * Replace container entries (file-based configs that declare `projects`) with 402 + * the projects they declare, recursively. A container behaves like the root 403 + * config: it doesn't run tests and never gets a Vite server; its projects 404 + * extend it by default and their names are prefixed with the container's name. 405 + */ 406 + async function flattenContainerEntries( 407 + context: ProjectsResolutionContext, 408 + entries: ResolvedProjectEntry[], 409 + ): Promise<ResolvedProjectEntry[]> { 410 + const result: ResolvedProjectEntry[] = [] 411 + for (const entry of entries) { 412 + const definitions = entry.projectConfig.projects 413 + // inline projects cannot declare `projects`; the declaring config's own 414 + // entry (emitted when it references its own config file) is kept as-is — 415 + // its `projects` are the definitions currently being resolved 416 + if (entry.inline || definitions === undefined || entry.projectConfig === context.parentConfig) { 417 + result.push(entry) 418 + continue 419 + } 420 + 421 + const configFile = entry.viteConfig.configFile 422 + const relativeFile = configFile 423 + ? relative(context.rootConfig.root, configFile) 424 + : entry.projectConfig.name 425 + let chain = context.chain 426 + if (configFile) { 427 + const realConfigFile = safeRealpath(configFile) 428 + if (chain.includes(realConfigFile)) { 429 + throw new Error( 430 + [ 431 + `Found a circular "projects" definition: `, 432 + [...chain, realConfigFile].map(file => `"${relative(context.rootConfig.root, file)}"`).join(' -> '), 433 + '. Make sure your configuration is correct.', 434 + ].join(''), 435 + ) 436 + } 437 + chain = [...chain, realConfigFile] 438 + context.containerConfigFiles.push(configFile) 439 + } 440 + 441 + const childContext: ProjectsResolutionContext = { 442 + ...context, 443 + parentViteConfig: entry.viteConfig, 444 + parentConfig: entry.projectConfig, 445 + ancestors: [...context.ancestors, entry.projectConfig.name], 446 + chain, 447 + } 448 + const children = await resolveDeclaredProjectEntries(childContext, definitions) 449 + if (!children.length) { 450 + throw new Error( 451 + `No projects were found in "${relativeFile}". Make sure your configuration is correct.`, 452 + ) 453 + } 454 + result.push(...children) 455 + } 456 + return result 457 + } 458 + 459 + function safeRealpath(path: string): string { 460 + try { 461 + return realpathSync(path) 462 + } 463 + catch { 464 + return path 465 + } 352 466 } 353 467 354 468 // `name` must stay unique per project, `projects` would redefine the whole workspace 355 469 const NON_INHERITED_OPTIONS = ['name', 'projects'] as const 356 470 357 471 // the root `globalSetup` already runs once per test run; a non-root 358 - // config keeps it because nothing else runs it 472 + // config (a shared config or a container) keeps it because nothing else runs it 359 473 const NON_INHERITED_ROOT_OPTIONS = [...NON_INHERITED_OPTIONS, 'globalSetup'] as const 360 474 361 - function ProjectInheritancePlugin(options: ViteInlineConfig, extendsRootConfig: boolean): VitePlugin { 362 - const nonInheritedOptions = extendsRootConfig 475 + function ProjectInheritancePlugin(options: ViteInlineConfig, extendsTrueRootConfig: boolean): VitePlugin { 476 + const nonInheritedOptions = extendsTrueRootConfig 363 477 ? NON_INHERITED_ROOT_OPTIONS 364 478 : NON_INHERITED_OPTIONS 365 479 return { ··· 405 519 * same guard in `CliOverride`) 406 520 */ 407 521 function inheritRootViteOverrides( 408 - globalConfig: ResolvedConfig, 522 + rootConfig: ResolvedConfig, 409 523 options: ViteInlineConfig, 410 524 ): ViteInlineConfig { 411 - const { plugins: _plugins, ...rootViteOverrides } = globalConfig.viteOverrides 525 + const { plugins: _plugins, ...rootViteOverrides } = rootConfig.viteOverrides 412 526 // cloned so plugins that mutate inherited arrays in place don't share 413 527 // them between the root and every project 414 528 const inherited = deepClone(rootViteOverrides) ··· 418 532 } 419 533 420 534 async function resolveSingleProjectEntry( 421 - harness: PluginHarness, 422 - globalViteConfig: ResolvedViteConfig, 423 - globalConfig: ResolvedConfig, 535 + context: ProjectsResolutionContext, 424 536 options: ViteInlineConfig & { extends?: string | boolean }, 425 537 workspacePath: string | number, 426 - cliOverrides: UserConfig, 427 538 ): Promise<ResolvedProjectEntry> { 539 + const { harness, rootViteConfig, rootConfig, parentViteConfig, parentConfig, cliOverrides } = context 428 540 const { configFile, ...restOptions } = options 429 541 430 542 const browserHolder: BrowserContributionHolder = {} ··· 432 544 // only inline entries (keyed by their index) extend another config; 433 545 // file-based projects own all of their values 434 546 const isInlineEntry = typeof workspacePath === 'number' 435 - const inheritsRootConfig = isInlineEntry 547 + const inheritsParentConfig = isInlineEntry 436 548 && options.extends !== false 437 549 && typeof options.extends !== 'string' 438 - // `extends: './path'` can still point back to the root config file 439 - const extendsRootConfig = inheritsRootConfig 440 - || (!!configFile && configFile === globalViteConfig.configFile) 550 + // the root `globalSetup` runs once per test run, so it is stripped from 551 + // projects extending the root config file (directly or via `extends: true` 552 + // at the top level); a container's `globalSetup` stays inherited because 553 + // nothing else runs it, like any other non-root extended config 554 + const extendsTrueRootConfig = (inheritsParentConfig && parentConfig === rootConfig) 555 + || (!!configFile && configFile === rootViteConfig.configFile) 441 556 442 - const inlineOptions = inheritsRootConfig 443 - ? inheritRootViteOverrides(globalConfig, restOptions) 557 + // the programmatic config is part of the effective root config; a container 558 + // is fully described by its file, so only root-level projects inherit it 559 + const inlineOptions = inheritsParentConfig && parentConfig === rootConfig 560 + ? inheritRootViteOverrides(rootConfig, restOptions) 444 561 : restOptions 445 562 446 563 const projectInline: ViteInlineConfig = { 447 564 ...inlineOptions, 448 565 configFile, 449 - configLoader: globalViteConfig.inlineConfig.configLoader, 566 + configLoader: parentViteConfig.inlineConfig.configLoader, 450 567 // this will make "mode": "test" inside defineConfig 451 - mode: options.test?.mode || options.mode || globalConfig.mode, 568 + mode: options.test?.mode || options.mode || parentConfig.mode, 452 569 plugins: [ 453 570 CliOverride(cliOverrides), 454 571 ...(options.plugins || []), 455 572 ...WorkspaceVitestPlugin( 456 573 harness, 457 - globalViteConfig, 458 - globalConfig, 574 + parentViteConfig, 575 + rootConfig, 459 576 options, 460 577 ), 461 578 ...BrowserLoaderPlugin(browserHolder, harness), 462 579 ...(isInlineEntry 463 - ? [ProjectInheritancePlugin(options, extendsRootConfig)] 580 + ? [ProjectInheritancePlugin(options, extendsTrueRootConfig)] 464 581 : []), 465 582 ], 466 583 } 467 584 468 585 const projectViteConfig = await viteResolveConfig(projectInline, 'serve') 469 586 470 - // inherit the root's resolved env as defaults; a project's own env wins, 471 - // like every other option a project can override 472 - for (const key in globalViteConfig.env) { 473 - projectViteConfig.env[key] ??= globalViteConfig.env[key] 587 + // inherit the declaring config's resolved env as defaults; a project's own 588 + // env wins, like every other option a project can override 589 + for (const key in parentViteConfig.env) { 590 + projectViteConfig.env[key] ??= parentViteConfig.env[key] 474 591 } 475 592 476 593 const mergedOptions = (projectViteConfig.test ?? {}) as UserConfig 477 594 478 595 // resolved after `viteResolveConfig` so a plugin can still set `test.name` 479 - mergedOptions.name = resolveProjectName(mergedOptions.name, workspacePath) 596 + mergedOptions.name = resolveProjectName( 597 + mergedOptions.name, 598 + workspacePath, 599 + context.ancestors.at(-1), 600 + ) 480 601 481 602 const projectConfig = resolveTestConfig( 482 603 harness.logger, 483 604 mergedOptions, 484 605 projectViteConfig, 485 - globalConfig, 606 + parentConfig, 486 607 ) 487 608 488 609 projectViteConfig.test = projectConfig ··· 496 617 viteConfig: projectViteConfig, 497 618 projectConfig, 498 619 inline: isInlineEntry, 620 + ancestors: context.ancestors.length ? [...context.ancestors] : undefined, 499 621 } 500 622 } 501 623 ··· 533 655 const parentName = projectConfig.name 534 656 535 657 const instances = projectConfig.browser.instances ?? [] 536 - if (instances.length === 0 || isExcludedByProjectFilter(globalConfig.project, parentName)) { 658 + if (instances.length === 0 || isEntryExcludedByFilter(globalConfig.project, parentName, entry.ancestors)) { 537 659 continue 538 660 } 539 661 540 - const keepAllInstances = matchesProjectFilter(globalConfig.project, parentName) 662 + const keepAllInstances = matchesEntryFilter(globalConfig.project, parentName, entry.ancestors) 541 663 const filteredInstances = keepAllInstances 542 664 ? instances 543 665 : instances.filter(instance => matchesProjectFilter(globalConfig.project, instance.name!)) ··· 641 763 result.push({ 642 764 viteConfig, // shared with parent 643 765 projectConfig: clonedConfig, 766 + ancestors: entry.ancestors, 644 767 }) 645 768 }) 646 769 } ··· 714 837 result.push({ 715 838 viteConfig: entry.viteConfig, // shared with non-benchmark counterpart 716 839 projectConfig: benchmarkConfig, 840 + ancestors: entry.ancestors, 717 841 }) 718 842 } 719 843 ··· 747 871 // Browser instance: already filtered during expansion. 748 872 return true 749 873 } 750 - return matchesProjectFilter(filter, entry.projectConfig.name) 874 + return matchesEntryFilter(filter, entry.projectConfig.name, entry.ancestors) 751 875 }) 752 876 } 753 877 ··· 799 923 } satisfies ResolvedConfig, overrideConfig) as ResolvedConfig 800 924 } 801 925 926 + /** 927 + * Match an entry against the `--project` filter. In addition to the entry's 928 + * own name, the names of the containers it is nested under are considered, 929 + * so a container name selects (or excludes) its whole subtree. 930 + */ 931 + function matchesEntryFilter( 932 + filter: string[], 933 + name: string, 934 + ancestors: string[] | undefined, 935 + ): boolean { 936 + if (!filter.length) { 937 + return true 938 + } 939 + const names = [name, ...(ancestors || [])] 940 + return filter.some((project) => { 941 + const regexp = wildcardPatternToRegExp(project) 942 + // a negated pattern compiles into a negative lookahead: the entry is kept 943 + // only when neither its name nor any of its containers match the exclusion 944 + return project.startsWith('!') 945 + ? names.every(candidate => regexp.test(candidate)) 946 + : names.some(candidate => regexp.test(candidate)) 947 + }) 948 + } 949 + 950 + function isEntryExcludedByFilter( 951 + filter: string[], 952 + name: string, 953 + ancestors: string[] | undefined, 954 + ): boolean { 955 + return isExcludedByProjectFilter(filter, name) 956 + || !!ancestors?.some(ancestor => isExcludedByProjectFilter(filter, ancestor)) 957 + } 958 + 802 959 async function resolveTestProjectConfigs( 803 - globalViteConfig: ResolvedViteConfig, 804 - globalConfig: ResolvedConfig, 960 + parentViteConfig: ResolvedViteConfig, 961 + parentConfig: ResolvedConfig, 805 962 projectsDefinition: TestProjectConfiguration[], 806 963 ) { 807 964 // project configurations that were specified directly ··· 818 975 819 976 for (const definition of projectsDefinition) { 820 977 if (typeof definition === 'string') { 821 - const stringOption = definition.replace('<rootDir>', globalConfig.root) 978 + const stringOption = definition.replace('<rootDir>', parentConfig.root) 822 979 // if the string doesn't contain a glob, we can resolve it directly 823 980 // ['./vitest.config.js'] 824 981 if (!isDynamicPattern(stringOption)) { 825 - const file = resolve(globalConfig.root, stringOption) 982 + const file = resolve(parentConfig.root, stringOption) 826 983 827 984 if (!existsSync(file)) { 828 985 throw new Error(`Projects definition references a non-existing file or a directory: ${file}`) ··· 834 991 const name = basename(file) 835 992 if (!CONFIG_REGEXP.test(name)) { 836 993 throw new Error( 837 - `The file "${relative(globalConfig.root, file)}" must start with "vitest.config"/"vite.config" ` 994 + `The file "${relative(parentConfig.root, file)}" must start with "vitest.config"/"vite.config" ` 838 995 + `or match the pattern "(vitest|vite).*.config.*" to be a valid project config.`, 839 996 ) 840 997 } ··· 867 1024 else if (typeof definition === 'function') { 868 1025 projectsOptions.push(await definition({ 869 1026 command: 'serve', 870 - mode: globalViteConfig.mode, 1027 + mode: parentViteConfig.mode, 871 1028 isPreview: false, 872 1029 isSsrBuild: false, 873 1030 })) ··· 883 1040 absolute: true, 884 1041 dot: true, 885 1042 onlyFiles: false, 886 - cwd: globalConfig.root, 1043 + cwd: parentConfig.root, 887 1044 expandDirectories: false, 888 1045 ignore: [ 889 1046 '**/node_modules/**', ··· 912 1069 const name = basename(path) 913 1070 if (!CONFIG_REGEXP.test(name)) { 914 1071 throw new Error( 915 - `The projects glob matched a file "${relative(globalConfig.root, path)}", ` 1072 + `The projects glob matched a file "${relative(parentConfig.root, path)}", ` 916 1073 + `but it should also either start with "vitest.config"/"vite.config" ` 917 1074 + `or match the pattern "(vitest|vite).*.config.*".`, 918 1075 ) ··· 945 1102 /** 946 1103 * Resolve a project's name, falling back to the `package.json` name or the 947 1104 * directory/index when neither the config nor a plugin provided one. 1105 + * 1106 + * Projects declared by a container config are namespaced by the container's 1107 + * name: the "unit" project of an "app" container is named "app (unit)". 948 1108 */ 949 1109 function resolveProjectName( 950 1110 name: string | ProjectName | undefined, 951 1111 workspacePath: string | number, 1112 + containerLabel: string | undefined, 952 1113 ): ProjectName { 953 1114 let { label, color }: ProjectName = typeof name === 'string' 954 1115 ? { label: name } 955 1116 : { label: '', ...name } 956 1117 957 - if (label) { 958 - return { label, color } 1118 + if (!label) { 1119 + if (typeof workspacePath === 'number') { 1120 + label = workspacePath.toString() 1121 + } 1122 + else { 1123 + const dir = workspacePath.endsWith('/') 1124 + ? workspacePath.slice(0, -1) 1125 + : dirname(workspacePath) 1126 + const pkgJsonPath = resolve(dir, 'package.json') 1127 + if (existsSync(pkgJsonPath)) { 1128 + label = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')).name 1129 + } 1130 + if (typeof label !== 'string' || !label) { 1131 + label = basename(dir) 1132 + } 1133 + } 959 1134 } 960 1135 961 - if (typeof workspacePath === 'number') { 962 - return { label: workspacePath.toString(), color } 1136 + if (containerLabel) { 1137 + label = `${containerLabel} (${label})` 963 1138 } 964 1139 965 - const dir = workspacePath.endsWith('/') 966 - ? workspacePath.slice(0, -1) 967 - : dirname(workspacePath) 968 - const pkgJsonPath = resolve(dir, 'package.json') 969 - if (existsSync(pkgJsonPath)) { 970 - label = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')).name 971 - } 972 - if (typeof label !== 'string' || !label) { 973 - label = basename(dir) 974 - } 975 1140 return { label, color } 976 1141 } 977 1142
+26 -3
packages/vitest/src/node/types/config.ts
··· 396 396 fileParallelism?: boolean 397 397 398 398 /** 399 - * Options for projects 399 + * Options for projects. 400 + * 401 + * When a project is referenced as a config file (or a directory with one) and 402 + * that config declares `projects` itself, it becomes a container: it doesn't 403 + * run tests, only provides the projects it declares. Inline configurations 404 + * cannot declare `projects`. 400 405 */ 401 406 projects?: TestProjectConfiguration[] 402 407 ··· 1280 1285 viteOverrides: ViteUserConfig 1281 1286 resolvedProjects: ResolvedProjectEntry[] 1282 1287 /** 1288 + * Config files that declared `projects` and act as containers (the file 1289 + * itself doesn't run tests). Set only on the root config; used to restart 1290 + * on change since containers have no Vite server of their own. 1291 + * 1292 + * @internal 1293 + */ 1294 + _containerConfigFiles?: string[] 1295 + /** 1283 1296 * Browser server contribution captured by the `vitest:browser:loader` plugin 1284 1297 * during this config's resolution (set only when `browser.enabled`). Used by 1285 1298 * server creation to build the single Vite server shared by `project.vite` and ··· 1319 1332 * by default), not a file of its own. 1320 1333 */ 1321 1334 inline?: boolean 1335 + /** 1336 + * Names of the container configs this project is nested under, outermost 1337 + * first. The `--project` filter matches these in addition to the project's 1338 + * own name, so a container name selects its whole subtree. 1339 + * 1340 + * @internal 1341 + */ 1342 + ancestors?: string[] 1322 1343 } 1323 1344 1324 1345 type NonProjectOptions ··· 1378 1399 export type ProjectConfig = Omit< 1379 1400 InlineConfig, 1380 1401 NonProjectOptions 1402 + // `projects` is only respected in config files; a container config is 1403 + // root-like and should be authored with `defineConfig`/`defineProject` 1404 + | 'projects' 1381 1405 | 'sequence' 1382 1406 | 'deps' 1383 1407 > & { ··· 1397 1421 > 1398 1422 1399 1423 export interface UserWorkspaceConfig extends ViteUserConfig { 1400 - test?: ProjectConfig 1424 + test?: ProjectConfig & { projects?: TestProjectConfiguration[] } 1401 1425 } 1402 1426 1403 - // TODO: remove types when "workspace" support is removed 1404 1427 export type UserProjectConfigFn = ( 1405 1428 env: ConfigEnv, 1406 1429 ) => UserWorkspaceConfig | Promise<UserWorkspaceConfig>