Skip to content

Commit 008fc74

Browse files
author
tomeelog
committed
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
1 parent 8b62c18 commit 008fc74

3 files changed

Lines changed: 155 additions & 0 deletions

File tree

packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,9 @@ export class VitestExecutor implements TestExecutor {
378378
projectPlugins,
379379
include,
380380
watch,
381+
tsConfigPath: this.options.tsConfig
382+
? path.join(workspaceRoot, this.options.tsConfig)
383+
: undefined,
381384
}),
382385
],
383386
};

packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,59 @@ interface VitestConfigPluginOptions {
5454
include: string[];
5555
optimizeDepsInclude: string[];
5656
watch: boolean;
57+
/** Absolute path to the tsconfig file. When provided, its `paths` are forwarded
58+
* as Vite resolve aliases so that import analysis during coverage does not fail
59+
* to resolve tsconfig path aliases (e.g. `#/util`). */
60+
tsConfigPath?: string;
61+
}
62+
63+
/**
64+
* Escapes special regex characters in a string so it can be used inside a RegExp.
65+
*/
66+
function escapeRegExp(str: string): string {
67+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
68+
}
69+
70+
/**
71+
* Reads a tsconfig file and converts its `compilerOptions.paths` entries to
72+
* Vite-compatible resolve aliases. This ensures that path aliases such as
73+
* `"#/util": ["./src/util"]` are honoured during Vitest coverage processing
74+
* where `vite:import-analysis` re-resolves imports from the original source
75+
* files (see https://github.com/angular/angular-cli/issues/32891).
76+
*/
77+
async function readTsconfigPathAliases(
78+
tsConfigPath: string,
79+
): Promise<{ find: string | RegExp; replacement: string }[]> {
80+
try {
81+
const raw = await readFile(tsConfigPath, 'utf-8');
82+
// tsconfig files may contain C-style comments – strip them before parsing.
83+
const json = JSON.parse(raw.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ''));
84+
const paths: Record<string, string[]> = json?.compilerOptions?.paths ?? {};
85+
const rawBaseUrl: string = json?.compilerOptions?.baseUrl ?? '.';
86+
const baseDir = path.isAbsolute(rawBaseUrl)
87+
? rawBaseUrl
88+
: path.join(path.dirname(tsConfigPath), rawBaseUrl);
89+
90+
return Object.entries(paths).flatMap(([pattern, targets]) => {
91+
if (!targets.length) {
92+
return [];
93+
}
94+
const target = targets[0];
95+
if (pattern.endsWith('/*')) {
96+
// Wildcard alias: "@app/*" -> "./src/app/*"
97+
const prefix = pattern.slice(0, -2);
98+
const targetDir = path.join(baseDir, target.replace(/\/\*$/, ''));
99+
return [{
100+
find: new RegExp(`^${escapeRegExp(prefix)}\/(.*)$`),
101+
replacement: `${targetDir}/$1`,
102+
}];
103+
}
104+
// Exact alias: "#/util" -> "./src/util"
105+
return [{ find: pattern, replacement: path.join(baseDir, target) }];
106+
});
107+
} catch {
108+
return [];
109+
}
57110
}
58111

59112
async function findTestEnvironment(
@@ -164,6 +217,10 @@ export async function createVitestConfigPlugin(
164217

165218
const projectResolver = createRequire(projectSourceRoot + '/').resolve;
166219

220+
const tsconfigAliases = options.tsConfigPath
221+
? await readTsconfigPathAliases(options.tsConfigPath)
222+
: [];
223+
167224
const projectDefaults: UserWorkspaceConfig = {
168225
test: {
169226
setupFiles,
@@ -179,6 +236,7 @@ export async function createVitestConfigPlugin(
179236
resolve: {
180237
mainFields: ['es2020', 'module', 'main'],
181238
conditions: ['es2015', 'es2020', 'module', ...(browser ? ['browser'] : [])],
239+
...(tsconfigAliases.length ? { alias: tsconfigAliases } : {}),
182240
},
183241
};
184242

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import {
11+
BASE_OPTIONS,
12+
UNIT_TEST_BUILDER_INFO,
13+
describeBuilder,
14+
setupApplicationTarget,
15+
} from '../setup';
16+
17+
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
18+
describe('Behavior: "Vitest coverage with tsconfig path aliases"', () => {
19+
beforeEach(async () => {
20+
setupApplicationTarget(harness);
21+
});
22+
23+
it('should resolve tsconfig path aliases during coverage instrumentation', async () => {
24+
// Write a utility module that will be imported via a path alias
25+
await harness.writeFile(
26+
'src/app/util.ts',
27+
`export function greet(name: string): string { return \`Hello, \${name}!\`; }`,
28+
);
29+
30+
// Add a path alias "#/util" -> "./src/app/util" to tsconfig
31+
await harness.modifyFile('src/tsconfig.spec.json', (content) => {
32+
const tsconfig = JSON.parse(content);
33+
tsconfig.compilerOptions ??= {};
34+
tsconfig.compilerOptions.paths = {
35+
'#/*': ['./app/*'],
36+
};
37+
return JSON.stringify(tsconfig, null, 2);
38+
});
39+
40+
// Write an app component that imports via the alias
41+
await harness.writeFile(
42+
'src/app/app.component.ts',
43+
`
44+
import { Component } from '@angular/core';
45+
import { greet } from '#/util';
46+
47+
@Component({
48+
selector: 'app-root',
49+
template: '<h1>{{ greeting }}</h1>',
50+
standalone: true,
51+
})
52+
export class AppComponent {
53+
greeting = greet('world');
54+
}
55+
`,
56+
);
57+
58+
// Write a spec that exercises the component (and hence imports #/util transitively)
59+
await harness.writeFile(
60+
'src/app/app.component.spec.ts',
61+
`
62+
import { TestBed } from '@angular/core/testing';
63+
import { AppComponent } from './app.component';
64+
65+
describe('AppComponent', () => {
66+
beforeEach(async () => {
67+
await TestBed.configureTestingModule({
68+
imports: [AppComponent],
69+
}).compileComponents();
70+
});
71+
72+
it('should create the app', () => {
73+
const fixture = TestBed.createComponent(AppComponent);
74+
expect(fixture.componentInstance).toBeTruthy();
75+
});
76+
});
77+
`,
78+
);
79+
80+
harness.useTarget('test', {
81+
...BASE_OPTIONS,
82+
coverage: true,
83+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
84+
coverageReporters: ['json'] as any,
85+
});
86+
87+
// Regression: this used to throw "vite:import-analysis Pre-transform error:
88+
// Failed to resolve import" when tsconfig paths were present and coverage was enabled.
89+
const { result } = await harness.executeOnce();
90+
expect(result?.success).toBeTrue();
91+
harness.expectFile('coverage/test/index.html').toExist();
92+
});
93+
});
94+
});

0 commit comments

Comments
 (0)