Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,9 @@ export class VitestExecutor implements TestExecutor {
projectPlugins,
include,
watch,
tsConfigPath: this.options.tsConfig
? path.join(workspaceRoot, this.options.tsConfig)
: undefined,
}),
],
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, ''));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current regex for stripping comments is prone to breaking valid JSON if it contains strings with // or /* (for example, URLs like https://angular.io or path patterns). A safer approach is to use a regex that accounts for strings to avoid accidental matches within them.

Suggested change
const json = JSON.parse(raw.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ''));
const json = JSON.parse(
raw.replace(/("(?:\\.|[^\\"])*")|\/\*[\s\S]*?\*\/|\/\/.*/g, (m, g1) => g1 ?? ''),
);

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure consistent behavior across different operating systems, especially on Windows, it is recommended to normalize the resolved alias paths to POSIX format using toPosixPath. Vite and Vitest generally prefer POSIX-style paths or correctly formatted absolute paths for aliases, and mixing slashes (e.g., C:\path/to/file) can lead to resolution issues.

Suggested change
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) }];
const targetDir = toPosixPath(path.join(baseDir, target.replace(/\/\*$/, '')));
return [{
find: new RegExp('^' + escapeRegExp(prefix) + '\\/(.*)$'),
replacement: targetDir + '/$1',
}];
}
// Exact alias: "#/util" -> "./src/util"
return [{ find: pattern, replacement: toPosixPath(path.join(baseDir, target)) }];

});
} catch {
return [];
}
}

async function findTestEnvironment(
Expand Down Expand Up @@ -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,
Expand All @@ -179,6 +236,7 @@ export async function createVitestConfigPlugin(
resolve: {
mainFields: ['es2020', 'module', 'main'],
conditions: ['es2015', 'es2020', 'module', ...(browser ? ['browser'] : [])],
...(tsconfigAliases.length ? { alias: tsconfigAliases } : {}),
},
};

Expand Down
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();
});
});
});
Loading