Skip to content

Commit 58ce827

Browse files
committed
feat(@angular/build): support Istanbul coverage in Vitest runner
This change enables code coverage reporting when running tests in non-Chromium browsers (like Firefox or Safari) with the Vitest runner. The system now automatically detects the best coverage provider based on the configured browsers and installed packages: - If non-Chromium browsers are configured, it selects 'istanbul'. - If only Chromium browsers are used, it selects 'istanbul' if it is the only provider package installed. - Otherwise, it defaults to 'v8'. It also respects the provider specified in the user's custom Vitest configuration.
1 parent a4f11c1 commit 58ce827

4 files changed

Lines changed: 140 additions & 24 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import assert from 'node:assert';
1010
import type { TestRunner } from '../api';
1111
import { DependencyChecker } from '../dependency-checker';
12+
import { normalizeBrowserName } from './browser-provider';
1213
import { getVitestBuildOptions } from './build-options';
1314
import { VitestExecutor } from './executor';
1415

@@ -50,7 +51,10 @@ const VitestTestRunner: TestRunner = {
5051
}
5152

5253
if (options.coverage.enabled) {
53-
checker.check('@vitest/coverage-v8');
54+
checker.checkAny(
55+
['@vitest/coverage-v8', '@vitest/coverage-istanbul'],
56+
'Code coverage requires either "@vitest/coverage-v8" or "@vitest/coverage-istanbul" to be installed.',
57+
);
5458
}
5559

5660
checker.report();

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

Lines changed: 92 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,81 @@ async function findTestEnvironment(
6969
}
7070
}
7171

72+
function determineCoverageProvider(
73+
browser: BrowserConfigOptions | undefined,
74+
testConfig: InlineConfig | undefined,
75+
optionsCoverageEnabled: boolean | undefined,
76+
projectSourceRoot: string,
77+
): 'istanbul' | 'v8' | 'custom' | undefined {
78+
let determinedProvider = testConfig?.coverage?.provider;
79+
if (!determinedProvider && (optionsCoverageEnabled || testConfig?.coverage?.enabled)) {
80+
const browsersToCheck = getBrowsersToCheck(browser, testConfig?.browser);
81+
82+
const hasNonChromium = browsersToCheck.some(
83+
(b) => !['chrome', 'chromium', 'edge'].includes(normalizeBrowserName(b).browser),
84+
);
85+
86+
if (hasNonChromium) {
87+
determinedProvider = 'istanbul';
88+
} else {
89+
const projectRequire = createRequire(projectSourceRoot + '/');
90+
const checkInstalled = (pkg: string) => {
91+
try {
92+
projectRequire.resolve(pkg);
93+
94+
return true;
95+
} catch {
96+
return false;
97+
}
98+
};
99+
const hasIstanbul = checkInstalled('@vitest/coverage-istanbul');
100+
const hasV8 = checkInstalled('@vitest/coverage-v8');
101+
102+
if (hasIstanbul && !hasV8) {
103+
determinedProvider = 'istanbul';
104+
} else {
105+
determinedProvider = 'v8';
106+
}
107+
}
108+
}
109+
110+
return determinedProvider;
111+
}
112+
113+
function getBrowsersToCheck(
114+
browser: BrowserConfigOptions | undefined,
115+
testConfigBrowser: BrowserConfigOptions | undefined,
116+
): string[] {
117+
const browsersToCheck: string[] = [];
118+
119+
const cliBrowser = browser as CustomBrowserConfigOptions | undefined;
120+
const userBrowser = testConfigBrowser as CustomBrowserConfigOptions | undefined;
121+
122+
// 1. CLI options override the Vitest configuration completely.
123+
if (cliBrowser) {
124+
if (cliBrowser.instances) {
125+
browsersToCheck.push(...cliBrowser.instances.map((i) => i.browser));
126+
}
127+
if (cliBrowser.name) {
128+
browsersToCheck.push(cliBrowser.name);
129+
}
130+
131+
return browsersToCheck;
132+
}
133+
134+
// 2. Fall back to Vitest configuration ONLY if browser testing is enabled.
135+
if (userBrowser && userBrowser.enabled !== false) {
136+
if (userBrowser.instances) {
137+
browsersToCheck.push(...userBrowser.instances.map((i) => i.browser));
138+
}
139+
if (userBrowser.name) {
140+
browsersToCheck.push(userBrowser.name);
141+
}
142+
}
143+
144+
return browsersToCheck;
145+
}
146+
72147
export async function createVitestConfigPlugin(
73148
options: VitestConfigPluginOptions,
74149
): Promise<VitestPlugins[0]> {
@@ -89,6 +164,13 @@ export async function createVitestConfigPlugin(
89164
async config(config) {
90165
const testConfig = config.test;
91166

167+
const determinedProvider = determineCoverageProvider(
168+
browser,
169+
testConfig,
170+
options.coverage.enabled,
171+
projectSourceRoot,
172+
);
173+
92174
if (reporters !== undefined) {
93175
delete testConfig?.reporters;
94176
}
@@ -155,8 +237,8 @@ export async function createVitestConfigPlugin(
155237
(browser || testConfig?.browser?.enabled) &&
156238
(options.coverage.enabled || testConfig?.coverage?.enabled)
157239
) {
158-
// Validate that enabled browsers support V8 coverage
159-
validateBrowserCoverage(browser, testConfig?.browser);
240+
// Validate that enabled browsers support the selected coverage provider
241+
validateBrowserCoverage(browser, testConfig?.browser, determinedProvider);
160242

161243
projectPlugins.unshift(createSourcemapSupportPlugin());
162244
setupFiles.unshift('virtual:source-map-support');
@@ -208,6 +290,7 @@ export async function createVitestConfigPlugin(
208290
options.coverage,
209291
testConfig?.coverage,
210292
projectName,
293+
determinedProvider,
211294
),
212295
// eslint-disable-next-line @typescript-eslint/no-explicit-any
213296
...(reporters ? ({ reporters } as any) : {}),
@@ -423,6 +506,7 @@ function createSourcemapSupportPlugin(): VitestPlugins[0] {
423506
}
424507

425508
interface CustomBrowserConfigOptions {
509+
enabled?: boolean;
426510
instances?: { browser: string }[];
427511
name?: string;
428512
}
@@ -434,25 +518,12 @@ interface CustomBrowserConfigOptions {
434518
function validateBrowserCoverage(
435519
browser: BrowserConfigOptions | undefined,
436520
testConfigBrowser: BrowserConfigOptions | undefined,
521+
provider?: string,
437522
): void {
438-
const browsersToCheck: string[] = [];
439-
440-
// 1. Check browsers passed by the Angular CLI options
441-
const cliBrowser = browser as CustomBrowserConfigOptions | undefined;
442-
if (cliBrowser?.instances) {
443-
browsersToCheck.push(...cliBrowser.instances.map((i) => i.browser));
444-
}
445-
446-
// 2. Check browsers defined in the user's vitest.config.ts
447-
const userBrowser = testConfigBrowser as CustomBrowserConfigOptions | undefined;
448-
if (userBrowser) {
449-
if (userBrowser.instances) {
450-
browsersToCheck.push(...userBrowser.instances.map((i) => i.browser));
451-
}
452-
if (userBrowser.name) {
453-
browsersToCheck.push(userBrowser.name);
454-
}
523+
if (provider === 'istanbul') {
524+
return;
455525
}
526+
const browsersToCheck = getBrowsersToCheck(browser, testConfigBrowser);
456527

457528
// Normalize and filter unsupported browsers
458529
const unsupportedBrowsers = browsersToCheck
@@ -473,6 +544,7 @@ async function generateCoverageOption(
473544
optionsCoverage: NormalizedUnitTestBuilderOptions['coverage'],
474545
configCoverage: VitestCoverageOption | undefined,
475546
projectName: string,
547+
provider?: 'istanbul' | 'v8' | 'custom',
476548
): Promise<VitestCoverageOption> {
477549
let defaultExcludes: string[] = [];
478550
// When a coverage exclude option is provided, Vitest's default coverage excludes
@@ -486,6 +558,7 @@ async function generateCoverageOption(
486558
}
487559

488560
return {
561+
provider,
489562
excludeAfterRemap: true,
490563
reportsDirectory:
491564
configCoverage?.reportsDirectory ?? toPosixPath(path.join('coverage', projectName)),
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ng } from '../../utils/process';
2+
import { applyVitestBuilder } from '../../utils/vitest';
3+
import assert from 'node:assert';
4+
import { installPackage } from '../../utils/packages';
5+
import { expectFileToExist, readFile } from '../../utils/fs';
6+
import { updateJsonFile } from '../../utils/project';
7+
8+
export default async function (): Promise<void> {
9+
await applyVitestBuilder();
10+
11+
// Install ONLY Istanbul coverage package.
12+
// This will trigger the auto-detection logic to use Istanbul even for Node tests.
13+
await installPackage('@vitest/coverage-istanbul@4');
14+
15+
// Use the 'json' reporter to get a machine-readable output for assertions.
16+
await updateJsonFile('angular.json', (json) => {
17+
const project = Object.values(json['projects'])[0] as any;
18+
const test = project['architect']['test'];
19+
test.options = {
20+
coverageReporters: ['json', 'text'],
21+
};
22+
});
23+
24+
// Run tests with coverage (defaults to Node/jsdom environment)
25+
const { stdout } = await ng('test', '--no-watch', '--coverage');
26+
27+
// Verify that tests passed
28+
assert.match(stdout, /1 passed/, 'Expected tests to run successfully.');
29+
30+
// Verify that coverage files are generated
31+
const coverageJsonPath = 'coverage/test-project/coverage-final.json';
32+
await expectFileToExist(coverageJsonPath);
33+
34+
const coverageSummary = JSON.parse(await readFile(coverageJsonPath));
35+
assert.ok(Object.keys(coverageSummary).length > 0, 'Expected coverage report to not be empty.');
36+
}

tests/e2e/tests/vitest/browser-coverage-validation.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,9 @@ import { unlink } from 'node:fs/promises';
1010
export default async function (): Promise<void> {
1111
await applyVitestBuilder();
1212

13-
// Install necessary packages to pass the provider check
13+
// Install necessary packages to pass the browser provider check
1414
await installPackage('playwright@1');
1515
await installPackage('@vitest/browser-playwright@4');
16-
await installPackage('@vitest/coverage-v8@4');
1716

1817
// === Case 1: Browser configured via CLI option ===
1918
const error1 = await execAndCaptureError('ng', [
@@ -26,10 +25,12 @@ export default async function (): Promise<void> {
2625
const output1 = stripVTControlCharacters(error1.message);
2726
assert.match(
2827
output1,
29-
/Code coverage is enabled, but the following configured browsers do not support the V8 coverage provider: firefox/,
30-
'Expected validation error for unsupported browser with coverage (CLI option).',
28+
/Code coverage requires either "@vitest\/coverage-v8" or "@vitest\/coverage-istanbul" to be installed./,
29+
'Expected validation error for missing coverage packages.',
3130
);
3231

32+
await installPackage('@vitest/coverage-v8@4');
33+
3334
const configPath = 'vitest.config.ts';
3435
const absoluteConfigPath = path.resolve(configPath);
3536

@@ -41,6 +42,7 @@ export default async function (): Promise<void> {
4142
import { defineConfig } from 'vitest/config';
4243
export default defineConfig({
4344
test: {
45+
coverage: { provider: 'v8' },
4446
browser: {
4547
enabled: true,
4648
name: 'firefox',
@@ -71,6 +73,7 @@ export default async function (): Promise<void> {
7173
import { defineConfig } from 'vitest/config';
7274
export default defineConfig({
7375
test: {
76+
coverage: { provider: 'v8' },
7477
browser: {
7578
enabled: true,
7679
provider: 'playwright',

0 commit comments

Comments
 (0)