@rstest/browser 0.10.5 → 0.11.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.
@@ -101,15 +101,6 @@ type RsbuildInstance = rsbuild.RsbuildInstance;
101
101
  const __dirname = dirname(fileURLToPath(import.meta.url));
102
102
  const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
103
103
 
104
- /**
105
- * Extra time added on top of a file's `testTimeout` before the host gives up
106
- * waiting for that file's RPC to settle. Covers fixed per-file overhead (page
107
- * navigation, runner boot) that is not part of the user's test budget. The
108
- * headless and headed scheduling paths both apply this same buffer, so it lives
109
- * here as one constant to keep their per-file timeout semantics identical.
110
- */
111
- const PER_FILE_TIMEOUT_BUFFER_MS = 30_000;
112
-
113
104
  /**
114
105
  * Monotonic counter for synthetic per-file Perfetto `pid` values in `--trace`
115
106
  * mode. Browser host runs every test file inside the same Node process, so
@@ -138,10 +129,6 @@ const serializeForInlineScript = (value: unknown): string => {
138
129
  // Type Definitions
139
130
  // ============================================================================
140
131
 
141
- type VirtualModulesPluginInstance = InstanceType<
142
- (typeof rspack.experiments)['VirtualModulesPlugin']
143
- >;
144
-
145
132
  type BrowserProjectEntries = {
146
133
  project: ProjectContext;
147
134
  setupFiles: string[];
@@ -415,16 +402,30 @@ class ContainerRpcManager {
415
402
  // Browser Runtime - Core runtime state
416
403
  // ============================================================================
417
404
 
418
- type BrowserRuntime = {
405
+ // One isolated rsbuild instance + dev server per browser project. Physical
406
+ // isolation (separate compiler, dev server, port, virtual manifest, runner)
407
+ // prevents one project's build/resolve config from leaking into another's
408
+ // compilation and avoids shared-dev-server races (chunk filenames,
409
+ // lazyCompilation backend) between projects.
410
+ type BrowserProjectServer = {
411
+ projectName: string;
412
+ environmentName: string;
419
413
  rsbuildInstance: RsbuildInstance;
420
414
  devServer: RsbuildDevServer;
415
+ port: number;
416
+ manifestPath: string;
417
+ };
418
+
419
+ type BrowserRuntime = {
420
+ // Per-project servers, keyed by project name.
421
+ projectServers: Map<string, BrowserProjectServer>;
422
+ // The server that hosts the container UI HTML (headed mode). The WebSocket
423
+ // server below is shared and reachable from any origin.
424
+ containerServer: BrowserProjectServer;
421
425
  browser: BrowserProviderBrowser;
422
426
  browserLaunchOptions: BrowserLaunchOptions;
423
- port: number;
424
427
  wsPort: number;
425
- manifestPath: string;
426
428
  tempDir: string;
427
- manifestPlugin: VirtualModulesPluginInstance;
428
429
  containerPage?: BrowserProviderPage;
429
430
  containerContext?: BrowserProviderContext;
430
431
  setContainerOptions: (options: BrowserHostConfig) => void;
@@ -600,8 +601,24 @@ export const createBrowserLazyCompilationConfig = (
600
601
  };
601
602
  };
602
603
 
604
+ /**
605
+ * HMR — and the lazyCompilation transport it carries — is wired only for headed
606
+ * watch, the sole path that reuses a persistent page and applies module updates
607
+ * in place. Headless always loads each test file in a fresh page (pulling the
608
+ * latest incrementally-built chunks over HTTP), and one-shot runs never rerun,
609
+ * so pushing HMR updates there is dead weight that only races factory
610
+ * registration for chunk-split node_modules (rspack#11922) and lets
611
+ * lazyCompilation's accept-chain walk abort the next spec when no boundary
612
+ * exists (#1472). Disabling HMR does not make watch rebuilds any less
613
+ * incremental — HMR is only the client push transport.
614
+ */
615
+ export const shouldEnableBrowserHmr = (
616
+ isWatchMode: boolean,
617
+ isHeadless: boolean,
618
+ ): boolean => isWatchMode && !isHeadless;
619
+
603
620
  export const createBrowserRsbuildDevConfig = (
604
- _isWatchMode: boolean,
621
+ enableHmr: boolean,
605
622
  ): {
606
623
  writeToDisk: boolean;
607
624
  hmr: boolean;
@@ -611,9 +628,10 @@ export const createBrowserRsbuildDevConfig = (
611
628
  } => {
612
629
  return {
613
630
  writeToDisk: isDebug(),
614
- // Keep HMR enabled in browser mode even for one-shot runs.
615
- // lazyCompilation depends on HMR runtime wiring for async import chains.
616
- hmr: true,
631
+ // `enableHmr` is gated to headed watch by `shouldEnableBrowserHmr` — the one
632
+ // path that reuses a page. See that helper for why fresh-page runs (headless,
633
+ // or any one-shot) must not receive HMR pushes.
634
+ hmr: enableHmr,
617
635
  client: {
618
636
  logLevel: 'error' as const,
619
637
  },
@@ -629,6 +647,7 @@ const globToRegexp = (glob: string): RegExp => {
629
647
  fastpaths: false,
630
648
  noglobstar: false,
631
649
  bash: false,
650
+ dot: true,
632
651
  });
633
652
 
634
653
  if (!regex) {
@@ -676,44 +695,189 @@ const globPatternsToRegExp = (patterns: string[]): RegExp => {
676
695
  return new RegExp(`(?:${regexParts.join('|')})$`);
677
696
  };
678
697
 
698
+ const REGEXP_SPECIAL_CHARACTERS = /[|\\{}()[\]^$+*?.]/g;
699
+ const PATH_SEPARATOR_SOURCE = String.raw`[\\/]`;
700
+ const WINDOWS_ABSOLUTE_PATH_SOURCE = String.raw`[A-Za-z]:[\\/]`;
701
+
702
+ const escapeRegExp = (value: string): string =>
703
+ value.replace(REGEXP_SPECIAL_CHARACTERS, '\\$&');
704
+
705
+ const normalizePathForRegExp = (value: string): string =>
706
+ normalize(value).replaceAll('\\', '/');
707
+
708
+ const normalizeExcludePatternForRegExp = (value: string): string =>
709
+ value.startsWith('./')
710
+ ? `./${normalizePathForRegExp(value.substring(2))}`
711
+ : normalizePathForRegExp(value);
712
+
713
+ const isAbsolutePatternForRegExp = (value: string): boolean =>
714
+ value.startsWith('/') || /^[A-Za-z]:\//.test(value);
715
+
716
+ const isEscapedRegExpCharacter = (source: string, index: number): boolean => {
717
+ let backslashCount = 0;
718
+ for (
719
+ let current = index - 1;
720
+ current >= 0 && source[current] === '\\';
721
+ current--
722
+ ) {
723
+ backslashCount++;
724
+ }
725
+ return backslashCount % 2 === 1;
726
+ };
727
+
728
+ const replacePathSeparatorsInRegExpSource = (source: string): string => {
729
+ let result = '';
730
+ let inCharacterClass = false;
731
+
732
+ for (let index = 0; index < source.length; index++) {
733
+ const character = source[index];
734
+ const isEscaped = isEscapedRegExpCharacter(source, index);
735
+
736
+ if (character === '[' && !isEscaped) {
737
+ inCharacterClass = true;
738
+ }
739
+
740
+ if (!inCharacterClass && character === '\\' && source[index + 1] === '/') {
741
+ result += PATH_SEPARATOR_SOURCE;
742
+ index++;
743
+ continue;
744
+ }
745
+
746
+ result += character;
747
+
748
+ if (character === ']' && !isEscaped) {
749
+ inCharacterClass = false;
750
+ }
751
+ }
752
+
753
+ return result;
754
+ };
755
+
756
+ type BrowserContextExcludeSource = {
757
+ relative: string;
758
+ absolute: string;
759
+ isAbsolute: boolean;
760
+ };
761
+
762
+ const createRelativeContextExcludeSource = (
763
+ source: string,
764
+ normalizedPattern: string,
765
+ ): string => {
766
+ if (normalizedPattern.startsWith('./')) {
767
+ return source;
768
+ }
769
+
770
+ return normalizedPattern.startsWith('**/')
771
+ ? `(?:(?:${source})|\\.(?:${source}))`
772
+ : `(?:(?:${source})|\\.${PATH_SEPARATOR_SOURCE}(?:${source}))`;
773
+ };
774
+
775
+ const createProjectAbsoluteExcludeSource = (
776
+ source: string,
777
+ normalizedPattern: string,
778
+ ): string =>
779
+ normalizedPattern.startsWith('./') || normalizedPattern.startsWith('**/')
780
+ ? source
781
+ : `${PATH_SEPARATOR_SOURCE}(?:${source})`;
782
+
679
783
  /**
680
784
  * Convert exclude patterns to a RegExp for import.meta.webpackContext's exclude option
681
785
  * This is used at compile time to filter out files during bundling
682
786
  *
683
787
  * Example:
684
788
  * Input: ['**\/node_modules\/**', '**\/dist\/**']
685
- * Output: /[\\/](node_modules|dist)[\\/]/
789
+ * Output: a regexp matching node_modules or dist path segments.
686
790
  */
687
- const excludePatternsToRegExp = (patterns: string[]): RegExp | null => {
688
- const keywords: string[] = [];
689
- for (const pattern of patterns) {
690
- // Extract the core part between ** wildcards
691
- // e.g., '**/node_modules/**' -> 'node_modules'
692
- // e.g., '**/dist/**' -> 'dist'
693
- // e.g., '**/.{idea,git,cache,output,temp}/**' -> extract each part
694
- const match = pattern.match(
695
- /\*\*\/\.?\{?([^/*{}]+(?:,[^/*{}]+)*)\}?\/?\*?\*?/,
696
- );
697
- if (match) {
698
- // Handle {a,b,c} patterns
699
- const parts = match[1]!.split(',');
700
- for (const part of parts) {
701
- // Clean up the part (remove leading dots for hidden dirs)
702
- const cleaned = part.replace(/^\./, '');
703
- if (cleaned && !keywords.includes(cleaned)) {
704
- keywords.push(cleaned);
705
- }
706
- }
791
+ const excludePatternsToRegExpSources = (
792
+ patterns: string[],
793
+ ): BrowserContextExcludeSource[] | null => {
794
+ const sources = patterns.map((pattern) => {
795
+ const normalizedPattern = normalizeExcludePatternForRegExp(pattern);
796
+ const regex = globToRegexp(normalizedPattern);
797
+ let source = regex.source;
798
+ if (source.startsWith('^')) {
799
+ source = source.substring(1);
707
800
  }
801
+ if (source.endsWith('$')) {
802
+ source = source.substring(0, source.length - 1);
803
+ }
804
+
805
+ source = replacePathSeparatorsInRegExpSource(source);
806
+ const isAbsolute = isAbsolutePatternForRegExp(normalizedPattern);
807
+ const absolute = normalizedPattern.startsWith('./')
808
+ ? source.substring(2)
809
+ : source;
810
+
811
+ return {
812
+ relative: isAbsolute
813
+ ? source
814
+ : createRelativeContextExcludeSource(source, normalizedPattern),
815
+ absolute: isAbsolute
816
+ ? absolute
817
+ : createProjectAbsoluteExcludeSource(absolute, normalizedPattern),
818
+ isAbsolute,
819
+ };
820
+ });
821
+
822
+ if (sources.length === 0) {
823
+ return null;
708
824
  }
709
825
 
710
- if (keywords.length === 0) {
826
+ return sources;
827
+ };
828
+
829
+ export const createBrowserContextExcludeRegExp = (
830
+ patterns: string[],
831
+ projectRoot: string,
832
+ ): RegExp | null => {
833
+ const excludeSources = excludePatternsToRegExpSources(patterns);
834
+ if (!excludeSources) {
711
835
  return null;
712
836
  }
713
837
 
714
- // Create regex that matches paths containing these directory names
715
- // Use [\\/] to match both forward and back slashes
716
- return new RegExp(`[\\\\/](${keywords.join('|')})[\\\\/]`);
838
+ const normalizedProjectRoot = normalizePathForRegExp(projectRoot).replace(
839
+ /[\\/]$/,
840
+ '',
841
+ );
842
+ const projectRootSource = normalizedProjectRoot
843
+ .split('/')
844
+ .map(escapeRegExp)
845
+ .join(PATH_SEPARATOR_SOURCE);
846
+ const relativeExcludeSources = excludeSources.filter(
847
+ (source) => !source.isAbsolute,
848
+ );
849
+ const absoluteExcludeSources = excludeSources.filter(
850
+ (source) => source.isAbsolute,
851
+ );
852
+ const sourceBranches: string[] = [];
853
+
854
+ if (relativeExcludeSources.length > 0) {
855
+ const relativePatternSource = `(?:${relativeExcludeSources
856
+ .map((source) => source.relative)
857
+ .join('|')})`;
858
+ const absolutePatternSource = `(?:${relativeExcludeSources
859
+ .map((source) => source.absolute)
860
+ .join('|')})`;
861
+ const relativeSource = `(?:${relativePatternSource})`;
862
+ const absoluteSource = normalizedProjectRoot
863
+ ? `${projectRootSource}(?=${PATH_SEPARATOR_SOURCE})(?:${absolutePatternSource})`
864
+ : `(?:${absolutePatternSource})`;
865
+
866
+ sourceBranches.push(
867
+ `(?!${WINDOWS_ABSOLUTE_PATH_SOURCE}|${PATH_SEPARATOR_SOURCE})${relativeSource}`,
868
+ absoluteSource,
869
+ );
870
+ }
871
+
872
+ if (absoluteExcludeSources.length > 0) {
873
+ sourceBranches.push(
874
+ `(?:${absoluteExcludeSources
875
+ .map((source) => source.relative)
876
+ .join('|')})`,
877
+ );
878
+ }
879
+
880
+ return new RegExp(`^(?:${sourceBranches.join('|')})$`);
717
881
  };
718
882
 
719
883
  type StatsModule = {
@@ -909,17 +1073,18 @@ const ensureConsistentBrowserLaunchOptions = (
909
1073
 
910
1074
  for (const project of projects.slice(1)) {
911
1075
  const options = getBrowserLaunchOptions(project);
1076
+ // Each browser project now runs on its own rsbuild dev server, so ports may
1077
+ // differ per project. Only the shared single Playwright browser forces
1078
+ // provider/browser/headless/providerOptions to match across projects.
912
1079
  if (
913
1080
  options.provider !== firstOptions.provider ||
914
1081
  options.browser !== firstOptions.browser ||
915
1082
  options.headless !== firstOptions.headless ||
916
- options.port !== firstOptions.port ||
917
- options.strictPort !== firstOptions.strictPort ||
918
1083
  !isDeepStrictEqual(options.providerOptions, firstOptions.providerOptions)
919
1084
  ) {
920
1085
  throw new Error(
921
1086
  `Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
922
- 'All browser-enabled projects in one run must share provider/browser/headless/port/strictPort/providerOptions.',
1087
+ 'All browser-enabled projects in one run must share provider/browser/headless/providerOptions.',
923
1088
  );
924
1089
  }
925
1090
  }
@@ -1027,12 +1192,38 @@ const toSafeVarName = (name: string): string => {
1027
1192
  return name.replace(/[^a-zA-Z0-9_]/g, '_');
1028
1193
  };
1029
1194
 
1195
+ // Host-side mirror of the browser runtime's `toContextKey` (client/entry.ts):
1196
+ // `./<path-relative-to-project-root>` with forward slashes. The runtime derives
1197
+ // the same key from the target test file, so the non-watch import map below must
1198
+ // key by the identical form for `loadTest(key)` to resolve.
1199
+ export const toContextKey = (
1200
+ filePath: string,
1201
+ projectRootPosix: string,
1202
+ ): string => {
1203
+ const posixPath = normalize(filePath);
1204
+ // Only strip the root at a path boundary: a bare `startsWith` would mangle a
1205
+ // sibling like `/repo/pkg-extra/a.test.ts` under root `/repo/pkg`.
1206
+ const withinRoot =
1207
+ posixPath === projectRootPosix ||
1208
+ posixPath.startsWith(`${projectRootPosix}/`);
1209
+ if (!withinRoot) {
1210
+ // Test file outside the project root: use the absolute path as the key so
1211
+ // the runtime `toAbsolutePath` can round-trip it. A `./`-prefixed relative
1212
+ // key would be re-rooted under projectRoot and point at a nonexistent file.
1213
+ return posixPath;
1214
+ }
1215
+ const rel = posixPath.slice(projectRootPosix.length);
1216
+ return rel.startsWith('/') ? `.${rel}` : `./${rel}`;
1217
+ };
1218
+
1030
1219
  const generateManifestModule = ({
1031
1220
  manifestPath,
1032
1221
  entries,
1222
+ isWatchMode,
1033
1223
  }: {
1034
1224
  manifestPath: string;
1035
1225
  entries: BrowserProjectEntries[];
1226
+ isWatchMode: boolean;
1036
1227
  }): string => {
1037
1228
  const manifestDirPosix = normalize(dirname(manifestPath));
1038
1229
 
@@ -1078,27 +1269,56 @@ const generateManifestModule = ({
1078
1269
  lines.push('};');
1079
1270
  lines.push('');
1080
1271
 
1081
- // 3. Test context for each project
1272
+ // 3. Test context for each project. Both branches expose the same shape as a
1273
+ // webpackContext (callable by key, plus `keys()`), consumed in section 4.
1082
1274
  lines.push('// Test context for each project');
1083
- for (const { project } of entries) {
1275
+ for (const { project, testFiles } of entries) {
1084
1276
  const varName = `context_${toSafeVarName(project.environmentName)}`;
1085
1277
  const projectRootPosix = normalize(project.rootPath);
1086
- const includeRegExp = globPatternsToRegExp(
1087
- project.normalizedConfig.include,
1088
- );
1089
- const excludePatterns = project.normalizedConfig.exclude.patterns;
1090
- const excludeRegExp = excludePatternsToRegExp(excludePatterns);
1091
1278
 
1092
- lines.push(
1093
- `const ${varName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
1094
- );
1095
- lines.push(' recursive: true,');
1096
- lines.push(` regExp: ${includeRegExp.toString()},`);
1097
- if (excludeRegExp) {
1098
- lines.push(` exclude: ${excludeRegExp.toString()},`);
1279
+ if (isWatchMode) {
1280
+ // Watch mode keeps the include-glob context so newly added files are
1281
+ // picked up on rebuild via `keys()` without regenerating the manifest.
1282
+ // `mode: 'lazy'` is plain code-splitting (async chunks over HTTP), so it
1283
+ // works with or without lazyCompilation; headed watch additionally layers
1284
+ // lazyCompilation on top to keep the initial build cheap.
1285
+ const includeRegExp = globPatternsToRegExp(
1286
+ project.normalizedConfig.include,
1287
+ );
1288
+ const excludeRegExp = createBrowserContextExcludeRegExp(
1289
+ project.normalizedConfig.exclude.patterns,
1290
+ projectRootPosix,
1291
+ );
1292
+ lines.push(
1293
+ `const ${varName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
1294
+ );
1295
+ lines.push(' recursive: true,');
1296
+ lines.push(` regExp: ${includeRegExp.toString()},`);
1297
+ if (excludeRegExp) {
1298
+ lines.push(` exclude: ${excludeRegExp.toString()},`);
1299
+ }
1300
+ lines.push(" mode: 'lazy',");
1301
+ lines.push('});');
1302
+ } else {
1303
+ // One-shot runs: the file set is fixed and already filtered, so emit an
1304
+ // explicit lazy-import map (one chunk per literal `import()`, like the
1305
+ // setup loaders above). The eager, non-lazyCompilation build then compiles
1306
+ // only the run set instead of every included test file.
1307
+ lines.push(`const ${varName}_modules = {`);
1308
+ for (const filePath of testFiles) {
1309
+ const key = toContextKey(filePath, projectRootPosix);
1310
+ const importPath = toRelativeImport(filePath);
1311
+ lines.push(
1312
+ ` ${JSON.stringify(key)}: () => import(${JSON.stringify(importPath)}),`,
1313
+ );
1314
+ }
1315
+ lines.push('};');
1316
+ lines.push(
1317
+ `const ${varName} = Object.assign((key) => ${varName}_modules[key](), {`,
1318
+ );
1319
+ lines.push(` keys: () => Object.keys(${varName}_modules),`);
1320
+ lines.push('});');
1099
1321
  }
1100
- lines.push(" mode: 'lazy',");
1101
- lines.push('});');
1102
1322
  lines.push('');
1103
1323
  }
1104
1324
 
@@ -1155,6 +1375,27 @@ const VIRTUAL_MANIFEST_FILENAME = 'virtual-manifest.ts';
1155
1375
  // Browser Runtime Lifecycle
1156
1376
  // ============================================================================
1157
1377
 
1378
+ const closeAllProjectServers = (
1379
+ servers: Iterable<BrowserProjectServer>,
1380
+ ): Promise<unknown> =>
1381
+ Promise.allSettled([...servers].map((server) => server.devServer.close()));
1382
+
1383
+ // Copy a proxied fetch Response's status + headers onto the Node response,
1384
+ // dropping content-length (the body is re-sent, so the original length may not
1385
+ // match).
1386
+ const copyProxyResponseHeaders = (
1387
+ response: Response,
1388
+ res: ServerResponse,
1389
+ ): void => {
1390
+ res.statusCode = response.status;
1391
+ response.headers.forEach((value, key) => {
1392
+ if (key.toLowerCase() === 'content-length') {
1393
+ return;
1394
+ }
1395
+ res.setHeader(key, value);
1396
+ });
1397
+ };
1398
+
1158
1399
  const destroyBrowserRuntime = async (
1159
1400
  runtime: BrowserRuntime,
1160
1401
  ): Promise<void> => {
@@ -1163,11 +1404,7 @@ const destroyBrowserRuntime = async (
1163
1404
  } catch {
1164
1405
  // ignore
1165
1406
  }
1166
- try {
1167
- await runtime.devServer?.close?.();
1168
- } catch {
1169
- // ignore
1170
- }
1407
+ await closeAllProjectServers(runtime.projectServers.values());
1171
1408
  try {
1172
1409
  runtime.wss?.close();
1173
1410
  } catch {
@@ -1218,8 +1455,7 @@ const registerWatchCleanup = (): void => {
1218
1455
 
1219
1456
  const createBrowserRuntime = async ({
1220
1457
  context,
1221
- manifestPath,
1222
- manifestSource,
1458
+ projectEntries,
1223
1459
  tempDir,
1224
1460
  isWatchMode,
1225
1461
  onTriggerRerun,
@@ -1228,8 +1464,7 @@ const createBrowserRuntime = async ({
1228
1464
  forceHeadless,
1229
1465
  }: {
1230
1466
  context: RstestContext;
1231
- manifestPath: string;
1232
- manifestSource: string;
1467
+ projectEntries: BrowserProjectEntries[];
1233
1468
  tempDir: string;
1234
1469
  isWatchMode: boolean;
1235
1470
  onTriggerRerun?: () => Promise<void>;
@@ -1238,10 +1473,7 @@ const createBrowserRuntime = async ({
1238
1473
  /** Force headless mode regardless of user config (used for list command) */
1239
1474
  forceHeadless?: boolean;
1240
1475
  }): Promise<BrowserRuntime> => {
1241
- const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin({
1242
- [manifestPath]: manifestSource,
1243
- });
1244
-
1476
+ // ---- Shared singletons (created once, wired into every project server) ----
1245
1477
  const containerHtmlTemplate = containerDistPath
1246
1478
  ? await fs.readFile(join(containerDistPath, 'index.html'), 'utf-8')
1247
1479
  : null;
@@ -1262,12 +1494,6 @@ const createBrowserRuntime = async ({
1262
1494
  };
1263
1495
 
1264
1496
  const browserProjects = getBrowserProjects(context);
1265
- const projectByEnvironmentName = new Map(
1266
- browserProjects.map((project) => [project.environmentName, project]),
1267
- );
1268
- const userPlugins = browserProjects.flatMap(
1269
- (project) => project.normalizedConfig.plugins || [],
1270
- );
1271
1497
  const browserLaunchOptions =
1272
1498
  ensureConsistentBrowserLaunchOptions(browserProjects);
1273
1499
 
@@ -1276,8 +1502,9 @@ const createBrowserRuntime = async ({
1276
1502
  import.meta.resolve('@rstest/core/internal/browser-runtime'),
1277
1503
  );
1278
1504
 
1279
- const rstestInternalAliases = {
1280
- '@rstest/browser-manifest': manifestPath,
1505
+ // Shared by every project — only the per-project `@rstest/browser-manifest`
1506
+ // alias varies (one virtual manifest per server).
1507
+ const staticRstestAliases = {
1281
1508
  // User test code: import { describe, it } from '@rstest/core'
1282
1509
  '@rstest/core': resolveBrowserFile('client/public.ts'),
1283
1510
  // User test code: import { page } from '@rstest/browser'
@@ -1285,228 +1512,17 @@ const createBrowserRuntime = async ({
1285
1512
  // Browser runtime APIs for entry.ts and public.ts
1286
1513
  // Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
1287
1514
  '@rstest/core/internal/browser-runtime': browserRuntimePath,
1288
- '@sinonjs/fake-timers': resolveBrowserFile('client/fakeTimersStub.ts'),
1289
1515
  };
1290
1516
 
1291
- const rsbuildInstance = await createRsbuild({
1292
- callerName: 'rstest-browser',
1293
- rsbuildConfig: {
1294
- root: context.rootPath,
1295
- mode: 'development',
1296
- plugins: userPlugins,
1297
- server: {
1298
- printUrls: false,
1299
- port: browserLaunchOptions.port ?? 4000,
1300
- strictPort: browserLaunchOptions.strictPort,
1301
- },
1302
- dev: createBrowserRsbuildDevConfig(isWatchMode),
1303
- environments: {
1304
- ...Object.fromEntries(
1305
- browserProjects.map((project) => [project.environmentName, {}]),
1306
- ),
1307
- },
1308
- },
1309
- });
1310
-
1311
- // Add plugin to merge user Rsbuild config with rstest required config
1312
- rsbuildInstance.addPlugins([
1313
- {
1314
- name: 'rstest:browser-user-config',
1315
- setup(api) {
1316
- // Internal extension entry: register host dispatch handlers without
1317
- // coupling scheduling to individual capability implementations.
1318
- (api as { expose?: (name: string, value: unknown) => void }).expose?.(
1319
- 'rstest:browser',
1320
- {
1321
- registerDispatchHandler: (
1322
- namespace: string,
1323
- handler: BrowserDispatchHandler,
1324
- ) => {
1325
- dispatchHandlers.set(namespace, handler);
1326
- },
1327
- },
1328
- );
1329
-
1330
- api.modifyEnvironmentConfig({
1331
- handler: (config, { mergeEnvironmentConfig, name }) => {
1332
- const project = projectByEnvironmentName.get(name);
1333
- if (!project) {
1334
- return config;
1335
- }
1517
+ // rspack `define` replaces `process.env` / `import.meta.env` with this literal
1518
+ // expression. JSON.stringify reproduces the exact double-quoted `"rstest.env"`
1519
+ // text, so the owned key can never drift from the runtime
1520
+ // `Symbol.for(RSTEST_ENV_SYMBOL_KEY)` sites.
1521
+ const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(
1522
+ RSTEST_ENV_SYMBOL_KEY,
1523
+ )})]`;
1336
1524
 
1337
- const userRsbuildConfig = project.normalizedConfig;
1338
- const buildCache = resolveProjectBuildCache({
1339
- context,
1340
- project,
1341
- });
1342
- const setupFiles = Object.values(
1343
- getSetupFiles(
1344
- project.normalizedConfig.setupFiles,
1345
- project.rootPath,
1346
- ),
1347
- );
1348
- // rspack `define` replaces `process.env` / `import.meta.env` with
1349
- // this literal expression. JSON.stringify reproduces the exact
1350
- // double-quoted `"rstest.env"` text, so the owned key can never
1351
- // drift from the runtime `Symbol.for(RSTEST_ENV_SYMBOL_KEY)` sites.
1352
- const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(
1353
- RSTEST_ENV_SYMBOL_KEY,
1354
- )})]`;
1355
- // Merge order: current config -> userConfig -> rstest required config (highest priority)
1356
- const merged = mergeEnvironmentConfig(
1357
- config,
1358
- {
1359
- ...userRsbuildConfig,
1360
- performance: buildCache
1361
- ? {
1362
- ...userRsbuildConfig.performance,
1363
- buildCache,
1364
- }
1365
- : userRsbuildConfig.performance,
1366
- },
1367
- {
1368
- resolve: {
1369
- alias: rstestInternalAliases,
1370
- },
1371
- source: {
1372
- define: {
1373
- 'process.env': rstestEnvDefine,
1374
- 'import.meta.env': rstestEnvDefine,
1375
- },
1376
- },
1377
- output: {
1378
- target: 'web',
1379
- // Enable source map for inline snapshot support
1380
- sourceMap: {
1381
- js: 'source-map',
1382
- },
1383
- },
1384
- tools: {
1385
- rspack: (rspackConfig) => {
1386
- rspackConfig.mode = 'development';
1387
- rspackConfig.lazyCompilation =
1388
- createBrowserLazyCompilationConfig(setupFiles);
1389
- rspackConfig.plugins = rspackConfig.plugins || [];
1390
- rspackConfig.plugins.push(virtualManifestPlugin);
1391
-
1392
- applyDefaultWatchOptions(rspackConfig, isWatchMode);
1393
-
1394
- // Extract and merge sourcemaps from pre-built @rstest/core files
1395
- // This preserves the sourcemap chain for inline snapshot support
1396
- // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
1397
- const browserRuntimeDir = dirname(browserRuntimePath);
1398
- rspackConfig.module = rspackConfig.module || {};
1399
- rspackConfig.module.rules = rspackConfig.module.rules || [];
1400
- rspackConfig.module.rules.unshift({
1401
- test: /\.js$/,
1402
- include: browserRuntimeDir,
1403
- extractSourceMap: true,
1404
- });
1405
-
1406
- if (isDebug()) {
1407
- logger.log(
1408
- `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
1409
- );
1410
- }
1411
- },
1412
- },
1413
- },
1414
- );
1415
-
1416
- // Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
1417
- // In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
1418
- // This must be done after mergeEnvironmentConfig to ensure highest priority.
1419
- merged.source = merged.source || {};
1420
- merged.source.entry = {
1421
- runner: resolveBrowserFile('client/entry.ts'),
1422
- };
1423
-
1424
- return merged;
1425
- },
1426
- // Execute after all other plugins to ensure rstest's entry config has the highest priority
1427
- order: 'post',
1428
- });
1429
- },
1430
- },
1431
- ]);
1432
-
1433
- // Register watch plugin if in watch mode
1434
- if (isWatchMode && onTriggerRerun) {
1435
- rsbuildInstance.addPlugins([
1436
- {
1437
- name: 'rstest:browser-watch',
1438
- setup(api) {
1439
- api.onBeforeDevCompile(() => {
1440
- if (!watchContext.hooksEnabled) {
1441
- return;
1442
- }
1443
- logger.log(color.cyan('\nFile changed, re-running tests...\n'));
1444
- });
1445
-
1446
- api.onAfterDevCompile(async ({ stats }) => {
1447
- // Collect hashes even during initial build to establish baseline
1448
- if (stats) {
1449
- const projectEntries = await collectProjectEntries(context);
1450
- const entryTestFiles = new Set<string>(
1451
- collectWatchTestFiles(projectEntries).map(
1452
- (file) => file.testPath,
1453
- ),
1454
- );
1455
-
1456
- const statsJson = stats.toJson({ all: true });
1457
- const affected = getAffectedTestFiles(
1458
- statsJson.chunks,
1459
- entryTestFiles,
1460
- );
1461
- watchContext.affectedTestFiles = affected;
1462
-
1463
- if (affected.length > 0) {
1464
- logger.debug(
1465
- `[Watch] Affected test files: ${affected.join(', ')}`,
1466
- );
1467
- }
1468
- }
1469
-
1470
- if (!watchContext.hooksEnabled) {
1471
- return;
1472
- }
1473
-
1474
- await onTriggerRerun();
1475
- });
1476
- },
1477
- },
1478
- ]);
1479
- }
1480
-
1481
- // Register coverage plugin for browser mode
1482
- const coverage = browserProjects.find(
1483
- (project) => project.normalizedConfig.coverage?.enabled,
1484
- )?.normalizedConfig.coverage;
1485
- if (coverage?.enabled && context.command !== 'list') {
1486
- const { pluginCoverage } = await loadCoverageProvider(
1487
- coverage,
1488
- context.rootPath,
1489
- );
1490
- rsbuildInstance.addPlugins([pluginCoverage(coverage)]);
1491
- }
1492
-
1493
- const devServer = await rsbuildInstance.createDevServer({
1494
- getPortSilently: true,
1495
- });
1496
-
1497
- if (isDebug()) {
1498
- await rsbuildInstance.inspectConfig({
1499
- writeToDisk: true,
1500
- extraConfigs: {
1501
- rstest: {
1502
- ...context.normalizedConfig,
1503
- projects: browserProjects.map((p) => p.normalizedConfig),
1504
- },
1505
- },
1506
- });
1507
- }
1508
-
1509
- // Serve prebuilt container assets (SPA) via sirv
1525
+ // Serve prebuilt container assets (SPA) via sirv (container origin only)
1510
1526
  const serveContainer = containerDistPath
1511
1527
  ? sirv(containerDistPath, {
1512
1528
  dev: false,
@@ -1536,13 +1552,7 @@ const createBrowserRuntime = async ({
1536
1552
  let html = await response.text();
1537
1553
  html = html.replace(OPTIONS_PLACEHOLDER, serializedOptions);
1538
1554
 
1539
- res.statusCode = response.status;
1540
- response.headers.forEach((value, key) => {
1541
- if (key.toLowerCase() === 'content-length') {
1542
- return;
1543
- }
1544
- res.setHeader(key, value);
1545
- });
1555
+ copyProxyResponseHeaders(response, res);
1546
1556
  res.setHeader('Content-Type', 'text/html');
1547
1557
  res.end(html);
1548
1558
  return true;
@@ -1570,13 +1580,7 @@ const createBrowserRuntime = async ({
1570
1580
  }
1571
1581
 
1572
1582
  const buffer = Buffer.from(await response.arrayBuffer());
1573
- res.statusCode = response.status;
1574
- response.headers.forEach((value, key) => {
1575
- if (key.toLowerCase() === 'content-length') {
1576
- return;
1577
- }
1578
- res.setHeader(key, value);
1579
- });
1583
+ copyProxyResponseHeaders(response, res);
1580
1584
  res.end(buffer);
1581
1585
  return true;
1582
1586
  } catch (error) {
@@ -1587,75 +1591,366 @@ const createBrowserRuntime = async ({
1587
1591
  }
1588
1592
  };
1589
1593
 
1590
- devServer.middlewares.use(
1591
- async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
1592
- if (!req.url) {
1593
- next();
1594
- return;
1595
- }
1596
- const url = new URL(req.url, 'http://localhost');
1597
- if (url.pathname === '/__open-in-editor') {
1598
- const file = url.searchParams.get('file');
1599
- if (!file) {
1600
- res.statusCode = 400;
1601
- res.end('Missing file');
1594
+ const entryByEnvironmentName = new Map(
1595
+ projectEntries.map((entry) => [entry.project.environmentName, entry]),
1596
+ );
1597
+
1598
+ // ---- Build one isolated rsbuild instance + dev server per project ----
1599
+ const buildProjectServer = async (
1600
+ project: ProjectContext,
1601
+ isContainerServer: boolean,
1602
+ ): Promise<BrowserProjectServer> => {
1603
+ const manifestPath = join(
1604
+ tempDir,
1605
+ toSafeVarName(project.environmentName),
1606
+ VIRTUAL_MANIFEST_FILENAME,
1607
+ );
1608
+ const entry = entryByEnvironmentName.get(project.environmentName);
1609
+ const manifestSource = generateManifestModule({
1610
+ manifestPath,
1611
+ entries: [
1612
+ {
1613
+ project,
1614
+ testFiles: entry?.testFiles ?? [],
1615
+ setupFiles: entry?.setupFiles ?? [],
1616
+ },
1617
+ ],
1618
+ isWatchMode,
1619
+ });
1620
+ const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin({
1621
+ [manifestPath]: manifestSource,
1622
+ });
1623
+
1624
+ const rstestInternalAliases = {
1625
+ '@rstest/browser-manifest': manifestPath,
1626
+ ...staticRstestAliases,
1627
+ };
1628
+
1629
+ const isHeadless =
1630
+ forceHeadless || project.normalizedConfig.browser.headless;
1631
+ const enableHmr = shouldEnableBrowserHmr(isWatchMode, isHeadless);
1632
+
1633
+ const rsbuildInstance = await createRsbuild({
1634
+ callerName: 'rstest-browser',
1635
+ rsbuildConfig: {
1636
+ root: context.rootPath,
1637
+ mode: 'development',
1638
+ plugins: project.normalizedConfig.plugins || [],
1639
+ server: {
1640
+ printUrls: false,
1641
+ // Each project gets its own dev server. Honor an explicitly
1642
+ // configured port; otherwise keep the historical 4000 default for the
1643
+ // container server and let the OS assign free ports for the rest, so
1644
+ // multiple projects never collide on one port.
1645
+ port:
1646
+ project.normalizedConfig.browser.port ??
1647
+ (isContainerServer ? 4000 : 0),
1648
+ strictPort: project.normalizedConfig.browser.strictPort,
1649
+ },
1650
+ dev: createBrowserRsbuildDevConfig(enableHmr),
1651
+ environments: {
1652
+ [project.environmentName]: {},
1653
+ },
1654
+ },
1655
+ });
1656
+
1657
+ // Add plugin to merge user Rsbuild config with rstest required config
1658
+ rsbuildInstance.addPlugins([
1659
+ {
1660
+ name: 'rstest:browser-user-config',
1661
+ setup(api) {
1662
+ // Internal extension entry: register host dispatch handlers without
1663
+ // coupling scheduling to individual capability implementations.
1664
+ (api as { expose?: (name: string, value: unknown) => void }).expose?.(
1665
+ 'rstest:browser',
1666
+ {
1667
+ registerDispatchHandler: (
1668
+ namespace: string,
1669
+ handler: BrowserDispatchHandler,
1670
+ ) => {
1671
+ dispatchHandlers.set(namespace, handler);
1672
+ },
1673
+ },
1674
+ );
1675
+
1676
+ api.modifyEnvironmentConfig({
1677
+ handler: (config, { mergeEnvironmentConfig, name }) => {
1678
+ if (name !== project.environmentName) {
1679
+ return config;
1680
+ }
1681
+
1682
+ const userRsbuildConfig = project.normalizedConfig;
1683
+ const buildCache = resolveProjectBuildCache({
1684
+ context,
1685
+ project,
1686
+ });
1687
+ const setupFiles = Object.values(
1688
+ getSetupFiles(
1689
+ project.normalizedConfig.setupFiles,
1690
+ project.rootPath,
1691
+ ),
1692
+ );
1693
+ // Merge order: current config -> userConfig -> rstest required config (highest priority)
1694
+ const merged = mergeEnvironmentConfig(
1695
+ config,
1696
+ {
1697
+ ...userRsbuildConfig,
1698
+ performance: buildCache
1699
+ ? {
1700
+ ...userRsbuildConfig.performance,
1701
+ buildCache,
1702
+ }
1703
+ : userRsbuildConfig.performance,
1704
+ },
1705
+ {
1706
+ resolve: {
1707
+ alias: rstestInternalAliases,
1708
+ },
1709
+ source: {
1710
+ define: {
1711
+ 'process.env': rstestEnvDefine,
1712
+ 'import.meta.env': rstestEnvDefine,
1713
+ },
1714
+ },
1715
+ output: {
1716
+ target: 'web',
1717
+ // Enable source map for inline snapshot support
1718
+ sourceMap: {
1719
+ js: 'source-map',
1720
+ },
1721
+ },
1722
+ tools: {
1723
+ rspack: (rspackConfig) => {
1724
+ rspackConfig.mode = 'development';
1725
+ // lazyCompilation's only delivery transport is the HMR
1726
+ // runtime, so it follows the same gate as HMR (see
1727
+ // `shouldEnableBrowserHmr`): headed watch only, everything
1728
+ // else compiles eagerly.
1729
+ rspackConfig.lazyCompilation = enableHmr
1730
+ ? createBrowserLazyCompilationConfig(setupFiles)
1731
+ : false;
1732
+ rspackConfig.plugins = rspackConfig.plugins || [];
1733
+ rspackConfig.plugins.push(virtualManifestPlugin);
1734
+
1735
+ applyDefaultWatchOptions(rspackConfig, isWatchMode);
1736
+
1737
+ // Extract and merge sourcemaps from pre-built @rstest/core files
1738
+ // This preserves the sourcemap chain for inline snapshot support
1739
+ // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
1740
+ const browserRuntimeDir = dirname(browserRuntimePath);
1741
+ rspackConfig.module = rspackConfig.module || {};
1742
+ rspackConfig.module.rules =
1743
+ rspackConfig.module.rules || [];
1744
+ rspackConfig.module.rules.unshift({
1745
+ test: /\.js$/,
1746
+ include: browserRuntimeDir,
1747
+ extractSourceMap: true,
1748
+ });
1749
+
1750
+ if (isDebug()) {
1751
+ logger.log(
1752
+ `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
1753
+ );
1754
+ }
1755
+ },
1756
+ },
1757
+ },
1758
+ );
1759
+
1760
+ // Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
1761
+ // In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
1762
+ // This must be done after mergeEnvironmentConfig to ensure highest priority.
1763
+ merged.source = merged.source || {};
1764
+ merged.source.entry = {
1765
+ runner: resolveBrowserFile('client/entry.ts'),
1766
+ };
1767
+
1768
+ return merged;
1769
+ },
1770
+ // Execute after all other plugins to ensure rstest's entry config has the highest priority
1771
+ order: 'post',
1772
+ });
1773
+ },
1774
+ },
1775
+ ]);
1776
+
1777
+ // Register watch plugin if in watch mode
1778
+ if (isWatchMode && onTriggerRerun) {
1779
+ rsbuildInstance.addPlugins([
1780
+ {
1781
+ name: 'rstest:browser-watch',
1782
+ setup(api) {
1783
+ api.onBeforeDevCompile(() => {
1784
+ if (!watchContext.hooksEnabled) {
1785
+ return;
1786
+ }
1787
+ logger.log(color.cyan('\nFile changed, re-running tests...\n'));
1788
+ });
1789
+
1790
+ api.onAfterDevCompile(async ({ stats }) => {
1791
+ // Collect hashes even during initial build to establish baseline
1792
+ if (stats) {
1793
+ const allProjectEntries = await collectProjectEntries(context);
1794
+ const entryTestFiles = new Set<string>(
1795
+ collectWatchTestFiles(allProjectEntries).map(
1796
+ (file) => file.testPath,
1797
+ ),
1798
+ );
1799
+
1800
+ const statsJson = stats.toJson({ all: true });
1801
+ const affected = getAffectedTestFiles(
1802
+ statsJson.chunks,
1803
+ entryTestFiles,
1804
+ );
1805
+ watchContext.affectedTestFiles = affected;
1806
+
1807
+ if (affected.length > 0) {
1808
+ logger.debug(
1809
+ `[Watch] Affected test files: ${affected.join(', ')}`,
1810
+ );
1811
+ }
1812
+ }
1813
+
1814
+ if (!watchContext.hooksEnabled) {
1815
+ return;
1816
+ }
1817
+
1818
+ await onTriggerRerun();
1819
+ });
1820
+ },
1821
+ },
1822
+ ]);
1823
+ }
1824
+
1825
+ // Register coverage plugin if this project enables coverage
1826
+ const coverage = project.normalizedConfig.coverage;
1827
+ if (coverage?.enabled && context.command !== 'list') {
1828
+ const { pluginCoverage } = await loadCoverageProvider(
1829
+ coverage,
1830
+ context.rootPath,
1831
+ );
1832
+ rsbuildInstance.addPlugins([pluginCoverage(coverage)]);
1833
+ }
1834
+
1835
+ const devServer = await rsbuildInstance.createDevServer({
1836
+ getPortSilently: true,
1837
+ });
1838
+
1839
+ if (isDebug()) {
1840
+ await rsbuildInstance.inspectConfig({
1841
+ writeToDisk: true,
1842
+ extraConfigs: {
1843
+ rstest: {
1844
+ ...context.normalizedConfig,
1845
+ projects: [project.normalizedConfig],
1846
+ },
1847
+ },
1848
+ });
1849
+ }
1850
+
1851
+ devServer.middlewares.use(
1852
+ async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
1853
+ if (!req.url) {
1854
+ next();
1602
1855
  return;
1603
1856
  }
1604
- try {
1605
- await openEditor([{ file }]);
1606
- res.statusCode = 204;
1607
- res.end();
1608
- } catch (error) {
1609
- logger.debug(`[Browser UI] Failed to open editor: ${String(error)}`);
1610
- res.statusCode = 500;
1611
- res.end('Failed to open editor');
1612
- }
1613
- return;
1614
- }
1615
- if (url.pathname === '/') {
1616
- if (await respondWithDevServerHtml(url, res)) {
1857
+ const url = new URL(req.url, 'http://localhost');
1858
+ if (url.pathname === '/__open-in-editor') {
1859
+ const file = url.searchParams.get('file');
1860
+ if (!file) {
1861
+ res.statusCode = 400;
1862
+ res.end('Missing file');
1863
+ return;
1864
+ }
1865
+ try {
1866
+ await openEditor([{ file }]);
1867
+ res.statusCode = 204;
1868
+ res.end();
1869
+ } catch (error) {
1870
+ logger.debug(
1871
+ `[Browser UI] Failed to open editor: ${String(error)}`,
1872
+ );
1873
+ res.statusCode = 500;
1874
+ res.end('Failed to open editor');
1875
+ }
1617
1876
  return;
1618
1877
  }
1878
+ // Container UI HTML + static assets are served by the container origin
1879
+ // only. Per-project runner servers expose just /runner.html + assets.
1880
+ if (isContainerServer) {
1881
+ if (url.pathname === '/') {
1882
+ if (await respondWithDevServerHtml(url, res)) {
1883
+ return;
1884
+ }
1885
+
1886
+ const html =
1887
+ injectedContainerHtml ||
1888
+ containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
1889
+
1890
+ if (html) {
1891
+ res.setHeader('Content-Type', 'text/html');
1892
+ res.end(html);
1893
+ return;
1894
+ }
1895
+
1896
+ res.statusCode = 502;
1897
+ res.end('Container UI is not available.');
1898
+ return;
1899
+ }
1900
+ if (url.pathname.startsWith('/container-static/')) {
1901
+ if (await proxyDevServerAsset(req, res)) {
1902
+ return;
1903
+ }
1619
1904
 
1620
- const html =
1621
- injectedContainerHtml ||
1622
- containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
1905
+ if (serveContainer) {
1906
+ serveContainer(req, res, next);
1907
+ return;
1908
+ }
1623
1909
 
1624
- if (html) {
1910
+ res.statusCode = 502;
1911
+ res.end('Container assets are not available.');
1912
+ return;
1913
+ }
1914
+ }
1915
+ if (url.pathname === '/runner.html') {
1625
1916
  res.setHeader('Content-Type', 'text/html');
1626
- res.end(html);
1917
+ res.end(htmlTemplate);
1627
1918
  return;
1628
1919
  }
1920
+ next();
1921
+ },
1922
+ );
1629
1923
 
1630
- res.statusCode = 502;
1631
- res.end('Container UI is not available.');
1632
- return;
1633
- }
1634
- if (url.pathname.startsWith('/container-static/')) {
1635
- if (await proxyDevServerAsset(req, res)) {
1636
- return;
1637
- }
1924
+ const { port: listenPort } = await devServer.listen();
1925
+ const port = resolveListenPort(listenPort, devServer.httpServer);
1638
1926
 
1639
- if (serveContainer) {
1640
- serveContainer(req, res, next);
1641
- return;
1642
- }
1927
+ return {
1928
+ projectName: project.name,
1929
+ environmentName: project.environmentName,
1930
+ rsbuildInstance,
1931
+ devServer,
1932
+ port,
1933
+ manifestPath,
1934
+ };
1935
+ };
1643
1936
 
1644
- res.statusCode = 502;
1645
- res.end('Container assets are not available.');
1646
- return;
1647
- }
1648
- if (url.pathname === '/runner.html') {
1649
- res.setHeader('Content-Type', 'text/html');
1650
- res.end(htmlTemplate);
1651
- return;
1652
- }
1653
- next();
1654
- },
1655
- );
1937
+ // Build each project's server sequentially. Servers must bind ports one at a
1938
+ // time: projects may share a configured port and rely on strictPort:false
1939
+ // bumping to the next free one, which races under concurrent listen().
1940
+ const projectServers = new Map<string, BrowserProjectServer>();
1941
+ try {
1942
+ for (const [index, project] of browserProjects.entries()) {
1943
+ const server = await buildProjectServer(project, index === 0);
1944
+ projectServers.set(server.projectName, server);
1945
+ }
1946
+ } catch (error) {
1947
+ await closeAllProjectServers(projectServers.values());
1948
+ throw error;
1949
+ }
1656
1950
 
1657
- const { port: listenPort } = await devServer.listen();
1658
- const port = resolveListenPort(listenPort, devServer.httpServer);
1951
+ // browserProjects is non-empty (ensureConsistentBrowserLaunchOptions throws
1952
+ // otherwise) and index 0 is the designated container origin.
1953
+ const containerServer = projectServers.get(browserProjects[0]!.name)!;
1659
1954
 
1660
1955
  // Create WebSocket server on an available port
1661
1956
  // Using port: 0 lets the OS assign an available port, avoiding conflicts
@@ -1679,22 +1974,19 @@ const createBrowserRuntime = async ({
1679
1974
  providerOptions: browserLaunchOptions.providerOptions,
1680
1975
  });
1681
1976
  return {
1682
- rsbuildInstance,
1683
- devServer,
1977
+ projectServers,
1978
+ containerServer,
1684
1979
  browser: runtime.browser,
1685
1980
  browserLaunchOptions,
1686
- port,
1687
1981
  wsPort,
1688
- manifestPath,
1689
1982
  tempDir,
1690
- manifestPlugin: virtualManifestPlugin,
1691
1983
  setContainerOptions,
1692
1984
  dispatchHandlers,
1693
1985
  wss,
1694
1986
  };
1695
1987
  } catch (error) {
1696
1988
  wss.close();
1697
- await devServer.close();
1989
+ await closeAllProjectServers(projectServers.values());
1698
1990
  throw error;
1699
1991
  }
1700
1992
  };
@@ -2020,12 +2312,6 @@ export const runBrowserController = async (
2020
2312
  Date.now().toString(),
2021
2313
  );
2022
2314
 
2023
- const manifestPath = join(tempDir, VIRTUAL_MANIFEST_FILENAME);
2024
- const manifestSource = generateManifestModule({
2025
- manifestPath,
2026
- entries: projectEntries,
2027
- });
2028
-
2029
2315
  // Track initial test files for watch mode
2030
2316
  if (isWatchMode) {
2031
2317
  watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
@@ -2040,8 +2326,7 @@ export const runBrowserController = async (
2040
2326
  try {
2041
2327
  runtime = await createBrowserRuntime({
2042
2328
  context,
2043
- manifestPath,
2044
- manifestSource,
2329
+ projectEntries,
2045
2330
  tempDir,
2046
2331
  isWatchMode,
2047
2332
  onTriggerRerun: isWatchMode
@@ -2070,7 +2355,7 @@ export const runBrowserController = async (
2070
2355
  }
2071
2356
  }
2072
2357
 
2073
- const { browser, browserLaunchOptions, port, wsPort, wss } = runtime;
2358
+ const { browser, browserLaunchOptions, wsPort, wss } = runtime;
2074
2359
  const buildTime = Date.now() - buildStart;
2075
2360
 
2076
2361
  // Collect all test files from project entries with project info
@@ -2101,13 +2386,22 @@ export const runBrowserController = async (
2101
2386
  ),
2102
2387
  );
2103
2388
 
2389
+ const projectRunnerUrls = Object.fromEntries(
2390
+ [...runtime.projectServers].map(([name, server]) => [
2391
+ name,
2392
+ `http://localhost:${server.port}`,
2393
+ ]),
2394
+ );
2395
+
2104
2396
  const hostOptions: BrowserHostConfig = {
2105
2397
  rootPath: normalize(context.rootPath),
2106
2398
  projects: projectRuntimeConfigs,
2107
2399
  snapshot: {
2108
2400
  updateSnapshot: context.snapshotManager.options.updateSnapshot,
2109
2401
  },
2110
- runnerUrl: `http://localhost:${port}`,
2402
+ // Container origin (fallback). Per-project runner origins below.
2403
+ runnerUrl: `http://localhost:${runtime.containerServer.port}`,
2404
+ projectRunnerUrls,
2111
2405
  wsPort,
2112
2406
  debug: isDebug(),
2113
2407
  rpcTimeout: maxTestTimeoutForRpc,
@@ -2134,8 +2428,7 @@ export const runBrowserController = async (
2134
2428
 
2135
2429
  let activeContainerPage: BrowserProviderPage | null = null;
2136
2430
  let getHeadlessRunnerPageBySessionId:
2137
- | ((sessionId: string) => BrowserProviderPage | undefined)
2138
- | undefined;
2431
+ ((sessionId: string) => BrowserProviderPage | undefined) | undefined;
2139
2432
 
2140
2433
  const dispatchBrowserRpcRequest = async ({
2141
2434
  request,
@@ -2682,17 +2975,34 @@ export const runBrowserController = async (
2682
2975
  resolveDone = resolve;
2683
2976
  });
2684
2977
 
2685
- const projectRuntime = projectRuntimeConfigs.find(
2686
- (project) => project.name === file.projectName,
2687
- );
2688
- const perFileTimeoutMs =
2689
- (projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) +
2690
- PER_FILE_TIMEOUT_BUFFER_MS;
2691
-
2692
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
2978
+ // Event-driven death detection (vitest-style): a renderer crash or an
2979
+ // unexpected page close produces no further messages, so fail the file at
2980
+ // once. Per-test/hook timeouts are enforced inside the runner, so the host
2981
+ // deliberately keeps no execution-duration watchdog. Our own teardown
2982
+ // close is ignored because `settled`/`run.cancelled` are set by then.
2983
+ const crashDeferred = createDeferredPromise<string>();
2984
+ const onPageDead = (reason: string): void => {
2985
+ if (
2986
+ settled ||
2987
+ run.cancelled ||
2988
+ !runLifecycle.isTokenActive(run.token)
2989
+ ) {
2990
+ return;
2991
+ }
2992
+ settled = true;
2993
+ crashDeferred.resolve(reason);
2994
+ };
2693
2995
 
2694
2996
  try {
2695
2997
  page = await browserContext.newPage();
2998
+ page.on('crash', () =>
2999
+ onPageDead(`Browser page crashed while running ${file.testPath}.`),
3000
+ );
3001
+ page.on('close', () =>
3002
+ onPageDead(
3003
+ `Browser page closed unexpectedly while running ${file.testPath}.`,
3004
+ ),
3005
+ );
2696
3006
 
2697
3007
  const session = sessionRegistry.register({
2698
3008
  testFile: file.testPath,
@@ -2751,32 +3061,35 @@ export const runBrowserController = async (
2751
3061
  `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
2752
3062
  );
2753
3063
 
2754
- await page.goto(`http://localhost:${port}/runner.html`, {
3064
+ const projectServer = runtime.projectServers.get(file.projectName);
3065
+ if (!projectServer) {
3066
+ throw new Error(
3067
+ `No browser dev server for project "${file.projectName}" (test file: ${file.testPath}).`,
3068
+ );
3069
+ }
3070
+ await page.goto(`http://localhost:${projectServer.port}/runner.html`, {
2755
3071
  waitUntil: 'load',
2756
3072
  });
2757
3073
 
2758
- const timeoutPromise = new Promise<'timeout'>((resolve) => {
2759
- timeoutId = setTimeout(() => resolve('timeout'), perFileTimeoutMs);
2760
- });
2761
-
2762
3074
  const state = await Promise.race([
2763
- donePromise.then(() => 'done' as const),
2764
- timeoutPromise,
2765
- run.cancelSignal.then(() => 'cancelled' as const),
3075
+ donePromise.then(() => ({ type: 'done' as const })),
3076
+ crashDeferred.promise.then((reason) => ({
3077
+ type: 'crash' as const,
3078
+ reason,
3079
+ })),
3080
+ run.cancelSignal.then(() => ({ type: 'cancelled' as const })),
2766
3081
  ]);
2767
3082
 
2768
- if (state === 'cancelled') {
3083
+ if (state.type === 'cancelled') {
2769
3084
  return;
2770
3085
  }
2771
3086
 
2772
3087
  if (
2773
- state === 'timeout' &&
3088
+ state.type === 'crash' &&
2774
3089
  runLifecycle.isTokenActive(run.token) &&
2775
3090
  !run.cancelled
2776
3091
  ) {
2777
- await handleFatal({
2778
- message: `Test execution timeout after ${perFileTimeoutMs / 1000}s for ${file.testPath}.`,
2779
- });
3092
+ await handleFatal({ message: state.reason });
2780
3093
  await cancelRun(run, false);
2781
3094
  }
2782
3095
  } catch (error) {
@@ -2789,9 +3102,6 @@ export const runBrowserController = async (
2789
3102
  await cancelRun(run, false);
2790
3103
  }
2791
3104
  } finally {
2792
- if (timeoutId) {
2793
- clearTimeout(timeoutId);
2794
- }
2795
3105
  if (page) {
2796
3106
  try {
2797
3107
  await page.close();
@@ -3173,16 +3483,6 @@ export const runBrowserController = async (
3173
3483
  return fileInfo;
3174
3484
  };
3175
3485
 
3176
- const getHeadedPerFileTimeoutMs = (file: TestFileInfo): number => {
3177
- const projectRuntime = projectRuntimeConfigs.find(
3178
- (project) => project.name === file.projectName,
3179
- );
3180
- return (
3181
- (projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) +
3182
- PER_FILE_TIMEOUT_BUFFER_MS
3183
- );
3184
- };
3185
-
3186
3486
  // Open a container page for user to view (reuse in watch mode)
3187
3487
  let containerContext: BrowserProviderContext;
3188
3488
  let containerPage: BrowserProviderPage;
@@ -3307,12 +3607,13 @@ export const runBrowserController = async (
3307
3607
  pending.deferred.resolve();
3308
3608
  };
3309
3609
 
3310
- const reloadTestFileWithTimeout = async (
3610
+ // No execution-duration watchdog: per-test/hook timeouts are enforced inside
3611
+ // the runner, and a dead container is caught event-driven by the WebSocket
3612
+ // `close` handler, which rejects every pending reload via `onDisconnect`.
3613
+ const reloadTestFileAndWait = async (
3311
3614
  file: TestFileInfo,
3312
3615
  testNamePattern?: string,
3313
3616
  ): Promise<void> => {
3314
- const timeoutMs = getHeadedPerFileTimeoutMs(file);
3315
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
3316
3617
  let reloadAck: ReloadTestFileAck | undefined;
3317
3618
 
3318
3619
  try {
@@ -3320,22 +3621,7 @@ export const runBrowserController = async (
3320
3621
  file.testPath,
3321
3622
  testNamePattern,
3322
3623
  );
3323
- const completionPromise = registerPendingHeadedReload(
3324
- file.testPath,
3325
- reloadAck.runId,
3326
- );
3327
- await Promise.race([
3328
- completionPromise,
3329
- new Promise<never>((_, reject) => {
3330
- timeoutId = setTimeout(() => {
3331
- reject(
3332
- new Error(
3333
- `Headed test execution timeout after ${timeoutMs / 1000}s for ${file.testPath}.`,
3334
- ),
3335
- );
3336
- }, timeoutMs);
3337
- }),
3338
- ]);
3624
+ await registerPendingHeadedReload(file.testPath, reloadAck.runId);
3339
3625
  } catch (error) {
3340
3626
  if (reloadAck?.runId) {
3341
3627
  rejectPendingHeadedReload(
@@ -3345,10 +3631,6 @@ export const runBrowserController = async (
3345
3631
  );
3346
3632
  }
3347
3633
  throw error;
3348
- } finally {
3349
- if (timeoutId) {
3350
- clearTimeout(timeoutId);
3351
- }
3352
3634
  }
3353
3635
  };
3354
3636
 
@@ -3432,13 +3714,14 @@ export const runBrowserController = async (
3432
3714
  // Only navigate on first creation
3433
3715
  if (isNewPage) {
3434
3716
  const pagePath = '/';
3435
- await containerPage.goto(`http://localhost:${port}${pagePath}`, {
3717
+ const containerPort = runtime.containerServer.port;
3718
+ await containerPage.goto(`http://localhost:${containerPort}${pagePath}`, {
3436
3719
  waitUntil: 'load',
3437
3720
  });
3438
3721
 
3439
3722
  logger.log(
3440
3723
  color.cyan(
3441
- `\nBrowser mode opened at http://localhost:${port}${pagePath}\n`,
3724
+ `\nBrowser mode opened at http://localhost:${containerPort}${pagePath}\n`,
3442
3725
  ),
3443
3726
  );
3444
3727
  }
@@ -3451,7 +3734,7 @@ export const runBrowserController = async (
3451
3734
  if (fatalError) {
3452
3735
  return;
3453
3736
  }
3454
- await reloadTestFileWithTimeout(file, testNamePattern);
3737
+ await reloadTestFileAndWait(file, testNamePattern);
3455
3738
  });
3456
3739
  };
3457
3740
 
@@ -3670,11 +3953,6 @@ export const listBrowserTests = async (
3670
3953
  `list-${Date.now()}`,
3671
3954
  );
3672
3955
 
3673
- const manifestPath = join(tempDir, VIRTUAL_MANIFEST_FILENAME);
3674
- const manifestSource = generateManifestModule({
3675
- manifestPath,
3676
- entries: projectEntries,
3677
- });
3678
3956
  const browserProjects = getBrowserProjects(context);
3679
3957
 
3680
3958
  // Create a simplified browser runtime for collect mode
@@ -3682,8 +3960,7 @@ export const listBrowserTests = async (
3682
3960
  try {
3683
3961
  runtime = await createBrowserRuntime({
3684
3962
  context,
3685
- manifestPath,
3686
- manifestSource,
3963
+ projectEntries,
3687
3964
  tempDir,
3688
3965
  isWatchMode: false,
3689
3966
  containerDistPath: undefined,
@@ -3705,7 +3982,7 @@ export const listBrowserTests = async (
3705
3982
  throw error;
3706
3983
  }
3707
3984
 
3708
- const { browser, browserLaunchOptions, port } = runtime;
3985
+ const { browser, browserLaunchOptions } = runtime;
3709
3986
 
3710
3987
  // Get browser projects for runtime config
3711
3988
  // Normalize projectRoot to posix format for cross-platform compatibility
@@ -3739,105 +4016,122 @@ export const listBrowserTests = async (
3739
4016
 
3740
4017
  runtime.setContainerOptions(hostOptions);
3741
4018
 
3742
- // Collect results
3743
- const collectResults: ListCommandResult[] = [];
3744
- let fatalError: Error | null = null;
3745
- let collectCompleted = false;
3746
-
3747
- // Promise that resolves when collection is complete
3748
- let resolveCollect: (() => void) | undefined;
3749
- const collectPromise = new Promise<void>((resolve) => {
3750
- resolveCollect = resolve;
3751
- });
3752
-
3753
- // Create a headless page to run collection
4019
+ // Collect results across every project's isolated dev server. Each server
4020
+ // serves only its own project's manifest, so collection navigates one page
4021
+ // per project; each page returns its own results, aggregated afterwards.
3754
4022
  const browserContext = await browser.newContext({
3755
4023
  providerOptions: browserLaunchOptions.providerOptions,
3756
4024
  viewport: null,
3757
4025
  });
3758
- const page = await browserContext.newPage();
3759
-
3760
- // Expose dispatch function for browser client to send messages
3761
- await page.exposeFunction(
3762
- DISPATCH_MESSAGE_TYPE,
3763
- (message: { type: string; payload?: unknown }) => {
3764
- switch (message.type) {
3765
- case 'collect-result': {
3766
- const payload = message.payload as {
3767
- testPath: string;
3768
- project: string;
3769
- tests: Test[];
3770
- };
3771
- collectResults.push({
3772
- testPath: payload.testPath,
3773
- project: payload.project,
3774
- tests: payload.tests,
3775
- });
3776
- break;
3777
- }
3778
- case 'collect-complete':
3779
- collectCompleted = true;
3780
- resolveCollect?.();
3781
- break;
3782
- case 'fatal': {
3783
- const payload = message.payload as {
3784
- message: string;
3785
- stack?: string;
3786
- };
3787
- fatalError = new Error(payload.message);
3788
- fatalError.stack = payload.stack;
3789
- resolveCollect?.();
3790
- break;
3791
- }
3792
- case 'ready':
3793
- case 'log':
3794
- // Ignore these messages during collection
3795
- break;
3796
- default:
3797
- // Log unexpected messages for debugging
3798
- logger.debug(`[List] Unexpected message: ${message.type}`);
3799
- }
3800
- },
3801
- );
3802
4026
 
3803
- // Inject host options before navigation so the runner can access them
3804
4027
  const serializedOptions = serializeForInlineScript(hostOptions);
3805
- await page.addInitScript(
3806
- `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
3807
- );
3808
4028
 
3809
- // Navigate to runner page
3810
- await page.goto(`http://localhost:${port}/runner.html`, {
3811
- waitUntil: 'load',
3812
- });
4029
+ const collectFromServer = async (
4030
+ server: BrowserProjectServer,
4031
+ ): Promise<{ results: ListCommandResult[]; error: Error | null }> => {
4032
+ const results: ListCommandResult[] = [];
4033
+ let error: Error | null = null;
4034
+ let collectCompleted = false;
4035
+ let resolveCollect: (() => void) | undefined;
4036
+ const collectPromise = new Promise<void>((resolve) => {
4037
+ resolveCollect = resolve;
4038
+ });
3813
4039
 
3814
- // Wait for collection to complete with timeout
3815
- const timeoutMs = 30000;
3816
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
3817
- const timeoutPromise = new Promise<void>((resolve) => {
3818
- timeoutId = setTimeout(() => {
3819
- if (!collectCompleted) {
3820
- logger.warn(
3821
- color.yellow(
3822
- `[List] Browser test collection timed out after ${timeoutMs}ms`,
3823
- ),
3824
- );
3825
- }
3826
- resolve();
3827
- }, timeoutMs);
3828
- });
4040
+ const page = await browserContext.newPage();
4041
+
4042
+ // Expose dispatch function for browser client to send messages
4043
+ await page.exposeFunction(
4044
+ DISPATCH_MESSAGE_TYPE,
4045
+ (message: { type: string; payload?: unknown }) => {
4046
+ switch (message.type) {
4047
+ case 'collect-result': {
4048
+ const payload = message.payload as {
4049
+ testPath: string;
4050
+ project: string;
4051
+ tests: Test[];
4052
+ };
4053
+ results.push({
4054
+ testPath: payload.testPath,
4055
+ project: payload.project,
4056
+ tests: payload.tests,
4057
+ });
4058
+ break;
4059
+ }
4060
+ case 'collect-complete':
4061
+ collectCompleted = true;
4062
+ resolveCollect?.();
4063
+ break;
4064
+ case 'fatal': {
4065
+ const payload = message.payload as {
4066
+ message: string;
4067
+ stack?: string;
4068
+ };
4069
+ error = new Error(payload.message);
4070
+ error.stack = payload.stack;
4071
+ resolveCollect?.();
4072
+ break;
4073
+ }
4074
+ case 'ready':
4075
+ case 'log':
4076
+ // Ignore these messages during collection
4077
+ break;
4078
+ default:
4079
+ // Log unexpected messages for debugging
4080
+ logger.debug(`[List] Unexpected message: ${message.type}`);
4081
+ }
4082
+ },
4083
+ );
3829
4084
 
3830
- await Promise.race([collectPromise, timeoutPromise]);
4085
+ // Inject host options before navigation so the runner can access them
4086
+ await page.addInitScript(
4087
+ `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
4088
+ );
3831
4089
 
3832
- // Clear timeout to prevent Node.js from waiting for it
3833
- if (timeoutId) {
3834
- clearTimeout(timeoutId);
3835
- }
4090
+ // Navigate to this project's runner page
4091
+ await page.goto(`http://localhost:${server.port}/runner.html`, {
4092
+ waitUntil: 'load',
4093
+ });
4094
+
4095
+ // Wait for collection to complete with timeout
4096
+ const timeoutMs = 30000;
4097
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
4098
+ const timeoutPromise = new Promise<void>((resolve) => {
4099
+ timeoutId = setTimeout(() => {
4100
+ if (!collectCompleted) {
4101
+ logger.warn(
4102
+ color.yellow(
4103
+ `[List] Browser test collection timed out after ${timeoutMs}ms`,
4104
+ ),
4105
+ );
4106
+ }
4107
+ resolve();
4108
+ }, timeoutMs);
4109
+ });
4110
+
4111
+ await Promise.race([collectPromise, timeoutPromise]);
4112
+
4113
+ // Clear timeout to prevent Node.js from waiting for it
4114
+ if (timeoutId) {
4115
+ clearTimeout(timeoutId);
4116
+ }
4117
+
4118
+ await page.close().catch(() => {});
4119
+ return { results, error };
4120
+ };
4121
+
4122
+ // Collect every project concurrently — each navigates its own page against
4123
+ // its own dev server and returns its own results.
4124
+ const collected = await Promise.all(
4125
+ [...runtime.projectServers.values()].map((server) =>
4126
+ collectFromServer(server),
4127
+ ),
4128
+ );
4129
+ const collectResults = collected.flatMap((entry) => entry.results);
4130
+ const fatalError = collected.find((entry) => entry.error)?.error ?? null;
3836
4131
 
3837
4132
  // Cleanup
3838
4133
  const cleanup = async () => {
3839
4134
  try {
3840
- await page.close();
3841
4135
  await browserContext.close();
3842
4136
  } catch {
3843
4137
  // ignore
@@ -3855,8 +4149,8 @@ export const listBrowserTests = async (
3855
4149
  errors: [
3856
4150
  {
3857
4151
  name: 'BrowserCollectError',
3858
- message: (fatalError as Error).message,
3859
- stack: (fatalError as Error).stack,
4152
+ message: fatalError.message,
4153
+ stack: fatalError.stack,
3860
4154
  } as FormattedError,
3861
4155
  ],
3862
4156
  };