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(); + }); + }); +});