From 09b791a84779e648ce2ee1e8e7b2f4efff9f18c8 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:09:26 -0500 Subject: [PATCH 1/2] refactor(@angular/build): avoid secondary import wrapper in Vitest unless coverage is enabled This commit optimizes the Vitest test runner by removing the secondary import wrapper for test entry points when code coverage is not enabled. The wrapper is only necessary to support coverage exclusion of the test files themselves. By capturing the resolved Vitest configuration within the `configureVitest` hook, the plugin can now determine if coverage is enabled and conditionally apply the wrapper. This simplifies the module graph for standard test runs. --- .../unit-test/runners/vitest/plugins.ts | 20 +++++++++++++------ 1 file changed, 14 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 39d42c62d05d..b497d690c4bf 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 @@ -14,6 +14,7 @@ import path from 'node:path'; import type { BrowserConfigOptions, InlineConfig, + ResolvedConfig, UserWorkspaceConfig, VitestPlugin, } from 'vitest/node'; @@ -185,11 +186,15 @@ async function loadResultFile(file: ResultFile): Promise { export function createVitestPlugins(pluginOptions: PluginOptions): VitestPlugins { const { workspaceRoot, buildResultFiles, testFileToEntryPoint } = pluginOptions; const isWindows = platform() === 'win32'; + let vitestConfig: ResolvedConfig; return [ { name: 'angular:test-in-memory-provider', enforce: 'pre', + configureVitest(context) { + vitestConfig = context.vitest.config; + }, resolveId: (id, importer) => { // Fast path for test entry points. if (testFileToEntryPoint.has(id)) { @@ -248,7 +253,7 @@ export function createVitestPlugins(pluginOptions: PluginOptions): VitestPlugins // If the module cannot be resolved from the build artifacts, let other plugins handle it. return undefined; }, - load: async (id) => { + async load(id) { assert(buildResultFiles.size > 0, 'buildResult must be available for in-memory loading.'); // Attempt to load as a source test file. @@ -257,11 +262,14 @@ export function createVitestPlugins(pluginOptions: PluginOptions): VitestPlugins if (entryPoint) { outputPath = entryPoint + '.js'; - // To support coverage exclusion of the actual test file, the virtual - // test entry point only references the built and bundled intermediate file. - return { - code: `import "./${outputPath}";`, - }; + if (vitestConfig.coverage.enabled) { + // 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. + return { + code: `import "./${outputPath}";`, + }; + } } else { // Attempt to load as a built artifact. const relativePath = path.relative(workspaceRoot, id); From 318dd47455a5b52eec43a401b21b0ea37019beed Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:06:13 -0500 Subject: [PATCH 2/2] fix(@angular/build): adjust sourcemap sources when Vitest wrapper is bypassed When code coverage is disabled, the Vitest builder bypasses the secondary import wrapper and serves the compiled output directly as the test file. This caused sourcemaps to be incorrect because the `sources` field in the map (relative to the build root) was being resolved relative to the test file's location. This commit adjusts the `sources` in the sourcemap to be relative to the test file's directory, ensuring correct source mapping in debuggers. --- .../unit-test/runners/vitest/plugins.ts | 18 ++++++++- tests/e2e/tests/vitest/node-sourcemaps.ts | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/tests/vitest/node-sourcemaps.ts 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 b497d690c4bf..6e621ecc588b 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 @@ -113,8 +113,11 @@ export async function createVitestConfigPlugin( delete config.plugins; } - // Add browser source map support - if (browser || testConfig?.browser?.enabled) { + // Add browser source map support if coverage is enabled + if ( + (browser || testConfig?.browser?.enabled) && + (options.coverage.enabled || testConfig?.coverage?.enabled) + ) { projectPlugins.unshift(createSourcemapSupportPlugin()); setupFiles.unshift('virtual:source-map-support'); } @@ -291,6 +294,17 @@ export function createVitestPlugins(pluginOptions: PluginOptions): VitestPlugins if (map) { if (!map.sources?.length && !map.sourcesContent?.length && !map.mappings) { map.sources = ['virtual:builder']; + } else if (!vitestConfig.coverage.enabled && Array.isArray(map.sources)) { + map.sources = (map.sources as string[]).map((source) => { + if (source.startsWith('angular:')) { + return source; + } + + // source is relative to the workspace root because the output file is at the root of the output. + const absoluteSource = path.join(workspaceRoot, source); + + return toPosixPath(path.relative(path.dirname(id), absoluteSource)); + }); } } diff --git a/tests/e2e/tests/vitest/node-sourcemaps.ts b/tests/e2e/tests/vitest/node-sourcemaps.ts new file mode 100644 index 000000000000..8e317b2b72cf --- /dev/null +++ b/tests/e2e/tests/vitest/node-sourcemaps.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng, noSilentNg } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { stripVTControlCharacters } from 'node:util'; + +export default async function (): Promise { + await applyVitestBuilder(); + await ng('generate', 'component', 'my-comp'); + + // Add a failing test to verify source map support + await writeFile( + 'src/app/failing.spec.ts', + ` + describe('Failing Test', () => { + it('should fail', () => { + expect(true).toBe(false); + }); + }); + `, + ); + + try { + await noSilentNg('test', '--no-watch'); + throw new Error('Expected "ng test" to fail.'); + } catch (error: any) { + const stdout = stripVTControlCharacters(error.stdout || error.message); + // We expect the failure from failing.spec.ts + assert.match(stdout, /1 failed/, 'Expected 1 test to fail.'); + // Check that the stack trace points to the correct file + assert.match( + stdout, + /\bsrc[\/\\]app[\/\\]failing\.spec\.ts:4:\d+/, + 'Expected stack trace to point to the source file.', + ); + } +}