From 9cb03fe5c92f550720e085ab7e7ad324c1e1e7da Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:20:56 -0400 Subject: [PATCH 1/3] fix(@angular/build): warn about performance of test.exclude in vitest configuration When a user specifies `test.exclude` inside their `vitest.config.ts`, the tests are correctly excluded during Vitest's execution phase. However, because the Angular CLI extracts test files earlier in the process using its own `exclude` builder option, those skipped tests are still unnecessarily compiled by esbuild in-memory. This adds a warning to explicitly notify developers of this hidden build overhead and suggests using the Angular CLI `exclude` option instead to improve performance. --- .../unit-test/runners/vitest/plugins.ts | 8 +++++ .../behavior/runner-config-vitest_spec.ts | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index bd8a6926bfd6..e526e5fa3f83 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -106,6 +106,14 @@ export async function createVitestConfigPlugin( delete testConfig.watch; } + if (testConfig?.exclude) { + this.warn( + 'The "test.exclude" option in the Vitest configuration file is evaluated after ' + + 'tests are compiled. For better build performance, please use the Angular CLI ' + + '"exclude" option instead.', + ); + } + // Merge user-defined plugins from the Vitest config with the CLI's internal plugins. if (config.plugins) { const userPlugins = config.plugins.filter( diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts index fcfa3644035f..a2c626828b6f 100644 --- a/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts @@ -348,6 +348,38 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { // ); }); + it('should warn about performance when "test.exclude" option is in runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.ts', + }); + + harness.writeFile( + 'vitest.config.ts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + exclude: ['src/app/non-existent.spec.ts'], + }, + }); + `, + ); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // TODO: Re-enable once Vite logs are remapped through build system + // expect(logs).toContain( + // jasmine.objectContaining({ + // level: 'warn', + // message: jasmine.stringMatching( + // 'The "test.exclude" option in the Vitest configuration file is evaluated after', + // ), + // }), + // ); + }); + it(`should append "test.setupFiles" (string) from runnerConfig to the CLI's setup`, async () => { harness.useTarget('test', { ...BASE_OPTIONS, From 70fb54608d9bbe6e4fc0c82112c4b001e98be246 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:44:48 -0400 Subject: [PATCH 2/3] fix(@angular/build): deduplicate and merge coverage excludes with vitest Previously, providing a --coverage-exclude CLI option to the builder would completely clobber any custom coverage.exclude items defined natively within vitest.config.ts. This correctly merges both sources using an internal Set to prevent duplicate exclusions and preserves configurations so developers can combine global ignores alongside CLI-specific boundaries. --- .../unit-test/runners/vitest/plugins.ts | 18 ++++--- .../behavior/runner-config-vitest_spec.ts | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index e526e5fa3f83..3eb47f1a459b 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -406,6 +406,9 @@ async function generateCoverageOption( projectName: string, ): Promise { let defaultExcludes: string[] = []; + // When a coverage exclude option is provided, Vitest's default coverage excludes + // will be overridden. To retain them, we manually fetch the defaults to append to the + // user's provided exclusions. if (optionsCoverage.exclude) { try { const vitestConfig = await import('vitest/config'); @@ -437,12 +440,15 @@ async function generateCoverageOption( // Special handling for `exclude`/`reporters` due to an undefined value causing upstream failures ...(optionsCoverage.exclude ? { - exclude: [ - // Augment the default exclude https://vitest.dev/config/#coverage-exclude - // with the user defined exclusions - ...optionsCoverage.exclude, - ...defaultExcludes, - ], + exclude: Array.from( + new Set([ + // Augment the default exclude https://vitest.dev/config/#coverage-exclude + // with the user defined exclusions + ...(configCoverage?.exclude || []), + ...optionsCoverage.exclude, + ...defaultExcludes, + ]), + ), } : {}), ...(optionsCoverage.reporters diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts index a2c626828b6f..5fbace9076e6 100644 --- a/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts @@ -164,6 +164,59 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { expect(results.numPassedTests).toBe(1); }); + it('should correctly merge coverage.exclude arrays from builder and runner options', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + runnerConfig: 'vitest.config.ts', + coverageExclude: ['src/app/cli-excluded.ts'], + }); + + harness.writeFile( + 'vitest.config.ts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + coverage: { + exclude: ['src/app/config-excluded.ts'], + }, + }, + }); + `, + ); + + // Create two files that would normally be covered + harness.writeFile('src/app/cli-excluded.ts', 'export const cliExcluded = true;'); + harness.writeFile('src/app/config-excluded.ts', 'export const configExcluded = true;'); + + // Update the test file to import them so they're picked up by coverage + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { test, expect } from 'vitest'; + import './cli-excluded'; + import './config-excluded'; + test('should pass', () => { + expect(true).toBe(true); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('coverage/test/coverage-final.json').toExist(); + + const coverageMap = JSON.parse(harness.readFile('coverage/test/coverage-final.json')); + const coveredFiles = Object.keys(coverageMap); + + const hasCliExcluded = coveredFiles.some((f) => f.includes('cli-excluded.ts')); + const hasConfigExcluded = coveredFiles.some((f) => f.includes('config-excluded.ts')); + + expect(hasCliExcluded).withContext('CLI target should be excluded').toBeFalse(); + expect(hasConfigExcluded).withContext('Config file target should be excluded').toBeFalse(); + }); + it('should allow overriding globals to false via runnerConfig file', async () => { harness.useTarget('test', { ...BASE_OPTIONS, From 58bcba8882b9f270264d236e292dc09704ac745a Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:39:31 -0400 Subject: [PATCH 3/3] fix(@angular/build): prevent reporter duplicates by explicitly overriding Vitest configuration When leveraging the Angular CLI to run tests with a specific set of `--reporters`, Vitest's underlying `mergeConfig` logic would normally array-concatenate the CLI reporters with any reporters defined natively inside `vitest.config.ts`. This led to duplicate output processors (e.g. running 'default' twice). By explicitly wiping the original configurations when CLI overrides are present, the CLI now acts as a strict Source-of-Truth array replacer, which is the expected behavior for CI and custom targets. --- .../builders/unit-test/runners/vitest/plugins.ts | 12 ++++++++++++ .../tests/behavior/runner-config-vitest_spec.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index 3eb47f1a459b..d36f8a05ffa6 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -82,6 +82,18 @@ export async function createVitestConfigPlugin( async config(config) { const testConfig = config.test; + if (reporters !== undefined) { + delete testConfig?.reporters; + } + + if ( + options.coverage.reporters !== undefined && + testConfig?.coverage && + 'reporter' in testConfig.coverage + ) { + delete testConfig.coverage.reporter; + } + if (testConfig?.projects?.length) { this.warn( 'The "test.projects" option in the Vitest configuration file is not supported. ' + diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts index 5fbace9076e6..609d736e00f7 100644 --- a/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts @@ -42,6 +42,21 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { harness.expectFile('vitest-results.xml').toExist(); }); + it('should override reporters defined in runnerConfig file when CLI option is present', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.ts', + reporters: ['default'], + }); + + harness.writeFile('vitest.config.ts', VITEST_CONFIG_CONTENT); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + // The CLI option 'default' should override the 'junit' reporter in VITEST_CONFIG_CONTENT + harness.expectFile('vitest-results.xml').toNotExist(); + }); + it('should use custom reportsDirectory defined in runnerConfig file', async () => { harness.useTarget('test', { ...BASE_OPTIONS,