From 0b4f32545163f95c92c34fb41fd1b87e7c7337bb Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:09:08 -0400 Subject: [PATCH] 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. --- .../unit-test/runners/vitest/index.ts | 6 +- .../unit-test/runners/vitest/plugins.ts | 103 ++++++++++++++---- .../tests/vitest/browser-coverage-istanbul.ts | 36 ++++++ .../vitest/browser-coverage-validation.ts | 11 +- 4 files changed, 132 insertions(+), 24 deletions(-) create mode 100644 tests/e2e/tests/vitest/browser-coverage-istanbul.ts diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts index e3c6910aea7d..a1342e5abba8 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts @@ -9,6 +9,7 @@ import assert from 'node:assert'; import type { TestRunner } from '../api'; import { DependencyChecker } from '../dependency-checker'; +import { normalizeBrowserName } from './browser-provider'; import { getVitestBuildOptions } from './build-options'; import { VitestExecutor } from './executor'; @@ -50,7 +51,10 @@ const VitestTestRunner: TestRunner = { } if (options.coverage.enabled) { - checker.check('@vitest/coverage-v8'); + checker.checkAny( + ['@vitest/coverage-v8', '@vitest/coverage-istanbul'], + 'Code coverage requires either "@vitest/coverage-v8" or "@vitest/coverage-istanbul" to be installed.', + ); } checker.report(); 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..961bc989033d 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 @@ -69,6 +69,74 @@ async function findTestEnvironment( } } +function determineCoverageProvider( + browser: BrowserConfigOptions | undefined, + testConfig: InlineConfig | undefined, + optionsCoverageEnabled: boolean | undefined, + projectSourceRoot: string, +): 'istanbul' | 'v8' | 'custom' | undefined { + let determinedProvider = testConfig?.coverage?.provider; + if (!determinedProvider && (optionsCoverageEnabled || testConfig?.coverage?.enabled)) { + const browsersToCheck = getBrowsersToCheck(browser, testConfig?.browser); + + const hasNonChromium = + browsersToCheck + .map((b) => normalizeBrowserName(b).browser) + .filter((b) => !['chrome', 'chromium', 'edge'].includes(b)).length > 0; + + if (hasNonChromium) { + determinedProvider = 'istanbul'; + } else { + const projectRequire = createRequire(projectSourceRoot + '/'); + const checkInstalled = (pkg: string) => { + try { + projectRequire.resolve(pkg); + + return true; + } catch { + return false; + } + }; + const hasIstanbul = checkInstalled('@vitest/coverage-istanbul'); + const hasV8 = checkInstalled('@vitest/coverage-v8'); + + if (hasIstanbul && !hasV8) { + determinedProvider = 'istanbul'; + } else { + determinedProvider = 'v8'; + } + } + } + + return determinedProvider; +} + +function getBrowsersToCheck( + browser: BrowserConfigOptions | undefined, + testConfigBrowser: BrowserConfigOptions | undefined, +): string[] { + const browsersToCheck: string[] = []; + + // 1. Check browsers passed by the Angular CLI options + const cliBrowser = browser as CustomBrowserConfigOptions | undefined; + if (cliBrowser?.instances) { + browsersToCheck.push(...cliBrowser.instances.map((i) => i.browser)); + } + + // 2. Check browsers defined in the user's vitest.config.ts + const userBrowser = testConfigBrowser as CustomBrowserConfigOptions | undefined; + if (userBrowser) { + if (userBrowser.instances) { + browsersToCheck.push(...userBrowser.instances.map((i) => i.browser)); + } + if (userBrowser.name) { + browsersToCheck.push(userBrowser.name); + } + } + + return browsersToCheck; +} + export async function createVitestConfigPlugin( options: VitestConfigPluginOptions, ): Promise { @@ -89,6 +157,13 @@ export async function createVitestConfigPlugin( async config(config) { const testConfig = config.test; + const determinedProvider = determineCoverageProvider( + browser, + testConfig, + options.coverage.enabled, + projectSourceRoot, + ); + if (reporters !== undefined) { delete testConfig?.reporters; } @@ -155,8 +230,8 @@ export async function createVitestConfigPlugin( (browser || testConfig?.browser?.enabled) && (options.coverage.enabled || testConfig?.coverage?.enabled) ) { - // Validate that enabled browsers support V8 coverage - validateBrowserCoverage(browser, testConfig?.browser); + // Validate that enabled browsers support the selected coverage provider + validateBrowserCoverage(browser, testConfig?.browser, determinedProvider); projectPlugins.unshift(createSourcemapSupportPlugin()); setupFiles.unshift('virtual:source-map-support'); @@ -208,6 +283,7 @@ export async function createVitestConfigPlugin( options.coverage, testConfig?.coverage, projectName, + determinedProvider, ), // eslint-disable-next-line @typescript-eslint/no-explicit-any ...(reporters ? ({ reporters } as any) : {}), @@ -434,25 +510,12 @@ interface CustomBrowserConfigOptions { function validateBrowserCoverage( browser: BrowserConfigOptions | undefined, testConfigBrowser: BrowserConfigOptions | undefined, + provider?: string, ): void { - const browsersToCheck: string[] = []; - - // 1. Check browsers passed by the Angular CLI options - const cliBrowser = browser as CustomBrowserConfigOptions | undefined; - if (cliBrowser?.instances) { - browsersToCheck.push(...cliBrowser.instances.map((i) => i.browser)); - } - - // 2. Check browsers defined in the user's vitest.config.ts - const userBrowser = testConfigBrowser as CustomBrowserConfigOptions | undefined; - if (userBrowser) { - if (userBrowser.instances) { - browsersToCheck.push(...userBrowser.instances.map((i) => i.browser)); - } - if (userBrowser.name) { - browsersToCheck.push(userBrowser.name); - } + if (provider === 'istanbul') { + return; } + const browsersToCheck = getBrowsersToCheck(browser, testConfigBrowser); // Normalize and filter unsupported browsers const unsupportedBrowsers = browsersToCheck @@ -473,6 +536,7 @@ async function generateCoverageOption( optionsCoverage: NormalizedUnitTestBuilderOptions['coverage'], configCoverage: VitestCoverageOption | undefined, projectName: string, + provider?: 'istanbul' | 'v8' | 'custom', ): Promise { let defaultExcludes: string[] = []; // When a coverage exclude option is provided, Vitest's default coverage excludes @@ -486,6 +550,7 @@ async function generateCoverageOption( } return { + provider, excludeAfterRemap: true, reportsDirectory: configCoverage?.reportsDirectory ?? toPosixPath(path.join('coverage', projectName)), diff --git a/tests/e2e/tests/vitest/browser-coverage-istanbul.ts b/tests/e2e/tests/vitest/browser-coverage-istanbul.ts new file mode 100644 index 000000000000..21f3987a3d1d --- /dev/null +++ b/tests/e2e/tests/vitest/browser-coverage-istanbul.ts @@ -0,0 +1,36 @@ +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; +import assert from 'node:assert'; +import { installPackage } from '../../utils/packages'; +import { expectFileToExist, readFile } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; + +export default async function (): Promise { + await applyVitestBuilder(); + + // Install ONLY Istanbul coverage package. + // This will trigger the auto-detection logic to use Istanbul even for Node tests. + await installPackage('@vitest/coverage-istanbul@4'); + + // Use the 'json' reporter to get a machine-readable output for assertions. + await updateJsonFile('angular.json', (json) => { + const project = Object.values(json['projects'])[0] as any; + const test = project['architect']['test']; + test.options = { + coverageReporters: ['json', 'text'], + }; + }); + + // Run tests with coverage (defaults to Node/jsdom environment) + const { stdout } = await ng('test', '--no-watch', '--coverage'); + + // Verify that tests passed + assert.match(stdout, /1 passed/, 'Expected tests to run successfully.'); + + // Verify that coverage files are generated + const coverageJsonPath = 'coverage/test-project/coverage-final.json'; + await expectFileToExist(coverageJsonPath); + + const coverageSummary = JSON.parse(await readFile(coverageJsonPath)); + assert.ok(Object.keys(coverageSummary).length > 0, 'Expected coverage report to not be empty.'); +} diff --git a/tests/e2e/tests/vitest/browser-coverage-validation.ts b/tests/e2e/tests/vitest/browser-coverage-validation.ts index 624d82743eac..9124af1bb3a8 100644 --- a/tests/e2e/tests/vitest/browser-coverage-validation.ts +++ b/tests/e2e/tests/vitest/browser-coverage-validation.ts @@ -10,10 +10,9 @@ import { unlink } from 'node:fs/promises'; export default async function (): Promise { await applyVitestBuilder(); - // Install necessary packages to pass the provider check + // Install necessary packages to pass the browser provider check await installPackage('playwright@1'); await installPackage('@vitest/browser-playwright@4'); - await installPackage('@vitest/coverage-v8@4'); // === Case 1: Browser configured via CLI option === const error1 = await execAndCaptureError('ng', [ @@ -26,10 +25,12 @@ export default async function (): Promise { const output1 = stripVTControlCharacters(error1.message); assert.match( output1, - /Code coverage is enabled, but the following configured browsers do not support the V8 coverage provider: firefox/, - 'Expected validation error for unsupported browser with coverage (CLI option).', + /Code coverage requires either "@vitest\/coverage-v8" or "@vitest\/coverage-istanbul" to be installed./, + 'Expected validation error for missing coverage packages.', ); + await installPackage('@vitest/coverage-v8@4'); + const configPath = 'vitest.config.ts'; const absoluteConfigPath = path.resolve(configPath); @@ -41,6 +42,7 @@ export default async function (): Promise { import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + coverage: { provider: 'v8' }, browser: { enabled: true, name: 'firefox', @@ -71,6 +73,7 @@ export default async function (): Promise { import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + coverage: { provider: 'v8' }, browser: { enabled: true, provider: 'playwright',