From 6b343f2a5fb9a2e76b232b9d6380c1b95abac7d2 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 19 Nov 2025 13:56:20 -0500 Subject: [PATCH] fix(@angular/build): correctly configure per-browser headless mode in Vitest runner This commit fixes an issue in the Vitest browser setup where headless mode was not correctly configured on a per-browser-instance basis. Previously, the headless mode was applied globally, leading to incorrect behavior when mixing headed and headless browser names, or in specific environments. Now, the configuration correctly: - Determines headless status from individual browser names (e.g., `ChromeHeadless` vs `Chrome`). - Forces all instances to be headless in CI environments. - Ensures the Preview provider forces instances to be headed. - Enables the UI only when running locally with at least one headed browser. --- .../runners/vitest/browser-provider.ts | 26 +-- .../runners/vitest/browser-provider_spec.ts | 163 ++++++++++++++++++ 2 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider_spec.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 4f7469ebf28b..3aeeb7afe474 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 @@ -36,13 +36,17 @@ function findBrowserProvider( return undefined; } -function normalizeBrowserName(browserName: string): string { +function normalizeBrowserName(browserName: string): { browser: string; headless: boolean } { // 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'. const normalized = browserName.toLowerCase(); + const headless = normalized.endsWith('headless'); - return normalized.replace(/headless$/, ''); + return { + browser: headless ? normalized.slice(0, -8) : normalized, + headless: headless, + }; } export async function setupBrowserConfiguration( @@ -120,21 +124,23 @@ export async function setupBrowserConfiguration( } const isCI = !!process.env['CI']; - let headless = isCI || browsers.some((name) => name.toLowerCase().includes('headless')); + const instances = browsers.map(normalizeBrowserName); if (providerName === 'preview') { - // `preview` provider does not support headless mode - headless = false; + instances.forEach((instance) => { + instance.headless = false; + }); + } else if (isCI) { + instances.forEach((instance) => { + instance.headless = true; + }); } const browser = { enabled: true, provider, - headless, - ui: !headless, + ui: !isCI && instances.some((instance) => !instance.headless), viewport, - instances: browsers.map((browserName) => ({ - browser: normalizeBrowserName(browserName), - })), + instances, } satisfies BrowserConfigOptions; return { browser }; diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider_spec.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider_spec.ts new file mode 100644 index 000000000000..2162ffa6ed5e --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider_spec.ts @@ -0,0 +1,163 @@ +/** + * @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 { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setupBrowserConfiguration } from './browser-provider'; + +describe('setupBrowserConfiguration', () => { + let workspaceRoot: string; + + beforeEach(async () => { + // Create a temporary workspace root + workspaceRoot = await mkdtemp(join(tmpdir(), 'angular-cli-test-')); + await writeFile(join(workspaceRoot, 'package.json'), '{}'); + + // Create a mock @vitest/browser-playwright package + const playwrightPkgPath = join(workspaceRoot, 'node_modules/@vitest/browser-playwright'); + await mkdir(playwrightPkgPath, { recursive: true }); + await writeFile( + join(playwrightPkgPath, 'package.json'), + JSON.stringify({ name: '@vitest/browser-playwright', main: 'index.js' }), + ); + await writeFile( + join(playwrightPkgPath, 'index.js'), + 'module.exports = { playwright: () => ({ name: "playwright" }) };', + ); + }); + + afterEach(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); + }); + + it('should configure headless mode for specific browsers based on name', async () => { + const { browser } = await setupBrowserConfiguration( + ['ChromeHeadless', 'Firefox'], + false, + workspaceRoot, + undefined, + ); + + expect(browser?.enabled).toBeTrue(); + expect(browser?.instances).toEqual([ + { browser: 'chrome', headless: true }, + { browser: 'firefox', headless: false }, + ]); + }); + + it('should force headless mode in CI environment', async () => { + const originalCI = process.env['CI']; + process.env['CI'] = 'true'; + + try { + const { browser } = await setupBrowserConfiguration( + ['Chrome', 'FirefoxHeadless'], + false, + workspaceRoot, + undefined, + ); + + expect(browser?.instances).toEqual([ + { browser: 'chrome', headless: true }, + { browser: 'firefox', headless: true }, + ]); + } finally { + if (originalCI === undefined) { + delete process.env['CI']; + } else { + process.env['CI'] = originalCI; + } + } + }); + + it('should set ui property based on headless instances (local)', async () => { + // Local run (not CI) + const originalCI = process.env['CI']; + delete process.env['CI']; + + try { + // Case 1: All headless -> UI false + let result = await setupBrowserConfiguration( + ['ChromeHeadless'], + false, + workspaceRoot, + undefined, + ); + expect(result.browser?.ui).toBeFalse(); + + // Case 2: Mixed -> UI true + result = await setupBrowserConfiguration( + ['ChromeHeadless', 'Firefox'], + false, + workspaceRoot, + undefined, + ); + expect(result.browser?.ui).toBeTrue(); + } finally { + if (originalCI !== undefined) { + process.env['CI'] = originalCI; + } + } + }); + + it('should disable UI in CI even if headed browsers are requested', async () => { + const originalCI = process.env['CI']; + process.env['CI'] = 'true'; + + try { + const { browser } = await setupBrowserConfiguration( + ['Chrome'], + false, + workspaceRoot, + undefined, + ); + + expect(browser?.ui).toBeFalse(); + // And verify instances are forced to headless + expect(browser?.instances?.[0].headless).toBeTrue(); + } finally { + if (originalCI === undefined) { + delete process.env['CI']; + } else { + process.env['CI'] = originalCI; + } + } + }); + + it('should support Preview provider forcing headless false', async () => { + // Create mock preview package + const previewPkgPath = join(workspaceRoot, 'node_modules/@vitest/browser-preview'); + await mkdir(previewPkgPath, { recursive: true }); + await writeFile( + join(previewPkgPath, 'package.json'), + JSON.stringify({ name: '@vitest/browser-preview', main: 'index.js' }), + ); + await writeFile( + join(previewPkgPath, 'index.js'), + 'module.exports = { preview: () => ({ name: "preview" }) };', + ); + + // Remove playwright mock for this test to force usage of preview + await rm(join(workspaceRoot, 'node_modules/@vitest/browser-playwright'), { + recursive: true, + force: true, + }); + + const { browser } = await setupBrowserConfiguration( + ['ChromeHeadless'], + false, + workspaceRoot, + undefined, + ); + + expect(browser?.provider).toBeDefined(); + // Preview forces headless false + expect(browser?.instances?.[0].headless).toBeFalse(); + }); +});