diff --git a/e2e/browser-mode/config.test.ts b/e2e/browser-mode/config.test.ts index 5935bdd59..f4ae03294 100644 --- a/e2e/browser-mode/config.test.ts +++ b/e2e/browser-mode/config.test.ts @@ -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/); + }); }); diff --git a/e2e/browser-mode/fixtures/invalid-provider/rstest.config.ts b/e2e/browser-mode/fixtures/invalid-provider/rstest.config.ts new file mode 100644 index 000000000..9ed10f981 --- /dev/null +++ b/e2e/browser-mode/fixtures/invalid-provider/rstest.config.ts @@ -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'], +}); diff --git a/e2e/browser-mode/fixtures/invalid-provider/tests/smoke.test.ts b/e2e/browser-mode/fixtures/invalid-provider/tests/smoke.test.ts new file mode 100644 index 000000000..316ca0399 --- /dev/null +++ b/e2e/browser-mode/fixtures/invalid-provider/tests/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from '@rstest/core'; + +describe('smoke', () => { + it('should not run with invalid provider', () => { + expect(true).toBe(true); + }); +}); diff --git a/packages/browser/src/configValidation.ts b/packages/browser/src/configValidation.ts new file mode 100644 index 000000000..be34ab30b --- /dev/null +++ b/packages/browser/src/configValidation.ts @@ -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 => { + 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); + } +}; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index a78b4ba71..b11c3e228 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -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, diff --git a/packages/browser/src/viewportPresets.ts b/packages/browser/src/viewportPresets.ts new file mode 100644 index 000000000..2f228254b --- /dev/null +++ b/packages/browser/src/viewportPresets.ts @@ -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; +}; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index a6e21670b..ac0b22dfd 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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( - 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, @@ -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, diff --git a/packages/core/src/core/browserLoader.ts b/packages/core/src/core/browserLoader.ts index 51a4ed131..18b52462e 100644 --- a/packages/core/src/core/browserLoader.ts +++ b/packages/core/src/core/browserLoader.ts @@ -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, diff --git a/packages/core/src/core/listTests.ts b/packages/core/src/core/listTests.ts index 32858e3b5..ff0eb0056 100644 --- a/packages/core/src/core/listTests.ts +++ b/packages/core/src/core/listTests.ts @@ -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 ({ diff --git a/packages/core/src/core/runTests.ts b/packages/core/src/core/runTests.ts index cb51a8c82..7ca4ceb8e 100644 --- a/packages/core/src/core/runTests.ts +++ b/packages/core/src/core/runTests.ts @@ -28,7 +28,10 @@ async function runBrowserModeTests( options: BrowserTestRunOptions, ): Promise { const projectRoots = browserProjects.map((p) => p.rootPath); - const { runBrowserTests } = await loadBrowserModule({ projectRoots }); + const { validateBrowserConfig, runBrowserTests } = await loadBrowserModule({ + projectRoots, + }); + validateBrowserConfig(context); return runBrowserTests(context, options); } @@ -166,6 +169,10 @@ export async function runTests(context: Rstest): Promise { 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. diff --git a/packages/core/src/env.d.ts b/packages/core/src/env.d.ts index c6ead8ed5..ec1bd7da3 100644 --- a/packages/core/src/env.d.ts +++ b/packages/core/src/env.d.ts @@ -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; export function listBrowserTests(context: RstestContext): Promise<{ list: ListCommandResult[]; diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 2e4f38906..df59030bc 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -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, @@ -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 = | { diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index 9873fc215..e36496ec8 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -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', diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts index ba470adfc..b84b959d6 100644 --- a/packages/core/tests/config.test.ts +++ b/packages/core/tests/config.test.ts @@ -92,26 +92,22 @@ describe('mergeRstestConfig', () => { }); }); -describe('withDefaultConfig browser validation', () => { - it('should throw error when browser.enabled is true but provider is missing', () => { - // Use 'as any' to bypass TypeScript check and test runtime validation +describe('withDefaultConfig browser normalization', () => { + it('should not throw when browser.enabled is true and provider is missing', () => { const config = { browser: { enabled: true }, } as RstestConfig; - expect(() => withDefaultConfig(config)).toThrow( - 'browser.provider is required when browser.enabled is true.', - ); + expect(() => withDefaultConfig(config)).not.toThrow(); }); - it('should throw error when browser.enabled is true but provider is invalid', () => { + it('should preserve custom provider value for browser loader validation', () => { const config = { browser: { enabled: true, provider: 'invalid' }, } as unknown as RstestConfig; - expect(() => withDefaultConfig(config)).toThrow( - 'browser.provider must be one of: playwright.', - ); + const normalized = withDefaultConfig(config); + expect(normalized.browser.provider).toBe('invalid'); }); it('should not throw when browser.enabled is true and provider is playwright', () => { @@ -122,7 +118,7 @@ describe('withDefaultConfig browser validation', () => { expect(() => withDefaultConfig(config)).not.toThrow(); }); - it('should throw when browser.viewport preset id is invalid', () => { + it('should preserve viewport value for browser loader validation', () => { const config = { browser: { enabled: true, @@ -131,9 +127,8 @@ describe('withDefaultConfig browser validation', () => { }, } as unknown as RstestConfig; - expect(() => withDefaultConfig(config)).toThrow( - 'browser.viewport must be a valid preset id.', - ); + const normalized = withDefaultConfig(config); + expect(normalized.browser.viewport).toBe('iPhone99'); }); it('should not throw when browser.viewport preset id is valid', () => {