@rstest/browser 0.10.6 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  },
@@ -1055,17 +1073,18 @@ const ensureConsistentBrowserLaunchOptions = (
1055
1073
 
1056
1074
  for (const project of projects.slice(1)) {
1057
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.
1058
1079
  if (
1059
1080
  options.provider !== firstOptions.provider ||
1060
1081
  options.browser !== firstOptions.browser ||
1061
1082
  options.headless !== firstOptions.headless ||
1062
- options.port !== firstOptions.port ||
1063
- options.strictPort !== firstOptions.strictPort ||
1064
1083
  !isDeepStrictEqual(options.providerOptions, firstOptions.providerOptions)
1065
1084
  ) {
1066
1085
  throw new Error(
1067
1086
  `Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
1068
- '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.',
1069
1088
  );
1070
1089
  }
1071
1090
  }
@@ -1173,12 +1192,38 @@ const toSafeVarName = (name: string): string => {
1173
1192
  return name.replace(/[^a-zA-Z0-9_]/g, '_');
1174
1193
  };
1175
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
+
1176
1219
  const generateManifestModule = ({
1177
1220
  manifestPath,
1178
1221
  entries,
1222
+ isWatchMode,
1179
1223
  }: {
1180
1224
  manifestPath: string;
1181
1225
  entries: BrowserProjectEntries[];
1226
+ isWatchMode: boolean;
1182
1227
  }): string => {
1183
1228
  const manifestDirPosix = normalize(dirname(manifestPath));
1184
1229
 
@@ -1224,30 +1269,56 @@ const generateManifestModule = ({
1224
1269
  lines.push('};');
1225
1270
  lines.push('');
1226
1271
 
1227
- // 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.
1228
1274
  lines.push('// Test context for each project');
1229
- for (const { project } of entries) {
1275
+ for (const { project, testFiles } of entries) {
1230
1276
  const varName = `context_${toSafeVarName(project.environmentName)}`;
1231
1277
  const projectRootPosix = normalize(project.rootPath);
1232
- const includeRegExp = globPatternsToRegExp(
1233
- project.normalizedConfig.include,
1234
- );
1235
- const excludePatterns = project.normalizedConfig.exclude.patterns;
1236
- const excludeRegExp = createBrowserContextExcludeRegExp(
1237
- excludePatterns,
1238
- projectRootPosix,
1239
- );
1240
1278
 
1241
- lines.push(
1242
- `const ${varName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
1243
- );
1244
- lines.push(' recursive: true,');
1245
- lines.push(` regExp: ${includeRegExp.toString()},`);
1246
- if (excludeRegExp) {
1247
- 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('});');
1248
1321
  }
1249
- lines.push(" mode: 'lazy',");
1250
- lines.push('});');
1251
1322
  lines.push('');
1252
1323
  }
1253
1324
 
@@ -1304,6 +1375,27 @@ const VIRTUAL_MANIFEST_FILENAME = 'virtual-manifest.ts';
1304
1375
  // Browser Runtime Lifecycle
1305
1376
  // ============================================================================
1306
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
+
1307
1399
  const destroyBrowserRuntime = async (
1308
1400
  runtime: BrowserRuntime,
1309
1401
  ): Promise<void> => {
@@ -1312,11 +1404,7 @@ const destroyBrowserRuntime = async (
1312
1404
  } catch {
1313
1405
  // ignore
1314
1406
  }
1315
- try {
1316
- await runtime.devServer?.close?.();
1317
- } catch {
1318
- // ignore
1319
- }
1407
+ await closeAllProjectServers(runtime.projectServers.values());
1320
1408
  try {
1321
1409
  runtime.wss?.close();
1322
1410
  } catch {
@@ -1367,8 +1455,7 @@ const registerWatchCleanup = (): void => {
1367
1455
 
1368
1456
  const createBrowserRuntime = async ({
1369
1457
  context,
1370
- manifestPath,
1371
- manifestSource,
1458
+ projectEntries,
1372
1459
  tempDir,
1373
1460
  isWatchMode,
1374
1461
  onTriggerRerun,
@@ -1377,8 +1464,7 @@ const createBrowserRuntime = async ({
1377
1464
  forceHeadless,
1378
1465
  }: {
1379
1466
  context: RstestContext;
1380
- manifestPath: string;
1381
- manifestSource: string;
1467
+ projectEntries: BrowserProjectEntries[];
1382
1468
  tempDir: string;
1383
1469
  isWatchMode: boolean;
1384
1470
  onTriggerRerun?: () => Promise<void>;
@@ -1387,10 +1473,7 @@ const createBrowserRuntime = async ({
1387
1473
  /** Force headless mode regardless of user config (used for list command) */
1388
1474
  forceHeadless?: boolean;
1389
1475
  }): Promise<BrowserRuntime> => {
1390
- const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin({
1391
- [manifestPath]: manifestSource,
1392
- });
1393
-
1476
+ // ---- Shared singletons (created once, wired into every project server) ----
1394
1477
  const containerHtmlTemplate = containerDistPath
1395
1478
  ? await fs.readFile(join(containerDistPath, 'index.html'), 'utf-8')
1396
1479
  : null;
@@ -1411,12 +1494,6 @@ const createBrowserRuntime = async ({
1411
1494
  };
1412
1495
 
1413
1496
  const browserProjects = getBrowserProjects(context);
1414
- const projectByEnvironmentName = new Map(
1415
- browserProjects.map((project) => [project.environmentName, project]),
1416
- );
1417
- const userPlugins = browserProjects.flatMap(
1418
- (project) => project.normalizedConfig.plugins || [],
1419
- );
1420
1497
  const browserLaunchOptions =
1421
1498
  ensureConsistentBrowserLaunchOptions(browserProjects);
1422
1499
 
@@ -1425,8 +1502,9 @@ const createBrowserRuntime = async ({
1425
1502
  import.meta.resolve('@rstest/core/internal/browser-runtime'),
1426
1503
  );
1427
1504
 
1428
- const rstestInternalAliases = {
1429
- '@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 = {
1430
1508
  // User test code: import { describe, it } from '@rstest/core'
1431
1509
  '@rstest/core': resolveBrowserFile('client/public.ts'),
1432
1510
  // User test code: import { page } from '@rstest/browser'
@@ -1434,228 +1512,17 @@ const createBrowserRuntime = async ({
1434
1512
  // Browser runtime APIs for entry.ts and public.ts
1435
1513
  // Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
1436
1514
  '@rstest/core/internal/browser-runtime': browserRuntimePath,
1437
- '@sinonjs/fake-timers': resolveBrowserFile('client/fakeTimersStub.ts'),
1438
1515
  };
1439
1516
 
1440
- const rsbuildInstance = await createRsbuild({
1441
- callerName: 'rstest-browser',
1442
- rsbuildConfig: {
1443
- root: context.rootPath,
1444
- mode: 'development',
1445
- plugins: userPlugins,
1446
- server: {
1447
- printUrls: false,
1448
- port: browserLaunchOptions.port ?? 4000,
1449
- strictPort: browserLaunchOptions.strictPort,
1450
- },
1451
- dev: createBrowserRsbuildDevConfig(isWatchMode),
1452
- environments: {
1453
- ...Object.fromEntries(
1454
- browserProjects.map((project) => [project.environmentName, {}]),
1455
- ),
1456
- },
1457
- },
1458
- });
1459
-
1460
- // Add plugin to merge user Rsbuild config with rstest required config
1461
- rsbuildInstance.addPlugins([
1462
- {
1463
- name: 'rstest:browser-user-config',
1464
- setup(api) {
1465
- // Internal extension entry: register host dispatch handlers without
1466
- // coupling scheduling to individual capability implementations.
1467
- (api as { expose?: (name: string, value: unknown) => void }).expose?.(
1468
- 'rstest:browser',
1469
- {
1470
- registerDispatchHandler: (
1471
- namespace: string,
1472
- handler: BrowserDispatchHandler,
1473
- ) => {
1474
- dispatchHandlers.set(namespace, handler);
1475
- },
1476
- },
1477
- );
1478
-
1479
- api.modifyEnvironmentConfig({
1480
- handler: (config, { mergeEnvironmentConfig, name }) => {
1481
- const project = projectByEnvironmentName.get(name);
1482
- if (!project) {
1483
- return config;
1484
- }
1485
-
1486
- const userRsbuildConfig = project.normalizedConfig;
1487
- const buildCache = resolveProjectBuildCache({
1488
- context,
1489
- project,
1490
- });
1491
- const setupFiles = Object.values(
1492
- getSetupFiles(
1493
- project.normalizedConfig.setupFiles,
1494
- project.rootPath,
1495
- ),
1496
- );
1497
- // rspack `define` replaces `process.env` / `import.meta.env` with
1498
- // this literal expression. JSON.stringify reproduces the exact
1499
- // double-quoted `"rstest.env"` text, so the owned key can never
1500
- // drift from the runtime `Symbol.for(RSTEST_ENV_SYMBOL_KEY)` sites.
1501
- const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(
1502
- RSTEST_ENV_SYMBOL_KEY,
1503
- )})]`;
1504
- // Merge order: current config -> userConfig -> rstest required config (highest priority)
1505
- const merged = mergeEnvironmentConfig(
1506
- config,
1507
- {
1508
- ...userRsbuildConfig,
1509
- performance: buildCache
1510
- ? {
1511
- ...userRsbuildConfig.performance,
1512
- buildCache,
1513
- }
1514
- : userRsbuildConfig.performance,
1515
- },
1516
- {
1517
- resolve: {
1518
- alias: rstestInternalAliases,
1519
- },
1520
- source: {
1521
- define: {
1522
- 'process.env': rstestEnvDefine,
1523
- 'import.meta.env': rstestEnvDefine,
1524
- },
1525
- },
1526
- output: {
1527
- target: 'web',
1528
- // Enable source map for inline snapshot support
1529
- sourceMap: {
1530
- js: 'source-map',
1531
- },
1532
- },
1533
- tools: {
1534
- rspack: (rspackConfig) => {
1535
- rspackConfig.mode = 'development';
1536
- rspackConfig.lazyCompilation =
1537
- createBrowserLazyCompilationConfig(setupFiles);
1538
- rspackConfig.plugins = rspackConfig.plugins || [];
1539
- rspackConfig.plugins.push(virtualManifestPlugin);
1540
-
1541
- applyDefaultWatchOptions(rspackConfig, isWatchMode);
1542
-
1543
- // Extract and merge sourcemaps from pre-built @rstest/core files
1544
- // This preserves the sourcemap chain for inline snapshot support
1545
- // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
1546
- const browserRuntimeDir = dirname(browserRuntimePath);
1547
- rspackConfig.module = rspackConfig.module || {};
1548
- rspackConfig.module.rules = rspackConfig.module.rules || [];
1549
- rspackConfig.module.rules.unshift({
1550
- test: /\.js$/,
1551
- include: browserRuntimeDir,
1552
- extractSourceMap: true,
1553
- });
1554
-
1555
- if (isDebug()) {
1556
- logger.log(
1557
- `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
1558
- );
1559
- }
1560
- },
1561
- },
1562
- },
1563
- );
1564
-
1565
- // Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
1566
- // In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
1567
- // This must be done after mergeEnvironmentConfig to ensure highest priority.
1568
- merged.source = merged.source || {};
1569
- merged.source.entry = {
1570
- runner: resolveBrowserFile('client/entry.ts'),
1571
- };
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
+ )})]`;
1572
1524
 
1573
- return merged;
1574
- },
1575
- // Execute after all other plugins to ensure rstest's entry config has the highest priority
1576
- order: 'post',
1577
- });
1578
- },
1579
- },
1580
- ]);
1581
-
1582
- // Register watch plugin if in watch mode
1583
- if (isWatchMode && onTriggerRerun) {
1584
- rsbuildInstance.addPlugins([
1585
- {
1586
- name: 'rstest:browser-watch',
1587
- setup(api) {
1588
- api.onBeforeDevCompile(() => {
1589
- if (!watchContext.hooksEnabled) {
1590
- return;
1591
- }
1592
- logger.log(color.cyan('\nFile changed, re-running tests...\n'));
1593
- });
1594
-
1595
- api.onAfterDevCompile(async ({ stats }) => {
1596
- // Collect hashes even during initial build to establish baseline
1597
- if (stats) {
1598
- const projectEntries = await collectProjectEntries(context);
1599
- const entryTestFiles = new Set<string>(
1600
- collectWatchTestFiles(projectEntries).map(
1601
- (file) => file.testPath,
1602
- ),
1603
- );
1604
-
1605
- const statsJson = stats.toJson({ all: true });
1606
- const affected = getAffectedTestFiles(
1607
- statsJson.chunks,
1608
- entryTestFiles,
1609
- );
1610
- watchContext.affectedTestFiles = affected;
1611
-
1612
- if (affected.length > 0) {
1613
- logger.debug(
1614
- `[Watch] Affected test files: ${affected.join(', ')}`,
1615
- );
1616
- }
1617
- }
1618
-
1619
- if (!watchContext.hooksEnabled) {
1620
- return;
1621
- }
1622
-
1623
- await onTriggerRerun();
1624
- });
1625
- },
1626
- },
1627
- ]);
1628
- }
1629
-
1630
- // Register coverage plugin for browser mode
1631
- const coverage = browserProjects.find(
1632
- (project) => project.normalizedConfig.coverage?.enabled,
1633
- )?.normalizedConfig.coverage;
1634
- if (coverage?.enabled && context.command !== 'list') {
1635
- const { pluginCoverage } = await loadCoverageProvider(
1636
- coverage,
1637
- context.rootPath,
1638
- );
1639
- rsbuildInstance.addPlugins([pluginCoverage(coverage)]);
1640
- }
1641
-
1642
- const devServer = await rsbuildInstance.createDevServer({
1643
- getPortSilently: true,
1644
- });
1645
-
1646
- if (isDebug()) {
1647
- await rsbuildInstance.inspectConfig({
1648
- writeToDisk: true,
1649
- extraConfigs: {
1650
- rstest: {
1651
- ...context.normalizedConfig,
1652
- projects: browserProjects.map((p) => p.normalizedConfig),
1653
- },
1654
- },
1655
- });
1656
- }
1657
-
1658
- // Serve prebuilt container assets (SPA) via sirv
1525
+ // Serve prebuilt container assets (SPA) via sirv (container origin only)
1659
1526
  const serveContainer = containerDistPath
1660
1527
  ? sirv(containerDistPath, {
1661
1528
  dev: false,
@@ -1685,13 +1552,7 @@ const createBrowserRuntime = async ({
1685
1552
  let html = await response.text();
1686
1553
  html = html.replace(OPTIONS_PLACEHOLDER, serializedOptions);
1687
1554
 
1688
- res.statusCode = response.status;
1689
- response.headers.forEach((value, key) => {
1690
- if (key.toLowerCase() === 'content-length') {
1691
- return;
1692
- }
1693
- res.setHeader(key, value);
1694
- });
1555
+ copyProxyResponseHeaders(response, res);
1695
1556
  res.setHeader('Content-Type', 'text/html');
1696
1557
  res.end(html);
1697
1558
  return true;
@@ -1719,13 +1580,7 @@ const createBrowserRuntime = async ({
1719
1580
  }
1720
1581
 
1721
1582
  const buffer = Buffer.from(await response.arrayBuffer());
1722
- res.statusCode = response.status;
1723
- response.headers.forEach((value, key) => {
1724
- if (key.toLowerCase() === 'content-length') {
1725
- return;
1726
- }
1727
- res.setHeader(key, value);
1728
- });
1583
+ copyProxyResponseHeaders(response, res);
1729
1584
  res.end(buffer);
1730
1585
  return true;
1731
1586
  } catch (error) {
@@ -1736,75 +1591,366 @@ const createBrowserRuntime = async ({
1736
1591
  }
1737
1592
  };
1738
1593
 
1739
- devServer.middlewares.use(
1740
- async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
1741
- if (!req.url) {
1742
- next();
1743
- return;
1744
- }
1745
- const url = new URL(req.url, 'http://localhost');
1746
- if (url.pathname === '/__open-in-editor') {
1747
- const file = url.searchParams.get('file');
1748
- if (!file) {
1749
- res.statusCode = 400;
1750
- 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();
1751
1855
  return;
1752
1856
  }
1753
- try {
1754
- await openEditor([{ file }]);
1755
- res.statusCode = 204;
1756
- res.end();
1757
- } catch (error) {
1758
- logger.debug(`[Browser UI] Failed to open editor: ${String(error)}`);
1759
- res.statusCode = 500;
1760
- res.end('Failed to open editor');
1761
- }
1762
- return;
1763
- }
1764
- if (url.pathname === '/') {
1765
- 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
+ }
1766
1876
  return;
1767
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');
1768
1889
 
1769
- const html =
1770
- injectedContainerHtml ||
1771
- containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
1890
+ if (html) {
1891
+ res.setHeader('Content-Type', 'text/html');
1892
+ res.end(html);
1893
+ return;
1894
+ }
1772
1895
 
1773
- if (html) {
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
+ }
1904
+
1905
+ if (serveContainer) {
1906
+ serveContainer(req, res, next);
1907
+ return;
1908
+ }
1909
+
1910
+ res.statusCode = 502;
1911
+ res.end('Container assets are not available.');
1912
+ return;
1913
+ }
1914
+ }
1915
+ if (url.pathname === '/runner.html') {
1774
1916
  res.setHeader('Content-Type', 'text/html');
1775
- res.end(html);
1917
+ res.end(htmlTemplate);
1776
1918
  return;
1777
1919
  }
1920
+ next();
1921
+ },
1922
+ );
1778
1923
 
1779
- res.statusCode = 502;
1780
- res.end('Container UI is not available.');
1781
- return;
1782
- }
1783
- if (url.pathname.startsWith('/container-static/')) {
1784
- if (await proxyDevServerAsset(req, res)) {
1785
- return;
1786
- }
1924
+ const { port: listenPort } = await devServer.listen();
1925
+ const port = resolveListenPort(listenPort, devServer.httpServer);
1787
1926
 
1788
- if (serveContainer) {
1789
- serveContainer(req, res, next);
1790
- return;
1791
- }
1927
+ return {
1928
+ projectName: project.name,
1929
+ environmentName: project.environmentName,
1930
+ rsbuildInstance,
1931
+ devServer,
1932
+ port,
1933
+ manifestPath,
1934
+ };
1935
+ };
1792
1936
 
1793
- res.statusCode = 502;
1794
- res.end('Container assets are not available.');
1795
- return;
1796
- }
1797
- if (url.pathname === '/runner.html') {
1798
- res.setHeader('Content-Type', 'text/html');
1799
- res.end(htmlTemplate);
1800
- return;
1801
- }
1802
- next();
1803
- },
1804
- );
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
+ }
1805
1950
 
1806
- const { port: listenPort } = await devServer.listen();
1807
- 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)!;
1808
1954
 
1809
1955
  // Create WebSocket server on an available port
1810
1956
  // Using port: 0 lets the OS assign an available port, avoiding conflicts
@@ -1828,22 +1974,19 @@ const createBrowserRuntime = async ({
1828
1974
  providerOptions: browserLaunchOptions.providerOptions,
1829
1975
  });
1830
1976
  return {
1831
- rsbuildInstance,
1832
- devServer,
1977
+ projectServers,
1978
+ containerServer,
1833
1979
  browser: runtime.browser,
1834
1980
  browserLaunchOptions,
1835
- port,
1836
1981
  wsPort,
1837
- manifestPath,
1838
1982
  tempDir,
1839
- manifestPlugin: virtualManifestPlugin,
1840
1983
  setContainerOptions,
1841
1984
  dispatchHandlers,
1842
1985
  wss,
1843
1986
  };
1844
1987
  } catch (error) {
1845
1988
  wss.close();
1846
- await devServer.close();
1989
+ await closeAllProjectServers(projectServers.values());
1847
1990
  throw error;
1848
1991
  }
1849
1992
  };
@@ -2169,12 +2312,6 @@ export const runBrowserController = async (
2169
2312
  Date.now().toString(),
2170
2313
  );
2171
2314
 
2172
- const manifestPath = join(tempDir, VIRTUAL_MANIFEST_FILENAME);
2173
- const manifestSource = generateManifestModule({
2174
- manifestPath,
2175
- entries: projectEntries,
2176
- });
2177
-
2178
2315
  // Track initial test files for watch mode
2179
2316
  if (isWatchMode) {
2180
2317
  watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
@@ -2189,8 +2326,7 @@ export const runBrowserController = async (
2189
2326
  try {
2190
2327
  runtime = await createBrowserRuntime({
2191
2328
  context,
2192
- manifestPath,
2193
- manifestSource,
2329
+ projectEntries,
2194
2330
  tempDir,
2195
2331
  isWatchMode,
2196
2332
  onTriggerRerun: isWatchMode
@@ -2219,7 +2355,7 @@ export const runBrowserController = async (
2219
2355
  }
2220
2356
  }
2221
2357
 
2222
- const { browser, browserLaunchOptions, port, wsPort, wss } = runtime;
2358
+ const { browser, browserLaunchOptions, wsPort, wss } = runtime;
2223
2359
  const buildTime = Date.now() - buildStart;
2224
2360
 
2225
2361
  // Collect all test files from project entries with project info
@@ -2250,13 +2386,22 @@ export const runBrowserController = async (
2250
2386
  ),
2251
2387
  );
2252
2388
 
2389
+ const projectRunnerUrls = Object.fromEntries(
2390
+ [...runtime.projectServers].map(([name, server]) => [
2391
+ name,
2392
+ `http://localhost:${server.port}`,
2393
+ ]),
2394
+ );
2395
+
2253
2396
  const hostOptions: BrowserHostConfig = {
2254
2397
  rootPath: normalize(context.rootPath),
2255
2398
  projects: projectRuntimeConfigs,
2256
2399
  snapshot: {
2257
2400
  updateSnapshot: context.snapshotManager.options.updateSnapshot,
2258
2401
  },
2259
- runnerUrl: `http://localhost:${port}`,
2402
+ // Container origin (fallback). Per-project runner origins below.
2403
+ runnerUrl: `http://localhost:${runtime.containerServer.port}`,
2404
+ projectRunnerUrls,
2260
2405
  wsPort,
2261
2406
  debug: isDebug(),
2262
2407
  rpcTimeout: maxTestTimeoutForRpc,
@@ -2283,8 +2428,7 @@ export const runBrowserController = async (
2283
2428
 
2284
2429
  let activeContainerPage: BrowserProviderPage | null = null;
2285
2430
  let getHeadlessRunnerPageBySessionId:
2286
- | ((sessionId: string) => BrowserProviderPage | undefined)
2287
- | undefined;
2431
+ ((sessionId: string) => BrowserProviderPage | undefined) | undefined;
2288
2432
 
2289
2433
  const dispatchBrowserRpcRequest = async ({
2290
2434
  request,
@@ -2831,17 +2975,34 @@ export const runBrowserController = async (
2831
2975
  resolveDone = resolve;
2832
2976
  });
2833
2977
 
2834
- const projectRuntime = projectRuntimeConfigs.find(
2835
- (project) => project.name === file.projectName,
2836
- );
2837
- const perFileTimeoutMs =
2838
- (projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) +
2839
- PER_FILE_TIMEOUT_BUFFER_MS;
2840
-
2841
- 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
+ };
2842
2995
 
2843
2996
  try {
2844
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
+ );
2845
3006
 
2846
3007
  const session = sessionRegistry.register({
2847
3008
  testFile: file.testPath,
@@ -2900,32 +3061,35 @@ export const runBrowserController = async (
2900
3061
  `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
2901
3062
  );
2902
3063
 
2903
- 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`, {
2904
3071
  waitUntil: 'load',
2905
3072
  });
2906
3073
 
2907
- const timeoutPromise = new Promise<'timeout'>((resolve) => {
2908
- timeoutId = setTimeout(() => resolve('timeout'), perFileTimeoutMs);
2909
- });
2910
-
2911
3074
  const state = await Promise.race([
2912
- donePromise.then(() => 'done' as const),
2913
- timeoutPromise,
2914
- 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 })),
2915
3081
  ]);
2916
3082
 
2917
- if (state === 'cancelled') {
3083
+ if (state.type === 'cancelled') {
2918
3084
  return;
2919
3085
  }
2920
3086
 
2921
3087
  if (
2922
- state === 'timeout' &&
3088
+ state.type === 'crash' &&
2923
3089
  runLifecycle.isTokenActive(run.token) &&
2924
3090
  !run.cancelled
2925
3091
  ) {
2926
- await handleFatal({
2927
- message: `Test execution timeout after ${perFileTimeoutMs / 1000}s for ${file.testPath}.`,
2928
- });
3092
+ await handleFatal({ message: state.reason });
2929
3093
  await cancelRun(run, false);
2930
3094
  }
2931
3095
  } catch (error) {
@@ -2938,9 +3102,6 @@ export const runBrowserController = async (
2938
3102
  await cancelRun(run, false);
2939
3103
  }
2940
3104
  } finally {
2941
- if (timeoutId) {
2942
- clearTimeout(timeoutId);
2943
- }
2944
3105
  if (page) {
2945
3106
  try {
2946
3107
  await page.close();
@@ -3322,16 +3483,6 @@ export const runBrowserController = async (
3322
3483
  return fileInfo;
3323
3484
  };
3324
3485
 
3325
- const getHeadedPerFileTimeoutMs = (file: TestFileInfo): number => {
3326
- const projectRuntime = projectRuntimeConfigs.find(
3327
- (project) => project.name === file.projectName,
3328
- );
3329
- return (
3330
- (projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) +
3331
- PER_FILE_TIMEOUT_BUFFER_MS
3332
- );
3333
- };
3334
-
3335
3486
  // Open a container page for user to view (reuse in watch mode)
3336
3487
  let containerContext: BrowserProviderContext;
3337
3488
  let containerPage: BrowserProviderPage;
@@ -3456,12 +3607,13 @@ export const runBrowserController = async (
3456
3607
  pending.deferred.resolve();
3457
3608
  };
3458
3609
 
3459
- 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 (
3460
3614
  file: TestFileInfo,
3461
3615
  testNamePattern?: string,
3462
3616
  ): Promise<void> => {
3463
- const timeoutMs = getHeadedPerFileTimeoutMs(file);
3464
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
3465
3617
  let reloadAck: ReloadTestFileAck | undefined;
3466
3618
 
3467
3619
  try {
@@ -3469,22 +3621,7 @@ export const runBrowserController = async (
3469
3621
  file.testPath,
3470
3622
  testNamePattern,
3471
3623
  );
3472
- const completionPromise = registerPendingHeadedReload(
3473
- file.testPath,
3474
- reloadAck.runId,
3475
- );
3476
- await Promise.race([
3477
- completionPromise,
3478
- new Promise<never>((_, reject) => {
3479
- timeoutId = setTimeout(() => {
3480
- reject(
3481
- new Error(
3482
- `Headed test execution timeout after ${timeoutMs / 1000}s for ${file.testPath}.`,
3483
- ),
3484
- );
3485
- }, timeoutMs);
3486
- }),
3487
- ]);
3624
+ await registerPendingHeadedReload(file.testPath, reloadAck.runId);
3488
3625
  } catch (error) {
3489
3626
  if (reloadAck?.runId) {
3490
3627
  rejectPendingHeadedReload(
@@ -3494,10 +3631,6 @@ export const runBrowserController = async (
3494
3631
  );
3495
3632
  }
3496
3633
  throw error;
3497
- } finally {
3498
- if (timeoutId) {
3499
- clearTimeout(timeoutId);
3500
- }
3501
3634
  }
3502
3635
  };
3503
3636
 
@@ -3581,13 +3714,14 @@ export const runBrowserController = async (
3581
3714
  // Only navigate on first creation
3582
3715
  if (isNewPage) {
3583
3716
  const pagePath = '/';
3584
- await containerPage.goto(`http://localhost:${port}${pagePath}`, {
3717
+ const containerPort = runtime.containerServer.port;
3718
+ await containerPage.goto(`http://localhost:${containerPort}${pagePath}`, {
3585
3719
  waitUntil: 'load',
3586
3720
  });
3587
3721
 
3588
3722
  logger.log(
3589
3723
  color.cyan(
3590
- `\nBrowser mode opened at http://localhost:${port}${pagePath}\n`,
3724
+ `\nBrowser mode opened at http://localhost:${containerPort}${pagePath}\n`,
3591
3725
  ),
3592
3726
  );
3593
3727
  }
@@ -3600,7 +3734,7 @@ export const runBrowserController = async (
3600
3734
  if (fatalError) {
3601
3735
  return;
3602
3736
  }
3603
- await reloadTestFileWithTimeout(file, testNamePattern);
3737
+ await reloadTestFileAndWait(file, testNamePattern);
3604
3738
  });
3605
3739
  };
3606
3740
 
@@ -3819,11 +3953,6 @@ export const listBrowserTests = async (
3819
3953
  `list-${Date.now()}`,
3820
3954
  );
3821
3955
 
3822
- const manifestPath = join(tempDir, VIRTUAL_MANIFEST_FILENAME);
3823
- const manifestSource = generateManifestModule({
3824
- manifestPath,
3825
- entries: projectEntries,
3826
- });
3827
3956
  const browserProjects = getBrowserProjects(context);
3828
3957
 
3829
3958
  // Create a simplified browser runtime for collect mode
@@ -3831,8 +3960,7 @@ export const listBrowserTests = async (
3831
3960
  try {
3832
3961
  runtime = await createBrowserRuntime({
3833
3962
  context,
3834
- manifestPath,
3835
- manifestSource,
3963
+ projectEntries,
3836
3964
  tempDir,
3837
3965
  isWatchMode: false,
3838
3966
  containerDistPath: undefined,
@@ -3854,7 +3982,7 @@ export const listBrowserTests = async (
3854
3982
  throw error;
3855
3983
  }
3856
3984
 
3857
- const { browser, browserLaunchOptions, port } = runtime;
3985
+ const { browser, browserLaunchOptions } = runtime;
3858
3986
 
3859
3987
  // Get browser projects for runtime config
3860
3988
  // Normalize projectRoot to posix format for cross-platform compatibility
@@ -3888,105 +4016,122 @@ export const listBrowserTests = async (
3888
4016
 
3889
4017
  runtime.setContainerOptions(hostOptions);
3890
4018
 
3891
- // Collect results
3892
- const collectResults: ListCommandResult[] = [];
3893
- let fatalError: Error | null = null;
3894
- let collectCompleted = false;
3895
-
3896
- // Promise that resolves when collection is complete
3897
- let resolveCollect: (() => void) | undefined;
3898
- const collectPromise = new Promise<void>((resolve) => {
3899
- resolveCollect = resolve;
3900
- });
3901
-
3902
- // 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.
3903
4022
  const browserContext = await browser.newContext({
3904
4023
  providerOptions: browserLaunchOptions.providerOptions,
3905
4024
  viewport: null,
3906
4025
  });
3907
- const page = await browserContext.newPage();
3908
-
3909
- // Expose dispatch function for browser client to send messages
3910
- await page.exposeFunction(
3911
- DISPATCH_MESSAGE_TYPE,
3912
- (message: { type: string; payload?: unknown }) => {
3913
- switch (message.type) {
3914
- case 'collect-result': {
3915
- const payload = message.payload as {
3916
- testPath: string;
3917
- project: string;
3918
- tests: Test[];
3919
- };
3920
- collectResults.push({
3921
- testPath: payload.testPath,
3922
- project: payload.project,
3923
- tests: payload.tests,
3924
- });
3925
- break;
3926
- }
3927
- case 'collect-complete':
3928
- collectCompleted = true;
3929
- resolveCollect?.();
3930
- break;
3931
- case 'fatal': {
3932
- const payload = message.payload as {
3933
- message: string;
3934
- stack?: string;
3935
- };
3936
- fatalError = new Error(payload.message);
3937
- fatalError.stack = payload.stack;
3938
- resolveCollect?.();
3939
- break;
3940
- }
3941
- case 'ready':
3942
- case 'log':
3943
- // Ignore these messages during collection
3944
- break;
3945
- default:
3946
- // Log unexpected messages for debugging
3947
- logger.debug(`[List] Unexpected message: ${message.type}`);
3948
- }
3949
- },
3950
- );
3951
4026
 
3952
- // Inject host options before navigation so the runner can access them
3953
4027
  const serializedOptions = serializeForInlineScript(hostOptions);
3954
- await page.addInitScript(
3955
- `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
3956
- );
3957
4028
 
3958
- // Navigate to runner page
3959
- await page.goto(`http://localhost:${port}/runner.html`, {
3960
- waitUntil: 'load',
3961
- });
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
+ });
3962
4039
 
3963
- // Wait for collection to complete with timeout
3964
- const timeoutMs = 30000;
3965
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
3966
- const timeoutPromise = new Promise<void>((resolve) => {
3967
- timeoutId = setTimeout(() => {
3968
- if (!collectCompleted) {
3969
- logger.warn(
3970
- color.yellow(
3971
- `[List] Browser test collection timed out after ${timeoutMs}ms`,
3972
- ),
3973
- );
3974
- }
3975
- resolve();
3976
- }, timeoutMs);
3977
- });
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
+ );
3978
4084
 
3979
- 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
+ );
3980
4089
 
3981
- // Clear timeout to prevent Node.js from waiting for it
3982
- if (timeoutId) {
3983
- clearTimeout(timeoutId);
3984
- }
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;
3985
4131
 
3986
4132
  // Cleanup
3987
4133
  const cleanup = async () => {
3988
4134
  try {
3989
- await page.close();
3990
4135
  await browserContext.close();
3991
4136
  } catch {
3992
4137
  // ignore
@@ -4004,8 +4149,8 @@ export const listBrowserTests = async (
4004
4149
  errors: [
4005
4150
  {
4006
4151
  name: 'BrowserCollectError',
4007
- message: (fatalError as Error).message,
4008
- stack: (fatalError as Error).stack,
4152
+ message: fatalError.message,
4153
+ stack: fatalError.stack,
4009
4154
  } as FormattedError,
4010
4155
  ],
4011
4156
  };