@rstest/browser 0.8.3 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1623,6 +1623,8 @@ function nanoid(size = 21) {
1623
1623
  const picomatch = __webpack_require__("../../node_modules/.pnpm/picomatch@4.0.3/node_modules/picomatch/index.js");
1624
1624
  const { createRsbuild: createRsbuild, rspack: rspack } = rsbuild;
1625
1625
  const hostController_dirname = dirname(fileURLToPath(import.meta.url));
1626
+ const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
1627
+ const serializeForInlineScript = (value)=>JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
1626
1628
  class ContainerRpcManager {
1627
1629
  wss;
1628
1630
  ws = null;
@@ -1917,6 +1919,20 @@ const htmlTemplate = `<!DOCTYPE html>
1917
1919
  </body>
1918
1920
  </html>
1919
1921
  `;
1922
+ const fallbackSchedulerHtmlTemplate = `<!DOCTYPE html>
1923
+ <html lang="en">
1924
+ <head>
1925
+ <meta charset="UTF-8" />
1926
+ <title>Rstest Browser Scheduler</title>
1927
+ <script>
1928
+ window.__RSTEST_BROWSER_OPTIONS__ = ${OPTIONS_PLACEHOLDER};
1929
+ </script>
1930
+ </head>
1931
+ <body>
1932
+ <script type="module" src="/container-static/js/scheduler.js"></script>
1933
+ </body>
1934
+ </html>
1935
+ `;
1920
1936
  const VIRTUAL_MANIFEST_FILENAME = 'virtual-manifest.ts';
1921
1937
  const destroyBrowserRuntime = async (runtime)=>{
1922
1938
  try {
@@ -1955,13 +1971,15 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
1955
1971
  const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin({
1956
1972
  [manifestPath]: manifestSource
1957
1973
  });
1958
- const optionsPlaceholder = '__RSTEST_OPTIONS_PLACEHOLDER__';
1959
- const containerHtmlTemplate = containerDistPath ? await promises.readFile(join(containerDistPath, 'container.html'), 'utf-8') : null;
1974
+ const containerHtmlTemplate = containerDistPath ? await promises.readFile(join(containerDistPath, 'index.html'), 'utf-8') : null;
1975
+ const schedulerHtmlTemplate = containerDistPath ? await promises.readFile(join(containerDistPath, 'scheduler.html'), 'utf-8').catch(()=>null) : null;
1960
1976
  let injectedContainerHtml = null;
1977
+ let injectedSchedulerHtml = null;
1961
1978
  let serializedOptions = 'null';
1962
1979
  const setContainerOptions = (options)=>{
1963
- serializedOptions = JSON.stringify(options).replace(/</g, '\\u003c');
1964
- if (containerHtmlTemplate) injectedContainerHtml = containerHtmlTemplate.replace(optionsPlaceholder, serializedOptions);
1980
+ serializedOptions = serializeForInlineScript(options);
1981
+ if (containerHtmlTemplate) injectedContainerHtml = containerHtmlTemplate.replace(OPTIONS_PLACEHOLDER, serializedOptions);
1982
+ injectedSchedulerHtml = (schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate).replace(OPTIONS_PLACEHOLDER, serializedOptions);
1965
1983
  };
1966
1984
  const browserProjects = getBrowserProjects(context);
1967
1985
  const firstProject = browserProjects[0];
@@ -2081,7 +2099,7 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2081
2099
  });
2082
2100
  const serveContainer = containerDistPath ? sirv(containerDistPath, {
2083
2101
  dev: false,
2084
- single: 'container.html'
2102
+ single: 'index.html'
2085
2103
  }) : null;
2086
2104
  const containerDevBase = containerDevServer ? new URL(containerDevServer) : null;
2087
2105
  const respondWithDevServerHtml = async (url, res)=>{
@@ -2091,7 +2109,7 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2091
2109
  const response = await fetch(target);
2092
2110
  if (!response.ok) return false;
2093
2111
  let html = await response.text();
2094
- html = html.replace(optionsPlaceholder, serializedOptions);
2112
+ html = html.replace(OPTIONS_PLACEHOLDER, serializedOptions);
2095
2113
  res.statusCode = response.status;
2096
2114
  response.headers.forEach((value, key)=>{
2097
2115
  if ('content-length' === key.toLowerCase()) return;
@@ -2149,9 +2167,14 @@ const createBrowserRuntime = async ({ context, manifestPath, manifestSource, tem
2149
2167
  }
2150
2168
  return;
2151
2169
  }
2152
- if ('/' === url.pathname) {
2170
+ if ('/' === url.pathname || '/scheduler.html' === url.pathname) {
2153
2171
  if (await respondWithDevServerHtml(url, res)) return;
2154
- const html = injectedContainerHtml || containerHtmlTemplate?.replace(optionsPlaceholder, 'null');
2172
+ if ('/scheduler.html' === url.pathname) {
2173
+ res.setHeader('Content-Type', 'text/html');
2174
+ res.end(injectedSchedulerHtml || (schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate).replace(OPTIONS_PLACEHOLDER, 'null'));
2175
+ return;
2176
+ }
2177
+ const html = injectedContainerHtml || containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
2155
2178
  if (html) {
2156
2179
  res.setHeader('Content-Type', 'text/html');
2157
2180
  res.end(html);
@@ -2245,6 +2268,39 @@ async function resolveProjectEntries(context, shardedEntries) {
2245
2268
  const runBrowserController = async (context, options)=>{
2246
2269
  const { skipOnTestRunEnd = false } = options ?? {};
2247
2270
  const buildStart = Date.now();
2271
+ const browserProjects = getBrowserProjects(context);
2272
+ const useSchedulerPage = browserProjects.every((project)=>project.normalizedConfig.browser.headless);
2273
+ const buildErrorResult = async (error)=>{
2274
+ const elapsed = Math.max(0, Date.now() - buildStart);
2275
+ const errorResult = {
2276
+ results: [],
2277
+ testResults: [],
2278
+ duration: {
2279
+ totalTime: elapsed,
2280
+ buildTime: elapsed,
2281
+ testTime: 0
2282
+ },
2283
+ hasFailure: true,
2284
+ unhandledErrors: [
2285
+ error
2286
+ ]
2287
+ };
2288
+ if (!skipOnTestRunEnd) for (const reporter of context.reporters)await reporter.onTestRunEnd?.({
2289
+ results: [],
2290
+ testResults: [],
2291
+ duration: errorResult.duration,
2292
+ snapshotSummary: context.snapshotManager.summary,
2293
+ getSourcemap: async ()=>null,
2294
+ unhandledErrors: errorResult.unhandledErrors
2295
+ });
2296
+ return errorResult;
2297
+ };
2298
+ const toError = (error)=>error instanceof Error ? error : new Error(String(error));
2299
+ const failWithError = async (error, cleanup)=>{
2300
+ ensureProcessExitCode(1);
2301
+ await cleanup?.();
2302
+ return buildErrorResult(toError(error));
2303
+ };
2248
2304
  const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
2249
2305
  let containerDevServer;
2250
2306
  let containerDistPath;
@@ -2252,16 +2308,14 @@ const runBrowserController = async (context, options)=>{
2252
2308
  containerDevServer = new URL(containerDevServerEnv).toString();
2253
2309
  logger.debug(`[Browser UI] Using dev server for container: ${containerDevServer}`);
2254
2310
  } catch (error) {
2255
- logger.error(color.red(`Invalid RSTEST_CONTAINER_DEV_SERVER value: ${String(error)}`));
2256
- ensureProcessExitCode(1);
2257
- return;
2311
+ const originalError = toError(error);
2312
+ originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
2313
+ return failWithError(originalError);
2258
2314
  }
2259
2315
  if (!containerDevServer) try {
2260
2316
  containerDistPath = resolveContainerDist();
2261
2317
  } catch (error) {
2262
- logger.error(color.red(String(error)));
2263
- ensureProcessExitCode(1);
2264
- return;
2318
+ return failWithError(error);
2265
2319
  }
2266
2320
  const projectEntries = await resolveProjectEntries(context, options?.shardedEntries);
2267
2321
  const totalTests = projectEntries.reduce((total, item)=>total + item.testFiles.length, 0);
@@ -2303,13 +2357,12 @@ const runBrowserController = async (context, options)=>{
2303
2357
  containerDevServer
2304
2358
  });
2305
2359
  } catch (error) {
2306
- logger.error(error instanceof Error ? error : new Error(String(error)));
2307
- ensureProcessExitCode(1);
2308
- await promises.rm(tempDir, {
2309
- recursive: true,
2310
- force: true
2311
- }).catch(()=>{});
2312
- return;
2360
+ return failWithError(error, async ()=>{
2361
+ await promises.rm(tempDir, {
2362
+ recursive: true,
2363
+ force: true
2364
+ }).catch(()=>{});
2365
+ });
2313
2366
  }
2314
2367
  if (isWatchMode) {
2315
2368
  watchContext.runtime = runtime;
@@ -2322,14 +2375,14 @@ const runBrowserController = async (context, options)=>{
2322
2375
  testPath: normalize(testPath),
2323
2376
  projectName: entry.project.name
2324
2377
  })));
2325
- const browserProjectsForRuntime = getBrowserProjects(context);
2326
- const projectRuntimeConfigs = browserProjectsForRuntime.map((project)=>({
2378
+ const projectRuntimeConfigs = browserProjects.map((project)=>({
2327
2379
  name: project.name,
2328
2380
  environmentName: project.environmentName,
2329
2381
  projectRoot: normalize(project.rootPath),
2330
- runtimeConfig: serializableConfig(getRuntimeConfigFromProject(project))
2382
+ runtimeConfig: serializableConfig(getRuntimeConfigFromProject(project)),
2383
+ viewport: project.normalizedConfig.browser.viewport
2331
2384
  }));
2332
- const maxTestTimeoutForRpc = Math.max(...browserProjectsForRuntime.map((p)=>p.normalizedConfig.testTimeout ?? 5000));
2385
+ const maxTestTimeoutForRpc = Math.max(...browserProjects.map((p)=>p.normalizedConfig.testTimeout ?? 5000));
2333
2386
  const hostOptions = {
2334
2387
  rootPath: normalize(context.rootPath),
2335
2388
  projects: projectRuntimeConfigs,
@@ -2375,7 +2428,7 @@ const runBrowserController = async (context, options)=>{
2375
2428
  }
2376
2429
  containerPage.on('console', (msg)=>{
2377
2430
  const text = msg.text();
2378
- if (text.includes('[Container]') || text.includes('[Runner]')) logger.log(color.gray(`[Browser Console] ${text}`));
2431
+ if (text.startsWith('[Container]') || text.startsWith('[Runner]') || text.startsWith('[Scheduler]')) logger.log(color.gray(`[Browser Console] ${text}`));
2379
2432
  });
2380
2433
  }
2381
2434
  const createRpcMethods = ()=>({
@@ -2458,12 +2511,17 @@ const runBrowserController = async (context, options)=>{
2458
2511
  if (isWatchMode) runtime.rpcManager = rpcManager;
2459
2512
  }
2460
2513
  if (isNewPage) {
2461
- await containerPage.goto(`http://localhost:${port}/`, {
2514
+ const pagePath = useSchedulerPage ? '/scheduler.html' : '/';
2515
+ if (useSchedulerPage) {
2516
+ const serializedOptions = serializeForInlineScript(hostOptions);
2517
+ await containerPage.addInitScript(`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`);
2518
+ }
2519
+ await containerPage.goto(`http://localhost:${port}${pagePath}`, {
2462
2520
  waitUntil: 'load'
2463
2521
  });
2464
- logger.log(color.cyan(`\nBrowser mode opened at http://localhost:${port}/\n`));
2522
+ logger.log(color.cyan(`\nBrowser mode opened at http://localhost:${port}${pagePath}\n`));
2465
2523
  }
2466
- const maxTestTimeout = Math.max(...browserProjectsForRuntime.map((p)=>p.normalizedConfig.testTimeout ?? 5000));
2524
+ const maxTestTimeout = Math.max(...browserProjects.map((p)=>p.normalizedConfig.testTimeout ?? 5000));
2467
2525
  const totalTimeoutMs = maxTestTimeout * allTestFiles.length + 30000;
2468
2526
  let timeoutId;
2469
2527
  const testTimeout = new Promise((resolve)=>{
@@ -2498,12 +2556,16 @@ const runBrowserController = async (context, options)=>{
2498
2556
  for (const testFile of affectedFiles)await rpcManager.reloadTestFile(testFile);
2499
2557
  } else if (!filesChanged) logger.log(color.cyan('Tests will be re-executed automatically\n'));
2500
2558
  };
2501
- if (!isWatchMode) await destroyBrowserRuntime(runtime);
2502
- if (fatalError) {
2503
- logger.error(color.red(`Browser test run failed: ${fatalError.message}`));
2504
- ensureProcessExitCode(1);
2505
- return;
2559
+ if (!isWatchMode) {
2560
+ try {
2561
+ await containerPage.close();
2562
+ } catch {}
2563
+ try {
2564
+ await containerContext.close();
2565
+ } catch {}
2566
+ await destroyBrowserRuntime(runtime);
2506
2567
  }
2568
+ if (fatalError) return failWithError(fatalError);
2507
2569
  const duration = {
2508
2570
  totalTime: buildTime + testTime,
2509
2571
  buildTime,
@@ -2566,7 +2628,8 @@ const listBrowserTests = async (context, options)=>{
2566
2628
  name: project.name,
2567
2629
  environmentName: project.environmentName,
2568
2630
  projectRoot: normalize(project.rootPath),
2569
- runtimeConfig: serializableConfig(getRuntimeConfigFromProject(project))
2631
+ runtimeConfig: serializableConfig(getRuntimeConfigFromProject(project)),
2632
+ viewport: project.normalizedConfig.browser.viewport
2570
2633
  }));
2571
2634
  const maxTestTimeoutForRpc = Math.max(...browserProjects.map((p)=>p.normalizedConfig.testTimeout ?? 5000));
2572
2635
  const hostOptions = {
@@ -2622,7 +2685,7 @@ const listBrowserTests = async (context, options)=>{
2622
2685
  logger.debug(`[List] Unexpected message: ${message.type}`);
2623
2686
  }
2624
2687
  });
2625
- const serializedOptions = JSON.stringify(hostOptions).replace(/</g, '\\u003c');
2688
+ const serializedOptions = serializeForInlineScript(hostOptions);
2626
2689
  await page.addInitScript(`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`);
2627
2690
  await page.goto(`http://localhost:${port}/runner.html`, {
2628
2691
  waitUntil: 'load'
@@ -2673,10 +2736,134 @@ const listBrowserTests = async (context, options)=>{
2673
2736
  close: cleanup
2674
2737
  };
2675
2738
  };
2739
+ const BROWSER_VIEWPORT_PRESET_IDS = [
2740
+ 'iPhoneSE',
2741
+ 'iPhoneXR',
2742
+ 'iPhone12Pro',
2743
+ 'iPhone14ProMax',
2744
+ 'Pixel7',
2745
+ 'SamsungGalaxyS8Plus',
2746
+ 'SamsungGalaxyS20Ultra',
2747
+ 'iPadMini',
2748
+ 'iPadAir',
2749
+ 'iPadPro',
2750
+ 'SurfacePro7',
2751
+ 'SurfaceDuo',
2752
+ 'GalaxyZFold5',
2753
+ 'AsusZenbookFold',
2754
+ 'SamsungGalaxyA51A71',
2755
+ 'NestHub',
2756
+ 'NestHubMax'
2757
+ ];
2758
+ const BROWSER_VIEWPORT_PRESET_DIMENSIONS = {
2759
+ iPhoneSE: {
2760
+ width: 375,
2761
+ height: 667
2762
+ },
2763
+ iPhoneXR: {
2764
+ width: 414,
2765
+ height: 896
2766
+ },
2767
+ iPhone12Pro: {
2768
+ width: 390,
2769
+ height: 844
2770
+ },
2771
+ iPhone14ProMax: {
2772
+ width: 430,
2773
+ height: 932
2774
+ },
2775
+ Pixel7: {
2776
+ width: 412,
2777
+ height: 915
2778
+ },
2779
+ SamsungGalaxyS8Plus: {
2780
+ width: 360,
2781
+ height: 740
2782
+ },
2783
+ SamsungGalaxyS20Ultra: {
2784
+ width: 412,
2785
+ height: 915
2786
+ },
2787
+ iPadMini: {
2788
+ width: 768,
2789
+ height: 1024
2790
+ },
2791
+ iPadAir: {
2792
+ width: 820,
2793
+ height: 1180
2794
+ },
2795
+ iPadPro: {
2796
+ width: 1024,
2797
+ height: 1366
2798
+ },
2799
+ SurfacePro7: {
2800
+ width: 912,
2801
+ height: 1368
2802
+ },
2803
+ SurfaceDuo: {
2804
+ width: 540,
2805
+ height: 720
2806
+ },
2807
+ GalaxyZFold5: {
2808
+ width: 344,
2809
+ height: 882
2810
+ },
2811
+ AsusZenbookFold: {
2812
+ width: 853,
2813
+ height: 1280
2814
+ },
2815
+ SamsungGalaxyA51A71: {
2816
+ width: 412,
2817
+ height: 914
2818
+ },
2819
+ NestHub: {
2820
+ width: 1024,
2821
+ height: 600
2822
+ },
2823
+ NestHubMax: {
2824
+ width: 1280,
2825
+ height: 800
2826
+ }
2827
+ };
2828
+ const resolveBrowserViewportPreset = (presetId)=>{
2829
+ const size = BROWSER_VIEWPORT_PRESET_DIMENSIONS[presetId];
2830
+ return size ?? null;
2831
+ };
2832
+ const SUPPORTED_PROVIDERS = [
2833
+ 'playwright'
2834
+ ];
2835
+ const isPlainObject = (value)=>'[object Object]' === Object.prototype.toString.call(value);
2836
+ const validateViewport = (viewport)=>{
2837
+ if (null == viewport) return;
2838
+ if ('string' == typeof viewport) {
2839
+ const presetId = viewport.trim();
2840
+ if (!presetId) throw new Error('browser.viewport must be a non-empty preset id.');
2841
+ if (!resolveBrowserViewportPreset(presetId)) throw new Error(`browser.viewport must be a valid preset id. Received: ${viewport}`);
2842
+ return;
2843
+ }
2844
+ if (isPlainObject(viewport)) {
2845
+ const width = viewport.width;
2846
+ const height = viewport.height;
2847
+ if (!Number.isFinite(width) || width <= 0) throw new Error('browser.viewport.width must be a positive number.');
2848
+ if (!Number.isFinite(height) || height <= 0) throw new Error('browser.viewport.height must be a positive number.');
2849
+ return;
2850
+ }
2851
+ throw new Error('browser.viewport must be either a preset id or { width, height }.');
2852
+ };
2853
+ const validateBrowserConfig = (context)=>{
2854
+ for (const project of context.projects){
2855
+ const browser = project.normalizedConfig.browser;
2856
+ if (browser.enabled) {
2857
+ if (!browser.provider) throw new Error('browser.provider is required when browser.enabled is true.');
2858
+ if (!SUPPORTED_PROVIDERS.includes(browser.provider)) throw new Error(`browser.provider must be one of: ${SUPPORTED_PROVIDERS.join(', ')}.`);
2859
+ validateViewport(browser.viewport);
2860
+ }
2861
+ }
2862
+ };
2676
2863
  async function runBrowserTests(context, options) {
2677
2864
  return runBrowserController(context, options);
2678
2865
  }
2679
2866
  async function src_listBrowserTests(context) {
2680
2867
  return listBrowserTests(context);
2681
2868
  }
2682
- export { runBrowserTests, src_listBrowserTests as listBrowserTests };
2869
+ export { BROWSER_VIEWPORT_PRESET_DIMENSIONS, BROWSER_VIEWPORT_PRESET_IDS, resolveBrowserViewportPreset, runBrowserTests, src_listBrowserTests as listBrowserTests, validateBrowserConfig };
@@ -1,11 +1,17 @@
1
+ import type { DevicePreset } from '@rstest/core/browser';
1
2
  import type { RuntimeConfig, Test, TestFileResult, TestResult } from '@rstest/core/browser-runtime';
2
3
  import type { SnapshotUpdateState } from '@vitest/snapshot';
3
4
  export type SerializedRuntimeConfig = RuntimeConfig;
5
+ export type BrowserViewport = {
6
+ width: number;
7
+ height: number;
8
+ } | DevicePreset;
4
9
  export type BrowserProjectRuntime = {
5
10
  name: string;
6
11
  environmentName: string;
7
12
  projectRoot: string;
8
13
  runtimeConfig: SerializedRuntimeConfig;
14
+ viewport?: BrowserViewport;
9
15
  };
10
16
  /**
11
17
  * Test file info with associated project name.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Runtime source of truth for browser viewport presets.
3
+ *
4
+ * IMPORTANT: Keep this list/map in sync with `DevicePreset` typing in
5
+ * `@rstest/core` (`packages/core/src/types/config.ts`) so `defineConfig`
6
+ * autocomplete and runtime validation stay consistent.
7
+ */
8
+ export declare const BROWSER_VIEWPORT_PRESET_IDS: readonly ["iPhoneSE", "iPhoneXR", "iPhone12Pro", "iPhone14ProMax", "Pixel7", "SamsungGalaxyS8Plus", "SamsungGalaxyS20Ultra", "iPadMini", "iPadAir", "iPadPro", "SurfacePro7", "SurfaceDuo", "GalaxyZFold5", "AsusZenbookFold", "SamsungGalaxyA51A71", "NestHub", "NestHubMax"];
9
+ type BrowserViewportPresetId = (typeof BROWSER_VIEWPORT_PRESET_IDS)[number];
10
+ type BrowserViewportSize = {
11
+ width: number;
12
+ height: number;
13
+ };
14
+ export declare const BROWSER_VIEWPORT_PRESET_DIMENSIONS: Record<BrowserViewportPresetId, BrowserViewportSize>;
15
+ export declare const resolveBrowserViewportPreset: (presetId: string) => BrowserViewportSize | null;
16
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rstest/browser",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "Browser mode support for Rstest testing framework.",
5
5
  "bugs": {
6
6
  "url": "https://github.com/web-infra-dev/rstest/issues"
@@ -53,12 +53,12 @@
53
53
  "picomatch": "^4.0.3",
54
54
  "playwright": "^1.49.1",
55
55
  "@rstest/browser-ui": "0.0.0",
56
- "@rstest/core": "0.8.3",
57
- "@rstest/tsconfig": "0.0.1"
56
+ "@rstest/tsconfig": "0.0.1",
57
+ "@rstest/core": "0.8.4"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "playwright": "^1.49.1",
61
- "@rstest/core": "^0.8.3"
61
+ "@rstest/core": "^0.8.4"
62
62
  },
63
63
  "peerDependenciesMeta": {
64
64
  "playwright": {
@@ -0,0 +1,66 @@
1
+ import type { Rstest } from '@rstest/core/browser';
2
+ import { resolveBrowserViewportPreset } from './viewportPresets';
3
+
4
+ const SUPPORTED_PROVIDERS = ['playwright'] as const;
5
+
6
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
7
+ return Object.prototype.toString.call(value) === '[object Object]';
8
+ };
9
+
10
+ const validateViewport = (viewport: unknown): void => {
11
+ if (viewport == null) {
12
+ return;
13
+ }
14
+
15
+ if (typeof viewport === 'string') {
16
+ const presetId = viewport.trim();
17
+ if (!presetId) {
18
+ throw new Error('browser.viewport must be a non-empty preset id.');
19
+ }
20
+ if (!resolveBrowserViewportPreset(presetId)) {
21
+ throw new Error(
22
+ `browser.viewport must be a valid preset id. Received: ${viewport}`,
23
+ );
24
+ }
25
+ return;
26
+ }
27
+
28
+ if (isPlainObject(viewport)) {
29
+ const width = (viewport as any).width;
30
+ const height = (viewport as any).height;
31
+ if (!Number.isFinite(width) || width <= 0) {
32
+ throw new Error('browser.viewport.width must be a positive number.');
33
+ }
34
+ if (!Number.isFinite(height) || height <= 0) {
35
+ throw new Error('browser.viewport.height must be a positive number.');
36
+ }
37
+ return;
38
+ }
39
+
40
+ throw new Error(
41
+ 'browser.viewport must be either a preset id or { width, height }.',
42
+ );
43
+ };
44
+
45
+ export const validateBrowserConfig = (context: Rstest): void => {
46
+ for (const project of context.projects) {
47
+ const browser = project.normalizedConfig.browser;
48
+ if (!browser.enabled) {
49
+ continue;
50
+ }
51
+
52
+ if (!browser.provider) {
53
+ throw new Error(
54
+ 'browser.provider is required when browser.enabled is true.',
55
+ );
56
+ }
57
+
58
+ if (!SUPPORTED_PROVIDERS.includes(browser.provider)) {
59
+ throw new Error(
60
+ `browser.provider must be one of: ${SUPPORTED_PROVIDERS.join(', ')}.`,
61
+ );
62
+ }
63
+
64
+ validateViewport(browser.viewport);
65
+ }
66
+ };