-
Notifications
You must be signed in to change notification settings - Fork 11.9k
fix(@angular/build): forward tsconfig paths as Vite aliases for Vitest coverage #33024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<string, string[]> = 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) }]; | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+98
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To ensure consistent behavior across different operating systems, especially on Windows, it is recommended to normalize the resolved alias paths to POSIX format using
Suggested change
|
||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| } 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 } : {}), | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: '<h1>{{ greeting }}</h1>', | ||
| 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(); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current regex for stripping comments is prone to breaking valid JSON if it contains strings with
//or/*(for example, URLs likehttps://angular.ioor path patterns). A safer approach is to use a regex that accounts for strings to avoid accidental matches within them.