From dba32cf15a00366f1441295267a6dde8ae22e4ca Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:52:22 -0400 Subject: [PATCH] fix(@angular/build): validate V8 coverage support for browsers in Vitest This change introduces a validation check in the Vitest runner to ensure that code coverage is only enabled when using supported Chromium-based browsers. Since the Angular CLI integration currently relies exclusively on the V8 coverage provider, running tests in non-Chromium browsers like Firefox or Safari with coverage enabled would result in incomplete data or missing reports. By adding this check, developers will receive a clear, actionable error message early in the process, preventing confusion and ensuring reliable coverage reports. --- .../runners/vitest/browser-provider.ts | 2 +- .../unit-test/runners/vitest/plugins.ts | 51 +++++++++ .../vitest/browser-coverage-validation.ts | 102 ++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/tests/vitest/browser-coverage-validation.ts diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts index 0ca80f4fa60f..d51b9d2771b8 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts @@ -47,7 +47,7 @@ export interface BrowserInstanceConfiguration { provider?: BrowserProviderOption; } -function normalizeBrowserName(browserName: string): BrowserInstanceConfiguration { +export function normalizeBrowserName(browserName: string): BrowserInstanceConfiguration { // Normalize browser names to match Vitest's expectations for headless but also supports karma's names // e.g., 'ChromeHeadless' -> 'chrome', 'FirefoxHeadless' -> 'firefox' // and 'Chrome' -> 'chrome', 'Firefox' -> 'firefox'. 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 489084fb0e8f..8e28f7f43cc5 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 @@ -23,6 +23,7 @@ import { createBuildAssetsMiddleware } from '../../../../tools/vite/middlewares/ import { toPosixPath } from '../../../../utils/path'; import type { ResultFile } from '../../../application/results'; import type { NormalizedUnitTestBuilderOptions } from '../../options'; +import { normalizeBrowserName } from './browser-provider'; interface ExistingRawSourceMap { sources?: string[]; @@ -154,6 +155,9 @@ 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); + projectPlugins.unshift(createSourcemapSupportPlugin()); setupFiles.unshift('virtual:source-map-support'); } @@ -418,6 +422,53 @@ function createSourcemapSupportPlugin(): VitestPlugins[0] { }; } +interface CustomBrowserConfigOptions { + instances?: { browser: string }[]; + name?: string; +} + +/** + * Validates that all enabled browsers support V8 coverage when coverage is enabled. + * Throws an error if an unsupported browser is detected. + */ +function validateBrowserCoverage( + browser: BrowserConfigOptions | undefined, + testConfigBrowser: BrowserConfigOptions | undefined, +): 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); + } + } + + // Normalize and filter unsupported browsers + const unsupportedBrowsers = browsersToCheck + .map((b) => normalizeBrowserName(b).browser) + .filter((b) => !['chrome', 'chromium', 'edge'].includes(b)); + + if (unsupportedBrowsers.length > 0) { + throw new Error( + `Code coverage is enabled, but the following configured browsers do not support the V8 coverage provider: ` + + `${unsupportedBrowsers.join(', ')}. ` + + `V8 coverage is only supported on Chromium-based browsers (e.g., Chrome, Chromium, Edge). ` + + `Please disable coverage or remove the unsupported browsers.`, + ); + } +} + async function generateCoverageOption( optionsCoverage: NormalizedUnitTestBuilderOptions['coverage'], configCoverage: VitestCoverageOption | undefined, diff --git a/tests/e2e/tests/vitest/browser-coverage-validation.ts b/tests/e2e/tests/vitest/browser-coverage-validation.ts new file mode 100644 index 000000000000..624d82743eac --- /dev/null +++ b/tests/e2e/tests/vitest/browser-coverage-validation.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { execAndCaptureError } from '../../utils/process'; +import { installPackage } from '../../utils/packages'; +import { writeFile } from '../../utils/fs'; +import { stripVTControlCharacters } from 'node:util'; +import { unlink } from 'node:fs/promises'; + +export default async function (): Promise { + await applyVitestBuilder(); + + // Install necessary packages to pass the 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', [ + 'test', + '--no-watch', + '--coverage', + '--browsers', + 'firefox', + ]); + 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).', + ); + + const configPath = 'vitest.config.ts'; + const absoluteConfigPath = path.resolve(configPath); + + try { + // === Case 2: Browser configured via vitest.config.ts (name) === + await writeFile( + configPath, + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + browser: { + enabled: true, + name: 'firefox', + provider: 'playwright', + }, + }, + }); + `, + ); + + const error2 = await execAndCaptureError('ng', [ + 'test', + '--no-watch', + '--coverage', + `--runner-config=${absoluteConfigPath}`, + ]); + const output2 = stripVTControlCharacters(error2.message); + assert.match( + output2, + /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 (config name).', + ); + + // === Case 3: Browser configured via vitest.config.ts (instances) === + await writeFile( + configPath, + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + browser: { + enabled: true, + provider: 'playwright', + instances: [{ browser: 'firefox' }], + } as any, + }, + }); + `, + ); + + const error3 = await execAndCaptureError('ng', [ + 'test', + '--no-watch', + '--coverage', + `--runner-config=${absoluteConfigPath}`, + ]); + const output3 = stripVTControlCharacters(error3.message); + assert.match( + output3, + /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 (config instances).', + ); + } finally { + // Clean up the config file so it doesn't affect other tests + try { + await unlink(configPath); + } catch {} + } +}