From 93eef3931490f56ffed1cf39899a385421b5d334 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:58:48 -0500 Subject: [PATCH] refactor(@angular/build): improve vitest `runnerConfig` support via plugin Refactors the Vitest unit test runner to use a dedicated plugin for merging builder-defined configurations with user-provided configurations from a `runnerConfig` file. Previously, the configuration merging logic was handled directly in the executor, which had limitations and did not always correctly apply user overrides. By moving this logic into a Vitest plugin, we leverage Vitest's intended extension mechanism, ensuring a more robust and predictable merge of configurations. This significantly improves the ability for users to customize their test setup. Adds a new test suite to verify that common custom configurations, such as custom reporters, file exclusions, option overrides, and environment settings, are correctly applied from a `vitest.config.ts` file. --- .../unit-test/runners/vitest/executor.ts | 89 +++-------- .../unit-test/runners/vitest/plugins.ts | 95 +++++++++++- .../behavior/runner-config-vitest_spec.ts | 146 ++++++++++++++++++ 3 files changed, 256 insertions(+), 74 deletions(-) create mode 100644 packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts index f7ff75c82d70..b3f4bc0c6bad 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts @@ -10,9 +10,8 @@ import type { BuilderOutput } from '@angular-devkit/architect'; import assert from 'node:assert'; import path from 'node:path'; import { isMatch } from 'picomatch'; -import type { InlineConfig, Vitest } from 'vitest/node'; +import type { Vitest } from 'vitest/node'; import { assertIsError } from '../../../../utils/error'; -import { toPosixPath } from '../../../../utils/path'; import { type FullResult, type IncrementalResult, @@ -23,9 +22,7 @@ import { NormalizedUnitTestBuilderOptions } from '../../options'; import type { TestExecutor } from '../api'; import { setupBrowserConfiguration } from './browser-provider'; import { findVitestBaseConfig } from './configuration'; -import { createVitestPlugins } from './plugins'; - -type VitestCoverageOption = Exclude; +import { createVitestConfigPlugin, createVitestPlugins } from './plugins'; export class VitestExecutor implements TestExecutor { private vitest: Vitest | undefined; @@ -89,7 +86,9 @@ export class VitestExecutor implements TestExecutor { if (source) { modifiedSourceFiles.add(source); } - vitest.invalidateFile(toPosixPath(path.join(this.options.workspaceRoot, modifiedFile))); + vitest.invalidateFile( + this.normalizePath(path.join(this.options.workspaceRoot, modifiedFile)), + ); } const specsToRerun = []; @@ -141,6 +140,7 @@ export class VitestExecutor implements TestExecutor { browserViewport, ui, } = this.options; + const projectName = this.projectName; let vitestNodeModule; let vitestCoverageModule; @@ -190,12 +190,10 @@ export class VitestExecutor implements TestExecutor { ); const testSetupFiles = this.prepareSetupFiles(); - const plugins = createVitestPlugins({ + const projectPlugins = createVitestPlugins({ workspaceRoot, projectSourceRoot: this.options.projectSourceRoot, - projectName: this.projectName, - include: this.options.include, - exclude: this.options.exclude, + projectName, buildResultFiles: this.buildResultFiles, testFileToEntryPoint: this.testFileToEntryPoint, }); @@ -213,7 +211,6 @@ export class VitestExecutor implements TestExecutor { runnerConfig === true ? await findVitestBaseConfig([this.options.projectRoot, this.options.workspaceRoot]) : runnerConfig; - const projectName = this.projectName; return startVitest( 'test', @@ -229,71 +226,23 @@ export class VitestExecutor implements TestExecutor { ...debugOptions, }, { - test: { - coverage: await generateCoverageOption(coverage, this.projectName), - ...(reporters ? { reporters } : {}), - projects: [ - { - extends: externalConfigPath || true, - test: { - name: projectName, - globals: true, - setupFiles: testSetupFiles, - ...(this.options.exclude ? { exclude: this.options.exclude } : {}), - browser: browserOptions.browser, - // Use `jsdom` if no browsers are explicitly configured. - ...(browserOptions.browser ? {} : { environment: 'jsdom' }), - ...(this.options.include ? { include: this.options.include } : {}), - }, - optimizeDeps: { - noDiscovery: true, - }, - plugins, - }, - ], - }, server: { // Disable the actual file watcher. The boolean watch option above should still // be enabled as it controls other internal behavior related to rerunning tests. watch: null, }, + plugins: [ + createVitestConfigPlugin({ + browser: browserOptions.browser, + coverage, + projectName, + reporters, + setupFiles: testSetupFiles, + projectPlugins, + include: [...this.testFileToEntryPoint.keys()], + }), + ], }, ); } } - -async function generateCoverageOption( - coverage: NormalizedUnitTestBuilderOptions['coverage'], - projectName: string, -): Promise { - let defaultExcludes: string[] = []; - if (coverage.exclude) { - try { - const vitestConfig = await import('vitest/config'); - defaultExcludes = vitestConfig.coverageConfigDefaults.exclude; - } catch {} - } - - return { - enabled: coverage.enabled, - excludeAfterRemap: true, - include: coverage.include, - reportsDirectory: toPosixPath(path.join('coverage', projectName)), - thresholds: coverage.thresholds, - watermarks: coverage.watermarks, - // Special handling for `exclude`/`reporters` due to an undefined value causing upstream failures - ...(coverage.exclude - ? { - exclude: [ - // Augment the default exclude https://vitest.dev/config/#coverage-exclude - // with the user defined exclusions - ...coverage.exclude, - ...defaultExcludes, - ], - } - : {}), - ...(coverage.reporters - ? ({ reporter: coverage.reporters } satisfies VitestCoverageOption) - : {}), - }; -} 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 fd06475e6980..22f3eeb77922 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 @@ -9,12 +9,16 @@ import assert from 'node:assert'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import type { VitestPlugin } from 'vitest/node'; +import type { + BrowserConfigOptions, + InlineConfig, + UserWorkspaceConfig, + VitestPlugin, +} from 'vitest/node'; import { createBuildAssetsMiddleware } from '../../../../tools/vite/middlewares/assets-middleware'; import { toPosixPath } from '../../../../utils/path'; import type { ResultFile } from '../../../application/results'; import type { NormalizedUnitTestBuilderOptions } from '../../options'; -import type { BrowserConfiguration } from './browser-provider'; type VitestPlugins = Awaited>; @@ -22,12 +26,59 @@ interface PluginOptions { workspaceRoot: string; projectSourceRoot: string; projectName: string; - include?: string[]; - exclude?: string[]; buildResultFiles: ReadonlyMap; testFileToEntryPoint: ReadonlyMap; } +type VitestCoverageOption = Exclude; + +interface VitestConfigPluginOptions { + browser: BrowserConfigOptions | undefined; + coverage: NormalizedUnitTestBuilderOptions['coverage']; + projectName: string; + reporters?: string[] | [string, object][]; + setupFiles: string[]; + projectPlugins: VitestPlugins; + include: string[]; +} + +export function createVitestConfigPlugin(options: VitestConfigPluginOptions): VitestPlugins[0] { + const { include, browser, projectName, reporters, setupFiles, projectPlugins } = options; + + return { + name: 'angular:vitest-configuration', + async config(config) { + const testConfig = config.test; + + const projectConfig: UserWorkspaceConfig = { + test: { + ...testConfig, + name: projectName, + setupFiles, + include, + globals: testConfig?.globals ?? true, + ...(browser ? { browser } : {}), + // If the user has not specified an environment, use `jsdom`. + ...(!testConfig?.environment ? { environment: 'jsdom' } : {}), + }, + optimizeDeps: { + noDiscovery: true, + }, + plugins: projectPlugins, + }; + + return { + test: { + coverage: await generateCoverageOption(options.coverage, projectName), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(reporters ? ({ reporters } as any) : {}), + projects: [projectConfig], + }, + }; + }, + }; +} + export function createVitestPlugins(pluginOptions: PluginOptions): VitestPlugins { const { workspaceRoot, buildResultFiles, testFileToEntryPoint } = pluginOptions; @@ -134,3 +185,39 @@ export function createVitestPlugins(pluginOptions: PluginOptions): VitestPlugins }, ]; } + +async function generateCoverageOption( + coverage: NormalizedUnitTestBuilderOptions['coverage'], + projectName: string, +): Promise { + let defaultExcludes: string[] = []; + if (coverage.exclude) { + try { + const vitestConfig = await import('vitest/config'); + defaultExcludes = vitestConfig.coverageConfigDefaults.exclude; + } catch {} + } + + return { + enabled: coverage.enabled, + excludeAfterRemap: true, + include: coverage.include, + reportsDirectory: toPosixPath(path.join('coverage', projectName)), + thresholds: coverage.thresholds, + watermarks: coverage.watermarks, + // Special handling for `exclude`/`reporters` due to an undefined value causing upstream failures + ...(coverage.exclude + ? { + exclude: [ + // Augment the default exclude https://vitest.dev/config/#coverage-exclude + // with the user defined exclusions + ...coverage.exclude, + ...defaultExcludes, + ], + } + : {}), + ...(coverage.reporters + ? ({ reporter: coverage.reporters } satisfies VitestCoverageOption) + : {}), + }; +} 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 new file mode 100644 index 000000000000..e2a50001dd9e --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +const VITEST_CONFIG_CONTENT = ` +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { + reporters: [['junit', { outputFile: './vitest-results.xml' }]], + }, +}); +`; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "runnerConfig with Vitest runner"', () => { + beforeEach(() => { + setupApplicationTarget(harness); + }); + + it('should use custom reporters defined in runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.ts', + }); + + harness.writeFile('vitest.config.ts', VITEST_CONFIG_CONTENT); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toExist(); + }); + + it('should exclude test files based on 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/app.component.spec.ts'], + reporters: ['default', ['json', { outputFile: 'vitest-results.json' }]], + }, + }); + `, + ); + + // Create a second test file that should be executed + harness.writeFile( + 'src/app/app-second.spec.ts', + ` + import { TestBed } from '@angular/core/testing'; + import { AppComponent } from './app.component'; + + describe('AppComponent', () => { + beforeEach(() => TestBed.configureTestingModule({ + declarations: [AppComponent], + })); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const results = JSON.parse(harness.readFile('vitest-results.json')); + expect(results.numPassedTests).toBe(1); + }); + it('should allow overriding builder options via 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: { + globals: false, + }, + }); + `, + ); + + // This test will fail if globals are enabled, because `test` will not be defined. + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { vi, test, expect } from 'vitest'; + test('should pass', () => { + expect(true).toBe(true); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + }); + + it('should fail when a DOM-dependent test is run in a node environment', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.ts', + }); + + harness.writeFile( + 'vitest.config.ts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + environment: 'node', + }, + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + }); + }); +});