From 008fc7413ed0491ec0673d5f0c054b9c4dda9b2d Mon Sep 17 00:00:00 2001 From: tomeelog Date: Mon, 20 Apr 2026 16:11:02 +0100 Subject: [PATCH] fix(@angular/build): forward tsconfig paths as Vite aliases for Vitest coverage When using tsconfig.json 'paths' (e.g. "#/util": ["./src/util"]) with coverage enabled, Vitest's vite:import-analysis plugin fails to resolve path-alias imports from original source files during coverage processing because the Angular CLI's Vitest integration did not expose those aliases to the Vite resolve configuration. The fix reads the tsconfig file, converts every paths entry to a Vite resolve.alias entry (supporting both exact and wildcard patterns), and injects them into the projectDefaults resolve config used by the project workspace. This makes path aliases available during both test execution and coverage instrumentation. Fixes #32891 --- .../unit-test/runners/vitest/executor.ts | 3 + .../unit-test/runners/vitest/plugins.ts | 58 ++++++++++++ .../vitest-coverage-tsconfig-paths_spec.ts | 94 +++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 packages/angular/build/src/builders/unit-test/tests/behavior/vitest-coverage-tsconfig-paths_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 c5b70e9a2487..86be72a7666c 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 @@ -378,6 +378,9 @@ export class VitestExecutor implements TestExecutor { projectPlugins, include, watch, + tsConfigPath: this.options.tsConfig + ? path.join(workspaceRoot, this.options.tsConfig) + : undefined, }), ], }; 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 8e28f7f43cc5..45fcbe6cf05a 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 @@ -54,6 +54,59 @@ interface VitestConfigPluginOptions { include: string[]; optimizeDepsInclude: string[]; watch: boolean; + /** Absolute path to the tsconfig file. When provided, its `paths` are forwarded + * as Vite resolve aliases so that import analysis during coverage does not fail + * to resolve tsconfig path aliases (e.g. `#/util`). */ + tsConfigPath?: string; +} + +/** + * Escapes special regex characters in a string so it can be used inside a RegExp. + */ +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Reads a tsconfig file and converts its `compilerOptions.paths` entries to + * Vite-compatible resolve aliases. This ensures that path aliases such as + * `"#/util": ["./src/util"]` are honoured during Vitest coverage processing + * where `vite:import-analysis` re-resolves imports from the original source + * files (see https://github.com/angular/angular-cli/issues/32891). + */ +async function readTsconfigPathAliases( + tsConfigPath: string, +): Promise<{ find: string | RegExp; replacement: string }[]> { + try { + const raw = await readFile(tsConfigPath, 'utf-8'); + // tsconfig files may contain C-style comments – strip them before parsing. + const json = JSON.parse(raw.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '')); + const paths: Record = json?.compilerOptions?.paths ?? {}; + const rawBaseUrl: string = json?.compilerOptions?.baseUrl ?? '.'; + const baseDir = path.isAbsolute(rawBaseUrl) + ? rawBaseUrl + : path.join(path.dirname(tsConfigPath), rawBaseUrl); + + return Object.entries(paths).flatMap(([pattern, targets]) => { + if (!targets.length) { + return []; + } + const target = targets[0]; + if (pattern.endsWith('/*')) { + // Wildcard alias: "@app/*" -> "./src/app/*" + const prefix = pattern.slice(0, -2); + const targetDir = path.join(baseDir, target.replace(/\/\*$/, '')); + return [{ + find: new RegExp(`^${escapeRegExp(prefix)}\/(.*)$`), + replacement: `${targetDir}/$1`, + }]; + } + // Exact alias: "#/util" -> "./src/util" + return [{ find: pattern, replacement: path.join(baseDir, target) }]; + }); + } catch { + return []; + } } async function findTestEnvironment( @@ -164,6 +217,10 @@ export async function createVitestConfigPlugin( const projectResolver = createRequire(projectSourceRoot + '/').resolve; + const tsconfigAliases = options.tsConfigPath + ? await readTsconfigPathAliases(options.tsConfigPath) + : []; + const projectDefaults: UserWorkspaceConfig = { test: { setupFiles, @@ -179,6 +236,7 @@ export async function createVitestConfigPlugin( resolve: { mainFields: ['es2020', 'module', 'main'], conditions: ['es2015', 'es2020', 'module', ...(browser ? ['browser'] : [])], + ...(tsconfigAliases.length ? { alias: tsconfigAliases } : {}), }, }; diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-coverage-tsconfig-paths_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-coverage-tsconfig-paths_spec.ts new file mode 100644 index 000000000000..04cef0a19a3d --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-coverage-tsconfig-paths_spec.ts @@ -0,0 +1,94 @@ +/** + * @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, + UNIT_TEST_BUILDER_INFO, + describeBuilder, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "Vitest coverage with tsconfig path aliases"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should resolve tsconfig path aliases during coverage instrumentation', async () => { + // Write a utility module that will be imported via a path alias + await harness.writeFile( + 'src/app/util.ts', + `export function greet(name: string): string { return \`Hello, \${name}!\`; }`, + ); + + // Add a path alias "#/util" -> "./src/app/util" to tsconfig + await harness.modifyFile('src/tsconfig.spec.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions ??= {}; + tsconfig.compilerOptions.paths = { + '#/*': ['./app/*'], + }; + return JSON.stringify(tsconfig, null, 2); + }); + + // Write an app component that imports via the alias + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core'; + import { greet } from '#/util'; + + @Component({ + selector: 'app-root', + template: '

{{ greeting }}

', + standalone: true, + }) + export class AppComponent { + greeting = greet('world'); + } + `, + ); + + // Write a spec that exercises the component (and hence imports #/util transitively) + await harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { TestBed } from '@angular/core/testing'; + import { AppComponent } from './app.component'; + + describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppComponent], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + expect(fixture.componentInstance).toBeTruthy(); + }); + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + coverageReporters: ['json'] as any, + }); + + // Regression: this used to throw "vite:import-analysis Pre-transform error: + // Failed to resolve import" when tsconfig paths were present and coverage was enabled. + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('coverage/test/index.html').toExist(); + }); + }); +});