Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions goldens/public-api/angular/build/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export type UnitTestBuilderOptions = {
browserViewport?: string;
browsers?: string[];
buildTarget?: string;
connectOptions?: ConnectOptions;
coverage?: boolean;
coverageExclude?: string[];
coverageInclude?: string[];
Expand Down
1 change: 1 addition & 0 deletions packages/angular/build/src/builders/unit-test/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export async function normalizeOptions(
outputFile: options.outputFile,
browsers: browsers?.length ? browsers : undefined,
browserViewport: width && height ? { width, height } : undefined,
connectOptions: options.connectOptions,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the ui and isolate options, connectOptions is only supported when using the Vitest runner (since Karma does not use the Playwright browser provider configured by this builder).

Consider adding a validation check in normalizeOptions to throw an error if connectOptions is provided with a non-Vitest runner:

if (options.connectOptions && runner !== Runner.Vitest) {
  throw new Error('The connectOptions option is only available for the vitest runner.');
}

watch,
debug: options.debug ?? false,
ui: process.env['CI'] ? false : ui,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
} from 'vitest/node';
import { assertIsError } from '../../../../utils/error';
import { createProjectResolver } from '../../../../utils/resolve-project';
import type { NormalizedUnitTestBuilderOptions } from '../../options';

export interface BrowserConfiguration {
browser?: BrowserConfigOptions;
Expand Down Expand Up @@ -119,6 +120,7 @@ export function applyHeadlessConfiguration(
* @param debug Whether the builder is running in watch or debug mode.
* @param projectSourceRoot The root directory of the project being tested for resolving installed packages.
* @param viewport Optional viewport dimensions to apply to the launched browser instances.
* @param connectOptions Optional Playwright connection options (e.g. `wsEndpoint`) forwarded to the provider.
* @returns A fully resolved Vitest browser configuration object alongside any generated warning or error messages.
*/
export async function setupBrowserConfiguration(
Expand All @@ -127,15 +129,18 @@ export async function setupBrowserConfiguration(
debug: boolean,
projectSourceRoot: string,
viewport: { width: number; height: number } | undefined,
connectOptions?: NormalizedUnitTestBuilderOptions['connectOptions'],
): Promise<BrowserConfiguration> {
if (browsers === undefined) {
const messages: string[] = [];
if (headless !== undefined) {
return {
messages: ['The "headless" option is ignored when no browsers are configured.'],
};
messages.push('The "headless" option is ignored when no browsers are configured.');
}
if (connectOptions) {
messages.push('The "connectOptions" option is ignored when no browsers are configured.');
}

return {};
return messages.length > 0 ? { messages } : {};
}

const projectResolver = createProjectResolver(projectSourceRoot);
Expand Down Expand Up @@ -168,6 +173,9 @@ export async function setupBrowserConfiguration(
// Enables `prefer-color-scheme` for Vitest browser instead of `light`
colorScheme: null,
},
// Forward user-provided Playwright connection options (e.g. a custom `wsEndpoint`)
// so tests can run against a remote or shared browser server.
...(connectOptions ? { connectOptions } : {}),
};

provider = providerFactory(baseOptions);
Expand Down Expand Up @@ -216,6 +224,12 @@ export async function setupBrowserConfiguration(
const isCI = !!process.env['CI'];
const messages = applyHeadlessConfiguration(instances, providerName, headless, isCI);

if (connectOptions && providerName !== 'playwright') {
messages.push(
'The "connectOptions" option is only supported by the Playwright browser provider and will be ignored.',
);
}

const browser = {
enabled: true,
provider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,87 @@ describe('setupBrowserConfiguration', () => {
);
});

describe('connectOptions', () => {
it('should forward connectOptions to the Playwright provider', async () => {
const connectOptions = {
wsEndpoint: 'ws://localhost:1234/playwright',
exposeNetwork: '*',
};

const { browser } = await setupBrowserConfiguration(
['chromium'],
undefined,
false,
workspaceRoot,
undefined,
connectOptions,
);

expect(browser?.provider?.options).toEqual(jasmine.objectContaining({ connectOptions }));
});

it('should not set connectOptions when none are provided', async () => {
const { browser } = await setupBrowserConfiguration(
['chromium'],
undefined,
false,
workspaceRoot,
undefined,
);

expect(browser?.provider?.options).not.toEqual(
jasmine.objectContaining({ connectOptions: jasmine.anything() }),
);
});

it('should ignore connectOptions and warn when no browsers are configured', async () => {
const { browser, messages } = await setupBrowserConfiguration(
undefined,
undefined,
false,
workspaceRoot,
undefined,
{ wsEndpoint: 'ws://localhost:1234/playwright' },
);

expect(browser).toBeUndefined();
expect(messages).toEqual([
'The "connectOptions" option is ignored when no browsers are configured.',
]);
});

it('should warn when connectOptions is used with a non-Playwright provider', async () => {
// Swap the Playwright mock for a WebdriverIO mock so a non-Playwright provider is resolved.
await rm(join(workspaceRoot, 'node_modules/@vitest/browser-playwright'), {
recursive: true,
force: true,
});
const wdioPkgPath = join(workspaceRoot, 'node_modules/@vitest/browser-webdriverio');
await mkdir(wdioPkgPath, { recursive: true });
await writeFile(
join(wdioPkgPath, 'package.json'),
JSON.stringify({ name: '@vitest/browser-webdriverio', main: 'index.js' }),
);
await writeFile(
join(wdioPkgPath, 'index.js'),
'module.exports = { webdriverio: (options) => ({ name: "webdriverio", options }) };',
);

const { messages } = await setupBrowserConfiguration(
['chrome'],
undefined,
false,
workspaceRoot,
undefined,
{ wsEndpoint: 'ws://localhost:1234/playwright' },
);

expect(messages).toContain(
'The "connectOptions" option is only supported by the Playwright browser provider and will be ignored.',
);
});
});

it('should support Preview provider forcing headless false', async () => {
// Create mock preview package
const previewPkgPath = join(workspaceRoot, 'node_modules/@vitest/browser-preview');
Expand Down Expand Up @@ -278,6 +359,28 @@ describe('setupBrowserConfiguration', () => {
// Verify firefox does not
expect(browser?.instances?.[1]?.provider).toBeUndefined();
});

it('should forward connectOptions alongside executablePath on per-instance providers', async () => {
const connectOptions = { wsEndpoint: 'ws://localhost:1234/playwright' };
const { browser } = await setupBrowserConfiguration(
['ChromeHeadless'],
undefined,
false,
workspaceRoot,
undefined,
connectOptions,
);

// The per-instance provider should carry both the connect options and the executable path.
expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(browser?.instances?.[0]?.provider as any)?.options?.connectOptions,
).toEqual(connectOptions);
expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(browser?.instances?.[0]?.provider as any)?.options?.launchOptions?.executablePath,
).toBe('/custom/path/to/chrome');
});
Comment on lines +363 to +383

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test asserts that executablePath is forwarded alongside connectOptions on per-instance providers. However, since executablePath is a local launch option and connectOptions is for remote connections, they are conceptually mutually exclusive. Playwright ignores launchOptions (including executablePath) when connecting to a remote browser via connectOptions.

To avoid passing redundant/ignored configuration and to simplify the setup, consider disabling the executablePath override in browser-provider.ts when connectOptions is active (e.g., if (executablePath && !connectOptions)), and update this test to assert that executablePath is not applied.

});

describe('applyHeadlessConfiguration', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export class VitestExecutor implements TestExecutor {
debug,
projectSourceRoot,
browserViewport,
this.options.connectOptions,
);
if (browserOptions.errors?.length) {
this.debugLog(DebugLogLevel.Info, 'Browser configuration errors found.', {
Expand Down
32 changes: 32 additions & 0 deletions packages/angular/build/src/builders/unit-test/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,38 @@
"type": "string",
"pattern": "^\\d+x\\d+$"
},
"connectOptions": {
"type": "object",
"description": "Options for connecting to an existing, remote, or preconfigured Playwright browser server. These are passed directly to the Playwright browser provider's `connectOptions`. This only applies when running browser-based tests with the Playwright provider and is useful for running tests against a consistent, shared browser environment (e.g., for stable visual regression testing across different operating systems).",
"properties": {
"wsEndpoint": {
"type": "string",
"description": "A browser WebSocket endpoint to connect to.",
"minLength": 1
},
"exposeNetwork": {
"type": "string",
"description": "A rule to expose the network available on the connecting client to the browser being connected to. For example, `*` exposes all addresses, or a comma-separated list of rules can be provided."
},
"headers": {
"type": "object",
"description": "Additional HTTP headers to be sent with the connection request.",
"additionalProperties": {
"type": "string"
}
},
"timeout": {
"type": "number",
"description": "Maximum time in milliseconds to wait for the connection to be established. Defaults to `0` (no timeout)."
},
"slowMo": {
"type": "number",
"description": "Slows down Playwright operations by the specified number of milliseconds."
}
},
"required": ["wsEndpoint"],
"additionalProperties": true
},
"include": {
"type": "array",
"items": {
Expand Down
Loading