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
9 changes: 9 additions & 0 deletions e2e/browser-mode/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,13 @@ describe('browser mode - config options', () => {
await expectExecSuccess();
expect(cli.stdout).toMatch(/Tests.*passed/);
});

it('should fail early when browser provider is invalid', async () => {
const { expectExecFailed, expectStderrLog, cli } =
await runBrowserCli('invalid-provider');

await expectExecFailed();
expectStderrLog(/browser\.provider must be one of: playwright\./);
expect(cli.stdout).not.toMatch(/Browser mode opened at/);
});
});
10 changes: 10 additions & 0 deletions e2e/browser-mode/fixtures/invalid-provider/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
browser: {
enabled: true,
provider: 'invalid' as unknown as 'playwright',
headless: true,
},
include: ['tests/**/*.test.ts'],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { describe, expect, it } from '@rstest/core';

describe('smoke', () => {
it('should not run with invalid provider', () => {
expect(true).toBe(true);
});
});
66 changes: 66 additions & 0 deletions packages/browser/src/configValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Rstest } from '@rstest/core/browser';
import { resolveBrowserViewportPreset } from './viewportPresets';

const SUPPORTED_PROVIDERS = ['playwright'] as const;

const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return Object.prototype.toString.call(value) === '[object Object]';
};

const validateViewport = (viewport: unknown): void => {
if (viewport == null) {
return;
}

if (typeof viewport === 'string') {
const presetId = viewport.trim();
if (!presetId) {
throw new Error('browser.viewport must be a non-empty preset id.');
}
if (!resolveBrowserViewportPreset(presetId)) {
throw new Error(
`browser.viewport must be a valid preset id. Received: ${viewport}`,
);
}
return;
}

if (isPlainObject(viewport)) {
const width = (viewport as any).width;
const height = (viewport as any).height;
if (!Number.isFinite(width) || width <= 0) {
throw new Error('browser.viewport.width must be a positive number.');
}
if (!Number.isFinite(height) || height <= 0) {
throw new Error('browser.viewport.height must be a positive number.');
}
return;
}

throw new Error(
'browser.viewport must be either a preset id or { width, height }.',
);
};

export const validateBrowserConfig = (context: Rstest): void => {
for (const project of context.projects) {
const browser = project.normalizedConfig.browser;
if (!browser.enabled) {
continue;
}

if (!browser.provider) {
throw new Error(
'browser.provider is required when browser.enabled is true.',
);
}

if (!SUPPORTED_PROVIDERS.includes(browser.provider)) {
throw new Error(
`browser.provider must be one of: ${SUPPORTED_PROVIDERS.join(', ')}.`,
);
}

validateViewport(browser.viewport);
}
};
Comment thread
fi3ework marked this conversation as resolved.
8 changes: 8 additions & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ import {
runBrowserController,
} from './hostController';

export { validateBrowserConfig } from './configValidation';

export {
BROWSER_VIEWPORT_PRESET_DIMENSIONS,
BROWSER_VIEWPORT_PRESET_IDS,
resolveBrowserViewportPreset,
} from './viewportPresets';

export async function runBrowserTests(
context: Rstest,
options?: BrowserTestRunOptions,
Expand Down
64 changes: 64 additions & 0 deletions packages/browser/src/viewportPresets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Runtime source of truth for browser viewport presets.
*
* IMPORTANT: Keep this list/map in sync with `DevicePreset` typing in
* `@rstest/core` (`packages/core/src/types/config.ts`) so `defineConfig`
* autocomplete and runtime validation stay consistent.
*/
export const BROWSER_VIEWPORT_PRESET_IDS = [
'iPhoneSE',
'iPhoneXR',
'iPhone12Pro',
'iPhone14ProMax',
'Pixel7',
'SamsungGalaxyS8Plus',
'SamsungGalaxyS20Ultra',
'iPadMini',
'iPadAir',
'iPadPro',
'SurfacePro7',
'SurfaceDuo',
'GalaxyZFold5',
'AsusZenbookFold',
'SamsungGalaxyA51A71',
'NestHub',
'NestHubMax',
] as const;

type BrowserViewportPresetId = (typeof BROWSER_VIEWPORT_PRESET_IDS)[number];

type BrowserViewportSize = {
width: number;
height: number;
};

export const BROWSER_VIEWPORT_PRESET_DIMENSIONS: Record<
BrowserViewportPresetId,
BrowserViewportSize
> = {
iPhoneSE: { width: 375, height: 667 },
iPhoneXR: { width: 414, height: 896 },
iPhone12Pro: { width: 390, height: 844 },
iPhone14ProMax: { width: 430, height: 932 },
Pixel7: { width: 412, height: 915 },
SamsungGalaxyS8Plus: { width: 360, height: 740 },
SamsungGalaxyS20Ultra: { width: 412, height: 915 },
iPadMini: { width: 768, height: 1024 },
iPadAir: { width: 820, height: 1180 },
iPadPro: { width: 1024, height: 1366 },
SurfacePro7: { width: 912, height: 1368 },
SurfaceDuo: { width: 540, height: 720 },
GalaxyZFold5: { width: 344, height: 882 },
AsusZenbookFold: { width: 853, height: 1280 },
SamsungGalaxyA51A71: { width: 412, height: 914 },
NestHub: { width: 1024, height: 600 },
NestHubMax: { width: 1280, height: 800 },
};

export const resolveBrowserViewportPreset = (
presetId: string,
): BrowserViewportSize | null => {
const size =
BROWSER_VIEWPORT_PRESET_DIMENSIONS[presetId as BrowserViewportPresetId];
return size ?? null;
Comment thread
fi3ework marked this conversation as resolved.
};
62 changes: 0 additions & 62 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,56 +8,15 @@ import { dirname, isAbsolute, join, resolve } from 'pathe';
import { isCI } from 'std-env';
import type { NormalizedConfig, ProjectConfig, RstestConfig } from './types';
import {
BROWSER_VIEWPORT_PRESET_IDS,
castArray,
color,
DEFAULT_CONFIG_EXTENSIONS,
DEFAULT_CONFIG_NAME,
formatRootStr,
isPlainObject,
logger,
TEMP_RSTEST_OUTPUT_DIR_GLOB,
} from './utils';

const VALID_BROWSER_VIEWPORT_PRESETS = new Set<string>(
BROWSER_VIEWPORT_PRESET_IDS,
);

const validateBrowserViewport = (viewport: unknown): void => {
if (viewport == null) {
return;
}

if (typeof viewport === 'string') {
const presetId = viewport.trim();
if (!presetId) {
throw new Error('browser.viewport must be a non-empty preset id.');
}
if (!VALID_BROWSER_VIEWPORT_PRESETS.has(presetId)) {
throw new Error(
`browser.viewport must be a valid preset id. Received: ${viewport}`,
);
}
return;
}

if (isPlainObject(viewport)) {
const width = (viewport as any).width;
const height = (viewport as any).height;
if (!Number.isFinite(width) || width <= 0) {
throw new Error('browser.viewport.width must be a positive number.');
}
if (!Number.isFinite(height) || height <= 0) {
throw new Error('browser.viewport.height must be a positive number.');
}
return;
}

throw new Error(
'browser.viewport must be either a preset id or { width, height }.',
);
};

const findConfig = (basePath: string): string | undefined => {
return DEFAULT_CONFIG_EXTENSIONS.map((ext) => basePath + ext).find(
fs.existsSync,
Expand Down Expand Up @@ -254,27 +213,6 @@ const createDefaultConfig = (): NormalizedConfig => ({
});

export const withDefaultConfig = (config: RstestConfig): NormalizedConfig => {
// Validate browser config when browser mode is enabled.
if (config.browser?.enabled === true) {
if (!config.browser.provider) {
throw new Error(
'browser.provider is required when browser.enabled is true.',
);
}

// Keep runtime validation even though TypeScript narrows the type, since
// config can be loaded from JS or forced via `as unknown as RstestConfig`.
const supportedProviders = ['playwright'] as const;
if (!supportedProviders.includes(config.browser.provider)) {
throw new Error(
`browser.provider must be one of: ${supportedProviders.join(', ')}.`,
);
}

// Validate viewport (optional)
validateBrowserViewport((config.browser as any).viewport);
}

const merged = mergeRstestConfig(
createDefaultConfig(),
config,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/core/browserLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type { BrowserTestRunOptions, BrowserTestRunResult } from '../types';
* Type definition for the @rstest/browser package exports.
*/
export interface BrowserModule {
validateBrowserConfig: (context: unknown) => void;
runBrowserTests: (
context: unknown,
options?: BrowserTestRunOptions,
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/core/listTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,11 @@ const collectBrowserTests = async ({
const { loadBrowserModule } = await import('./browserLoader');
// Pass project roots to resolve @rstest/browser from project-specific node_modules
const projectRoots = browserProjects.map((p) => p.rootPath);
const { listBrowserTests } = await loadBrowserModule({ projectRoots });
// Cast to any because listBrowserTests expects Rstest but we have RstestContext
// In practice, context is always a Rstest instance
return listBrowserTests(context as any, { shardedEntries });
const { validateBrowserConfig, listBrowserTests } = await loadBrowserModule({
projectRoots,
});
validateBrowserConfig(context);
return listBrowserTests(context, { shardedEntries });
};

const collectTestFiles = async ({
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/core/runTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ async function runBrowserModeTests(
options: BrowserTestRunOptions,
): Promise<BrowserTestRunResult | void> {
const projectRoots = browserProjects.map((p) => p.rootPath);
const { runBrowserTests } = await loadBrowserModule({ projectRoots });
const { validateBrowserConfig, runBrowserTests } = await loadBrowserModule({
projectRoots,
});
validateBrowserConfig(context);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Handle validation failures before parallel browser launch

runBrowserModeTests now calls validateBrowserConfig(context) and can reject immediately on invalid browser config, but in mixed node+browser runs runTests starts this promise and only awaits it much later after node work. In Node 22 this creates an unhandled rejection window (default --unhandled-rejections=throw), so an invalid browser provider/viewport can crash the process before reporters/cleanup run instead of producing a controlled test failure; this regression is introduced by the new synchronous throw point here.

Useful? React with 👍 / 👎.

return runBrowserTests(context, options);
}

Expand Down Expand Up @@ -166,6 +169,10 @@ export async function runTests(context: Rstest): Promise<void> {
skipOnTestRunEnd: shouldUnifyReporter,
shardedEntries: shard ? browserEntries : undefined,
});

// Prevent an unhandled rejection window in mixed node+browser runs.
// We still await the original promise later to surface the error.
browserResultPromise.catch(() => {});
}

// If there are no node tests to run, we can potentially exit early.
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ declare const PLAYWRIGHT_VERSION: string;
declare module '@rstest/browser' {
import type { ListCommandResult, RstestContext } from './types';

export function validateBrowserConfig(context: RstestContext): void;
export function runBrowserTests(context: RstestContext): Promise<void>;
export function listBrowserTests(context: RstestContext): Promise<{
list: ListCommandResult[];
Expand Down
27 changes: 25 additions & 2 deletions packages/core/src/types/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { RsbuildConfig } from '@rsbuild/core';
import type { SnapshotStateOptions } from '@vitest/snapshot';
import type { config } from 'chai';
import type { BROWSER_VIEWPORT_PRESET_IDS } from '../utils/constants';
import type { CoverageOptions, NormalizedCoverageOptions } from './coverage';
import type {
BuiltInReporterNames,
Expand Down Expand Up @@ -54,8 +53,32 @@ export type BrowserName = 'chromium' | 'firefox' | 'webkit';
* Device presets aligned with Chrome DevTools device toolbar.
*
* These values are stable identifiers (not user-facing labels).
*
* IMPORTANT: Keep this union in sync with
* `@rstest/browser` preset runtime source:
* `packages/browser/src/viewportPresets.ts`.
*
* `@rstest/core` owns `defineConfig` typing, while `@rstest/browser` owns
* runtime validation and resolution for preset ids.
*/
export type DevicePreset = (typeof BROWSER_VIEWPORT_PRESET_IDS)[number];
export type DevicePreset =
| 'iPhoneSE'
| 'iPhoneXR'
| 'iPhone12Pro'
| 'iPhone14ProMax'
| 'Pixel7'
| 'SamsungGalaxyS8Plus'
| 'SamsungGalaxyS20Ultra'
| 'iPadMini'
| 'iPadAir'
| 'iPadPro'
| 'SurfacePro7'
| 'SurfaceDuo'
| 'GalaxyZFold5'
| 'AsusZenbookFold'
| 'SamsungGalaxyA51A71'
| 'NestHub'
| 'NestHubMax';

export type BrowserViewport =
| {
Expand Down
20 changes: 0 additions & 20 deletions packages/core/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,6 @@ export const DEFAULT_CONFIG_EXTENSIONS = [
'.cts',
] as const;

export const BROWSER_VIEWPORT_PRESET_IDS = [
'iPhoneSE',
'iPhoneXR',
'iPhone12Pro',
'iPhone14ProMax',
'Pixel7',
'SamsungGalaxyS8Plus',
'SamsungGalaxyS20Ultra',
'iPadMini',
'iPadAir',
'iPadPro',
'SurfacePro7',
'SurfaceDuo',
'GalaxyZFold5',
'AsusZenbookFold',
'SamsungGalaxyA51A71',
'NestHub',
'NestHubMax',
] as const;

export const globalApis: (keyof Rstest)[] = [
'test',
'describe',
Expand Down
Loading
Loading