@rstest/browser 0.8.5 → 0.9.1

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.
Files changed (84) hide show
  1. package/LICENSE-APACHE-2.0 +202 -0
  2. package/NOTICE +11 -0
  3. package/dist/361.js +8 -0
  4. package/dist/augmentExpect.d.ts +73 -0
  5. package/dist/browser-container/container-static/css/index.5c72297783.css +1 -0
  6. package/dist/browser-container/container-static/js/{565.226c9ef5.js → 101.36a8ccdf84.js} +4024 -3856
  7. package/dist/browser-container/container-static/js/101.36a8ccdf84.js.LICENSE.txt +1 -0
  8. package/dist/browser-container/container-static/js/{index.c1d17467.js → index.28d833de0b.js} +732 -675
  9. package/dist/browser-container/container-static/js/{lib-react.97ee79b0.js → lib-react.dcf2a5e57a.js} +10 -10
  10. package/dist/browser-container/container-static/js/lib-react.dcf2a5e57a.js.LICENSE.txt +1 -0
  11. package/dist/browser-container/index.html +1 -1
  12. package/dist/browser.d.ts +2 -0
  13. package/dist/browser.js +583 -0
  14. package/dist/browserRpcRegistry.d.ts +18 -0
  15. package/dist/client/api.d.ts +3 -0
  16. package/dist/client/browserRpc.d.ts +2 -0
  17. package/dist/client/dispatchTransport.d.ts +11 -0
  18. package/dist/client/entry.d.ts +1 -5
  19. package/dist/client/locator.d.ts +125 -0
  20. package/dist/client/snapshot.d.ts +0 -6
  21. package/dist/concurrency.d.ts +12 -0
  22. package/dist/dispatchCapabilities.d.ts +34 -0
  23. package/dist/dispatchRouter.d.ts +20 -0
  24. package/dist/headedSerialTaskQueue.d.ts +8 -0
  25. package/dist/headlessLatestRerunScheduler.d.ts +19 -0
  26. package/dist/headlessTransport.d.ts +12 -0
  27. package/dist/hostController.d.ts +16 -0
  28. package/dist/index.js +1790 -296
  29. package/dist/protocol.d.ts +44 -33
  30. package/dist/providers/index.d.ts +79 -0
  31. package/dist/providers/playwright/compileLocator.d.ts +3 -0
  32. package/dist/providers/playwright/dispatchBrowserRpc.d.ts +13 -0
  33. package/dist/providers/playwright/expectUtils.d.ts +24 -0
  34. package/dist/providers/playwright/implementation.d.ts +2 -0
  35. package/dist/providers/playwright/index.d.ts +1 -0
  36. package/dist/providers/playwright/runtime.d.ts +5 -0
  37. package/dist/providers/playwright/textMatcher.d.ts +8 -0
  38. package/dist/rpcProtocol.d.ts +145 -0
  39. package/dist/runSession.d.ts +33 -0
  40. package/dist/sessionRegistry.d.ts +34 -0
  41. package/dist/sourceMap/sourceMapLoader.d.ts +14 -0
  42. package/dist/watchCliShortcuts.d.ts +6 -0
  43. package/dist/watchRerunPlanner.d.ts +21 -0
  44. package/package.json +17 -12
  45. package/src/AGENTS.md +128 -0
  46. package/src/augmentExpect.ts +62 -0
  47. package/src/browser.ts +3 -0
  48. package/src/browserRpcRegistry.ts +57 -0
  49. package/src/client/AGENTS.md +82 -0
  50. package/src/client/api.ts +213 -0
  51. package/src/client/browserRpc.ts +86 -0
  52. package/src/client/dispatchTransport.ts +178 -0
  53. package/src/client/entry.ts +96 -33
  54. package/src/client/locator.ts +452 -0
  55. package/src/client/snapshot.ts +32 -97
  56. package/src/client/sourceMapSupport.ts +26 -37
  57. package/src/concurrency.ts +62 -0
  58. package/src/dispatchCapabilities.ts +162 -0
  59. package/src/dispatchRouter.ts +82 -0
  60. package/src/env.d.ts +8 -1
  61. package/src/headedSerialTaskQueue.ts +19 -0
  62. package/src/headlessLatestRerunScheduler.ts +76 -0
  63. package/src/headlessTransport.ts +28 -0
  64. package/src/hostController.ts +1538 -384
  65. package/src/protocol.ts +66 -31
  66. package/src/providers/index.ts +103 -0
  67. package/src/providers/playwright/compileLocator.ts +130 -0
  68. package/src/providers/playwright/dispatchBrowserRpc.ts +372 -0
  69. package/src/providers/playwright/expectUtils.ts +57 -0
  70. package/src/providers/playwright/implementation.ts +33 -0
  71. package/src/providers/playwright/index.ts +1 -0
  72. package/src/providers/playwright/runtime.ts +32 -0
  73. package/src/providers/playwright/textMatcher.ts +10 -0
  74. package/src/rpcProtocol.ts +220 -0
  75. package/src/runSession.ts +110 -0
  76. package/src/sessionRegistry.ts +89 -0
  77. package/src/sourceMap/sourceMapLoader.ts +96 -0
  78. package/src/watchCliShortcuts.ts +77 -0
  79. package/src/watchRerunPlanner.ts +77 -0
  80. package/dist/browser-container/container-static/css/index.5a71c757.css +0 -1
  81. package/dist/browser-container/container-static/js/565.226c9ef5.js.LICENSE.txt +0 -1
  82. package/dist/browser-container/container-static/js/lib-react.97ee79b0.js.LICENSE.txt +0 -1
  83. package/dist/browser-container/container-static/js/scheduler.5accca0c.js +0 -407
  84. package/dist/browser-container/scheduler.html +0 -19
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http';
4
4
  import type { AddressInfo } from 'node:net';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import type { Rspack } from '@rstest/core';
6
7
  import {
7
8
  type BrowserTestRunOptions,
8
9
  type BrowserTestRunResult,
@@ -30,14 +31,59 @@ import { type BirpcReturn, createBirpc } from 'birpc';
30
31
  import openEditor from 'open-editor';
31
32
  import { basename, dirname, join, normalize, relative, resolve } from 'pathe';
32
33
  import * as picomatch from 'picomatch';
33
- import type { BrowserContext, ConsoleMessage, Page } from 'playwright';
34
34
  import sirv from 'sirv';
35
35
  import { type WebSocket, WebSocketServer } from 'ws';
36
+ import { getHeadlessConcurrency } from './concurrency';
37
+ import {
38
+ createHostDispatchRouter,
39
+ type HostDispatchRouterOptions,
40
+ } from './dispatchCapabilities';
41
+ import { createHeadedSerialTaskQueue } from './headedSerialTaskQueue';
42
+ import { createHeadlessLatestRerunScheduler } from './headlessLatestRerunScheduler';
43
+ import { attachHeadlessRunnerTransport } from './headlessTransport';
36
44
  import type {
45
+ BrowserClientMessage,
46
+ BrowserDispatchHandler,
47
+ BrowserDispatchRequest,
48
+ BrowserDispatchResponse,
37
49
  BrowserHostConfig,
38
50
  BrowserProjectRuntime,
51
+ BrowserRpcRequest,
52
+ BrowserViewport,
53
+ SnapshotRpcRequest,
39
54
  TestFileInfo,
40
55
  } from './protocol';
56
+ import {
57
+ DISPATCH_MESSAGE_TYPE,
58
+ DISPATCH_NAMESPACE_RUNNER,
59
+ validateBrowserRpcRequest,
60
+ } from './protocol';
61
+ import {
62
+ type BrowserProvider,
63
+ type BrowserProviderBrowser,
64
+ type BrowserProviderContext,
65
+ type BrowserProviderImplementation,
66
+ type BrowserProviderPage,
67
+ getBrowserProviderImplementation,
68
+ } from './providers';
69
+ import {
70
+ createRunSession,
71
+ type RunSession,
72
+ RunSessionLifecycle,
73
+ } from './runSession';
74
+ import { RunnerSessionRegistry } from './sessionRegistry';
75
+ import {
76
+ loadSourceMapWithCache,
77
+ normalizeJavaScriptUrl,
78
+ type SourceMapPayload,
79
+ } from './sourceMap/sourceMapLoader';
80
+ import { resolveBrowserViewportPreset } from './viewportPresets';
81
+ import {
82
+ isBrowserWatchCliShortcutsEnabled,
83
+ logBrowserWatchReadyMessage,
84
+ setupBrowserWatchCliShortcuts,
85
+ } from './watchCliShortcuts';
86
+ import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';
41
87
 
42
88
  const { createRsbuild, rspack } = rsbuild;
43
89
  type RsbuildDevServer = rsbuild.RsbuildDevServer;
@@ -66,16 +112,22 @@ type VirtualModulesPluginInstance = InstanceType<
66
112
  (typeof rspack.experiments)['VirtualModulesPlugin']
67
113
  >;
68
114
 
69
- type PlaywrightModule = typeof import('playwright');
70
- type BrowserType = PlaywrightModule['chromium'];
71
- type BrowserInstance = Awaited<ReturnType<BrowserType['launch']>>;
72
-
73
115
  type BrowserProjectEntries = {
74
116
  project: ProjectContext;
75
117
  setupFiles: string[];
76
118
  testFiles: string[];
77
119
  };
78
120
 
121
+ type BrowserProviderProject = {
122
+ rootPath: string;
123
+ provider: BrowserProvider;
124
+ };
125
+
126
+ type BrowserLaunchOptions = Pick<
127
+ ProjectContext['normalizedConfig']['browser'],
128
+ 'provider' | 'browser' | 'headless' | 'port' | 'strictPort'
129
+ >;
130
+
79
131
  /** Payload for test file start event */
80
132
  type TestFileStartPayload = {
81
133
  testPath: string;
@@ -97,21 +149,30 @@ type FatalPayload = {
97
149
  stack?: string;
98
150
  };
99
151
 
152
+ type ReporterHookArg<THook extends keyof Reporter> = Parameters<
153
+ NonNullable<Reporter[THook]>
154
+ >[0];
155
+
156
+ type TestFileReadyPayload = ReporterHookArg<'onTestFileReady'>;
157
+ type TestSuiteStartPayload = ReporterHookArg<'onTestSuiteStart'>;
158
+ type TestSuiteResultPayload = ReporterHookArg<'onTestSuiteResult'>;
159
+ type TestCaseStartPayload = ReporterHookArg<'onTestCaseStart'>;
160
+
100
161
  /** RPC methods exposed by the host (server) to the container (client) */
101
162
  type HostRpcMethods = {
102
163
  rerunTest: (testFile: string, testNamePattern?: string) => Promise<void>;
103
164
  getTestFiles: () => Promise<TestFileInfo[]>;
165
+ onRunnerFramesReady: (testFiles: string[]) => Promise<void>;
104
166
  // Test result callbacks from container
105
167
  onTestFileStart: (payload: TestFileStartPayload) => Promise<void>;
106
168
  onTestCaseResult: (payload: TestResult) => Promise<void>;
107
169
  onTestFileComplete: (payload: TestFileResult) => Promise<void>;
108
170
  onLog: (payload: LogPayload) => Promise<void>;
109
171
  onFatal: (payload: FatalPayload) => Promise<void>;
110
- // Snapshot file operations (for browser mode snapshot support)
111
- resolveSnapshotPath: (testPath: string) => Promise<string>;
112
- readSnapshotFile: (filepath: string) => Promise<string | null>;
113
- saveSnapshotFile: (filepath: string, content: string) => Promise<void>;
114
- removeSnapshotFile: (filepath: string) => Promise<void>;
172
+ // Generic dispatch endpoint used by runner RPC requests.
173
+ dispatch: (
174
+ request: BrowserDispatchRequest,
175
+ ) => Promise<BrowserDispatchResponse>;
115
176
  };
116
177
 
117
178
  /** RPC methods exposed by the container (client) to the host (server) */
@@ -237,15 +298,17 @@ class ContainerRpcManager {
237
298
  type BrowserRuntime = {
238
299
  rsbuildInstance: RsbuildInstance;
239
300
  devServer: RsbuildDevServer;
240
- browser: BrowserInstance;
301
+ browser: BrowserProviderBrowser;
241
302
  port: number;
242
303
  wsPort: number;
243
304
  manifestPath: string;
244
305
  tempDir: string;
245
306
  manifestPlugin: VirtualModulesPluginInstance;
246
- containerPage?: Page;
247
- containerContext?: BrowserContext;
307
+ containerPage?: BrowserProviderPage;
308
+ containerContext?: BrowserProviderContext;
248
309
  setContainerOptions: (options: BrowserHostConfig) => void;
310
+ // Reserved extension seam for host-side dispatch capabilities.
311
+ dispatchHandlers: Map<string, BrowserDispatchHandler>;
249
312
  wss: WebSocketServer;
250
313
  rpcManager?: ContainerRpcManager;
251
314
  };
@@ -259,6 +322,8 @@ type WatchContext = {
259
322
  lastTestFiles: TestFileInfo[];
260
323
  hooksEnabled: boolean;
261
324
  cleanupRegistered: boolean;
325
+ cleanupPromise: Promise<void> | null;
326
+ closeCliShortcuts: (() => void) | null;
262
327
  chunkHashes: Map<string, string>;
263
328
  affectedTestFiles: string[];
264
329
  };
@@ -268,6 +333,8 @@ const watchContext: WatchContext = {
268
333
  lastTestFiles: [],
269
334
  hooksEnabled: false,
270
335
  cleanupRegistered: false,
336
+ cleanupPromise: null,
337
+ closeCliShortcuts: null,
271
338
  chunkHashes: new Map(),
272
339
  affectedTestFiles: [],
273
340
  };
@@ -276,12 +343,128 @@ const watchContext: WatchContext = {
276
343
  // Utility Functions
277
344
  // ============================================================================
278
345
 
346
+ const resolveViewport = (
347
+ viewport: BrowserViewport | undefined,
348
+ ): { width: number; height: number } | null => {
349
+ if (!viewport) {
350
+ return null;
351
+ }
352
+
353
+ if (typeof viewport === 'string') {
354
+ return resolveBrowserViewportPreset(viewport);
355
+ }
356
+
357
+ if (
358
+ typeof viewport.width === 'number' &&
359
+ Number.isFinite(viewport.width) &&
360
+ viewport.width > 0 &&
361
+ typeof viewport.height === 'number' &&
362
+ Number.isFinite(viewport.height) &&
363
+ viewport.height > 0
364
+ ) {
365
+ return {
366
+ width: viewport.width,
367
+ height: viewport.height,
368
+ };
369
+ }
370
+
371
+ return null;
372
+ };
373
+
374
+ const mapViewportByProject = (
375
+ projects: BrowserProjectRuntime[],
376
+ ): Map<string, { width: number; height: number }> => {
377
+ const map = new Map<string, { width: number; height: number }>();
378
+ for (const project of projects) {
379
+ const viewport = resolveViewport(project.viewport);
380
+ if (viewport) {
381
+ map.set(project.name, viewport);
382
+ }
383
+ }
384
+ return map;
385
+ };
386
+
279
387
  const ensureProcessExitCode = (code: number): void => {
280
388
  if (process.exitCode === undefined || process.exitCode === 0) {
281
389
  process.exitCode = code;
282
390
  }
283
391
  };
284
392
 
393
+ const castArray = <T>(arr?: T | T[]): T[] => {
394
+ if (arr === undefined) {
395
+ return [];
396
+ }
397
+ return Array.isArray(arr) ? arr : [arr];
398
+ };
399
+
400
+ const applyDefaultWatchOptions = (
401
+ rspackConfig: Rspack.Configuration,
402
+ isWatchMode: boolean,
403
+ ) => {
404
+ rspackConfig.watchOptions ??= {};
405
+
406
+ if (!isWatchMode) {
407
+ rspackConfig.watchOptions.ignored = '**/**';
408
+ return;
409
+ }
410
+
411
+ rspackConfig.watchOptions.ignored = castArray(
412
+ rspackConfig.watchOptions.ignored || [],
413
+ ) as string[];
414
+
415
+ if (rspackConfig.watchOptions.ignored.length === 0) {
416
+ rspackConfig.watchOptions.ignored.push('**/.git', '**/node_modules');
417
+ }
418
+
419
+ rspackConfig.output?.path &&
420
+ rspackConfig.watchOptions.ignored.push(rspackConfig.output.path);
421
+ };
422
+
423
+ type LazyCompilationModule = {
424
+ nameForCondition?: () => string | null | undefined;
425
+ };
426
+
427
+ type BrowserLazyCompilationConfig = {
428
+ imports: true;
429
+ entries: false;
430
+ test?: (module: LazyCompilationModule) => boolean;
431
+ };
432
+
433
+ export const createBrowserLazyCompilationConfig = (
434
+ setupFiles: string[],
435
+ ): BrowserLazyCompilationConfig => {
436
+ const eagerSetupFiles = new Set(
437
+ setupFiles.map((filePath) => normalize(filePath)),
438
+ );
439
+
440
+ if (eagerSetupFiles.size === 0) {
441
+ return {
442
+ imports: true,
443
+ entries: false,
444
+ };
445
+ }
446
+
447
+ return {
448
+ imports: true,
449
+ entries: false,
450
+ test(module: LazyCompilationModule) {
451
+ const filePath = module.nameForCondition?.();
452
+ return !filePath || !eagerSetupFiles.has(normalize(filePath));
453
+ },
454
+ };
455
+ };
456
+
457
+ export const createBrowserRsbuildDevConfig = (isWatchMode: boolean) => {
458
+ return {
459
+ // Disable HMR in non-watch mode (tests run once and exit).
460
+ // Aligns with node mode behavior (packages/core/src/core/rsbuild.ts).
461
+ hmr: isWatchMode,
462
+ client: {
463
+ logLevel: 'error' as const,
464
+ },
465
+ };
466
+ };
467
+
285
468
  /**
286
469
  * Convert a single glob pattern to RegExp using picomatch
287
470
  * Based on Storybook's implementation
@@ -533,6 +716,69 @@ const getBrowserProjects = (context: Rstest): ProjectContext[] => {
533
716
  );
534
717
  };
535
718
 
719
+ const getBrowserLaunchOptions = (
720
+ project: ProjectContext,
721
+ ): BrowserLaunchOptions => ({
722
+ provider: project.normalizedConfig.browser.provider,
723
+ browser: project.normalizedConfig.browser.browser,
724
+ headless: project.normalizedConfig.browser.headless,
725
+ port: project.normalizedConfig.browser.port,
726
+ strictPort: project.normalizedConfig.browser.strictPort,
727
+ });
728
+
729
+ const ensureConsistentBrowserLaunchOptions = (
730
+ projects: ProjectContext[],
731
+ ): BrowserLaunchOptions => {
732
+ if (projects.length === 0) {
733
+ throw new Error('No browser-enabled projects found.');
734
+ }
735
+
736
+ const firstProject = projects[0]!;
737
+ const firstOptions = getBrowserLaunchOptions(firstProject);
738
+
739
+ for (const project of projects.slice(1)) {
740
+ const options = getBrowserLaunchOptions(project);
741
+ if (
742
+ options.provider !== firstOptions.provider ||
743
+ options.browser !== firstOptions.browser ||
744
+ options.headless !== firstOptions.headless ||
745
+ options.port !== firstOptions.port ||
746
+ options.strictPort !== firstOptions.strictPort
747
+ ) {
748
+ throw new Error(
749
+ `Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
750
+ 'All browser-enabled projects in one run must share provider/browser/headless/port/strictPort.',
751
+ );
752
+ }
753
+ }
754
+
755
+ return firstOptions;
756
+ };
757
+
758
+ const resolveProviderForTestPath = ({
759
+ testPath,
760
+ browserProjects,
761
+ }: {
762
+ testPath: string;
763
+ browserProjects: BrowserProviderProject[];
764
+ }): BrowserProvider => {
765
+ const normalizedTestPath = normalize(testPath);
766
+ const sortedProjects = [...browserProjects].sort(
767
+ (a, b) => b.rootPath.length - a.rootPath.length,
768
+ );
769
+
770
+ for (const project of sortedProjects) {
771
+ if (normalizedTestPath.startsWith(project.rootPath)) {
772
+ return project.provider;
773
+ }
774
+ }
775
+
776
+ throw new Error(
777
+ `Cannot resolve browser provider for test path: ${JSON.stringify(testPath)}. ` +
778
+ `Known project roots: ${JSON.stringify(sortedProjects.map((p) => p.rootPath))}`,
779
+ );
780
+ };
781
+
536
782
  const collectProjectEntries = async (
537
783
  context: Rstest,
538
784
  ): Promise<BrowserProjectEntries[]> => {
@@ -729,21 +975,6 @@ const htmlTemplate = `<!DOCTYPE html>
729
975
  </html>
730
976
  `;
731
977
 
732
- const fallbackSchedulerHtmlTemplate = `<!DOCTYPE html>
733
- <html lang="en">
734
- <head>
735
- <meta charset="UTF-8" />
736
- <title>Rstest Browser Scheduler</title>
737
- <script>
738
- window.__RSTEST_BROWSER_OPTIONS__ = ${OPTIONS_PLACEHOLDER};
739
- </script>
740
- </head>
741
- <body>
742
- <script type="module" src="/container-static/js/scheduler.js"></script>
743
- </body>
744
- </html>
745
- `;
746
-
747
978
  // Workaround for noisy "removed ..." logs caused by VirtualModulesPlugin.
748
979
  // Rsbuild suppresses the removed-file log if all removed paths include "virtual":
749
980
  // https://github.com/web-infra-dev/rsbuild/blob/1258fa9dba5c321a4629b591a6dadbd2e26c6963/packages/core/src/createCompiler.ts#L73-L76
@@ -776,27 +1007,39 @@ const destroyBrowserRuntime = async (
776
1007
  .catch(() => {});
777
1008
  };
778
1009
 
779
- const registerWatchCleanup = (): void => {
780
- if (watchContext.cleanupRegistered) {
781
- return;
1010
+ const cleanupWatchRuntime = (): Promise<void> => {
1011
+ if (watchContext.cleanupPromise) {
1012
+ return watchContext.cleanupPromise;
782
1013
  }
783
1014
 
784
- const cleanup = async () => {
1015
+ watchContext.cleanupPromise = (async () => {
1016
+ watchContext.closeCliShortcuts?.();
1017
+ watchContext.closeCliShortcuts = null;
1018
+
785
1019
  if (!watchContext.runtime) {
786
1020
  return;
787
1021
  }
1022
+
788
1023
  await destroyBrowserRuntime(watchContext.runtime);
789
1024
  watchContext.runtime = null;
790
- };
1025
+ })();
1026
+
1027
+ return watchContext.cleanupPromise;
1028
+ };
1029
+
1030
+ const registerWatchCleanup = (): void => {
1031
+ if (watchContext.cleanupRegistered) {
1032
+ return;
1033
+ }
791
1034
 
792
- for (const signal of ['SIGINT', 'SIGTERM'] as const) {
1035
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGTSTP'] as const) {
793
1036
  process.once(signal, () => {
794
- void cleanup();
1037
+ void cleanupWatchRuntime();
795
1038
  });
796
1039
  }
797
1040
 
798
1041
  process.once('exit', () => {
799
- void cleanup();
1042
+ void cleanupWatchRuntime();
800
1043
  });
801
1044
 
802
1045
  watchContext.cleanupRegistered = true;
@@ -831,15 +1074,11 @@ const createBrowserRuntime = async ({
831
1074
  const containerHtmlTemplate = containerDistPath
832
1075
  ? await fs.readFile(join(containerDistPath, 'index.html'), 'utf-8')
833
1076
  : null;
834
- const schedulerHtmlTemplate = containerDistPath
835
- ? await fs
836
- .readFile(join(containerDistPath, 'scheduler.html'), 'utf-8')
837
- .catch(() => null)
838
- : null;
839
1077
 
840
1078
  let injectedContainerHtml: string | null = null;
841
- let injectedSchedulerHtml: string | null = null;
842
1079
  let serializedOptions = 'null';
1080
+ // Reserved extension seam for future browser-side capabilities.
1081
+ const dispatchHandlers = new Map<string, BrowserDispatchHandler>();
843
1082
 
844
1083
  const setContainerOptions = (options: BrowserHostConfig): void => {
845
1084
  serializedOptions = serializeForInlineScript(options);
@@ -849,18 +1088,17 @@ const createBrowserRuntime = async ({
849
1088
  serializedOptions,
850
1089
  );
851
1090
  }
852
- injectedSchedulerHtml = (
853
- schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate
854
- ).replace(OPTIONS_PLACEHOLDER, serializedOptions);
855
1091
  };
856
1092
 
857
- // Get user Rsbuild config from the first browser project
858
1093
  const browserProjects = getBrowserProjects(context);
859
- const firstProject = browserProjects[0];
860
- const userPlugins = firstProject?.normalizedConfig.plugins || [];
861
- const userRsbuildConfig = firstProject?.normalizedConfig ?? {};
862
- const browserConfig =
863
- firstProject?.normalizedConfig.browser ?? context.normalizedConfig.browser;
1094
+ const projectByEnvironmentName = new Map(
1095
+ browserProjects.map((project) => [project.environmentName, project]),
1096
+ );
1097
+ const userPlugins = browserProjects.flatMap(
1098
+ (project) => project.normalizedConfig.plugins || [],
1099
+ );
1100
+ const browserLaunchOptions =
1101
+ ensureConsistentBrowserLaunchOptions(browserProjects);
864
1102
 
865
1103
  // Rstest internal aliases that must not be overridden by user config
866
1104
  const browserRuntimePath = fileURLToPath(
@@ -871,6 +1109,8 @@ const createBrowserRuntime = async ({
871
1109
  '@rstest/browser-manifest': manifestPath,
872
1110
  // User test code: import { describe, it } from '@rstest/core'
873
1111
  '@rstest/core': resolveBrowserFile('client/public.ts'),
1112
+ // User test code: import { page } from '@rstest/browser'
1113
+ '@rstest/browser': resolveBrowserFile('browser.ts'),
874
1114
  // Browser runtime APIs for entry.ts and public.ts
875
1115
  // Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
876
1116
  '@rstest/core/browser-runtime': browserRuntimePath,
@@ -885,16 +1125,14 @@ const createBrowserRuntime = async ({
885
1125
  plugins: userPlugins,
886
1126
  server: {
887
1127
  printUrls: false,
888
- port: browserConfig.port ?? 4000,
889
- strictPort: browserConfig.strictPort,
890
- },
891
- dev: {
892
- client: {
893
- logLevel: 'error',
894
- },
1128
+ port: browserLaunchOptions.port ?? 4000,
1129
+ strictPort: browserLaunchOptions.strictPort,
895
1130
  },
1131
+ dev: createBrowserRsbuildDevConfig(isWatchMode),
896
1132
  environments: {
897
- [firstProject?.environmentName || 'web']: {},
1133
+ ...Object.fromEntries(
1134
+ browserProjects.map((project) => [project.environmentName, {}]),
1135
+ ),
898
1136
  },
899
1137
  },
900
1138
  });
@@ -904,13 +1142,45 @@ const createBrowserRuntime = async ({
904
1142
  {
905
1143
  name: 'rstest:browser-user-config',
906
1144
  setup(api) {
1145
+ // Internal extension entry: register host dispatch handlers without
1146
+ // coupling scheduling to individual capability implementations.
1147
+ (api as { expose?: (name: string, value: unknown) => void }).expose?.(
1148
+ 'rstest:browser',
1149
+ {
1150
+ registerDispatchHandler: (
1151
+ namespace: string,
1152
+ handler: BrowserDispatchHandler,
1153
+ ) => {
1154
+ dispatchHandlers.set(namespace, handler);
1155
+ },
1156
+ },
1157
+ );
1158
+
907
1159
  api.modifyEnvironmentConfig({
908
- handler: (config, { mergeEnvironmentConfig }) => {
1160
+ handler: (config, { mergeEnvironmentConfig, name }) => {
1161
+ const project = projectByEnvironmentName.get(name);
1162
+ if (!project) {
1163
+ return config;
1164
+ }
1165
+
1166
+ const userRsbuildConfig = project.normalizedConfig;
1167
+ const setupFiles = Object.values(
1168
+ getSetupFiles(
1169
+ project.normalizedConfig.setupFiles,
1170
+ project.rootPath,
1171
+ ),
1172
+ );
909
1173
  // Merge order: current config -> userConfig -> rstest required config (highest priority)
910
1174
  const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
911
1175
  resolve: {
912
1176
  alias: rstestInternalAliases,
913
1177
  },
1178
+ source: {
1179
+ define: {
1180
+ 'process.env': 'globalThis[Symbol.for("rstest.env")]',
1181
+ 'import.meta.env': 'globalThis[Symbol.for("rstest.env")]',
1182
+ },
1183
+ },
914
1184
  output: {
915
1185
  target: 'web',
916
1186
  // Enable source map for inline snapshot support
@@ -921,13 +1191,13 @@ const createBrowserRuntime = async ({
921
1191
  tools: {
922
1192
  rspack: (rspackConfig) => {
923
1193
  rspackConfig.mode = 'development';
924
- rspackConfig.lazyCompilation = {
925
- imports: true,
926
- entries: false,
927
- };
1194
+ rspackConfig.lazyCompilation =
1195
+ createBrowserLazyCompilationConfig(setupFiles);
928
1196
  rspackConfig.plugins = rspackConfig.plugins || [];
929
1197
  rspackConfig.plugins.push(virtualManifestPlugin);
930
1198
 
1199
+ applyDefaultWatchOptions(rspackConfig, isWatchMode);
1200
+
931
1201
  // Extract and merge sourcemaps from pre-built @rstest/core files
932
1202
  // This preserves the sourcemap chain for inline snapshot support
933
1203
  // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
@@ -984,8 +1254,8 @@ const createBrowserRuntime = async ({
984
1254
  if (stats) {
985
1255
  const projectEntries = await collectProjectEntries(context);
986
1256
  const entryTestFiles = new Set<string>(
987
- projectEntries.flatMap((entry) =>
988
- entry.testFiles.map((f) => normalize(f)),
1257
+ collectWatchTestFiles(projectEntries).map(
1258
+ (file) => file.testPath,
989
1259
  ),
990
1260
  );
991
1261
 
@@ -1015,7 +1285,9 @@ const createBrowserRuntime = async ({
1015
1285
  }
1016
1286
 
1017
1287
  // Register coverage plugin for browser mode
1018
- const coverage = firstProject?.normalizedConfig.coverage;
1288
+ const coverage = browserProjects.find(
1289
+ (project) => project.normalizedConfig.coverage?.enabled,
1290
+ )?.normalizedConfig.coverage;
1019
1291
  if (coverage?.enabled && context.command !== 'list') {
1020
1292
  const { pluginCoverage } = await loadCoverageProvider(
1021
1293
  coverage,
@@ -1109,82 +1381,72 @@ const createBrowserRuntime = async ({
1109
1381
  }
1110
1382
  };
1111
1383
 
1112
- devServer.middlewares.use(async (req, res, next) => {
1113
- if (!req.url) {
1114
- next();
1115
- return;
1116
- }
1117
- const url = new URL(req.url, 'http://localhost');
1118
- if (url.pathname === '/__open-in-editor') {
1119
- const file = url.searchParams.get('file');
1120
- if (!file) {
1121
- res.statusCode = 400;
1122
- res.end('Missing file');
1384
+ devServer.middlewares.use(
1385
+ async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
1386
+ if (!req.url) {
1387
+ next();
1123
1388
  return;
1124
1389
  }
1125
- try {
1126
- await openEditor([{ file }]);
1127
- res.statusCode = 204;
1128
- res.end();
1129
- } catch (error) {
1130
- logger.debug(`[Browser UI] Failed to open editor: ${String(error)}`);
1131
- res.statusCode = 500;
1132
- res.end('Failed to open editor');
1133
- }
1134
- return;
1135
- }
1136
- if (url.pathname === '/' || url.pathname === '/scheduler.html') {
1137
- if (await respondWithDevServerHtml(url, res)) {
1390
+ const url = new URL(req.url, 'http://localhost');
1391
+ if (url.pathname === '/__open-in-editor') {
1392
+ const file = url.searchParams.get('file');
1393
+ if (!file) {
1394
+ res.statusCode = 400;
1395
+ res.end('Missing file');
1396
+ return;
1397
+ }
1398
+ try {
1399
+ await openEditor([{ file }]);
1400
+ res.statusCode = 204;
1401
+ res.end();
1402
+ } catch (error) {
1403
+ logger.debug(`[Browser UI] Failed to open editor: ${String(error)}`);
1404
+ res.statusCode = 500;
1405
+ res.end('Failed to open editor');
1406
+ }
1138
1407
  return;
1139
1408
  }
1409
+ if (url.pathname === '/') {
1410
+ if (await respondWithDevServerHtml(url, res)) {
1411
+ return;
1412
+ }
1140
1413
 
1141
- if (url.pathname === '/scheduler.html') {
1142
- res.setHeader('Content-Type', 'text/html');
1143
- res.end(
1144
- injectedSchedulerHtml ||
1145
- (schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate).replace(
1146
- OPTIONS_PLACEHOLDER,
1147
- 'null',
1148
- ),
1149
- );
1150
- return;
1151
- }
1414
+ const html =
1415
+ injectedContainerHtml ||
1416
+ containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
1152
1417
 
1153
- const html =
1154
- injectedContainerHtml ||
1155
- containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
1418
+ if (html) {
1419
+ res.setHeader('Content-Type', 'text/html');
1420
+ res.end(html);
1421
+ return;
1422
+ }
1156
1423
 
1157
- if (html) {
1158
- res.setHeader('Content-Type', 'text/html');
1159
- res.end(html);
1424
+ res.statusCode = 502;
1425
+ res.end('Container UI is not available.');
1160
1426
  return;
1161
1427
  }
1428
+ if (url.pathname.startsWith('/container-static/')) {
1429
+ if (await proxyDevServerAsset(req, res)) {
1430
+ return;
1431
+ }
1162
1432
 
1163
- res.statusCode = 502;
1164
- res.end('Container UI is not available.');
1165
- return;
1166
- }
1167
- if (url.pathname.startsWith('/container-static/')) {
1168
- if (await proxyDevServerAsset(req, res)) {
1433
+ if (serveContainer) {
1434
+ serveContainer(req, res, next);
1435
+ return;
1436
+ }
1437
+
1438
+ res.statusCode = 502;
1439
+ res.end('Container assets are not available.');
1169
1440
  return;
1170
1441
  }
1171
-
1172
- if (serveContainer) {
1173
- serveContainer(req, res, next);
1442
+ if (url.pathname === '/runner.html') {
1443
+ res.setHeader('Content-Type', 'text/html');
1444
+ res.end(htmlTemplate);
1174
1445
  return;
1175
1446
  }
1176
-
1177
- res.statusCode = 502;
1178
- res.end('Container assets are not available.');
1179
- return;
1180
- }
1181
- if (url.pathname === '/runner.html') {
1182
- res.setHeader('Content-Type', 'text/html');
1183
- res.end(htmlTemplate);
1184
- return;
1185
- }
1186
- next();
1187
- });
1447
+ next();
1448
+ },
1449
+ );
1188
1450
 
1189
1451
  const { port } = await devServer.listen();
1190
1452
 
@@ -1199,49 +1461,33 @@ const createBrowserRuntime = async ({
1199
1461
  const wsPort = (wss.address() as AddressInfo).port;
1200
1462
  logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`);
1201
1463
 
1202
- let browserLauncher: BrowserType;
1203
- const browserName = browserConfig.browser;
1464
+ const browserName = browserLaunchOptions.browser ?? 'chromium';
1204
1465
  try {
1205
- const playwright = await import('playwright');
1206
- browserLauncher = playwright[browserName];
1207
- } catch (_error) {
1208
- wss.close();
1209
- await devServer.close();
1210
- throw _error;
1211
- }
1212
-
1213
- let browser: BrowserInstance;
1214
- try {
1215
- browser = await browserLauncher.launch({
1216
- headless: forceHeadless ?? browserConfig.headless,
1217
- // Chromium-specific args (ignored by other browsers)
1218
- args:
1219
- browserName === 'chromium'
1220
- ? [
1221
- '--disable-popup-blocking',
1222
- '--no-first-run',
1223
- '--no-default-browser-check',
1224
- ]
1225
- : undefined,
1466
+ const providerImplementation = getBrowserProviderImplementation(
1467
+ browserLaunchOptions.provider,
1468
+ );
1469
+ const runtime = await providerImplementation.launchRuntime({
1470
+ browserName,
1471
+ headless: forceHeadless ?? browserLaunchOptions.headless,
1226
1472
  });
1473
+ return {
1474
+ rsbuildInstance,
1475
+ devServer,
1476
+ browser: runtime.browser,
1477
+ port,
1478
+ wsPort,
1479
+ manifestPath,
1480
+ tempDir,
1481
+ manifestPlugin: virtualManifestPlugin,
1482
+ setContainerOptions,
1483
+ dispatchHandlers,
1484
+ wss,
1485
+ };
1227
1486
  } catch (_error) {
1228
1487
  wss.close();
1229
1488
  await devServer.close();
1230
1489
  throw _error;
1231
1490
  }
1232
-
1233
- return {
1234
- rsbuildInstance,
1235
- devServer,
1236
- browser,
1237
- port,
1238
- wsPort,
1239
- manifestPath,
1240
- tempDir,
1241
- manifestPlugin: virtualManifestPlugin,
1242
- setContainerOptions,
1243
- wss,
1244
- };
1245
1491
  };
1246
1492
 
1247
1493
  async function resolveProjectEntries(
@@ -1281,24 +1527,71 @@ export const runBrowserController = async (
1281
1527
  const { skipOnTestRunEnd = false } = options ?? {};
1282
1528
  const buildStart = Date.now();
1283
1529
  const browserProjects = getBrowserProjects(context);
1284
- const useSchedulerPage = browserProjects.every(
1530
+ const useHeadlessDirect = browserProjects.every(
1285
1531
  (project) => project.normalizedConfig.browser.headless,
1286
1532
  );
1287
1533
 
1534
+ const browserSourceMapCache = new Map<string, SourceMapPayload | null>();
1535
+
1536
+ const isHttpLikeFile = (file: string): boolean => /^https?:\/\//.test(file);
1537
+
1538
+ const resolveBrowserSourcemap = async (sourcePath: string) => {
1539
+ if (!isHttpLikeFile(sourcePath)) {
1540
+ return {
1541
+ handled: false,
1542
+ sourcemap: null,
1543
+ };
1544
+ }
1545
+
1546
+ const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
1547
+ if (!normalizedUrl) {
1548
+ return {
1549
+ handled: true,
1550
+ sourcemap: null,
1551
+ };
1552
+ }
1553
+
1554
+ if (browserSourceMapCache.has(normalizedUrl)) {
1555
+ return {
1556
+ handled: true,
1557
+ sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null,
1558
+ };
1559
+ }
1560
+
1561
+ return {
1562
+ handled: true,
1563
+ sourcemap: await loadSourceMapWithCache({
1564
+ jsUrl: normalizedUrl,
1565
+ cache: browserSourceMapCache,
1566
+ }),
1567
+ };
1568
+ };
1569
+
1570
+ const getBrowserSourcemap = async (
1571
+ sourcePath: string,
1572
+ ): Promise<SourceMapPayload | null> => {
1573
+ const result = await resolveBrowserSourcemap(sourcePath);
1574
+ return result.handled ? result.sourcemap : null;
1575
+ };
1576
+
1288
1577
  /**
1289
1578
  * Build an error BrowserTestRunResult and call onTestRunEnd if needed.
1290
1579
  * Used for early-exit error paths to ensure errors reach the summary report.
1291
1580
  */
1292
1581
  const buildErrorResult = async (
1293
1582
  error: Error,
1583
+ close?: () => Promise<void>,
1294
1584
  ): Promise<BrowserTestRunResult> => {
1295
1585
  const elapsed = Math.max(0, Date.now() - buildStart);
1296
- const errorResult: BrowserTestRunResult = {
1586
+ const errorResult = {
1297
1587
  results: [],
1298
1588
  testResults: [],
1299
1589
  duration: { totalTime: elapsed, buildTime: elapsed, testTime: 0 },
1300
1590
  hasFailure: true,
1301
1591
  unhandledErrors: [error],
1592
+ getSourcemap: getBrowserSourcemap,
1593
+ resolveSourcemap: resolveBrowserSourcemap,
1594
+ close,
1302
1595
  };
1303
1596
 
1304
1597
  if (!skipOnTestRunEnd) {
@@ -1308,7 +1601,7 @@ export const runBrowserController = async (
1308
1601
  testResults: [],
1309
1602
  duration: errorResult.duration,
1310
1603
  snapshotSummary: context.snapshotManager.summary,
1311
- getSourcemap: async () => null,
1604
+ getSourcemap: getBrowserSourcemap,
1312
1605
  unhandledErrors: errorResult.unhandledErrors,
1313
1606
  });
1314
1607
  }
@@ -1326,32 +1619,94 @@ export const runBrowserController = async (
1326
1619
  cleanup?: () => Promise<void>,
1327
1620
  ): Promise<BrowserTestRunResult> => {
1328
1621
  ensureProcessExitCode(1);
1329
- await cleanup?.();
1330
- return buildErrorResult(toError(error));
1622
+
1623
+ const normalizedError = toError(error);
1624
+
1625
+ if (cleanup && skipOnTestRunEnd) {
1626
+ return buildErrorResult(normalizedError, cleanup);
1627
+ }
1628
+
1629
+ try {
1630
+ return await buildErrorResult(normalizedError);
1631
+ } finally {
1632
+ await cleanup?.();
1633
+ }
1634
+ };
1635
+
1636
+ const collectDeletedTestPaths = (
1637
+ previous: TestFileInfo[],
1638
+ current: TestFileInfo[],
1639
+ ): string[] => {
1640
+ const currentPathSet = new Set(current.map((file) => file.testPath));
1641
+ return previous
1642
+ .map((file) => file.testPath)
1643
+ .filter((testPath) => !currentPathSet.has(testPath));
1644
+ };
1645
+
1646
+ const notifyTestRunStart = async (): Promise<void> => {
1647
+ if (skipOnTestRunEnd) {
1648
+ return;
1649
+ }
1650
+
1651
+ for (const reporter of context.reporters) {
1652
+ await reporter.onTestRunStart?.();
1653
+ }
1654
+ };
1655
+
1656
+ const notifyTestRunEnd = async ({
1657
+ duration,
1658
+ unhandledErrors,
1659
+ filterRerunTestPaths,
1660
+ }: {
1661
+ duration: {
1662
+ totalTime: number;
1663
+ buildTime: number;
1664
+ testTime: number;
1665
+ };
1666
+ unhandledErrors?: Error[];
1667
+ filterRerunTestPaths?: string[];
1668
+ }): Promise<void> => {
1669
+ if (skipOnTestRunEnd) {
1670
+ return;
1671
+ }
1672
+
1673
+ for (const reporter of context.reporters) {
1674
+ await reporter.onTestRunEnd?.({
1675
+ results: context.reporterResults.results,
1676
+ testResults: context.reporterResults.testResults,
1677
+ duration,
1678
+ snapshotSummary: context.snapshotManager.summary,
1679
+ getSourcemap: getBrowserSourcemap,
1680
+ unhandledErrors,
1681
+ filterRerunTestPaths,
1682
+ });
1683
+ }
1331
1684
  };
1332
1685
 
1333
1686
  const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
1334
1687
  let containerDevServer: string | undefined;
1335
1688
  let containerDistPath: string | undefined;
1336
1689
 
1337
- if (containerDevServerEnv) {
1338
- try {
1339
- containerDevServer = new URL(containerDevServerEnv).toString();
1340
- logger.debug(
1341
- `[Browser UI] Using dev server for container: ${containerDevServer}`,
1342
- );
1343
- } catch (error) {
1344
- const originalError = toError(error);
1345
- originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
1346
- return failWithError(originalError);
1690
+ if (!useHeadlessDirect) {
1691
+ if (containerDevServerEnv) {
1692
+ try {
1693
+ containerDevServer = new URL(containerDevServerEnv).toString();
1694
+ logger.debug(
1695
+ `[Browser UI] Using dev server for container: ${containerDevServer}`,
1696
+ );
1697
+ } catch (error) {
1698
+ const originalError = toError(error);
1699
+ originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
1700
+ return failWithError(originalError);
1701
+ }
1347
1702
  }
1348
- }
1349
1703
 
1350
- if (!containerDevServer) {
1351
- try {
1352
- containerDistPath = resolveContainerDist();
1353
- } catch (error) {
1354
- return failWithError(error);
1704
+ if (!containerDevServer) {
1705
+ try {
1706
+ containerDistPath = resolveContainerDist();
1707
+ } catch (error) {
1708
+ return failWithError(error);
1709
+ }
1355
1710
  }
1356
1711
  }
1357
1712
 
@@ -1381,7 +1736,10 @@ export const runBrowserController = async (
1381
1736
  return;
1382
1737
  }
1383
1738
 
1739
+ await notifyTestRunStart();
1740
+
1384
1741
  const isWatchMode = context.command === 'watch';
1742
+ const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
1385
1743
  const tempDir =
1386
1744
  isWatchMode && watchContext.runtime
1387
1745
  ? watchContext.runtime.tempDir
@@ -1402,12 +1760,7 @@ export const runBrowserController = async (
1402
1760
 
1403
1761
  // Track initial test files for watch mode
1404
1762
  if (isWatchMode) {
1405
- watchContext.lastTestFiles = projectEntries.flatMap((entry) =>
1406
- entry.testFiles.map((testPath) => ({
1407
- testPath,
1408
- projectName: entry.project.name,
1409
- })),
1410
- );
1763
+ watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
1411
1764
  }
1412
1765
 
1413
1766
  let runtime = isWatchMode ? watchContext.runtime : null;
@@ -1440,6 +1793,12 @@ export const runBrowserController = async (
1440
1793
  if (isWatchMode) {
1441
1794
  watchContext.runtime = runtime;
1442
1795
  registerWatchCleanup();
1796
+
1797
+ if (enableCliShortcuts && !watchContext.closeCliShortcuts) {
1798
+ watchContext.closeCliShortcuts = await setupBrowserWatchCliShortcuts({
1799
+ close: cleanupWatchRuntime,
1800
+ });
1801
+ }
1443
1802
  }
1444
1803
  }
1445
1804
 
@@ -1484,23 +1843,827 @@ export const runBrowserController = async (
1484
1843
  rpcTimeout: maxTestTimeoutForRpc,
1485
1844
  };
1486
1845
 
1487
- runtime.setContainerOptions(hostOptions);
1846
+ const browserProviderProjects: BrowserProviderProject[] = browserProjects.map(
1847
+ (project) => ({
1848
+ rootPath: normalize(project.rootPath),
1849
+ provider: project.normalizedConfig.browser.provider,
1850
+ }),
1851
+ );
1852
+ const implementationByProvider = new Map<
1853
+ BrowserProvider,
1854
+ BrowserProviderImplementation
1855
+ >();
1856
+ for (const browserProject of browserProviderProjects) {
1857
+ if (!implementationByProvider.has(browserProject.provider)) {
1858
+ implementationByProvider.set(
1859
+ browserProject.provider,
1860
+ getBrowserProviderImplementation(browserProject.provider),
1861
+ );
1862
+ }
1863
+ }
1488
1864
 
1489
- // Track test results from iframes
1490
- const reporterResults: TestFileResult[] = [];
1491
- const caseResults: TestResult[] = [];
1492
- let completedTests = 0;
1493
- let fatalError: Error | null = null;
1865
+ let activeContainerPage: BrowserProviderPage | null = null;
1866
+ let getHeadlessRunnerPageBySessionId:
1867
+ | ((sessionId: string) => BrowserProviderPage | undefined)
1868
+ | undefined;
1869
+
1870
+ const dispatchBrowserRpcRequest = async ({
1871
+ request,
1872
+ target,
1873
+ }: {
1874
+ request: BrowserRpcRequest;
1875
+ target?: BrowserDispatchRequest['target'];
1876
+ }): Promise<unknown> => {
1877
+ const timeoutFallbackMs = maxTestTimeoutForRpc;
1878
+ const provider = resolveProviderForTestPath({
1879
+ testPath: request.testPath,
1880
+ browserProjects: browserProviderProjects,
1881
+ });
1882
+ const implementation = implementationByProvider.get(provider);
1883
+ if (!implementation) {
1884
+ throw new Error(`Browser provider implementation not found: ${provider}`);
1885
+ }
1494
1886
 
1495
- // Promise that resolves when all tests complete
1496
- let resolveAllTests: (() => void) | undefined;
1497
- const allTestsPromise = new Promise<void>((resolve) => {
1498
- resolveAllTests = resolve;
1499
- });
1887
+ const runnerPage = target?.sessionId
1888
+ ? getHeadlessRunnerPageBySessionId?.(target.sessionId)
1889
+ : undefined;
1890
+
1891
+ if (target?.sessionId && !runnerPage) {
1892
+ throw new Error(
1893
+ `Runner page session not found for browser dispatch: ${target.sessionId}`,
1894
+ );
1895
+ }
1896
+
1897
+ if (!runnerPage && !activeContainerPage) {
1898
+ throw new Error('Browser container page is not initialized');
1899
+ }
1900
+
1901
+ try {
1902
+ return await implementation.dispatchRpc({
1903
+ containerPage: runnerPage
1904
+ ? undefined
1905
+ : (activeContainerPage ?? undefined),
1906
+ runnerPage,
1907
+ request,
1908
+ timeoutFallbackMs,
1909
+ });
1910
+ } catch (error) {
1911
+ // birpc serializes thrown Errors as `{}` over JSON; throw a string instead.
1912
+ if (error instanceof Error) {
1913
+ throw error.message;
1914
+ }
1915
+ throw String(error);
1916
+ }
1917
+ };
1918
+
1919
+ runtime.dispatchHandlers.set('browser', async (dispatchRequest) => {
1920
+ const request = validateBrowserRpcRequest(dispatchRequest.args);
1921
+ return dispatchBrowserRpcRequest({
1922
+ request,
1923
+ target: dispatchRequest.target,
1924
+ });
1925
+ });
1926
+
1927
+ runtime.setContainerOptions(hostOptions);
1928
+
1929
+ // Track test results from browser runners
1930
+ const reporterResults: TestFileResult[] = [];
1931
+ const caseResults: TestResult[] = [];
1932
+ let fatalError: Error | null = null;
1933
+
1934
+ const snapshotRpcMethods = {
1935
+ async resolveSnapshotPath(testPath: string): Promise<string> {
1936
+ const snapExtension = '.snap';
1937
+ const resolver =
1938
+ context.normalizedConfig.resolveSnapshotPath ||
1939
+ (() =>
1940
+ join(
1941
+ dirname(testPath),
1942
+ '__snapshots__',
1943
+ `${basename(testPath)}${snapExtension}`,
1944
+ ));
1945
+ return resolver(testPath, snapExtension);
1946
+ },
1947
+ async readSnapshotFile(filepath: string): Promise<string | null> {
1948
+ try {
1949
+ return await fs.readFile(filepath, 'utf-8');
1950
+ } catch {
1951
+ return null;
1952
+ }
1953
+ },
1954
+ async saveSnapshotFile(filepath: string, content: string): Promise<void> {
1955
+ const dir = dirname(filepath);
1956
+ await fs.mkdir(dir, { recursive: true });
1957
+ await fs.writeFile(filepath, content, 'utf-8');
1958
+ },
1959
+ async removeSnapshotFile(filepath: string): Promise<void> {
1960
+ try {
1961
+ await fs.unlink(filepath);
1962
+ } catch {
1963
+ // ignore if file doesn't exist
1964
+ }
1965
+ },
1966
+ };
1967
+
1968
+ const handleTestFileStart = async (
1969
+ payload: TestFileStartPayload,
1970
+ ): Promise<void> => {
1971
+ await Promise.all(
1972
+ context.reporters.map((reporter) =>
1973
+ (reporter as Reporter).onTestFileStart?.({
1974
+ testPath: payload.testPath,
1975
+ tests: [],
1976
+ }),
1977
+ ),
1978
+ );
1979
+ };
1980
+
1981
+ const handleTestFileReady = async (
1982
+ payload: TestFileReadyPayload,
1983
+ ): Promise<void> => {
1984
+ await Promise.all(
1985
+ context.reporters.map((reporter) =>
1986
+ (reporter as Reporter).onTestFileReady?.(payload),
1987
+ ),
1988
+ );
1989
+ };
1990
+
1991
+ const handleTestSuiteStart = async (
1992
+ payload: TestSuiteStartPayload,
1993
+ ): Promise<void> => {
1994
+ await Promise.all(
1995
+ context.reporters.map((reporter) =>
1996
+ (reporter as Reporter).onTestSuiteStart?.(payload),
1997
+ ),
1998
+ );
1999
+ };
2000
+
2001
+ const handleTestSuiteResult = async (
2002
+ payload: TestSuiteResultPayload,
2003
+ ): Promise<void> => {
2004
+ await Promise.all(
2005
+ context.reporters.map((reporter) =>
2006
+ (reporter as Reporter).onTestSuiteResult?.(payload),
2007
+ ),
2008
+ );
2009
+ };
2010
+
2011
+ const handleTestCaseStart = async (
2012
+ payload: TestCaseStartPayload,
2013
+ ): Promise<void> => {
2014
+ await Promise.all(
2015
+ context.reporters.map((reporter) =>
2016
+ (reporter as Reporter).onTestCaseStart?.(payload),
2017
+ ),
2018
+ );
2019
+ };
2020
+
2021
+ const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
2022
+ caseResults.push(payload);
2023
+ await Promise.all(
2024
+ context.reporters.map((reporter) =>
2025
+ (reporter as Reporter).onTestCaseResult?.(payload),
2026
+ ),
2027
+ );
2028
+ };
2029
+
2030
+ const handleTestFileComplete = async (
2031
+ payload: TestFileResult,
2032
+ ): Promise<void> => {
2033
+ reporterResults.push(payload);
2034
+ context.updateReporterResultState([payload], payload.results);
2035
+ if (payload.snapshotResult) {
2036
+ context.snapshotManager.add(payload.snapshotResult);
2037
+ }
2038
+ await Promise.all(
2039
+ context.reporters.map((reporter) =>
2040
+ (reporter as Reporter).onTestFileResult?.(payload),
2041
+ ),
2042
+ );
2043
+ if (payload.status === 'fail') {
2044
+ ensureProcessExitCode(1);
2045
+ }
2046
+ };
2047
+
2048
+ const handleLog = async (payload: LogPayload): Promise<void> => {
2049
+ const log: UserConsoleLog = {
2050
+ content: payload.content,
2051
+ name: payload.level,
2052
+ testPath: payload.testPath,
2053
+ type: payload.type,
2054
+ trace: payload.trace,
2055
+ };
2056
+ const shouldLog =
2057
+ context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
2058
+ if (shouldLog) {
2059
+ await Promise.all(
2060
+ context.reporters.map((reporter) =>
2061
+ (reporter as Reporter).onUserConsoleLog?.(log),
2062
+ ),
2063
+ );
2064
+ }
2065
+ };
2066
+
2067
+ const handleFatal = async (payload: FatalPayload): Promise<void> => {
2068
+ const error = new Error(payload.message);
2069
+ error.stack = payload.stack;
2070
+ fatalError = error;
2071
+ ensureProcessExitCode(1);
2072
+ };
2073
+
2074
+ const runSnapshotRpc = async (
2075
+ request: SnapshotRpcRequest,
2076
+ ): Promise<unknown> => {
2077
+ switch (request.method) {
2078
+ case 'resolveSnapshotPath':
2079
+ return snapshotRpcMethods.resolveSnapshotPath(request.args.testPath);
2080
+ case 'readSnapshotFile':
2081
+ return snapshotRpcMethods.readSnapshotFile(request.args.filepath);
2082
+ case 'saveSnapshotFile':
2083
+ return snapshotRpcMethods.saveSnapshotFile(
2084
+ request.args.filepath,
2085
+ request.args.content,
2086
+ );
2087
+ case 'removeSnapshotFile':
2088
+ return snapshotRpcMethods.removeSnapshotFile(request.args.filepath);
2089
+ default:
2090
+ return undefined;
2091
+ }
2092
+ };
2093
+
2094
+ const createDispatchRouter = (options?: HostDispatchRouterOptions) => {
2095
+ return createHostDispatchRouter({
2096
+ routerOptions: options,
2097
+ runnerCallbacks: {
2098
+ onTestFileStart: handleTestFileStart,
2099
+ onTestFileReady: handleTestFileReady,
2100
+ onTestSuiteStart: handleTestSuiteStart,
2101
+ onTestSuiteResult: handleTestSuiteResult,
2102
+ onTestCaseStart: handleTestCaseStart,
2103
+ onTestCaseResult: handleTestCaseResult,
2104
+ onTestFileComplete: handleTestFileComplete,
2105
+ onLog: handleLog,
2106
+ onFatal: handleFatal,
2107
+ },
2108
+ runSnapshotRpc,
2109
+ extensionHandlers: runtime.dispatchHandlers,
2110
+ onDuplicateNamespace: (namespace) => {
2111
+ logger.debug(
2112
+ `[Dispatch] Skip registering dispatch namespace "${namespace}" because it is already reserved`,
2113
+ );
2114
+ },
2115
+ });
2116
+ };
2117
+
2118
+ if (useHeadlessDirect) {
2119
+ // Session-based scheduling path: lifecycle + session index + dispatch routing.
2120
+ type ActiveHeadlessRun = RunSession & {
2121
+ contexts: Set<BrowserProviderContext>;
2122
+ };
2123
+
2124
+ const viewportByProject = mapViewportByProject(projectRuntimeConfigs);
2125
+ const runLifecycle = new RunSessionLifecycle<ActiveHeadlessRun>();
2126
+ const sessionRegistry = new RunnerSessionRegistry();
2127
+ getHeadlessRunnerPageBySessionId = (sessionId) => {
2128
+ return sessionRegistry.getById(sessionId)?.page;
2129
+ };
2130
+ let dispatchRequestCounter = 0;
2131
+
2132
+ const nextDispatchRequestId = (namespace: string): string => {
2133
+ return `${namespace}-${++dispatchRequestCounter}`;
2134
+ };
2135
+
2136
+ const closeContextSafely = async (
2137
+ browserContext: BrowserProviderContext,
2138
+ ): Promise<void> => {
2139
+ try {
2140
+ await browserContext.close();
2141
+ } catch {
2142
+ // ignore
2143
+ }
2144
+ };
2145
+
2146
+ const cancelRun = async (
2147
+ run: ActiveHeadlessRun,
2148
+ waitForDone = true,
2149
+ ): Promise<void> => {
2150
+ await runLifecycle.cancel(run, {
2151
+ waitForDone,
2152
+ onCancel: async (session) => {
2153
+ await Promise.all(
2154
+ Array.from(session.contexts).map((browserContext) =>
2155
+ closeContextSafely(browserContext),
2156
+ ),
2157
+ );
2158
+ },
2159
+ });
2160
+ };
2161
+
2162
+ const dispatchRouter = createDispatchRouter({
2163
+ isRunTokenStale: (runToken) => runLifecycle.isTokenStale(runToken),
2164
+ onStale: (request) => {
2165
+ if (request.namespace === DISPATCH_NAMESPACE_RUNNER) {
2166
+ logger.debug(
2167
+ `[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`,
2168
+ );
2169
+ }
2170
+ },
2171
+ });
2172
+
2173
+ const dispatchRunnerMessage = async (
2174
+ run: ActiveHeadlessRun,
2175
+ file: TestFileInfo,
2176
+ sessionId: string,
2177
+ message: BrowserClientMessage,
2178
+ ): Promise<void> => {
2179
+ const response = await dispatchRouter.dispatch({
2180
+ requestId: nextDispatchRequestId('runner'),
2181
+ runToken: run.token,
2182
+ namespace: DISPATCH_NAMESPACE_RUNNER,
2183
+ method: message.type,
2184
+ args: 'payload' in message ? message.payload : undefined,
2185
+ target: {
2186
+ sessionId,
2187
+ testFile: file.testPath,
2188
+ projectName: file.projectName,
2189
+ },
2190
+ });
2191
+
2192
+ if (response.stale) {
2193
+ return;
2194
+ }
2195
+
2196
+ if (response.error) {
2197
+ throw new Error(response.error);
2198
+ }
2199
+ };
2200
+
2201
+ const runSingleFile = async (
2202
+ run: ActiveHeadlessRun,
2203
+ file: TestFileInfo,
2204
+ ): Promise<void> => {
2205
+ if (run.cancelled || runLifecycle.isTokenStale(run.token)) {
2206
+ return;
2207
+ }
2208
+
2209
+ const viewport = viewportByProject.get(file.projectName);
2210
+ const browserContext = await browser.newContext({
2211
+ viewport: viewport ?? null,
2212
+ });
2213
+ run.contexts.add(browserContext);
2214
+
2215
+ let page: BrowserProviderPage | null = null;
2216
+ let sessionId: string | null = null;
2217
+ let settled = false;
2218
+ let resolveDone: (() => void) | null = null;
2219
+
2220
+ const markDone = (): void => {
2221
+ if (!settled) {
2222
+ settled = true;
2223
+ resolveDone?.();
2224
+ }
2225
+ };
2226
+
2227
+ const donePromise = new Promise<void>((resolve) => {
2228
+ resolveDone = resolve;
2229
+ });
2230
+
2231
+ const projectRuntime = projectRuntimeConfigs.find(
2232
+ (project) => project.name === file.projectName,
2233
+ );
2234
+ const perFileTimeoutMs =
2235
+ (projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) +
2236
+ 30_000;
2237
+
2238
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
2239
+
2240
+ try {
2241
+ page = await browserContext.newPage();
2242
+
2243
+ const session = sessionRegistry.register({
2244
+ testFile: file.testPath,
2245
+ projectName: file.projectName,
2246
+ runToken: run.token,
2247
+ mode: 'headless-page',
2248
+ context: browserContext,
2249
+ page,
2250
+ });
2251
+ sessionId = session.id;
2252
+
2253
+ await attachHeadlessRunnerTransport(page, {
2254
+ onDispatchMessage: async (message) => {
2255
+ try {
2256
+ await dispatchRunnerMessage(run, file, session.id, message);
2257
+ if (
2258
+ message.type === 'file-complete' ||
2259
+ message.type === 'complete'
2260
+ ) {
2261
+ markDone();
2262
+ } else if (message.type === 'fatal') {
2263
+ markDone();
2264
+ await cancelRun(run, false);
2265
+ }
2266
+ } catch (error) {
2267
+ const formatted = toError(error);
2268
+ await handleFatal({
2269
+ message: formatted.message,
2270
+ stack: formatted.stack,
2271
+ });
2272
+ markDone();
2273
+ await cancelRun(run, false);
2274
+ }
2275
+ },
2276
+ onDispatchRpc: async (request) => {
2277
+ return dispatchRouter.dispatch({
2278
+ ...request,
2279
+ runToken: run.token,
2280
+ target: {
2281
+ sessionId: session.id,
2282
+ testFile: file.testPath,
2283
+ projectName: file.projectName,
2284
+ ...request.target,
2285
+ },
2286
+ });
2287
+ },
2288
+ });
2289
+
2290
+ const inlineOptions: BrowserHostConfig = {
2291
+ ...hostOptions,
2292
+ testFile: file.testPath,
2293
+ runId: `${run.token}:${session.id}`,
2294
+ };
2295
+ const serializedOptions = serializeForInlineScript(inlineOptions);
2296
+ await page.addInitScript(
2297
+ `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
2298
+ );
2299
+
2300
+ await page.goto(`http://localhost:${port}/runner.html`, {
2301
+ waitUntil: 'load',
2302
+ });
2303
+
2304
+ const timeoutPromise = new Promise<'timeout'>((resolve) => {
2305
+ timeoutId = setTimeout(() => resolve('timeout'), perFileTimeoutMs);
2306
+ });
2307
+
2308
+ const state = await Promise.race([
2309
+ donePromise.then(() => 'done' as const),
2310
+ timeoutPromise,
2311
+ run.cancelSignal.then(() => 'cancelled' as const),
2312
+ ]);
2313
+
2314
+ if (state === 'cancelled') {
2315
+ return;
2316
+ }
2317
+
2318
+ if (
2319
+ state === 'timeout' &&
2320
+ runLifecycle.isTokenActive(run.token) &&
2321
+ !run.cancelled
2322
+ ) {
2323
+ await handleFatal({
2324
+ message: `Test execution timeout after ${perFileTimeoutMs / 1000}s for ${file.testPath}.`,
2325
+ });
2326
+ await cancelRun(run, false);
2327
+ }
2328
+ } catch (error) {
2329
+ if (runLifecycle.isTokenActive(run.token) && !run.cancelled) {
2330
+ const formatted = toError(error);
2331
+ await handleFatal({
2332
+ message: formatted.message,
2333
+ stack: formatted.stack,
2334
+ });
2335
+ await cancelRun(run, false);
2336
+ }
2337
+ } finally {
2338
+ if (timeoutId) {
2339
+ clearTimeout(timeoutId);
2340
+ }
2341
+ if (page) {
2342
+ try {
2343
+ await page.close();
2344
+ } catch {
2345
+ // ignore
2346
+ }
2347
+ }
2348
+ if (sessionId) {
2349
+ sessionRegistry.deleteById(sessionId);
2350
+ }
2351
+ run.contexts.delete(browserContext);
2352
+ await closeContextSafely(browserContext);
2353
+ }
2354
+ };
2355
+
2356
+ const runFilesWithPool = async (files: TestFileInfo[]): Promise<void> => {
2357
+ if (files.length === 0) {
2358
+ return;
2359
+ }
2360
+
2361
+ const previous = runLifecycle.activeSession;
2362
+ if (previous) {
2363
+ await cancelRun(previous);
2364
+ }
2365
+
2366
+ const run = runLifecycle.createSession((token) => ({
2367
+ ...createRunSession(token),
2368
+ contexts: new Set<BrowserProviderContext>(),
2369
+ }));
2370
+
2371
+ const queue = [...files];
2372
+ const concurrency = getHeadlessConcurrency(context, queue.length);
2373
+
2374
+ const worker = async (): Promise<void> => {
2375
+ while (
2376
+ queue.length > 0 &&
2377
+ !run.cancelled &&
2378
+ runLifecycle.isTokenActive(run.token)
2379
+ ) {
2380
+ const next = queue.shift();
2381
+ if (!next) {
2382
+ return;
2383
+ }
2384
+ await runSingleFile(run, next);
2385
+ }
2386
+ };
2387
+
2388
+ run.done = Promise.all(
2389
+ Array.from(
2390
+ { length: Math.min(queue.length, Math.max(concurrency, 1)) },
2391
+ () => worker(),
2392
+ ),
2393
+ ).then(() => {});
2394
+
2395
+ await run.done;
2396
+ runLifecycle.clearIfActive(run);
2397
+ };
2398
+
2399
+ const latestRerunScheduler = createHeadlessLatestRerunScheduler<
2400
+ TestFileInfo,
2401
+ ActiveHeadlessRun
2402
+ >({
2403
+ getActiveRun: () => runLifecycle.activeSession,
2404
+ isRunCancelled: (run) => run.cancelled,
2405
+ invalidateActiveRun: () => {
2406
+ runLifecycle.invalidateActiveToken();
2407
+ },
2408
+ interruptActiveRun: async (run) => {
2409
+ await cancelRun(run, false);
2410
+ },
2411
+ runFiles: async (files) => {
2412
+ await notifyTestRunStart();
2413
+
2414
+ const rerunStartTime = Date.now();
2415
+ const fatalErrorBeforeRun = fatalError;
2416
+ let rerunError: Error | undefined;
2417
+
2418
+ try {
2419
+ await runFilesWithPool(files);
2420
+ } catch (error) {
2421
+ rerunError = toError(error);
2422
+ throw error;
2423
+ } finally {
2424
+ const testTime = Math.max(0, Date.now() - rerunStartTime);
2425
+ const rerunFatalError =
2426
+ fatalError && fatalError !== fatalErrorBeforeRun
2427
+ ? fatalError
2428
+ : undefined;
2429
+ await notifyTestRunEnd({
2430
+ duration: {
2431
+ totalTime: testTime,
2432
+ buildTime: 0,
2433
+ testTime,
2434
+ },
2435
+ filterRerunTestPaths: files.map((file) => file.testPath),
2436
+ unhandledErrors: rerunError
2437
+ ? [rerunError]
2438
+ : rerunFatalError
2439
+ ? [rerunFatalError]
2440
+ : undefined,
2441
+ });
2442
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2443
+ }
2444
+ },
2445
+ onError: async (error) => {
2446
+ const formatted = toError(error);
2447
+ await handleFatal({
2448
+ message: formatted.message,
2449
+ stack: formatted.stack,
2450
+ });
2451
+ },
2452
+ onInterrupt: (run) => {
2453
+ logger.debug(
2454
+ `[Headless] Interrupting active run token ${run.token} before scheduling latest rerun`,
2455
+ );
2456
+ },
2457
+ });
2458
+
2459
+ const testStart = Date.now();
2460
+ await runFilesWithPool(allTestFiles);
2461
+ const testTime = Date.now() - testStart;
2462
+
2463
+ if (isWatchMode) {
2464
+ triggerRerun = async () => {
2465
+ const newProjectEntries = await collectProjectEntries(context);
2466
+ const rerunPlan = planWatchRerun({
2467
+ projectEntries: newProjectEntries,
2468
+ previousTestFiles: watchContext.lastTestFiles,
2469
+ affectedTestFiles: watchContext.affectedTestFiles,
2470
+ });
2471
+ watchContext.affectedTestFiles = [];
2472
+
2473
+ if (rerunPlan.filesChanged) {
2474
+ const deletedTestPaths = collectDeletedTestPaths(
2475
+ watchContext.lastTestFiles,
2476
+ rerunPlan.currentTestFiles,
2477
+ );
2478
+ if (deletedTestPaths.length > 0) {
2479
+ context.updateReporterResultState([], [], deletedTestPaths);
2480
+ }
2481
+ watchContext.lastTestFiles = rerunPlan.currentTestFiles;
2482
+ if (rerunPlan.currentTestFiles.length === 0) {
2483
+ await latestRerunScheduler.enqueueLatest([]);
2484
+ logger.log(
2485
+ color.cyan('No browser test files remain after update.\n'),
2486
+ );
2487
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2488
+ return;
2489
+ }
2490
+
2491
+ logger.log(
2492
+ color.cyan(
2493
+ `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
2494
+ ),
2495
+ );
2496
+ void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
2497
+ return;
2498
+ }
2499
+
2500
+ if (rerunPlan.affectedTestFiles.length === 0) {
2501
+ logger.log(
2502
+ color.cyan(
2503
+ 'No affected browser test files detected, skipping re-run.\n',
2504
+ ),
2505
+ );
2506
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2507
+ return;
2508
+ }
2509
+
2510
+ logger.log(
2511
+ color.cyan(
2512
+ `Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`,
2513
+ ),
2514
+ );
2515
+ void latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
2516
+ };
2517
+ }
2518
+
2519
+ const closeHeadlessRuntime = !isWatchMode
2520
+ ? async () => {
2521
+ sessionRegistry.clear();
2522
+ await destroyBrowserRuntime(runtime);
2523
+ }
2524
+ : undefined;
2525
+
2526
+ if (fatalError) {
2527
+ return failWithError(fatalError, closeHeadlessRuntime);
2528
+ }
2529
+
2530
+ const duration = {
2531
+ totalTime: buildTime + testTime,
2532
+ buildTime,
2533
+ testTime,
2534
+ };
2535
+
2536
+ context.updateReporterResultState(reporterResults, caseResults);
2537
+
2538
+ const isFailure = reporterResults.some(
2539
+ (result: TestFileResult) => result.status === 'fail',
2540
+ );
2541
+ if (isFailure) {
2542
+ ensureProcessExitCode(1);
2543
+ }
2544
+
2545
+ const result = {
2546
+ results: reporterResults,
2547
+ testResults: caseResults,
2548
+ duration,
2549
+ hasFailure: isFailure,
2550
+ getSourcemap: getBrowserSourcemap,
2551
+ resolveSourcemap: resolveBrowserSourcemap,
2552
+ close: skipOnTestRunEnd ? closeHeadlessRuntime : undefined,
2553
+ };
2554
+
2555
+ if (!skipOnTestRunEnd) {
2556
+ try {
2557
+ await notifyTestRunEnd({ duration });
2558
+ } finally {
2559
+ await closeHeadlessRuntime?.();
2560
+ }
2561
+ }
2562
+
2563
+ if (isWatchMode && triggerRerun) {
2564
+ watchContext.hooksEnabled = true;
2565
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2566
+ }
2567
+
2568
+ return result;
2569
+ }
2570
+
2571
+ let currentTestFiles = allTestFiles;
2572
+ const RUNNER_FRAMES_READY_TIMEOUT_MS = 30_000;
2573
+ let currentRunnerFramesSignature: string | null = null;
2574
+ const runnerFramesWaiters = new Map<string, Set<() => void>>();
2575
+
2576
+ const createTestFilesSignature = (testFiles: readonly string[]): string => {
2577
+ return JSON.stringify(testFiles.map((testFile) => normalize(testFile)));
2578
+ };
2579
+
2580
+ const markRunnerFramesReady = (testFiles: string[]): void => {
2581
+ const signature = createTestFilesSignature(testFiles);
2582
+ currentRunnerFramesSignature = signature;
2583
+ const waiters = runnerFramesWaiters.get(signature);
2584
+ if (!waiters) {
2585
+ return;
2586
+ }
2587
+ runnerFramesWaiters.delete(signature);
2588
+ for (const waiter of waiters) {
2589
+ waiter();
2590
+ }
2591
+ };
2592
+
2593
+ const waitForRunnerFramesReady = async (
2594
+ testFiles: readonly string[],
2595
+ ): Promise<void> => {
2596
+ const signature = createTestFilesSignature(testFiles);
2597
+ if (currentRunnerFramesSignature === signature) {
2598
+ return;
2599
+ }
2600
+
2601
+ await new Promise<void>((resolve, reject) => {
2602
+ const waiters =
2603
+ runnerFramesWaiters.get(signature) ?? new Set<() => void>();
2604
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
2605
+
2606
+ const cleanup = () => {
2607
+ const currentWaiters = runnerFramesWaiters.get(signature);
2608
+ if (!currentWaiters) {
2609
+ return;
2610
+ }
2611
+ currentWaiters.delete(onReady);
2612
+ if (currentWaiters.size === 0) {
2613
+ runnerFramesWaiters.delete(signature);
2614
+ }
2615
+ };
2616
+
2617
+ const onReady = () => {
2618
+ if (timeoutId) {
2619
+ clearTimeout(timeoutId);
2620
+ }
2621
+ cleanup();
2622
+ resolve();
2623
+ };
2624
+
2625
+ timeoutId = setTimeout(() => {
2626
+ cleanup();
2627
+ reject(
2628
+ new Error(
2629
+ `Timed out waiting for headed runner frames to be ready for ${testFiles.length} file(s).`,
2630
+ ),
2631
+ );
2632
+ }, RUNNER_FRAMES_READY_TIMEOUT_MS);
2633
+
2634
+ waiters.add(onReady);
2635
+ runnerFramesWaiters.set(signature, waiters);
2636
+
2637
+ if (currentRunnerFramesSignature === signature) {
2638
+ onReady();
2639
+ }
2640
+ });
2641
+ };
2642
+
2643
+ const getTestFileInfo = (testFile: string): TestFileInfo => {
2644
+ const normalizedTestFile = normalize(testFile);
2645
+ const fileInfo = currentTestFiles.find(
2646
+ (file) => file.testPath === normalizedTestFile,
2647
+ );
2648
+ if (!fileInfo) {
2649
+ throw new Error(`Unknown browser test file: ${JSON.stringify(testFile)}`);
2650
+ }
2651
+ return fileInfo;
2652
+ };
2653
+
2654
+ const getHeadedPerFileTimeoutMs = (file: TestFileInfo): number => {
2655
+ const projectRuntime = projectRuntimeConfigs.find(
2656
+ (project) => project.name === file.projectName,
2657
+ );
2658
+ return (
2659
+ (projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) +
2660
+ 30_000
2661
+ );
2662
+ };
1500
2663
 
1501
2664
  // Open a container page for user to view (reuse in watch mode)
1502
- let containerContext: BrowserContext;
1503
- let containerPage: Page;
2665
+ let containerContext: BrowserProviderContext;
2666
+ let containerPage: BrowserProviderPage;
1504
2667
  let isNewPage = false;
1505
2668
 
1506
2669
  if (isWatchMode && runtime.containerPage && runtime.containerContext) {
@@ -1515,11 +2678,11 @@ export const runBrowserController = async (
1515
2678
  containerPage = await containerContext.newPage();
1516
2679
 
1517
2680
  // Prevent popup windows from being created
1518
- containerPage.on('popup', async (popup: Page) => {
2681
+ containerPage.on('popup', async (popup: BrowserProviderPage) => {
1519
2682
  await popup.close().catch(() => {});
1520
2683
  });
1521
2684
 
1522
- containerContext.on('page', async (page: Page) => {
2685
+ containerContext.on('page', async (page: BrowserProviderPage) => {
1523
2686
  if (page !== containerPage) {
1524
2687
  await page.close().catch(() => {});
1525
2688
  }
@@ -1531,18 +2694,52 @@ export const runBrowserController = async (
1531
2694
  }
1532
2695
 
1533
2696
  // Forward browser console to terminal
1534
- containerPage.on('console', (msg: ConsoleMessage) => {
2697
+ containerPage.on('console', (msg) => {
1535
2698
  const text = msg.text();
1536
- if (
1537
- text.startsWith('[Container]') ||
1538
- text.startsWith('[Runner]') ||
1539
- text.startsWith('[Scheduler]')
1540
- ) {
2699
+ if (text.startsWith('[Container]') || text.startsWith('[Runner]')) {
1541
2700
  logger.log(color.gray(`[Browser Console] ${text}`));
1542
2701
  }
1543
2702
  });
1544
2703
  }
1545
2704
 
2705
+ activeContainerPage = containerPage;
2706
+
2707
+ const dispatchRouter = createDispatchRouter();
2708
+ const headedReloadQueue = createHeadedSerialTaskQueue();
2709
+ let enqueueHeadedReload = async (
2710
+ _file: TestFileInfo,
2711
+ _testNamePattern?: string,
2712
+ ): Promise<void> => {
2713
+ throw new Error('Headed reload queue is not initialized');
2714
+ };
2715
+
2716
+ const reloadTestFileWithTimeout = async (
2717
+ file: TestFileInfo,
2718
+ testNamePattern?: string,
2719
+ ): Promise<void> => {
2720
+ const timeoutMs = getHeadedPerFileTimeoutMs(file);
2721
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
2722
+
2723
+ try {
2724
+ await Promise.race([
2725
+ rpcManager.reloadTestFile(file.testPath, testNamePattern),
2726
+ new Promise<never>((_, reject) => {
2727
+ timeoutId = setTimeout(() => {
2728
+ reject(
2729
+ new Error(
2730
+ `Headed test execution timeout after ${timeoutMs / 1000}s for ${file.testPath}.`,
2731
+ ),
2732
+ );
2733
+ }, timeoutMs);
2734
+ }),
2735
+ ]);
2736
+ } finally {
2737
+ if (timeoutId) {
2738
+ clearTimeout(timeoutId);
2739
+ }
2740
+ }
2741
+ };
2742
+
1546
2743
  // Create RPC methods that can access test state variables
1547
2744
  const createRpcMethods = (): HostRpcMethods => ({
1548
2745
  async rerunTest(testFile: string, testNamePattern?: string) {
@@ -1554,105 +2751,32 @@ export const runBrowserController = async (
1554
2751
  `\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
1555
2752
  ),
1556
2753
  );
1557
- await rpcManager.reloadTestFile(testFile, testNamePattern);
2754
+ await enqueueHeadedReload(getTestFileInfo(testFile), testNamePattern);
1558
2755
  },
1559
2756
  async getTestFiles() {
1560
- return allTestFiles;
2757
+ return currentTestFiles;
2758
+ },
2759
+ async onRunnerFramesReady(testFiles: string[]) {
2760
+ markRunnerFramesReady(testFiles);
1561
2761
  },
1562
2762
  async onTestFileStart(payload: TestFileStartPayload) {
1563
- await Promise.all(
1564
- context.reporters.map((reporter) =>
1565
- (reporter as Reporter).onTestFileStart?.({
1566
- testPath: payload.testPath,
1567
- tests: [],
1568
- }),
1569
- ),
1570
- );
2763
+ await handleTestFileStart(payload);
1571
2764
  },
1572
2765
  async onTestCaseResult(payload: TestResult) {
1573
- caseResults.push(payload);
1574
- await Promise.all(
1575
- context.reporters.map((reporter) =>
1576
- (reporter as Reporter).onTestCaseResult?.(payload),
1577
- ),
1578
- );
2766
+ await handleTestCaseResult(payload);
1579
2767
  },
1580
2768
  async onTestFileComplete(payload: TestFileResult) {
1581
- reporterResults.push(payload);
1582
- if (payload.snapshotResult) {
1583
- context.snapshotManager.add(payload.snapshotResult);
1584
- }
1585
- await Promise.all(
1586
- context.reporters.map((reporter) =>
1587
- (reporter as Reporter).onTestFileResult?.(payload),
1588
- ),
1589
- );
1590
-
1591
- completedTests++;
1592
- if (completedTests >= allTestFiles.length && resolveAllTests) {
1593
- resolveAllTests();
1594
- }
2769
+ await handleTestFileComplete(payload);
1595
2770
  },
1596
2771
  async onLog(payload: LogPayload) {
1597
- const log: UserConsoleLog = {
1598
- content: payload.content,
1599
- name: payload.level,
1600
- testPath: payload.testPath,
1601
- type: payload.type,
1602
- trace: payload.trace,
1603
- };
1604
-
1605
- // Check onConsoleLog filter
1606
- const shouldLog =
1607
- context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
1608
-
1609
- if (shouldLog) {
1610
- await Promise.all(
1611
- context.reporters.map((reporter) =>
1612
- (reporter as Reporter).onUserConsoleLog?.(log),
1613
- ),
1614
- );
1615
- }
2772
+ await handleLog(payload);
1616
2773
  },
1617
2774
  async onFatal(payload: FatalPayload) {
1618
- fatalError = new Error(payload.message);
1619
- fatalError.stack = payload.stack;
1620
- if (resolveAllTests) {
1621
- resolveAllTests();
1622
- }
1623
- },
1624
- // Snapshot file operations
1625
- async resolveSnapshotPath(testPath: string) {
1626
- const snapExtension = '.snap';
1627
- const resolver =
1628
- context.normalizedConfig.resolveSnapshotPath ||
1629
- // test/index.ts -> test/__snapshots__/index.ts.snap
1630
- (() =>
1631
- join(
1632
- dirname(testPath),
1633
- '__snapshots__',
1634
- `${basename(testPath)}${snapExtension}`,
1635
- ));
1636
- return resolver(testPath, snapExtension);
1637
- },
1638
- async readSnapshotFile(filepath: string) {
1639
- try {
1640
- return await fs.readFile(filepath, 'utf-8');
1641
- } catch {
1642
- return null;
1643
- }
2775
+ await handleFatal(payload);
1644
2776
  },
1645
- async saveSnapshotFile(filepath: string, content: string) {
1646
- const dir = dirname(filepath);
1647
- await fs.mkdir(dir, { recursive: true });
1648
- await fs.writeFile(filepath, content, 'utf-8');
1649
- },
1650
- async removeSnapshotFile(filepath: string) {
1651
- try {
1652
- await fs.unlink(filepath);
1653
- } catch {
1654
- // ignore if file doesn't exist
1655
- }
2777
+ async dispatch(request: BrowserDispatchRequest) {
2778
+ // Headed/container path now shares the same dispatch contract as headless.
2779
+ return dispatchRouter.dispatch(request);
1656
2780
  },
1657
2781
  });
1658
2782
 
@@ -1678,13 +2802,7 @@ export const runBrowserController = async (
1678
2802
 
1679
2803
  // Only navigate on first creation
1680
2804
  if (isNewPage) {
1681
- const pagePath = useSchedulerPage ? '/scheduler.html' : '/';
1682
- if (useSchedulerPage) {
1683
- const serializedOptions = serializeForInlineScript(hostOptions);
1684
- await containerPage.addInitScript(
1685
- `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
1686
- );
1687
- }
2805
+ const pagePath = '/';
1688
2806
  await containerPage.goto(`http://localhost:${port}${pagePath}`, {
1689
2807
  waitUntil: 'load',
1690
2808
  });
@@ -1696,31 +2814,33 @@ export const runBrowserController = async (
1696
2814
  );
1697
2815
  }
1698
2816
 
1699
- // Wait for all tests to complete
1700
- // Calculate total timeout based on config: max testTimeout * file count + buffer
1701
- const maxTestTimeout = Math.max(
1702
- ...browserProjects.map((p) => p.normalizedConfig.testTimeout ?? 5000),
1703
- );
1704
- const totalTimeoutMs = maxTestTimeout * allTestFiles.length + 30_000;
1705
-
1706
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
1707
- const testTimeout = new Promise<void>((resolve) => {
1708
- timeoutId = setTimeout(() => {
1709
- logger.log(
1710
- color.yellow(
1711
- `\nTest execution timeout after ${totalTimeoutMs / 1000}s. ` +
1712
- `Completed: ${completedTests}/${allTestFiles.length}\n`,
1713
- ),
1714
- );
1715
- resolve();
1716
- }, totalTimeoutMs);
1717
- });
2817
+ enqueueHeadedReload = async (
2818
+ file: TestFileInfo,
2819
+ testNamePattern?: string,
2820
+ ): Promise<void> => {
2821
+ return headedReloadQueue.enqueue(async () => {
2822
+ if (fatalError) {
2823
+ return;
2824
+ }
2825
+ await reloadTestFileWithTimeout(file, testNamePattern);
2826
+ });
2827
+ };
1718
2828
 
1719
2829
  const testStart = Date.now();
1720
- await Promise.race([allTestsPromise, testTimeout]);
2830
+ try {
2831
+ await waitForRunnerFramesReady(
2832
+ currentTestFiles.map((file) => file.testPath),
2833
+ );
1721
2834
 
1722
- if (timeoutId) {
1723
- clearTimeout(timeoutId);
2835
+ for (const file of currentTestFiles) {
2836
+ await enqueueHeadedReload(file);
2837
+ if (fatalError) {
2838
+ break;
2839
+ }
2840
+ }
2841
+ } catch (error) {
2842
+ fatalError = fatalError ?? toError(error);
2843
+ ensureProcessExitCode(1);
1724
2844
  }
1725
2845
 
1726
2846
  const testTime = Date.now() - testStart;
@@ -1729,63 +2849,96 @@ export const runBrowserController = async (
1729
2849
  if (isWatchMode) {
1730
2850
  triggerRerun = async () => {
1731
2851
  const newProjectEntries = await collectProjectEntries(context);
1732
- // Normalize paths to posix format for cross-platform compatibility
1733
- const currentTestFiles: TestFileInfo[] = newProjectEntries.flatMap(
1734
- (entry) =>
1735
- entry.testFiles.map((testPath) => ({
1736
- testPath: normalize(testPath),
1737
- projectName: entry.project.name,
1738
- })),
1739
- );
2852
+ const rerunPlan = planWatchRerun({
2853
+ projectEntries: newProjectEntries,
2854
+ previousTestFiles: watchContext.lastTestFiles,
2855
+ affectedTestFiles: watchContext.affectedTestFiles,
2856
+ });
2857
+ watchContext.affectedTestFiles = [];
1740
2858
 
1741
- // Compare test files by serializing to JSON for deep comparison
1742
- const serialize = (files: TestFileInfo[]) =>
1743
- JSON.stringify(
1744
- files.map((f) => `${f.projectName}:${f.testPath}`).sort(),
2859
+ if (rerunPlan.filesChanged) {
2860
+ const deletedTestPaths = collectDeletedTestPaths(
2861
+ watchContext.lastTestFiles,
2862
+ rerunPlan.currentTestFiles,
1745
2863
  );
1746
-
1747
- const filesChanged =
1748
- serialize(currentTestFiles) !== serialize(watchContext.lastTestFiles);
1749
-
1750
- if (filesChanged) {
1751
- watchContext.lastTestFiles = currentTestFiles;
2864
+ if (deletedTestPaths.length > 0) {
2865
+ context.updateReporterResultState([], [], deletedTestPaths);
2866
+ }
2867
+ watchContext.lastTestFiles = rerunPlan.currentTestFiles;
2868
+ currentTestFiles = rerunPlan.currentTestFiles;
1752
2869
  await rpcManager.notifyTestFileUpdate(currentTestFiles);
2870
+ await waitForRunnerFramesReady(
2871
+ currentTestFiles.map((file) => file.testPath),
2872
+ );
1753
2873
  }
1754
2874
 
1755
- const affectedFiles = watchContext.affectedTestFiles;
1756
- watchContext.affectedTestFiles = [];
1757
-
1758
- if (affectedFiles.length > 0) {
2875
+ if (rerunPlan.normalizedAffectedTestFiles.length > 0) {
1759
2876
  logger.log(
1760
2877
  color.cyan(
1761
- `Re-running ${affectedFiles.length} affected test file(s)...\n`,
2878
+ `Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`,
1762
2879
  ),
1763
2880
  );
1764
- for (const testFile of affectedFiles) {
1765
- await rpcManager.reloadTestFile(testFile);
2881
+ await notifyTestRunStart();
2882
+
2883
+ const rerunStartTime = Date.now();
2884
+ const fatalErrorBeforeRun = fatalError;
2885
+ let rerunError: Error | undefined;
2886
+
2887
+ try {
2888
+ for (const testFile of rerunPlan.normalizedAffectedTestFiles) {
2889
+ await enqueueHeadedReload(getTestFileInfo(testFile));
2890
+ }
2891
+ } catch (error) {
2892
+ rerunError = toError(error);
2893
+ throw error;
2894
+ } finally {
2895
+ const testTime = Math.max(0, Date.now() - rerunStartTime);
2896
+ const rerunFatalError =
2897
+ fatalError && fatalError !== fatalErrorBeforeRun
2898
+ ? fatalError
2899
+ : undefined;
2900
+ await notifyTestRunEnd({
2901
+ duration: {
2902
+ totalTime: testTime,
2903
+ buildTime: 0,
2904
+ testTime,
2905
+ },
2906
+ filterRerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
2907
+ unhandledErrors: rerunError
2908
+ ? [rerunError]
2909
+ : rerunFatalError
2910
+ ? [rerunFatalError]
2911
+ : undefined,
2912
+ });
2913
+ logBrowserWatchReadyMessage(enableCliShortcuts);
1766
2914
  }
1767
- } else if (!filesChanged) {
2915
+ } else if (!rerunPlan.filesChanged) {
1768
2916
  logger.log(color.cyan('Tests will be re-executed automatically\n'));
2917
+ logBrowserWatchReadyMessage(enableCliShortcuts);
2918
+ } else {
2919
+ logBrowserWatchReadyMessage(enableCliShortcuts);
1769
2920
  }
1770
2921
  };
1771
2922
  }
1772
2923
 
1773
- if (!isWatchMode) {
1774
- try {
1775
- await containerPage.close();
1776
- } catch {
1777
- // ignore
1778
- }
1779
- try {
1780
- await containerContext.close();
1781
- } catch {
1782
- // ignore
1783
- }
1784
- await destroyBrowserRuntime(runtime);
1785
- }
2924
+ const closeContainerRuntime = !isWatchMode
2925
+ ? async () => {
2926
+ try {
2927
+ await containerPage.close();
2928
+ } catch {
2929
+ // ignore
2930
+ }
2931
+ try {
2932
+ await containerContext.close();
2933
+ } catch {
2934
+ // ignore
2935
+ }
2936
+ await destroyBrowserRuntime(runtime);
2937
+ }
2938
+ : undefined;
1786
2939
 
1787
2940
  if (fatalError) {
1788
- return failWithError(fatalError);
2941
+ return failWithError(fatalError, closeContainerRuntime);
1789
2942
  }
1790
2943
 
1791
2944
  const duration = {
@@ -1803,32 +2956,28 @@ export const runBrowserController = async (
1803
2956
  ensureProcessExitCode(1);
1804
2957
  }
1805
2958
 
1806
- const result: BrowserTestRunResult = {
2959
+ const result = {
1807
2960
  results: reporterResults,
1808
2961
  testResults: caseResults,
1809
2962
  duration,
1810
2963
  hasFailure: isFailure,
2964
+ getSourcemap: getBrowserSourcemap,
2965
+ resolveSourcemap: resolveBrowserSourcemap,
2966
+ close: skipOnTestRunEnd ? closeContainerRuntime : undefined,
1811
2967
  };
1812
2968
 
1813
- // Only call onTestRunEnd if not skipped (for unified reporter output)
1814
2969
  if (!skipOnTestRunEnd) {
1815
- for (const reporter of context.reporters) {
1816
- await reporter.onTestRunEnd?.({
1817
- results: context.reporterResults.results,
1818
- testResults: context.reporterResults.testResults,
1819
- duration,
1820
- snapshotSummary: context.snapshotManager.summary,
1821
- getSourcemap: async () => null,
1822
- });
2970
+ try {
2971
+ await notifyTestRunEnd({ duration });
2972
+ } finally {
2973
+ await closeContainerRuntime?.();
1823
2974
  }
1824
2975
  }
1825
2976
 
1826
2977
  // Enable watch hooks AFTER initial test run to avoid duplicate runs
1827
2978
  if (isWatchMode && triggerRerun) {
1828
2979
  watchContext.hooksEnabled = true;
1829
- logger.log(
1830
- color.cyan('\nWatch mode enabled - will re-run tests on file changes\n'),
1831
- );
2980
+ logBrowserWatchReadyMessage(enableCliShortcuts);
1832
2981
  }
1833
2982
 
1834
2983
  return result;
@@ -1887,6 +3036,7 @@ export const listBrowserTests = async (
1887
3036
  manifestPath,
1888
3037
  entries: projectEntries,
1889
3038
  });
3039
+ const browserProjects = getBrowserProjects(context);
1890
3040
 
1891
3041
  // Create a simplified browser runtime for collect mode
1892
3042
  let runtime: BrowserRuntime;
@@ -1902,9 +3052,14 @@ export const listBrowserTests = async (
1902
3052
  forceHeadless: true, // Always use headless for list command
1903
3053
  });
1904
3054
  } catch (error) {
3055
+ const providers = [
3056
+ ...new Set(
3057
+ browserProjects.map((p) => p.normalizedConfig.browser.provider),
3058
+ ),
3059
+ ];
1905
3060
  logger.error(
1906
3061
  color.red(
1907
- 'Failed to load Playwright. Please install "playwright" to use browser mode.',
3062
+ `Failed to initialize browser provider runtime (${providers.join(', ')}).`,
1908
3063
  ),
1909
3064
  error,
1910
3065
  );
@@ -1915,7 +3070,6 @@ export const listBrowserTests = async (
1915
3070
 
1916
3071
  // Get browser projects for runtime config
1917
3072
  // Normalize projectRoot to posix format for cross-platform compatibility
1918
- const browserProjects = getBrowserProjects(context);
1919
3073
  const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map(
1920
3074
  (project: ProjectContext) => ({
1921
3075
  name: project.name,
@@ -1961,7 +3115,7 @@ export const listBrowserTests = async (
1961
3115
 
1962
3116
  // Expose dispatch function for browser client to send messages
1963
3117
  await page.exposeFunction(
1964
- '__rstest_dispatch__',
3118
+ DISPATCH_MESSAGE_TYPE,
1965
3119
  (message: { type: string; payload?: unknown }) => {
1966
3120
  switch (message.type) {
1967
3121
  case 'collect-result': {