Skip to content
Merged
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ When validating changes through `examples/` or `e2e/`, remember that package sou

- Don't mix CommonJS and ESM in the same module
- Don't add heavy dependencies without discussion
- Don't use namespace imports like `import * as foo from 'foo'` unless the module shape requires it
- Don't commit without running lint
- Don't make repo-wide rewrites unless explicitly asked

Expand Down
13 changes: 13 additions & 0 deletions packages/browser/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ Contract ownership:
- `@rstest/browser-ui` owns transport bridging and UI state projection only.
- Runner runtime (`src/client`) owns test execution and emits protocol messages, but does not own filesystem access.

## Provider-agnostic design

Browser mode must stay provider-neutral at the framework boundary.

- Keep shared config, protocol, scheduling, and public APIs provider-agnostic.
- Treat `browser.providerOptions` as an opaque passthrough at the framework boundary.
- Do not export provider-owned config types from `@rstest/browser` public entrypoints.
- Do not reference optional peer provider modules from public declarations, including `import type` and `import('pkg')` in type positions.
- Keep provider-specific behavior, config decoding, and runtime quirks inside provider implementations whenever possible.
- Prefer direct passthrough to provider APIs over provider-specific post-init translation layers. If a capability cannot be expressed as passthrough, only promote it when the behavior is meaningful across multiple providers.
- Do not introduce new shared abstractions for a single provider convenience; promote behavior into shared contracts only when it is meaningful across multiple providers.
- When richer DX is needed later, prefer provider-owned helpers or separate optional type entrypoints over coupling the main package surface to a specific provider.

## Module structure

- `src/index.ts` — Package entry, exports runBrowserTests and listBrowserTests
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/configValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,9 @@ export const validateBrowserConfig = (context: Rstest): void => {
}

validateViewport(browser.viewport);

if (!isPlainObject(browser.providerOptions)) {
throw new Error('browser.providerOptions must be a plain object.');
}
}
};
43 changes: 34 additions & 9 deletions packages/browser/src/hostController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import { fileURLToPath } from 'node:url';
import { isDeepStrictEqual } from 'node:util';
import type { Rspack } from '@rstest/core';
import {
type BrowserTestRunOptions,
Expand Down Expand Up @@ -123,10 +124,24 @@ type BrowserProviderProject = {
provider: BrowserProvider;
};

type BrowserLaunchOptions = Pick<
ProjectContext['normalizedConfig']['browser'],
'provider' | 'browser' | 'headless' | 'port' | 'strictPort'
>;
type BrowserLaunchOptions = {
provider: BrowserProvider;
browser: ProjectContext['normalizedConfig']['browser']['browser'];
headless: ProjectContext['normalizedConfig']['browser']['headless'];
port: ProjectContext['normalizedConfig']['browser']['port'];
strictPort: ProjectContext['normalizedConfig']['browser']['strictPort'];
providerOptions: Record<string, unknown>;
};

const getBrowserProviderOptions = (
project: ProjectContext,
): Record<string, unknown> => {
const browserConfig = project.normalizedConfig.browser as {
providerOptions?: Record<string, unknown>;
};

return browserConfig.providerOptions ?? {};
};

/** Payload for test file start event */
type TestFileStartPayload = {
Expand Down Expand Up @@ -372,6 +387,7 @@ type BrowserRuntime = {
rsbuildInstance: RsbuildInstance;
devServer: RsbuildDevServer;
browser: BrowserProviderBrowser;
browserLaunchOptions: BrowserLaunchOptions;
port: number;
wsPort: number;
manifestPath: string;
Expand Down Expand Up @@ -797,6 +813,7 @@ const getBrowserLaunchOptions = (
headless: project.normalizedConfig.browser.headless,
port: project.normalizedConfig.browser.port,
strictPort: project.normalizedConfig.browser.strictPort,
providerOptions: getBrowserProviderOptions(project),
});

const ensureConsistentBrowserLaunchOptions = (
Expand All @@ -816,11 +833,12 @@ const ensureConsistentBrowserLaunchOptions = (
options.browser !== firstOptions.browser ||
options.headless !== firstOptions.headless ||
options.port !== firstOptions.port ||
options.strictPort !== firstOptions.strictPort
options.strictPort !== firstOptions.strictPort ||
!isDeepStrictEqual(options.providerOptions, firstOptions.providerOptions)
) {
throw new Error(
`Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
'All browser-enabled projects in one run must share provider/browser/headless/port/strictPort.',
'All browser-enabled projects in one run must share provider/browser/headless/port/strictPort/providerOptions.',
);
}
}
Expand Down Expand Up @@ -1542,11 +1560,13 @@ const createBrowserRuntime = async ({
const runtime = await providerImplementation.launchRuntime({
browserName,
headless: forceHeadless ?? browserLaunchOptions.headless,
providerOptions: browserLaunchOptions.providerOptions,
});
return {
rsbuildInstance,
devServer,
browser: runtime.browser,
browserLaunchOptions,
port,
wsPort,
manifestPath,
Expand Down Expand Up @@ -1875,7 +1895,7 @@ export const runBrowserController = async (
}
}

const { browser, port, wsPort, wss } = runtime;
const { browser, browserLaunchOptions, port, wsPort, wss } = runtime;
const buildTime = Date.now() - buildStart;

// Collect all test files from project entries with project info
Expand Down Expand Up @@ -2281,6 +2301,7 @@ export const runBrowserController = async (

const viewport = viewportByProject.get(file.projectName);
const browserContext = await browser.newContext({
providerOptions: browserLaunchOptions.providerOptions,
viewport: viewport ?? null,
});
run.contexts.add(browserContext);
Expand Down Expand Up @@ -2746,6 +2767,7 @@ export const runBrowserController = async (
} else {
isNewPage = true;
containerContext = await browser.newContext({
providerOptions: browserLaunchOptions.providerOptions,
viewport: null,
});
containerPage = await containerContext.newPage();
Expand Down Expand Up @@ -3245,7 +3267,7 @@ export const listBrowserTests = async (
throw error;
}

const { browser, port } = runtime;
const { browser, browserLaunchOptions, port } = runtime;

// Get browser projects for runtime config
// Normalize projectRoot to posix format for cross-platform compatibility
Expand Down Expand Up @@ -3289,7 +3311,10 @@ export const listBrowserTests = async (
});

// Create a headless page to run collection
const browserContext = await browser.newContext({ viewport: null });
const browserContext = await browser.newContext({
providerOptions: browserLaunchOptions.providerOptions,
viewport: null,
});
const page = await browserContext.newPage();

// Expose dispatch function for browser client to send messages
Expand Down
1 change: 0 additions & 1 deletion packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
} from './hostController';

export { validateBrowserConfig } from './configValidation';

export {
BROWSER_VIEWPORT_PRESET_DIMENSIONS,
BROWSER_VIEWPORT_PRESET_IDS,
Expand Down
10 changes: 10 additions & 0 deletions packages/browser/src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import { playwrightProviderImplementation } from './playwright';
*
* When adding a new built-in provider, implement `BrowserProviderImplementation`
* and register it in `providerImplementations` below.
*
* Provider-agnostic policy:
* - keep shared contracts and behavior provider-neutral
* - `browser.providerOptions` stays opaque at the framework boundary
* - do not export provider-owned config types from `@rstest/browser`
* - do not reference optional peer provider types from public declarations
* - keep provider-specific behavior and config decoding inside provider implementations
* - prefer direct passthrough to provider APIs over provider-specific translation
*/
export type BrowserProvider = 'playwright';

Expand Down Expand Up @@ -50,6 +58,7 @@ export type BrowserProviderBrowser = {
close: () => Promise<void>;
newContext: (options: {
viewport: { width: number; height: number } | null;
providerOptions?: Record<string, unknown>;
}) => Promise<BrowserProviderContext>;
};

Expand All @@ -62,6 +71,7 @@ export type BrowserProviderRuntime = {
export type LaunchBrowserInput = {
browserName: 'chromium' | 'firefox' | 'webkit';
headless: boolean | undefined;
providerOptions: Record<string, unknown>;
};

/** Input contract for provider-side browser RPC dispatch. */
Expand Down
2 changes: 2 additions & 0 deletions packages/browser/src/providers/playwright/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ export const playwrightProviderImplementation: BrowserProviderImplementation = {
async launchRuntime({
browserName,
headless,
providerOptions,
}): Promise<BrowserProviderRuntime> {
return launchPlaywrightBrowser({
browserName,
headless,
providerOptions,
});
},
async dispatchRpc({
Expand Down
52 changes: 37 additions & 15 deletions packages/browser/src/providers/playwright/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,54 @@
import type { BrowserProviderRuntime } from '../index';

type PlaywrightModule = typeof import('playwright');
type PlaywrightBrowserType = PlaywrightModule['chromium'];
import type { BrowserProviderContext, BrowserProviderRuntime } from '../index';

export async function launchPlaywrightBrowser({
browserName,
headless,
providerOptions,
}: {
browserName: 'chromium' | 'firefox' | 'webkit';
headless: boolean | undefined;
providerOptions: Record<string, unknown>;
}): Promise<BrowserProviderRuntime> {
const playwright = await import('playwright');
const browserType = playwright[browserName] as PlaywrightBrowserType;
const browserType = playwright[browserName];
const launchOptions = providerOptions.launch as
| Record<string, unknown>
| undefined;
const launchArgs = Array.isArray(launchOptions?.args)
? launchOptions.args
: browserName === 'chromium'
? [
'--disable-popup-blocking',
'--no-first-run',
'--no-default-browser-check',
]
: undefined;

const browser = await browserType.launch({
...launchOptions,
headless,
// Chromium-specific args (ignored by other browsers)
args:
browserName === 'chromium'
? [
'--disable-popup-blocking',
'--no-first-run',
'--no-default-browser-check',
]
: undefined,
args: launchArgs,
});
Comment thread
fi3ework marked this conversation as resolved.

const wrappedBrowser: BrowserProviderRuntime['browser'] = {
close: async () => browser.close(),
newContext: async ({
providerOptions: contextProviderOptions,
viewport,
}) => {
const contextOptions = contextProviderOptions?.context as
| Record<string, unknown>
| undefined;
const context = await browser.newContext({
...contextOptions,
viewport,
});

return context as unknown as BrowserProviderContext;
},
};

return {
browser: browser as unknown as BrowserProviderRuntime['browser'],
browser: wrappedBrowser,
};
}
Loading
Loading