@rstest/browser 0.8.5 → 0.9.0

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 (79) 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.0687a8142a.js} +742 -692
  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/headlessLatestRerunScheduler.d.ts +19 -0
  25. package/dist/headlessTransport.d.ts +12 -0
  26. package/dist/index.js +1580 -258
  27. package/dist/protocol.d.ts +44 -33
  28. package/dist/providers/index.d.ts +79 -0
  29. package/dist/providers/playwright/compileLocator.d.ts +3 -0
  30. package/dist/providers/playwright/dispatchBrowserRpc.d.ts +13 -0
  31. package/dist/providers/playwright/expectUtils.d.ts +24 -0
  32. package/dist/providers/playwright/implementation.d.ts +2 -0
  33. package/dist/providers/playwright/index.d.ts +1 -0
  34. package/dist/providers/playwright/runtime.d.ts +5 -0
  35. package/dist/providers/playwright/textMatcher.d.ts +8 -0
  36. package/dist/rpcProtocol.d.ts +145 -0
  37. package/dist/runSession.d.ts +33 -0
  38. package/dist/sessionRegistry.d.ts +34 -0
  39. package/dist/sourceMap/sourceMapLoader.d.ts +14 -0
  40. package/dist/watchRerunPlanner.d.ts +21 -0
  41. package/package.json +15 -10
  42. package/src/AGENTS.md +128 -0
  43. package/src/augmentExpect.ts +62 -0
  44. package/src/browser.ts +3 -0
  45. package/src/browserRpcRegistry.ts +57 -0
  46. package/src/client/AGENTS.md +82 -0
  47. package/src/client/api.ts +213 -0
  48. package/src/client/browserRpc.ts +86 -0
  49. package/src/client/dispatchTransport.ts +178 -0
  50. package/src/client/entry.ts +96 -33
  51. package/src/client/locator.ts +452 -0
  52. package/src/client/snapshot.ts +32 -97
  53. package/src/client/sourceMapSupport.ts +26 -37
  54. package/src/concurrency.ts +62 -0
  55. package/src/dispatchCapabilities.ts +162 -0
  56. package/src/dispatchRouter.ts +82 -0
  57. package/src/env.d.ts +8 -1
  58. package/src/headlessLatestRerunScheduler.ts +76 -0
  59. package/src/headlessTransport.ts +28 -0
  60. package/src/hostController.ts +1292 -367
  61. package/src/protocol.ts +66 -31
  62. package/src/providers/index.ts +103 -0
  63. package/src/providers/playwright/compileLocator.ts +130 -0
  64. package/src/providers/playwright/dispatchBrowserRpc.ts +372 -0
  65. package/src/providers/playwright/expectUtils.ts +57 -0
  66. package/src/providers/playwright/implementation.ts +33 -0
  67. package/src/providers/playwright/index.ts +1 -0
  68. package/src/providers/playwright/runtime.ts +32 -0
  69. package/src/providers/playwright/textMatcher.ts +10 -0
  70. package/src/rpcProtocol.ts +220 -0
  71. package/src/runSession.ts +110 -0
  72. package/src/sessionRegistry.ts +89 -0
  73. package/src/sourceMap/sourceMapLoader.ts +96 -0
  74. package/src/watchRerunPlanner.ts +77 -0
  75. package/dist/browser-container/container-static/css/index.5a71c757.css +0 -1
  76. package/dist/browser-container/container-static/js/565.226c9ef5.js.LICENSE.txt +0 -1
  77. package/dist/browser-container/container-static/js/lib-react.97ee79b0.js.LICENSE.txt +0 -1
  78. package/dist/browser-container/container-static/js/scheduler.5accca0c.js +0 -407
  79. package/dist/browser-container/scheduler.html +0 -19
@@ -30,14 +30,53 @@ import { type BirpcReturn, createBirpc } from 'birpc';
30
30
  import openEditor from 'open-editor';
31
31
  import { basename, dirname, join, normalize, relative, resolve } from 'pathe';
32
32
  import * as picomatch from 'picomatch';
33
- import type { BrowserContext, ConsoleMessage, Page } from 'playwright';
34
33
  import sirv from 'sirv';
35
34
  import { type WebSocket, WebSocketServer } from 'ws';
35
+ import { getHeadlessConcurrency } from './concurrency';
36
+ import {
37
+ createHostDispatchRouter,
38
+ type HostDispatchRouterOptions,
39
+ } from './dispatchCapabilities';
40
+ import { createHeadlessLatestRerunScheduler } from './headlessLatestRerunScheduler';
41
+ import { attachHeadlessRunnerTransport } from './headlessTransport';
36
42
  import type {
43
+ BrowserClientMessage,
44
+ BrowserDispatchHandler,
45
+ BrowserDispatchRequest,
46
+ BrowserDispatchResponse,
37
47
  BrowserHostConfig,
38
48
  BrowserProjectRuntime,
49
+ BrowserRpcRequest,
50
+ BrowserViewport,
51
+ SnapshotRpcRequest,
39
52
  TestFileInfo,
40
53
  } from './protocol';
54
+ import {
55
+ DISPATCH_MESSAGE_TYPE,
56
+ DISPATCH_NAMESPACE_RUNNER,
57
+ validateBrowserRpcRequest,
58
+ } from './protocol';
59
+ import {
60
+ type BrowserProvider,
61
+ type BrowserProviderBrowser,
62
+ type BrowserProviderContext,
63
+ type BrowserProviderImplementation,
64
+ type BrowserProviderPage,
65
+ getBrowserProviderImplementation,
66
+ } from './providers';
67
+ import {
68
+ createRunSession,
69
+ type RunSession,
70
+ RunSessionLifecycle,
71
+ } from './runSession';
72
+ import { RunnerSessionRegistry } from './sessionRegistry';
73
+ import {
74
+ loadSourceMapWithCache,
75
+ normalizeJavaScriptUrl,
76
+ type SourceMapPayload,
77
+ } from './sourceMap/sourceMapLoader';
78
+ import { resolveBrowserViewportPreset } from './viewportPresets';
79
+ import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';
41
80
 
42
81
  const { createRsbuild, rspack } = rsbuild;
43
82
  type RsbuildDevServer = rsbuild.RsbuildDevServer;
@@ -66,16 +105,22 @@ type VirtualModulesPluginInstance = InstanceType<
66
105
  (typeof rspack.experiments)['VirtualModulesPlugin']
67
106
  >;
68
107
 
69
- type PlaywrightModule = typeof import('playwright');
70
- type BrowserType = PlaywrightModule['chromium'];
71
- type BrowserInstance = Awaited<ReturnType<BrowserType['launch']>>;
72
-
73
108
  type BrowserProjectEntries = {
74
109
  project: ProjectContext;
75
110
  setupFiles: string[];
76
111
  testFiles: string[];
77
112
  };
78
113
 
114
+ type BrowserProviderProject = {
115
+ rootPath: string;
116
+ provider: BrowserProvider;
117
+ };
118
+
119
+ type BrowserLaunchOptions = Pick<
120
+ ProjectContext['normalizedConfig']['browser'],
121
+ 'provider' | 'browser' | 'headless' | 'port' | 'strictPort'
122
+ >;
123
+
79
124
  /** Payload for test file start event */
80
125
  type TestFileStartPayload = {
81
126
  testPath: string;
@@ -97,6 +142,15 @@ type FatalPayload = {
97
142
  stack?: string;
98
143
  };
99
144
 
145
+ type ReporterHookArg<THook extends keyof Reporter> = Parameters<
146
+ NonNullable<Reporter[THook]>
147
+ >[0];
148
+
149
+ type TestFileReadyPayload = ReporterHookArg<'onTestFileReady'>;
150
+ type TestSuiteStartPayload = ReporterHookArg<'onTestSuiteStart'>;
151
+ type TestSuiteResultPayload = ReporterHookArg<'onTestSuiteResult'>;
152
+ type TestCaseStartPayload = ReporterHookArg<'onTestCaseStart'>;
153
+
100
154
  /** RPC methods exposed by the host (server) to the container (client) */
101
155
  type HostRpcMethods = {
102
156
  rerunTest: (testFile: string, testNamePattern?: string) => Promise<void>;
@@ -107,11 +161,10 @@ type HostRpcMethods = {
107
161
  onTestFileComplete: (payload: TestFileResult) => Promise<void>;
108
162
  onLog: (payload: LogPayload) => Promise<void>;
109
163
  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>;
164
+ // Generic dispatch endpoint used by runner RPC requests.
165
+ dispatch: (
166
+ request: BrowserDispatchRequest,
167
+ ) => Promise<BrowserDispatchResponse>;
115
168
  };
116
169
 
117
170
  /** RPC methods exposed by the container (client) to the host (server) */
@@ -237,15 +290,17 @@ class ContainerRpcManager {
237
290
  type BrowserRuntime = {
238
291
  rsbuildInstance: RsbuildInstance;
239
292
  devServer: RsbuildDevServer;
240
- browser: BrowserInstance;
293
+ browser: BrowserProviderBrowser;
241
294
  port: number;
242
295
  wsPort: number;
243
296
  manifestPath: string;
244
297
  tempDir: string;
245
298
  manifestPlugin: VirtualModulesPluginInstance;
246
- containerPage?: Page;
247
- containerContext?: BrowserContext;
299
+ containerPage?: BrowserProviderPage;
300
+ containerContext?: BrowserProviderContext;
248
301
  setContainerOptions: (options: BrowserHostConfig) => void;
302
+ // Reserved extension seam for host-side dispatch capabilities.
303
+ dispatchHandlers: Map<string, BrowserDispatchHandler>;
249
304
  wss: WebSocketServer;
250
305
  rpcManager?: ContainerRpcManager;
251
306
  };
@@ -276,6 +331,47 @@ const watchContext: WatchContext = {
276
331
  // Utility Functions
277
332
  // ============================================================================
278
333
 
334
+ const resolveViewport = (
335
+ viewport: BrowserViewport | undefined,
336
+ ): { width: number; height: number } | null => {
337
+ if (!viewport) {
338
+ return null;
339
+ }
340
+
341
+ if (typeof viewport === 'string') {
342
+ return resolveBrowserViewportPreset(viewport);
343
+ }
344
+
345
+ if (
346
+ typeof viewport.width === 'number' &&
347
+ Number.isFinite(viewport.width) &&
348
+ viewport.width > 0 &&
349
+ typeof viewport.height === 'number' &&
350
+ Number.isFinite(viewport.height) &&
351
+ viewport.height > 0
352
+ ) {
353
+ return {
354
+ width: viewport.width,
355
+ height: viewport.height,
356
+ };
357
+ }
358
+
359
+ return null;
360
+ };
361
+
362
+ const mapViewportByProject = (
363
+ projects: BrowserProjectRuntime[],
364
+ ): Map<string, { width: number; height: number }> => {
365
+ const map = new Map<string, { width: number; height: number }>();
366
+ for (const project of projects) {
367
+ const viewport = resolveViewport(project.viewport);
368
+ if (viewport) {
369
+ map.set(project.name, viewport);
370
+ }
371
+ }
372
+ return map;
373
+ };
374
+
279
375
  const ensureProcessExitCode = (code: number): void => {
280
376
  if (process.exitCode === undefined || process.exitCode === 0) {
281
377
  process.exitCode = code;
@@ -533,6 +629,69 @@ const getBrowserProjects = (context: Rstest): ProjectContext[] => {
533
629
  );
534
630
  };
535
631
 
632
+ const getBrowserLaunchOptions = (
633
+ project: ProjectContext,
634
+ ): BrowserLaunchOptions => ({
635
+ provider: project.normalizedConfig.browser.provider,
636
+ browser: project.normalizedConfig.browser.browser,
637
+ headless: project.normalizedConfig.browser.headless,
638
+ port: project.normalizedConfig.browser.port,
639
+ strictPort: project.normalizedConfig.browser.strictPort,
640
+ });
641
+
642
+ const ensureConsistentBrowserLaunchOptions = (
643
+ projects: ProjectContext[],
644
+ ): BrowserLaunchOptions => {
645
+ if (projects.length === 0) {
646
+ throw new Error('No browser-enabled projects found.');
647
+ }
648
+
649
+ const firstProject = projects[0]!;
650
+ const firstOptions = getBrowserLaunchOptions(firstProject);
651
+
652
+ for (const project of projects.slice(1)) {
653
+ const options = getBrowserLaunchOptions(project);
654
+ if (
655
+ options.provider !== firstOptions.provider ||
656
+ options.browser !== firstOptions.browser ||
657
+ options.headless !== firstOptions.headless ||
658
+ options.port !== firstOptions.port ||
659
+ options.strictPort !== firstOptions.strictPort
660
+ ) {
661
+ throw new Error(
662
+ `Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
663
+ 'All browser-enabled projects in one run must share provider/browser/headless/port/strictPort.',
664
+ );
665
+ }
666
+ }
667
+
668
+ return firstOptions;
669
+ };
670
+
671
+ const resolveProviderForTestPath = ({
672
+ testPath,
673
+ browserProjects,
674
+ }: {
675
+ testPath: string;
676
+ browserProjects: BrowserProviderProject[];
677
+ }): BrowserProvider => {
678
+ const normalizedTestPath = normalize(testPath);
679
+ const sortedProjects = [...browserProjects].sort(
680
+ (a, b) => b.rootPath.length - a.rootPath.length,
681
+ );
682
+
683
+ for (const project of sortedProjects) {
684
+ if (normalizedTestPath.startsWith(project.rootPath)) {
685
+ return project.provider;
686
+ }
687
+ }
688
+
689
+ throw new Error(
690
+ `Cannot resolve browser provider for test path: ${JSON.stringify(testPath)}. ` +
691
+ `Known project roots: ${JSON.stringify(sortedProjects.map((p) => p.rootPath))}`,
692
+ );
693
+ };
694
+
536
695
  const collectProjectEntries = async (
537
696
  context: Rstest,
538
697
  ): Promise<BrowserProjectEntries[]> => {
@@ -729,21 +888,6 @@ const htmlTemplate = `<!DOCTYPE html>
729
888
  </html>
730
889
  `;
731
890
 
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
891
  // Workaround for noisy "removed ..." logs caused by VirtualModulesPlugin.
748
892
  // Rsbuild suppresses the removed-file log if all removed paths include "virtual":
749
893
  // https://github.com/web-infra-dev/rsbuild/blob/1258fa9dba5c321a4629b591a6dadbd2e26c6963/packages/core/src/createCompiler.ts#L73-L76
@@ -831,15 +975,11 @@ const createBrowserRuntime = async ({
831
975
  const containerHtmlTemplate = containerDistPath
832
976
  ? await fs.readFile(join(containerDistPath, 'index.html'), 'utf-8')
833
977
  : null;
834
- const schedulerHtmlTemplate = containerDistPath
835
- ? await fs
836
- .readFile(join(containerDistPath, 'scheduler.html'), 'utf-8')
837
- .catch(() => null)
838
- : null;
839
978
 
840
979
  let injectedContainerHtml: string | null = null;
841
- let injectedSchedulerHtml: string | null = null;
842
980
  let serializedOptions = 'null';
981
+ // Reserved extension seam for future browser-side capabilities.
982
+ const dispatchHandlers = new Map<string, BrowserDispatchHandler>();
843
983
 
844
984
  const setContainerOptions = (options: BrowserHostConfig): void => {
845
985
  serializedOptions = serializeForInlineScript(options);
@@ -849,18 +989,17 @@ const createBrowserRuntime = async ({
849
989
  serializedOptions,
850
990
  );
851
991
  }
852
- injectedSchedulerHtml = (
853
- schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate
854
- ).replace(OPTIONS_PLACEHOLDER, serializedOptions);
855
992
  };
856
993
 
857
- // Get user Rsbuild config from the first browser project
858
994
  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;
995
+ const projectByEnvironmentName = new Map(
996
+ browserProjects.map((project) => [project.environmentName, project]),
997
+ );
998
+ const userPlugins = browserProjects.flatMap(
999
+ (project) => project.normalizedConfig.plugins || [],
1000
+ );
1001
+ const browserLaunchOptions =
1002
+ ensureConsistentBrowserLaunchOptions(browserProjects);
864
1003
 
865
1004
  // Rstest internal aliases that must not be overridden by user config
866
1005
  const browserRuntimePath = fileURLToPath(
@@ -871,6 +1010,8 @@ const createBrowserRuntime = async ({
871
1010
  '@rstest/browser-manifest': manifestPath,
872
1011
  // User test code: import { describe, it } from '@rstest/core'
873
1012
  '@rstest/core': resolveBrowserFile('client/public.ts'),
1013
+ // User test code: import { page } from '@rstest/browser'
1014
+ '@rstest/browser': resolveBrowserFile('browser.ts'),
874
1015
  // Browser runtime APIs for entry.ts and public.ts
875
1016
  // Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
876
1017
  '@rstest/core/browser-runtime': browserRuntimePath,
@@ -885,8 +1026,8 @@ const createBrowserRuntime = async ({
885
1026
  plugins: userPlugins,
886
1027
  server: {
887
1028
  printUrls: false,
888
- port: browserConfig.port ?? 4000,
889
- strictPort: browserConfig.strictPort,
1029
+ port: browserLaunchOptions.port ?? 4000,
1030
+ strictPort: browserLaunchOptions.strictPort,
890
1031
  },
891
1032
  dev: {
892
1033
  client: {
@@ -894,7 +1035,9 @@ const createBrowserRuntime = async ({
894
1035
  },
895
1036
  },
896
1037
  environments: {
897
- [firstProject?.environmentName || 'web']: {},
1038
+ ...Object.fromEntries(
1039
+ browserProjects.map((project) => [project.environmentName, {}]),
1040
+ ),
898
1041
  },
899
1042
  },
900
1043
  });
@@ -904,13 +1047,39 @@ const createBrowserRuntime = async ({
904
1047
  {
905
1048
  name: 'rstest:browser-user-config',
906
1049
  setup(api) {
1050
+ // Internal extension entry: register host dispatch handlers without
1051
+ // coupling scheduling to individual capability implementations.
1052
+ (api as { expose?: (name: string, value: unknown) => void }).expose?.(
1053
+ 'rstest:browser',
1054
+ {
1055
+ registerDispatchHandler: (
1056
+ namespace: string,
1057
+ handler: BrowserDispatchHandler,
1058
+ ) => {
1059
+ dispatchHandlers.set(namespace, handler);
1060
+ },
1061
+ },
1062
+ );
1063
+
907
1064
  api.modifyEnvironmentConfig({
908
- handler: (config, { mergeEnvironmentConfig }) => {
1065
+ handler: (config, { mergeEnvironmentConfig, name }) => {
1066
+ const project = projectByEnvironmentName.get(name);
1067
+ if (!project) {
1068
+ return config;
1069
+ }
1070
+
1071
+ const userRsbuildConfig = project.normalizedConfig;
909
1072
  // Merge order: current config -> userConfig -> rstest required config (highest priority)
910
1073
  const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
911
1074
  resolve: {
912
1075
  alias: rstestInternalAliases,
913
1076
  },
1077
+ source: {
1078
+ define: {
1079
+ 'process.env': 'globalThis[Symbol.for("rstest.env")]',
1080
+ 'import.meta.env': 'globalThis[Symbol.for("rstest.env")]',
1081
+ },
1082
+ },
914
1083
  output: {
915
1084
  target: 'web',
916
1085
  // Enable source map for inline snapshot support
@@ -984,8 +1153,8 @@ const createBrowserRuntime = async ({
984
1153
  if (stats) {
985
1154
  const projectEntries = await collectProjectEntries(context);
986
1155
  const entryTestFiles = new Set<string>(
987
- projectEntries.flatMap((entry) =>
988
- entry.testFiles.map((f) => normalize(f)),
1156
+ collectWatchTestFiles(projectEntries).map(
1157
+ (file) => file.testPath,
989
1158
  ),
990
1159
  );
991
1160
 
@@ -1015,7 +1184,9 @@ const createBrowserRuntime = async ({
1015
1184
  }
1016
1185
 
1017
1186
  // Register coverage plugin for browser mode
1018
- const coverage = firstProject?.normalizedConfig.coverage;
1187
+ const coverage = browserProjects.find(
1188
+ (project) => project.normalizedConfig.coverage?.enabled,
1189
+ )?.normalizedConfig.coverage;
1019
1190
  if (coverage?.enabled && context.command !== 'list') {
1020
1191
  const { pluginCoverage } = await loadCoverageProvider(
1021
1192
  coverage,
@@ -1109,82 +1280,72 @@ const createBrowserRuntime = async ({
1109
1280
  }
1110
1281
  };
1111
1282
 
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');
1283
+ devServer.middlewares.use(
1284
+ async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
1285
+ if (!req.url) {
1286
+ next();
1123
1287
  return;
1124
1288
  }
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)) {
1289
+ const url = new URL(req.url, 'http://localhost');
1290
+ if (url.pathname === '/__open-in-editor') {
1291
+ const file = url.searchParams.get('file');
1292
+ if (!file) {
1293
+ res.statusCode = 400;
1294
+ res.end('Missing file');
1295
+ return;
1296
+ }
1297
+ try {
1298
+ await openEditor([{ file }]);
1299
+ res.statusCode = 204;
1300
+ res.end();
1301
+ } catch (error) {
1302
+ logger.debug(`[Browser UI] Failed to open editor: ${String(error)}`);
1303
+ res.statusCode = 500;
1304
+ res.end('Failed to open editor');
1305
+ }
1138
1306
  return;
1139
1307
  }
1308
+ if (url.pathname === '/') {
1309
+ if (await respondWithDevServerHtml(url, res)) {
1310
+ return;
1311
+ }
1140
1312
 
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
- }
1313
+ const html =
1314
+ injectedContainerHtml ||
1315
+ containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
1152
1316
 
1153
- const html =
1154
- injectedContainerHtml ||
1155
- containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
1317
+ if (html) {
1318
+ res.setHeader('Content-Type', 'text/html');
1319
+ res.end(html);
1320
+ return;
1321
+ }
1156
1322
 
1157
- if (html) {
1158
- res.setHeader('Content-Type', 'text/html');
1159
- res.end(html);
1323
+ res.statusCode = 502;
1324
+ res.end('Container UI is not available.');
1160
1325
  return;
1161
1326
  }
1327
+ if (url.pathname.startsWith('/container-static/')) {
1328
+ if (await proxyDevServerAsset(req, res)) {
1329
+ return;
1330
+ }
1162
1331
 
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)) {
1332
+ if (serveContainer) {
1333
+ serveContainer(req, res, next);
1334
+ return;
1335
+ }
1336
+
1337
+ res.statusCode = 502;
1338
+ res.end('Container assets are not available.');
1169
1339
  return;
1170
1340
  }
1171
-
1172
- if (serveContainer) {
1173
- serveContainer(req, res, next);
1341
+ if (url.pathname === '/runner.html') {
1342
+ res.setHeader('Content-Type', 'text/html');
1343
+ res.end(htmlTemplate);
1174
1344
  return;
1175
1345
  }
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
- });
1346
+ next();
1347
+ },
1348
+ );
1188
1349
 
1189
1350
  const { port } = await devServer.listen();
1190
1351
 
@@ -1199,49 +1360,33 @@ const createBrowserRuntime = async ({
1199
1360
  const wsPort = (wss.address() as AddressInfo).port;
1200
1361
  logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`);
1201
1362
 
1202
- let browserLauncher: BrowserType;
1203
- const browserName = browserConfig.browser;
1204
- 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;
1363
+ const browserName = browserLaunchOptions.browser ?? 'chromium';
1214
1364
  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,
1365
+ const providerImplementation = getBrowserProviderImplementation(
1366
+ browserLaunchOptions.provider,
1367
+ );
1368
+ const runtime = await providerImplementation.launchRuntime({
1369
+ browserName,
1370
+ headless: forceHeadless ?? browserLaunchOptions.headless,
1226
1371
  });
1372
+ return {
1373
+ rsbuildInstance,
1374
+ devServer,
1375
+ browser: runtime.browser,
1376
+ port,
1377
+ wsPort,
1378
+ manifestPath,
1379
+ tempDir,
1380
+ manifestPlugin: virtualManifestPlugin,
1381
+ setContainerOptions,
1382
+ dispatchHandlers,
1383
+ wss,
1384
+ };
1227
1385
  } catch (_error) {
1228
1386
  wss.close();
1229
1387
  await devServer.close();
1230
1388
  throw _error;
1231
1389
  }
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
1390
  };
1246
1391
 
1247
1392
  async function resolveProjectEntries(
@@ -1281,24 +1426,71 @@ export const runBrowserController = async (
1281
1426
  const { skipOnTestRunEnd = false } = options ?? {};
1282
1427
  const buildStart = Date.now();
1283
1428
  const browserProjects = getBrowserProjects(context);
1284
- const useSchedulerPage = browserProjects.every(
1429
+ const useHeadlessDirect = browserProjects.every(
1285
1430
  (project) => project.normalizedConfig.browser.headless,
1286
1431
  );
1287
1432
 
1433
+ const browserSourceMapCache = new Map<string, SourceMapPayload | null>();
1434
+
1435
+ const isHttpLikeFile = (file: string): boolean => /^https?:\/\//.test(file);
1436
+
1437
+ const resolveBrowserSourcemap = async (sourcePath: string) => {
1438
+ if (!isHttpLikeFile(sourcePath)) {
1439
+ return {
1440
+ handled: false,
1441
+ sourcemap: null,
1442
+ };
1443
+ }
1444
+
1445
+ const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
1446
+ if (!normalizedUrl) {
1447
+ return {
1448
+ handled: true,
1449
+ sourcemap: null,
1450
+ };
1451
+ }
1452
+
1453
+ if (browserSourceMapCache.has(normalizedUrl)) {
1454
+ return {
1455
+ handled: true,
1456
+ sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null,
1457
+ };
1458
+ }
1459
+
1460
+ return {
1461
+ handled: true,
1462
+ sourcemap: await loadSourceMapWithCache({
1463
+ jsUrl: normalizedUrl,
1464
+ cache: browserSourceMapCache,
1465
+ }),
1466
+ };
1467
+ };
1468
+
1469
+ const getBrowserSourcemap = async (
1470
+ sourcePath: string,
1471
+ ): Promise<SourceMapPayload | null> => {
1472
+ const result = await resolveBrowserSourcemap(sourcePath);
1473
+ return result.handled ? result.sourcemap : null;
1474
+ };
1475
+
1288
1476
  /**
1289
1477
  * Build an error BrowserTestRunResult and call onTestRunEnd if needed.
1290
1478
  * Used for early-exit error paths to ensure errors reach the summary report.
1291
1479
  */
1292
1480
  const buildErrorResult = async (
1293
1481
  error: Error,
1482
+ close?: () => Promise<void>,
1294
1483
  ): Promise<BrowserTestRunResult> => {
1295
1484
  const elapsed = Math.max(0, Date.now() - buildStart);
1296
- const errorResult: BrowserTestRunResult = {
1485
+ const errorResult = {
1297
1486
  results: [],
1298
1487
  testResults: [],
1299
1488
  duration: { totalTime: elapsed, buildTime: elapsed, testTime: 0 },
1300
1489
  hasFailure: true,
1301
1490
  unhandledErrors: [error],
1491
+ getSourcemap: getBrowserSourcemap,
1492
+ resolveSourcemap: resolveBrowserSourcemap,
1493
+ close,
1302
1494
  };
1303
1495
 
1304
1496
  if (!skipOnTestRunEnd) {
@@ -1308,7 +1500,7 @@ export const runBrowserController = async (
1308
1500
  testResults: [],
1309
1501
  duration: errorResult.duration,
1310
1502
  snapshotSummary: context.snapshotManager.summary,
1311
- getSourcemap: async () => null,
1503
+ getSourcemap: getBrowserSourcemap,
1312
1504
  unhandledErrors: errorResult.unhandledErrors,
1313
1505
  });
1314
1506
  }
@@ -1326,32 +1518,94 @@ export const runBrowserController = async (
1326
1518
  cleanup?: () => Promise<void>,
1327
1519
  ): Promise<BrowserTestRunResult> => {
1328
1520
  ensureProcessExitCode(1);
1329
- await cleanup?.();
1330
- return buildErrorResult(toError(error));
1521
+
1522
+ const normalizedError = toError(error);
1523
+
1524
+ if (cleanup && skipOnTestRunEnd) {
1525
+ return buildErrorResult(normalizedError, cleanup);
1526
+ }
1527
+
1528
+ try {
1529
+ return await buildErrorResult(normalizedError);
1530
+ } finally {
1531
+ await cleanup?.();
1532
+ }
1533
+ };
1534
+
1535
+ const collectDeletedTestPaths = (
1536
+ previous: TestFileInfo[],
1537
+ current: TestFileInfo[],
1538
+ ): string[] => {
1539
+ const currentPathSet = new Set(current.map((file) => file.testPath));
1540
+ return previous
1541
+ .map((file) => file.testPath)
1542
+ .filter((testPath) => !currentPathSet.has(testPath));
1543
+ };
1544
+
1545
+ const notifyTestRunStart = async (): Promise<void> => {
1546
+ if (skipOnTestRunEnd) {
1547
+ return;
1548
+ }
1549
+
1550
+ for (const reporter of context.reporters) {
1551
+ await reporter.onTestRunStart?.();
1552
+ }
1553
+ };
1554
+
1555
+ const notifyTestRunEnd = async ({
1556
+ duration,
1557
+ unhandledErrors,
1558
+ filterRerunTestPaths,
1559
+ }: {
1560
+ duration: {
1561
+ totalTime: number;
1562
+ buildTime: number;
1563
+ testTime: number;
1564
+ };
1565
+ unhandledErrors?: Error[];
1566
+ filterRerunTestPaths?: string[];
1567
+ }): Promise<void> => {
1568
+ if (skipOnTestRunEnd) {
1569
+ return;
1570
+ }
1571
+
1572
+ for (const reporter of context.reporters) {
1573
+ await reporter.onTestRunEnd?.({
1574
+ results: context.reporterResults.results,
1575
+ testResults: context.reporterResults.testResults,
1576
+ duration,
1577
+ snapshotSummary: context.snapshotManager.summary,
1578
+ getSourcemap: getBrowserSourcemap,
1579
+ unhandledErrors,
1580
+ filterRerunTestPaths,
1581
+ });
1582
+ }
1331
1583
  };
1332
1584
 
1333
1585
  const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
1334
1586
  let containerDevServer: string | undefined;
1335
1587
  let containerDistPath: string | undefined;
1336
1588
 
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);
1589
+ if (!useHeadlessDirect) {
1590
+ if (containerDevServerEnv) {
1591
+ try {
1592
+ containerDevServer = new URL(containerDevServerEnv).toString();
1593
+ logger.debug(
1594
+ `[Browser UI] Using dev server for container: ${containerDevServer}`,
1595
+ );
1596
+ } catch (error) {
1597
+ const originalError = toError(error);
1598
+ originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
1599
+ return failWithError(originalError);
1600
+ }
1347
1601
  }
1348
- }
1349
1602
 
1350
- if (!containerDevServer) {
1351
- try {
1352
- containerDistPath = resolveContainerDist();
1353
- } catch (error) {
1354
- return failWithError(error);
1603
+ if (!containerDevServer) {
1604
+ try {
1605
+ containerDistPath = resolveContainerDist();
1606
+ } catch (error) {
1607
+ return failWithError(error);
1608
+ }
1355
1609
  }
1356
1610
  }
1357
1611
 
@@ -1381,6 +1635,8 @@ export const runBrowserController = async (
1381
1635
  return;
1382
1636
  }
1383
1637
 
1638
+ await notifyTestRunStart();
1639
+
1384
1640
  const isWatchMode = context.command === 'watch';
1385
1641
  const tempDir =
1386
1642
  isWatchMode && watchContext.runtime
@@ -1402,12 +1658,7 @@ export const runBrowserController = async (
1402
1658
 
1403
1659
  // Track initial test files for watch mode
1404
1660
  if (isWatchMode) {
1405
- watchContext.lastTestFiles = projectEntries.flatMap((entry) =>
1406
- entry.testFiles.map((testPath) => ({
1407
- testPath,
1408
- projectName: entry.project.name,
1409
- })),
1410
- );
1661
+ watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
1411
1662
  }
1412
1663
 
1413
1664
  let runtime = isWatchMode ? watchContext.runtime : null;
@@ -1484,149 +1735,99 @@ export const runBrowserController = async (
1484
1735
  rpcTimeout: maxTestTimeoutForRpc,
1485
1736
  };
1486
1737
 
1487
- runtime.setContainerOptions(hostOptions);
1488
-
1489
- // Track test results from iframes
1490
- const reporterResults: TestFileResult[] = [];
1491
- const caseResults: TestResult[] = [];
1492
- let completedTests = 0;
1493
- let fatalError: Error | null = null;
1494
-
1495
- // Promise that resolves when all tests complete
1496
- let resolveAllTests: (() => void) | undefined;
1497
- const allTestsPromise = new Promise<void>((resolve) => {
1498
- resolveAllTests = resolve;
1499
- });
1500
-
1501
- // Open a container page for user to view (reuse in watch mode)
1502
- let containerContext: BrowserContext;
1503
- let containerPage: Page;
1504
- let isNewPage = false;
1738
+ const browserProviderProjects: BrowserProviderProject[] = browserProjects.map(
1739
+ (project) => ({
1740
+ rootPath: normalize(project.rootPath),
1741
+ provider: project.normalizedConfig.browser.provider,
1742
+ }),
1743
+ );
1744
+ const implementationByProvider = new Map<
1745
+ BrowserProvider,
1746
+ BrowserProviderImplementation
1747
+ >();
1748
+ for (const browserProject of browserProviderProjects) {
1749
+ if (!implementationByProvider.has(browserProject.provider)) {
1750
+ implementationByProvider.set(
1751
+ browserProject.provider,
1752
+ getBrowserProviderImplementation(browserProject.provider),
1753
+ );
1754
+ }
1755
+ }
1505
1756
 
1506
- if (isWatchMode && runtime.containerPage && runtime.containerContext) {
1507
- containerContext = runtime.containerContext;
1508
- containerPage = runtime.containerPage;
1509
- logger.log(color.gray('\n[Watch] Reusing existing container page\n'));
1510
- } else {
1511
- isNewPage = true;
1512
- containerContext = await browser.newContext({
1513
- viewport: null,
1757
+ let activeContainerPage: BrowserProviderPage | null = null;
1758
+ let getHeadlessRunnerPageBySessionId:
1759
+ | ((sessionId: string) => BrowserProviderPage | undefined)
1760
+ | undefined;
1761
+
1762
+ const dispatchBrowserRpcRequest = async ({
1763
+ request,
1764
+ target,
1765
+ }: {
1766
+ request: BrowserRpcRequest;
1767
+ target?: BrowserDispatchRequest['target'];
1768
+ }): Promise<unknown> => {
1769
+ const timeoutFallbackMs = maxTestTimeoutForRpc;
1770
+ const provider = resolveProviderForTestPath({
1771
+ testPath: request.testPath,
1772
+ browserProjects: browserProviderProjects,
1514
1773
  });
1515
- containerPage = await containerContext.newPage();
1774
+ const implementation = implementationByProvider.get(provider);
1775
+ if (!implementation) {
1776
+ throw new Error(`Browser provider implementation not found: ${provider}`);
1777
+ }
1516
1778
 
1517
- // Prevent popup windows from being created
1518
- containerPage.on('popup', async (popup: Page) => {
1519
- await popup.close().catch(() => {});
1520
- });
1779
+ const runnerPage = target?.sessionId
1780
+ ? getHeadlessRunnerPageBySessionId?.(target.sessionId)
1781
+ : undefined;
1521
1782
 
1522
- containerContext.on('page', async (page: Page) => {
1523
- if (page !== containerPage) {
1524
- await page.close().catch(() => {});
1525
- }
1526
- });
1783
+ if (target?.sessionId && !runnerPage) {
1784
+ throw new Error(
1785
+ `Runner page session not found for browser dispatch: ${target.sessionId}`,
1786
+ );
1787
+ }
1527
1788
 
1528
- if (isWatchMode) {
1529
- runtime.containerPage = containerPage;
1530
- runtime.containerContext = containerContext;
1789
+ if (!runnerPage && !activeContainerPage) {
1790
+ throw new Error('Browser container page is not initialized');
1531
1791
  }
1532
1792
 
1533
- // Forward browser console to terminal
1534
- containerPage.on('console', (msg: ConsoleMessage) => {
1535
- const text = msg.text();
1536
- if (
1537
- text.startsWith('[Container]') ||
1538
- text.startsWith('[Runner]') ||
1539
- text.startsWith('[Scheduler]')
1540
- ) {
1541
- logger.log(color.gray(`[Browser Console] ${text}`));
1793
+ try {
1794
+ return await implementation.dispatchRpc({
1795
+ containerPage: runnerPage
1796
+ ? undefined
1797
+ : (activeContainerPage ?? undefined),
1798
+ runnerPage,
1799
+ request,
1800
+ timeoutFallbackMs,
1801
+ });
1802
+ } catch (error) {
1803
+ // birpc serializes thrown Errors as `{}` over JSON; throw a string instead.
1804
+ if (error instanceof Error) {
1805
+ throw error.message;
1542
1806
  }
1807
+ throw String(error);
1808
+ }
1809
+ };
1810
+
1811
+ runtime.dispatchHandlers.set('browser', async (dispatchRequest) => {
1812
+ const request = validateBrowserRpcRequest(dispatchRequest.args);
1813
+ return dispatchBrowserRpcRequest({
1814
+ request,
1815
+ target: dispatchRequest.target,
1543
1816
  });
1544
- }
1817
+ });
1545
1818
 
1546
- // Create RPC methods that can access test state variables
1547
- const createRpcMethods = (): HostRpcMethods => ({
1548
- async rerunTest(testFile: string, testNamePattern?: string) {
1549
- const projectName = context.normalizedConfig.name || 'project';
1550
- const relativePath = relative(context.rootPath, testFile);
1551
- const displayPath = `<${projectName}>/${relativePath}`;
1552
- logger.log(
1553
- color.cyan(
1554
- `\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
1555
- ),
1556
- );
1557
- await rpcManager.reloadTestFile(testFile, testNamePattern);
1558
- },
1559
- async getTestFiles() {
1560
- return allTestFiles;
1561
- },
1562
- 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
- );
1571
- },
1572
- 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
- );
1579
- },
1580
- 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
- }
1595
- },
1596
- 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
- };
1819
+ runtime.setContainerOptions(hostOptions);
1604
1820
 
1605
- // Check onConsoleLog filter
1606
- const shouldLog =
1607
- context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
1821
+ // Track test results from browser runners
1822
+ const reporterResults: TestFileResult[] = [];
1823
+ const caseResults: TestResult[] = [];
1824
+ let fatalError: Error | null = null;
1608
1825
 
1609
- if (shouldLog) {
1610
- await Promise.all(
1611
- context.reporters.map((reporter) =>
1612
- (reporter as Reporter).onUserConsoleLog?.(log),
1613
- ),
1614
- );
1615
- }
1616
- },
1617
- 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) {
1826
+ const snapshotRpcMethods = {
1827
+ async resolveSnapshotPath(testPath: string): Promise<string> {
1626
1828
  const snapExtension = '.snap';
1627
1829
  const resolver =
1628
1830
  context.normalizedConfig.resolveSnapshotPath ||
1629
- // test/index.ts -> test/__snapshots__/index.ts.snap
1630
1831
  (() =>
1631
1832
  join(
1632
1833
  dirname(testPath),
@@ -1635,25 +1836,727 @@ export const runBrowserController = async (
1635
1836
  ));
1636
1837
  return resolver(testPath, snapExtension);
1637
1838
  },
1638
- async readSnapshotFile(filepath: string) {
1839
+ async readSnapshotFile(filepath: string): Promise<string | null> {
1639
1840
  try {
1640
1841
  return await fs.readFile(filepath, 'utf-8');
1641
1842
  } catch {
1642
1843
  return null;
1643
1844
  }
1644
1845
  },
1645
- async saveSnapshotFile(filepath: string, content: string) {
1846
+ async saveSnapshotFile(filepath: string, content: string): Promise<void> {
1646
1847
  const dir = dirname(filepath);
1647
1848
  await fs.mkdir(dir, { recursive: true });
1648
1849
  await fs.writeFile(filepath, content, 'utf-8');
1649
1850
  },
1650
- async removeSnapshotFile(filepath: string) {
1851
+ async removeSnapshotFile(filepath: string): Promise<void> {
1651
1852
  try {
1652
1853
  await fs.unlink(filepath);
1653
1854
  } catch {
1654
1855
  // ignore if file doesn't exist
1655
1856
  }
1656
1857
  },
1858
+ };
1859
+
1860
+ const handleTestFileStart = async (
1861
+ payload: TestFileStartPayload,
1862
+ ): Promise<void> => {
1863
+ await Promise.all(
1864
+ context.reporters.map((reporter) =>
1865
+ (reporter as Reporter).onTestFileStart?.({
1866
+ testPath: payload.testPath,
1867
+ tests: [],
1868
+ }),
1869
+ ),
1870
+ );
1871
+ };
1872
+
1873
+ const handleTestFileReady = async (
1874
+ payload: TestFileReadyPayload,
1875
+ ): Promise<void> => {
1876
+ await Promise.all(
1877
+ context.reporters.map((reporter) =>
1878
+ (reporter as Reporter).onTestFileReady?.(payload),
1879
+ ),
1880
+ );
1881
+ };
1882
+
1883
+ const handleTestSuiteStart = async (
1884
+ payload: TestSuiteStartPayload,
1885
+ ): Promise<void> => {
1886
+ await Promise.all(
1887
+ context.reporters.map((reporter) =>
1888
+ (reporter as Reporter).onTestSuiteStart?.(payload),
1889
+ ),
1890
+ );
1891
+ };
1892
+
1893
+ const handleTestSuiteResult = async (
1894
+ payload: TestSuiteResultPayload,
1895
+ ): Promise<void> => {
1896
+ await Promise.all(
1897
+ context.reporters.map((reporter) =>
1898
+ (reporter as Reporter).onTestSuiteResult?.(payload),
1899
+ ),
1900
+ );
1901
+ };
1902
+
1903
+ const handleTestCaseStart = async (
1904
+ payload: TestCaseStartPayload,
1905
+ ): Promise<void> => {
1906
+ await Promise.all(
1907
+ context.reporters.map((reporter) =>
1908
+ (reporter as Reporter).onTestCaseStart?.(payload),
1909
+ ),
1910
+ );
1911
+ };
1912
+
1913
+ const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
1914
+ caseResults.push(payload);
1915
+ await Promise.all(
1916
+ context.reporters.map((reporter) =>
1917
+ (reporter as Reporter).onTestCaseResult?.(payload),
1918
+ ),
1919
+ );
1920
+ };
1921
+
1922
+ const handleTestFileComplete = async (
1923
+ payload: TestFileResult,
1924
+ ): Promise<void> => {
1925
+ reporterResults.push(payload);
1926
+ context.updateReporterResultState([payload], payload.results);
1927
+ if (payload.snapshotResult) {
1928
+ context.snapshotManager.add(payload.snapshotResult);
1929
+ }
1930
+ await Promise.all(
1931
+ context.reporters.map((reporter) =>
1932
+ (reporter as Reporter).onTestFileResult?.(payload),
1933
+ ),
1934
+ );
1935
+ if (payload.status === 'fail') {
1936
+ ensureProcessExitCode(1);
1937
+ }
1938
+ };
1939
+
1940
+ const handleLog = async (payload: LogPayload): Promise<void> => {
1941
+ const log: UserConsoleLog = {
1942
+ content: payload.content,
1943
+ name: payload.level,
1944
+ testPath: payload.testPath,
1945
+ type: payload.type,
1946
+ trace: payload.trace,
1947
+ };
1948
+ const shouldLog =
1949
+ context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
1950
+ if (shouldLog) {
1951
+ await Promise.all(
1952
+ context.reporters.map((reporter) =>
1953
+ (reporter as Reporter).onUserConsoleLog?.(log),
1954
+ ),
1955
+ );
1956
+ }
1957
+ };
1958
+
1959
+ const handleFatal = async (payload: FatalPayload): Promise<void> => {
1960
+ const error = new Error(payload.message);
1961
+ error.stack = payload.stack;
1962
+ fatalError = error;
1963
+ ensureProcessExitCode(1);
1964
+ };
1965
+
1966
+ const runSnapshotRpc = async (
1967
+ request: SnapshotRpcRequest,
1968
+ ): Promise<unknown> => {
1969
+ switch (request.method) {
1970
+ case 'resolveSnapshotPath':
1971
+ return snapshotRpcMethods.resolveSnapshotPath(request.args.testPath);
1972
+ case 'readSnapshotFile':
1973
+ return snapshotRpcMethods.readSnapshotFile(request.args.filepath);
1974
+ case 'saveSnapshotFile':
1975
+ return snapshotRpcMethods.saveSnapshotFile(
1976
+ request.args.filepath,
1977
+ request.args.content,
1978
+ );
1979
+ case 'removeSnapshotFile':
1980
+ return snapshotRpcMethods.removeSnapshotFile(request.args.filepath);
1981
+ default:
1982
+ return undefined;
1983
+ }
1984
+ };
1985
+
1986
+ const createDispatchRouter = (options?: HostDispatchRouterOptions) => {
1987
+ return createHostDispatchRouter({
1988
+ routerOptions: options,
1989
+ runnerCallbacks: {
1990
+ onTestFileStart: handleTestFileStart,
1991
+ onTestFileReady: handleTestFileReady,
1992
+ onTestSuiteStart: handleTestSuiteStart,
1993
+ onTestSuiteResult: handleTestSuiteResult,
1994
+ onTestCaseStart: handleTestCaseStart,
1995
+ onTestCaseResult: handleTestCaseResult,
1996
+ onTestFileComplete: handleTestFileComplete,
1997
+ onLog: handleLog,
1998
+ onFatal: handleFatal,
1999
+ },
2000
+ runSnapshotRpc,
2001
+ extensionHandlers: runtime.dispatchHandlers,
2002
+ onDuplicateNamespace: (namespace) => {
2003
+ logger.debug(
2004
+ `[Dispatch] Skip registering dispatch namespace "${namespace}" because it is already reserved`,
2005
+ );
2006
+ },
2007
+ });
2008
+ };
2009
+
2010
+ if (useHeadlessDirect) {
2011
+ // Session-based scheduling path: lifecycle + session index + dispatch routing.
2012
+ type ActiveHeadlessRun = RunSession & {
2013
+ contexts: Set<BrowserProviderContext>;
2014
+ };
2015
+
2016
+ const viewportByProject = mapViewportByProject(projectRuntimeConfigs);
2017
+ const runLifecycle = new RunSessionLifecycle<ActiveHeadlessRun>();
2018
+ const sessionRegistry = new RunnerSessionRegistry();
2019
+ getHeadlessRunnerPageBySessionId = (sessionId) => {
2020
+ return sessionRegistry.getById(sessionId)?.page;
2021
+ };
2022
+ let dispatchRequestCounter = 0;
2023
+
2024
+ const nextDispatchRequestId = (namespace: string): string => {
2025
+ return `${namespace}-${++dispatchRequestCounter}`;
2026
+ };
2027
+
2028
+ const closeContextSafely = async (
2029
+ browserContext: BrowserProviderContext,
2030
+ ): Promise<void> => {
2031
+ try {
2032
+ await browserContext.close();
2033
+ } catch {
2034
+ // ignore
2035
+ }
2036
+ };
2037
+
2038
+ const cancelRun = async (
2039
+ run: ActiveHeadlessRun,
2040
+ waitForDone = true,
2041
+ ): Promise<void> => {
2042
+ await runLifecycle.cancel(run, {
2043
+ waitForDone,
2044
+ onCancel: async (session) => {
2045
+ await Promise.all(
2046
+ Array.from(session.contexts).map((browserContext) =>
2047
+ closeContextSafely(browserContext),
2048
+ ),
2049
+ );
2050
+ },
2051
+ });
2052
+ };
2053
+
2054
+ const dispatchRouter = createDispatchRouter({
2055
+ isRunTokenStale: (runToken) => runLifecycle.isTokenStale(runToken),
2056
+ onStale: (request) => {
2057
+ if (request.namespace === DISPATCH_NAMESPACE_RUNNER) {
2058
+ logger.debug(
2059
+ `[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`,
2060
+ );
2061
+ }
2062
+ },
2063
+ });
2064
+
2065
+ const dispatchRunnerMessage = async (
2066
+ run: ActiveHeadlessRun,
2067
+ file: TestFileInfo,
2068
+ sessionId: string,
2069
+ message: BrowserClientMessage,
2070
+ ): Promise<void> => {
2071
+ const response = await dispatchRouter.dispatch({
2072
+ requestId: nextDispatchRequestId('runner'),
2073
+ runToken: run.token,
2074
+ namespace: DISPATCH_NAMESPACE_RUNNER,
2075
+ method: message.type,
2076
+ args: 'payload' in message ? message.payload : undefined,
2077
+ target: {
2078
+ sessionId,
2079
+ testFile: file.testPath,
2080
+ projectName: file.projectName,
2081
+ },
2082
+ });
2083
+
2084
+ if (response.stale) {
2085
+ return;
2086
+ }
2087
+
2088
+ if (response.error) {
2089
+ throw new Error(response.error);
2090
+ }
2091
+ };
2092
+
2093
+ const runSingleFile = async (
2094
+ run: ActiveHeadlessRun,
2095
+ file: TestFileInfo,
2096
+ ): Promise<void> => {
2097
+ if (run.cancelled || runLifecycle.isTokenStale(run.token)) {
2098
+ return;
2099
+ }
2100
+
2101
+ const viewport = viewportByProject.get(file.projectName);
2102
+ const browserContext = await browser.newContext({
2103
+ viewport: viewport ?? null,
2104
+ });
2105
+ run.contexts.add(browserContext);
2106
+
2107
+ let page: BrowserProviderPage | null = null;
2108
+ let sessionId: string | null = null;
2109
+ let settled = false;
2110
+ let resolveDone: (() => void) | null = null;
2111
+
2112
+ const markDone = (): void => {
2113
+ if (!settled) {
2114
+ settled = true;
2115
+ resolveDone?.();
2116
+ }
2117
+ };
2118
+
2119
+ const donePromise = new Promise<void>((resolve) => {
2120
+ resolveDone = resolve;
2121
+ });
2122
+
2123
+ const projectRuntime = projectRuntimeConfigs.find(
2124
+ (project) => project.name === file.projectName,
2125
+ );
2126
+ const perFileTimeoutMs =
2127
+ (projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) +
2128
+ 30_000;
2129
+
2130
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
2131
+
2132
+ try {
2133
+ page = await browserContext.newPage();
2134
+
2135
+ const session = sessionRegistry.register({
2136
+ testFile: file.testPath,
2137
+ projectName: file.projectName,
2138
+ runToken: run.token,
2139
+ mode: 'headless-page',
2140
+ context: browserContext,
2141
+ page,
2142
+ });
2143
+ sessionId = session.id;
2144
+
2145
+ await attachHeadlessRunnerTransport(page, {
2146
+ onDispatchMessage: async (message) => {
2147
+ try {
2148
+ await dispatchRunnerMessage(run, file, session.id, message);
2149
+ if (
2150
+ message.type === 'file-complete' ||
2151
+ message.type === 'complete'
2152
+ ) {
2153
+ markDone();
2154
+ } else if (message.type === 'fatal') {
2155
+ markDone();
2156
+ await cancelRun(run, false);
2157
+ }
2158
+ } catch (error) {
2159
+ const formatted = toError(error);
2160
+ await handleFatal({
2161
+ message: formatted.message,
2162
+ stack: formatted.stack,
2163
+ });
2164
+ markDone();
2165
+ await cancelRun(run, false);
2166
+ }
2167
+ },
2168
+ onDispatchRpc: async (request) => {
2169
+ return dispatchRouter.dispatch({
2170
+ ...request,
2171
+ runToken: run.token,
2172
+ target: {
2173
+ sessionId: session.id,
2174
+ testFile: file.testPath,
2175
+ projectName: file.projectName,
2176
+ ...request.target,
2177
+ },
2178
+ });
2179
+ },
2180
+ });
2181
+
2182
+ const inlineOptions: BrowserHostConfig = {
2183
+ ...hostOptions,
2184
+ testFile: file.testPath,
2185
+ runId: `${run.token}:${session.id}`,
2186
+ };
2187
+ const serializedOptions = serializeForInlineScript(inlineOptions);
2188
+ await page.addInitScript(
2189
+ `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
2190
+ );
2191
+
2192
+ await page.goto(`http://localhost:${port}/runner.html`, {
2193
+ waitUntil: 'load',
2194
+ });
2195
+
2196
+ const timeoutPromise = new Promise<'timeout'>((resolve) => {
2197
+ timeoutId = setTimeout(() => resolve('timeout'), perFileTimeoutMs);
2198
+ });
2199
+
2200
+ const state = await Promise.race([
2201
+ donePromise.then(() => 'done' as const),
2202
+ timeoutPromise,
2203
+ run.cancelSignal.then(() => 'cancelled' as const),
2204
+ ]);
2205
+
2206
+ if (state === 'cancelled') {
2207
+ return;
2208
+ }
2209
+
2210
+ if (
2211
+ state === 'timeout' &&
2212
+ runLifecycle.isTokenActive(run.token) &&
2213
+ !run.cancelled
2214
+ ) {
2215
+ await handleFatal({
2216
+ message: `Test execution timeout after ${perFileTimeoutMs / 1000}s for ${file.testPath}.`,
2217
+ });
2218
+ await cancelRun(run, false);
2219
+ }
2220
+ } catch (error) {
2221
+ if (runLifecycle.isTokenActive(run.token) && !run.cancelled) {
2222
+ const formatted = toError(error);
2223
+ await handleFatal({
2224
+ message: formatted.message,
2225
+ stack: formatted.stack,
2226
+ });
2227
+ await cancelRun(run, false);
2228
+ }
2229
+ } finally {
2230
+ if (timeoutId) {
2231
+ clearTimeout(timeoutId);
2232
+ }
2233
+ if (page) {
2234
+ try {
2235
+ await page.close();
2236
+ } catch {
2237
+ // ignore
2238
+ }
2239
+ }
2240
+ if (sessionId) {
2241
+ sessionRegistry.deleteById(sessionId);
2242
+ }
2243
+ run.contexts.delete(browserContext);
2244
+ await closeContextSafely(browserContext);
2245
+ }
2246
+ };
2247
+
2248
+ const runFilesWithPool = async (files: TestFileInfo[]): Promise<void> => {
2249
+ if (files.length === 0) {
2250
+ return;
2251
+ }
2252
+
2253
+ const previous = runLifecycle.activeSession;
2254
+ if (previous) {
2255
+ await cancelRun(previous);
2256
+ }
2257
+
2258
+ const run = runLifecycle.createSession((token) => ({
2259
+ ...createRunSession(token),
2260
+ contexts: new Set<BrowserProviderContext>(),
2261
+ }));
2262
+
2263
+ const queue = [...files];
2264
+ const concurrency = getHeadlessConcurrency(context, queue.length);
2265
+
2266
+ const worker = async (): Promise<void> => {
2267
+ while (
2268
+ queue.length > 0 &&
2269
+ !run.cancelled &&
2270
+ runLifecycle.isTokenActive(run.token)
2271
+ ) {
2272
+ const next = queue.shift();
2273
+ if (!next) {
2274
+ return;
2275
+ }
2276
+ await runSingleFile(run, next);
2277
+ }
2278
+ };
2279
+
2280
+ run.done = Promise.all(
2281
+ Array.from(
2282
+ { length: Math.min(queue.length, Math.max(concurrency, 1)) },
2283
+ () => worker(),
2284
+ ),
2285
+ ).then(() => {});
2286
+
2287
+ await run.done;
2288
+ runLifecycle.clearIfActive(run);
2289
+ };
2290
+
2291
+ const latestRerunScheduler = createHeadlessLatestRerunScheduler<
2292
+ TestFileInfo,
2293
+ ActiveHeadlessRun
2294
+ >({
2295
+ getActiveRun: () => runLifecycle.activeSession,
2296
+ isRunCancelled: (run) => run.cancelled,
2297
+ invalidateActiveRun: () => {
2298
+ runLifecycle.invalidateActiveToken();
2299
+ },
2300
+ interruptActiveRun: async (run) => {
2301
+ await cancelRun(run, false);
2302
+ },
2303
+ runFiles: async (files) => {
2304
+ await notifyTestRunStart();
2305
+
2306
+ const rerunStartTime = Date.now();
2307
+ const fatalErrorBeforeRun = fatalError;
2308
+ let rerunError: Error | undefined;
2309
+
2310
+ try {
2311
+ await runFilesWithPool(files);
2312
+ } catch (error) {
2313
+ rerunError = toError(error);
2314
+ throw error;
2315
+ } finally {
2316
+ const testTime = Math.max(0, Date.now() - rerunStartTime);
2317
+ const rerunFatalError =
2318
+ fatalError && fatalError !== fatalErrorBeforeRun
2319
+ ? fatalError
2320
+ : undefined;
2321
+ await notifyTestRunEnd({
2322
+ duration: {
2323
+ totalTime: testTime,
2324
+ buildTime: 0,
2325
+ testTime,
2326
+ },
2327
+ filterRerunTestPaths: files.map((file) => file.testPath),
2328
+ unhandledErrors: rerunError
2329
+ ? [rerunError]
2330
+ : rerunFatalError
2331
+ ? [rerunFatalError]
2332
+ : undefined,
2333
+ });
2334
+ }
2335
+ },
2336
+ onError: async (error) => {
2337
+ const formatted = toError(error);
2338
+ await handleFatal({
2339
+ message: formatted.message,
2340
+ stack: formatted.stack,
2341
+ });
2342
+ },
2343
+ onInterrupt: (run) => {
2344
+ logger.debug(
2345
+ `[Headless] Interrupting active run token ${run.token} before scheduling latest rerun`,
2346
+ );
2347
+ },
2348
+ });
2349
+
2350
+ const testStart = Date.now();
2351
+ await runFilesWithPool(allTestFiles);
2352
+ const testTime = Date.now() - testStart;
2353
+
2354
+ if (isWatchMode) {
2355
+ triggerRerun = async () => {
2356
+ const newProjectEntries = await collectProjectEntries(context);
2357
+ const rerunPlan = planWatchRerun({
2358
+ projectEntries: newProjectEntries,
2359
+ previousTestFiles: watchContext.lastTestFiles,
2360
+ affectedTestFiles: watchContext.affectedTestFiles,
2361
+ });
2362
+ watchContext.affectedTestFiles = [];
2363
+
2364
+ if (rerunPlan.filesChanged) {
2365
+ const deletedTestPaths = collectDeletedTestPaths(
2366
+ watchContext.lastTestFiles,
2367
+ rerunPlan.currentTestFiles,
2368
+ );
2369
+ if (deletedTestPaths.length > 0) {
2370
+ context.updateReporterResultState([], [], deletedTestPaths);
2371
+ }
2372
+ watchContext.lastTestFiles = rerunPlan.currentTestFiles;
2373
+ if (rerunPlan.currentTestFiles.length === 0) {
2374
+ await latestRerunScheduler.enqueueLatest([]);
2375
+ logger.log(
2376
+ color.cyan('No browser test files remain after update.\n'),
2377
+ );
2378
+ return;
2379
+ }
2380
+
2381
+ logger.log(
2382
+ color.cyan(
2383
+ `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
2384
+ ),
2385
+ );
2386
+ void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
2387
+ return;
2388
+ }
2389
+
2390
+ if (rerunPlan.affectedTestFiles.length === 0) {
2391
+ logger.log(
2392
+ color.cyan(
2393
+ 'No affected browser test files detected, skipping re-run.\n',
2394
+ ),
2395
+ );
2396
+ return;
2397
+ }
2398
+
2399
+ logger.log(
2400
+ color.cyan(
2401
+ `Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`,
2402
+ ),
2403
+ );
2404
+ void latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
2405
+ };
2406
+ }
2407
+
2408
+ const closeHeadlessRuntime = !isWatchMode
2409
+ ? async () => {
2410
+ sessionRegistry.clear();
2411
+ await destroyBrowserRuntime(runtime);
2412
+ }
2413
+ : undefined;
2414
+
2415
+ if (fatalError) {
2416
+ return failWithError(fatalError, closeHeadlessRuntime);
2417
+ }
2418
+
2419
+ const duration = {
2420
+ totalTime: buildTime + testTime,
2421
+ buildTime,
2422
+ testTime,
2423
+ };
2424
+
2425
+ context.updateReporterResultState(reporterResults, caseResults);
2426
+
2427
+ const isFailure = reporterResults.some(
2428
+ (result: TestFileResult) => result.status === 'fail',
2429
+ );
2430
+ if (isFailure) {
2431
+ ensureProcessExitCode(1);
2432
+ }
2433
+
2434
+ const result = {
2435
+ results: reporterResults,
2436
+ testResults: caseResults,
2437
+ duration,
2438
+ hasFailure: isFailure,
2439
+ getSourcemap: getBrowserSourcemap,
2440
+ resolveSourcemap: resolveBrowserSourcemap,
2441
+ close: skipOnTestRunEnd ? closeHeadlessRuntime : undefined,
2442
+ };
2443
+
2444
+ if (!skipOnTestRunEnd) {
2445
+ try {
2446
+ await notifyTestRunEnd({ duration });
2447
+ } finally {
2448
+ await closeHeadlessRuntime?.();
2449
+ }
2450
+ }
2451
+
2452
+ if (isWatchMode && triggerRerun) {
2453
+ watchContext.hooksEnabled = true;
2454
+ logger.log(
2455
+ color.cyan(
2456
+ '\nWatch mode enabled - will re-run tests on file changes\n',
2457
+ ),
2458
+ );
2459
+ }
2460
+
2461
+ return result;
2462
+ }
2463
+
2464
+ let completedTests = 0;
2465
+
2466
+ // Promise that resolves when all tests complete
2467
+ let resolveAllTests: (() => void) | undefined;
2468
+ const allTestsPromise = new Promise<void>((resolve) => {
2469
+ resolveAllTests = resolve;
2470
+ });
2471
+
2472
+ // Open a container page for user to view (reuse in watch mode)
2473
+ let containerContext: BrowserProviderContext;
2474
+ let containerPage: BrowserProviderPage;
2475
+ let isNewPage = false;
2476
+
2477
+ if (isWatchMode && runtime.containerPage && runtime.containerContext) {
2478
+ containerContext = runtime.containerContext;
2479
+ containerPage = runtime.containerPage;
2480
+ logger.log(color.gray('\n[Watch] Reusing existing container page\n'));
2481
+ } else {
2482
+ isNewPage = true;
2483
+ containerContext = await browser.newContext({
2484
+ viewport: null,
2485
+ });
2486
+ containerPage = await containerContext.newPage();
2487
+
2488
+ // Prevent popup windows from being created
2489
+ containerPage.on('popup', async (popup: BrowserProviderPage) => {
2490
+ await popup.close().catch(() => {});
2491
+ });
2492
+
2493
+ containerContext.on('page', async (page: BrowserProviderPage) => {
2494
+ if (page !== containerPage) {
2495
+ await page.close().catch(() => {});
2496
+ }
2497
+ });
2498
+
2499
+ if (isWatchMode) {
2500
+ runtime.containerPage = containerPage;
2501
+ runtime.containerContext = containerContext;
2502
+ }
2503
+
2504
+ // Forward browser console to terminal
2505
+ containerPage.on('console', (msg) => {
2506
+ const text = msg.text();
2507
+ if (text.startsWith('[Container]') || text.startsWith('[Runner]')) {
2508
+ logger.log(color.gray(`[Browser Console] ${text}`));
2509
+ }
2510
+ });
2511
+ }
2512
+
2513
+ activeContainerPage = containerPage;
2514
+
2515
+ const dispatchRouter = createDispatchRouter();
2516
+
2517
+ // Create RPC methods that can access test state variables
2518
+ const createRpcMethods = (): HostRpcMethods => ({
2519
+ async rerunTest(testFile: string, testNamePattern?: string) {
2520
+ const projectName = context.normalizedConfig.name || 'project';
2521
+ const relativePath = relative(context.rootPath, testFile);
2522
+ const displayPath = `<${projectName}>/${relativePath}`;
2523
+ logger.log(
2524
+ color.cyan(
2525
+ `\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
2526
+ ),
2527
+ );
2528
+ await rpcManager.reloadTestFile(testFile, testNamePattern);
2529
+ },
2530
+ async getTestFiles() {
2531
+ return allTestFiles;
2532
+ },
2533
+ async onTestFileStart(payload: TestFileStartPayload) {
2534
+ await handleTestFileStart(payload);
2535
+ },
2536
+ async onTestCaseResult(payload: TestResult) {
2537
+ await handleTestCaseResult(payload);
2538
+ },
2539
+ async onTestFileComplete(payload: TestFileResult) {
2540
+ await handleTestFileComplete(payload);
2541
+
2542
+ completedTests++;
2543
+ if (completedTests >= allTestFiles.length && resolveAllTests) {
2544
+ resolveAllTests();
2545
+ }
2546
+ },
2547
+ async onLog(payload: LogPayload) {
2548
+ await handleLog(payload);
2549
+ },
2550
+ async onFatal(payload: FatalPayload) {
2551
+ await handleFatal(payload);
2552
+ if (resolveAllTests) {
2553
+ resolveAllTests();
2554
+ }
2555
+ },
2556
+ async dispatch(request: BrowserDispatchRequest) {
2557
+ // Headed/container path now shares the same dispatch contract as headless.
2558
+ return dispatchRouter.dispatch(request);
2559
+ },
1657
2560
  });
1658
2561
 
1659
2562
  // Setup RPC manager
@@ -1678,13 +2581,7 @@ export const runBrowserController = async (
1678
2581
 
1679
2582
  // Only navigate on first creation
1680
2583
  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
- }
2584
+ const pagePath = '/';
1688
2585
  await containerPage.goto(`http://localhost:${port}${pagePath}`, {
1689
2586
  waitUntil: 'load',
1690
2587
  });
@@ -1729,63 +2626,88 @@ export const runBrowserController = async (
1729
2626
  if (isWatchMode) {
1730
2627
  triggerRerun = async () => {
1731
2628
  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
- );
2629
+ const rerunPlan = planWatchRerun({
2630
+ projectEntries: newProjectEntries,
2631
+ previousTestFiles: watchContext.lastTestFiles,
2632
+ affectedTestFiles: watchContext.affectedTestFiles,
2633
+ });
2634
+ watchContext.affectedTestFiles = [];
1740
2635
 
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(),
2636
+ if (rerunPlan.filesChanged) {
2637
+ const deletedTestPaths = collectDeletedTestPaths(
2638
+ watchContext.lastTestFiles,
2639
+ rerunPlan.currentTestFiles,
1745
2640
  );
1746
-
1747
- const filesChanged =
1748
- serialize(currentTestFiles) !== serialize(watchContext.lastTestFiles);
1749
-
1750
- if (filesChanged) {
1751
- watchContext.lastTestFiles = currentTestFiles;
1752
- await rpcManager.notifyTestFileUpdate(currentTestFiles);
2641
+ if (deletedTestPaths.length > 0) {
2642
+ context.updateReporterResultState([], [], deletedTestPaths);
2643
+ }
2644
+ watchContext.lastTestFiles = rerunPlan.currentTestFiles;
2645
+ await rpcManager.notifyTestFileUpdate(rerunPlan.currentTestFiles);
1753
2646
  }
1754
2647
 
1755
- const affectedFiles = watchContext.affectedTestFiles;
1756
- watchContext.affectedTestFiles = [];
1757
-
1758
- if (affectedFiles.length > 0) {
2648
+ if (rerunPlan.normalizedAffectedTestFiles.length > 0) {
1759
2649
  logger.log(
1760
2650
  color.cyan(
1761
- `Re-running ${affectedFiles.length} affected test file(s)...\n`,
2651
+ `Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`,
1762
2652
  ),
1763
2653
  );
1764
- for (const testFile of affectedFiles) {
1765
- await rpcManager.reloadTestFile(testFile);
2654
+ await notifyTestRunStart();
2655
+
2656
+ const rerunStartTime = Date.now();
2657
+ const fatalErrorBeforeRun = fatalError;
2658
+ let rerunError: Error | undefined;
2659
+
2660
+ try {
2661
+ for (const testFile of rerunPlan.normalizedAffectedTestFiles) {
2662
+ await rpcManager.reloadTestFile(testFile);
2663
+ }
2664
+ } catch (error) {
2665
+ rerunError = toError(error);
2666
+ throw error;
2667
+ } finally {
2668
+ const testTime = Math.max(0, Date.now() - rerunStartTime);
2669
+ const rerunFatalError =
2670
+ fatalError && fatalError !== fatalErrorBeforeRun
2671
+ ? fatalError
2672
+ : undefined;
2673
+ await notifyTestRunEnd({
2674
+ duration: {
2675
+ totalTime: testTime,
2676
+ buildTime: 0,
2677
+ testTime,
2678
+ },
2679
+ filterRerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
2680
+ unhandledErrors: rerunError
2681
+ ? [rerunError]
2682
+ : rerunFatalError
2683
+ ? [rerunFatalError]
2684
+ : undefined,
2685
+ });
1766
2686
  }
1767
- } else if (!filesChanged) {
2687
+ } else if (!rerunPlan.filesChanged) {
1768
2688
  logger.log(color.cyan('Tests will be re-executed automatically\n'));
1769
2689
  }
1770
2690
  };
1771
2691
  }
1772
2692
 
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
- }
2693
+ const closeContainerRuntime = !isWatchMode
2694
+ ? async () => {
2695
+ try {
2696
+ await containerPage.close();
2697
+ } catch {
2698
+ // ignore
2699
+ }
2700
+ try {
2701
+ await containerContext.close();
2702
+ } catch {
2703
+ // ignore
2704
+ }
2705
+ await destroyBrowserRuntime(runtime);
2706
+ }
2707
+ : undefined;
1786
2708
 
1787
2709
  if (fatalError) {
1788
- return failWithError(fatalError);
2710
+ return failWithError(fatalError, closeContainerRuntime);
1789
2711
  }
1790
2712
 
1791
2713
  const duration = {
@@ -1803,23 +2725,21 @@ export const runBrowserController = async (
1803
2725
  ensureProcessExitCode(1);
1804
2726
  }
1805
2727
 
1806
- const result: BrowserTestRunResult = {
2728
+ const result = {
1807
2729
  results: reporterResults,
1808
2730
  testResults: caseResults,
1809
2731
  duration,
1810
2732
  hasFailure: isFailure,
2733
+ getSourcemap: getBrowserSourcemap,
2734
+ resolveSourcemap: resolveBrowserSourcemap,
2735
+ close: skipOnTestRunEnd ? closeContainerRuntime : undefined,
1811
2736
  };
1812
2737
 
1813
- // Only call onTestRunEnd if not skipped (for unified reporter output)
1814
2738
  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
- });
2739
+ try {
2740
+ await notifyTestRunEnd({ duration });
2741
+ } finally {
2742
+ await closeContainerRuntime?.();
1823
2743
  }
1824
2744
  }
1825
2745
 
@@ -1887,6 +2807,7 @@ export const listBrowserTests = async (
1887
2807
  manifestPath,
1888
2808
  entries: projectEntries,
1889
2809
  });
2810
+ const browserProjects = getBrowserProjects(context);
1890
2811
 
1891
2812
  // Create a simplified browser runtime for collect mode
1892
2813
  let runtime: BrowserRuntime;
@@ -1902,9 +2823,14 @@ export const listBrowserTests = async (
1902
2823
  forceHeadless: true, // Always use headless for list command
1903
2824
  });
1904
2825
  } catch (error) {
2826
+ const providers = [
2827
+ ...new Set(
2828
+ browserProjects.map((p) => p.normalizedConfig.browser.provider),
2829
+ ),
2830
+ ];
1905
2831
  logger.error(
1906
2832
  color.red(
1907
- 'Failed to load Playwright. Please install "playwright" to use browser mode.',
2833
+ `Failed to initialize browser provider runtime (${providers.join(', ')}).`,
1908
2834
  ),
1909
2835
  error,
1910
2836
  );
@@ -1915,7 +2841,6 @@ export const listBrowserTests = async (
1915
2841
 
1916
2842
  // Get browser projects for runtime config
1917
2843
  // Normalize projectRoot to posix format for cross-platform compatibility
1918
- const browserProjects = getBrowserProjects(context);
1919
2844
  const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map(
1920
2845
  (project: ProjectContext) => ({
1921
2846
  name: project.name,
@@ -1961,7 +2886,7 @@ export const listBrowserTests = async (
1961
2886
 
1962
2887
  // Expose dispatch function for browser client to send messages
1963
2888
  await page.exposeFunction(
1964
- '__rstest_dispatch__',
2889
+ DISPATCH_MESSAGE_TYPE,
1965
2890
  (message: { type: string; payload?: unknown }) => {
1966
2891
  switch (message.type) {
1967
2892
  case 'collect-result': {