From a75ea7d652b6ea8479e6a45040916bc00921d245 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:29:16 +0000 Subject: [PATCH] fix(@angular/build): execute setup file hooks for each spec file when coverage is enabled When code coverage is enabled with the Vitest runner, test entry points are served as a virtual one-line import stub (`import "./${outputPath}";`) so that the test files themselves can be excluded from coverage reports after sourcemap remapping. However, setup files configured via `setupFiles` were also treated as test entry points and served as virtual stubs. Because Vitest only invalidates the top-level setup module between spec files, the imported intermediate bundle was cached in `vite-node` after its initial evaluation and never re-evaluated on subsequent spec files. This caused setup file hooks (e.g. `beforeEach` / `afterEach`) to silently only run for the first spec file of each worker. This commit excludes setup files from being wrapped in the virtual coverage stub, allowing them to be directly evaluated and re-evaluated by Vitest before each spec file. Fixes #34137 --- .../unit-test/runners/vitest/executor.ts | 1 + .../unit-test/runners/vitest/plugins.ts | 14 ++++- .../tests/options/setup-files_spec.ts | 63 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) 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 fc64e432e19f..55003b4fc82a 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 @@ -333,6 +333,7 @@ export class VitestExecutor implements TestExecutor { projectName, buildResultFiles: this.buildResultFiles, testFileToEntryPoint: this.testFileToEntryPoint, + setupFiles: testSetupFiles, }); const debugOptions = debug 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 ee81e417bb5d..6be4c6e8ebe3 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 @@ -37,6 +37,7 @@ interface PluginOptions { projectName: string; buildResultFiles: ReadonlyMap; testFileToEntryPoint: ReadonlyMap; + setupFiles: readonly string[]; } type VitestCoverageOption = Exclude; @@ -313,8 +314,13 @@ async function loadResultFile(file: ResultFile): Promise { } export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] { - const { workspaceRoot, buildResultFiles, testFileToEntryPoint } = pluginOptions; + const { workspaceRoot, buildResultFiles, testFileToEntryPoint, setupFiles } = pluginOptions; const isWindows = platform() === 'win32'; + const setupFileSet = new Set( + setupFiles.map((file) => + toPosixPath(path.isAbsolute(file) ? file : path.join(workspaceRoot, file)), + ), + ); let vitestConfig: ResolvedConfig; return [ @@ -387,7 +393,11 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] if (entryPoint) { outputPath = entryPoint + '.js'; - if (vitestConfig?.coverage?.enabled) { + // Setup files must not be wrapped in a virtual import stub because Vitest only invalidates + // the setup file itself between test files; wrapping it would cause the underlying bundle + // to be cached, preventing per-test hooks from running on subsequent test files. + const isSetupFile = setupFileSet.has(id); + if (vitestConfig?.coverage?.enabled && !isSetupFile) { // To support coverage exclusion of the actual test file, the virtual // test entry point only references the built and bundled intermediate file. // If vitest supported an "excludeOnlyAfterRemap" option, this could be removed completely. diff --git a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts index 65d0dadf17b8..44241bbcfd8a 100644 --- a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts @@ -54,5 +54,68 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); }); + + it('should run setup file hooks for each spec file when coverage is enabled', async () => { + await harness.writeFiles({ + 'custom-vitest.config.mts': ` + import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + fileParallelism: false, + }, + }); + `, + 'src/setup.ts': ` + import { afterEach, beforeEach, expect } from 'vitest'; + const global = globalThis as typeof globalThis & { + setupHookCalls?: string[]; + }; + const setupHookCalls = (global.setupHookCalls ??= []); + beforeEach(() => { + const testName = expect.getState().currentTestName ?? ''; + setupHookCalls.push('beforeEach:' + testName); + }); + afterEach(() => { + const testName = expect.getState().currentTestName ?? ''; + setupHookCalls.push('afterEach:' + testName); + }); + `, + 'src/app/app.component.spec.ts': ` + import { expect, it } from 'vitest'; + it('runs setup hooks for first test in app.component.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for first test in app.component.spec'); + }); + it('runs setup hooks for second test in app.component.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for second test in app.component.spec'); + expect(global.setupHookCalls).toContain('afterEach:runs setup hooks for first test in app.component.spec'); + }); + `, + 'src/app/second.spec.ts': ` + import { expect, it } from 'vitest'; + it('runs setup hooks for first test in second.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for first test in second.spec'); + }); + it('runs setup hooks for second test in second.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for second test in second.spec'); + expect(global.setupHookCalls).toContain('afterEach:runs setup hooks for first test in second.spec'); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + runnerConfig: 'custom-vitest.config.mts', + setupFiles: ['src/setup.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); }); });