Skip to content

Commit a71411f

Browse files
committed
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.
1 parent 7c6a643 commit a71411f

File tree

2 files changed

+179
-10
lines changed

2 files changed

+179
-10
lines changed

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

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,17 @@ function findBrowserProvider(
3636
return undefined;
3737
}
3838

39-
function normalizeBrowserName(browserName: string): string {
39+
function normalizeBrowserName(browserName: string): { browser: string; headless: boolean } {
4040
// Normalize browser names to match Vitest's expectations for headless but also supports karma's names
4141
// e.g., 'ChromeHeadless' -> 'chrome', 'FirefoxHeadless' -> 'firefox'
4242
// and 'Chrome' -> 'chrome', 'Firefox' -> 'firefox'.
4343
const normalized = browserName.toLowerCase();
44+
const headless = normalized.endsWith('headless');
4445

45-
return normalized.replace(/headless$/, '');
46+
return {
47+
browser: headless ? normalized.slice(0, -8) : normalized,
48+
headless: headless,
49+
};
4650
}
4751

4852
export async function setupBrowserConfiguration(
@@ -120,21 +124,23 @@ export async function setupBrowserConfiguration(
120124
}
121125

122126
const isCI = !!process.env['CI'];
123-
let headless = isCI || browsers.some((name) => name.toLowerCase().includes('headless'));
127+
const instances = browsers.map(normalizeBrowserName);
124128
if (providerName === 'preview') {
125-
// `preview` provider does not support headless mode
126-
headless = false;
129+
instances.forEach((instance) => {
130+
instance.headless = false;
131+
});
132+
} else if (isCI) {
133+
instances.forEach((instance) => {
134+
instance.headless = true;
135+
});
127136
}
128137

129138
const browser = {
130139
enabled: true,
131140
provider,
132-
headless,
133-
ui: !headless,
141+
ui: !isCI && instances.some((instance) => !instance.headless),
134142
viewport,
135-
instances: browsers.map((browserName) => ({
136-
browser: normalizeBrowserName(browserName),
137-
})),
143+
instances,
138144
} satisfies BrowserConfigOptions;
139145

140146
return { browser };
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
10+
import { tmpdir } from 'node:os';
11+
import { join } from 'node:path';
12+
import { setupBrowserConfiguration } from './browser-provider';
13+
14+
describe('setupBrowserConfiguration', () => {
15+
let workspaceRoot: string;
16+
17+
beforeEach(async () => {
18+
// Create a temporary workspace root
19+
workspaceRoot = await mkdtemp(join(tmpdir(), 'angular-cli-test-'));
20+
await writeFile(join(workspaceRoot, 'package.json'), '{}');
21+
22+
// Create a mock @vitest/browser-playwright package
23+
const playwrightPkgPath = join(workspaceRoot, 'node_modules/@vitest/browser-playwright');
24+
await mkdir(playwrightPkgPath, { recursive: true });
25+
await writeFile(
26+
join(playwrightPkgPath, 'package.json'),
27+
JSON.stringify({ name: '@vitest/browser-playwright', main: 'index.js' }),
28+
);
29+
await writeFile(
30+
join(playwrightPkgPath, 'index.js'),
31+
'module.exports = { playwright: () => ({ name: "playwright" }) };',
32+
);
33+
});
34+
35+
afterEach(async () => {
36+
await rm(workspaceRoot, { recursive: true, force: true });
37+
});
38+
39+
it('should configure headless mode for specific browsers based on name', async () => {
40+
const { browser } = await setupBrowserConfiguration(
41+
['ChromeHeadless', 'Firefox'],
42+
false,
43+
workspaceRoot,
44+
undefined,
45+
);
46+
47+
expect(browser?.enabled).toBeTrue();
48+
expect(browser?.instances).toEqual([
49+
{ browser: 'chrome', headless: true },
50+
{ browser: 'firefox', headless: false },
51+
]);
52+
});
53+
54+
it('should force headless mode in CI environment', async () => {
55+
const originalCI = process.env['CI'];
56+
process.env['CI'] = 'true';
57+
58+
try {
59+
const { browser } = await setupBrowserConfiguration(
60+
['Chrome', 'FirefoxHeadless'],
61+
false,
62+
workspaceRoot,
63+
undefined,
64+
);
65+
66+
expect(browser?.instances).toEqual([
67+
{ browser: 'chrome', headless: true },
68+
{ browser: 'firefox', headless: true },
69+
]);
70+
} finally {
71+
if (originalCI === undefined) {
72+
delete process.env['CI'];
73+
} else {
74+
process.env['CI'] = originalCI;
75+
}
76+
}
77+
});
78+
79+
it('should set ui property based on headless instances (local)', async () => {
80+
// Local run (not CI)
81+
const originalCI = process.env['CI'];
82+
delete process.env['CI'];
83+
84+
try {
85+
// Case 1: All headless -> UI false
86+
let result = await setupBrowserConfiguration(
87+
['ChromeHeadless'],
88+
false,
89+
workspaceRoot,
90+
undefined,
91+
);
92+
expect(result.browser?.ui).toBeFalse();
93+
94+
// Case 2: Mixed -> UI true
95+
result = await setupBrowserConfiguration(
96+
['ChromeHeadless', 'Firefox'],
97+
false,
98+
workspaceRoot,
99+
undefined,
100+
);
101+
expect(result.browser?.ui).toBeTrue();
102+
} finally {
103+
if (originalCI !== undefined) {
104+
process.env['CI'] = originalCI;
105+
}
106+
}
107+
});
108+
109+
it('should disable UI in CI even if headed browsers are requested', async () => {
110+
const originalCI = process.env['CI'];
111+
process.env['CI'] = 'true';
112+
113+
try {
114+
const { browser } = await setupBrowserConfiguration(
115+
['Chrome'],
116+
false,
117+
workspaceRoot,
118+
undefined,
119+
);
120+
121+
expect(browser?.ui).toBeFalse();
122+
// And verify instances are forced to headless
123+
expect(browser?.instances?.[0].headless).toBeTrue();
124+
} finally {
125+
if (originalCI === undefined) {
126+
delete process.env['CI'];
127+
} else {
128+
process.env['CI'] = originalCI;
129+
}
130+
}
131+
});
132+
133+
it('should support Preview provider forcing headless false', async () => {
134+
// Create mock preview package
135+
const previewPkgPath = join(workspaceRoot, 'node_modules/@vitest/browser-preview');
136+
await mkdir(previewPkgPath, { recursive: true });
137+
await writeFile(
138+
join(previewPkgPath, 'package.json'),
139+
JSON.stringify({ name: '@vitest/browser-preview', main: 'index.js' }),
140+
);
141+
await writeFile(
142+
join(previewPkgPath, 'index.js'),
143+
'module.exports = { preview: () => ({ name: "preview" }) };',
144+
);
145+
146+
// Remove playwright mock for this test to force usage of preview
147+
await rm(join(workspaceRoot, 'node_modules/@vitest/browser-playwright'), {
148+
recursive: true,
149+
force: true,
150+
});
151+
152+
const { browser } = await setupBrowserConfiguration(
153+
['ChromeHeadless'],
154+
false,
155+
workspaceRoot,
156+
undefined,
157+
);
158+
159+
expect(browser?.provider).toBeDefined();
160+
// Preview forces headless false
161+
expect(browser?.instances?.[0].headless).toBeFalse();
162+
});
163+
});

0 commit comments

Comments
 (0)