@rstest/browser 0.11.1 → 0.11.3

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.
@@ -6,26 +6,39 @@ import { fileURLToPath } from 'node:url';
6
6
  import { isDeepStrictEqual } from 'node:util';
7
7
  import type { Rspack } from '@rstest/core';
8
8
  import {
9
+ applyWebMockRspackConfig,
9
10
  type BrowserTestRunOptions,
10
11
  type BrowserTestRunResult,
11
12
  type CoverageMapData,
13
+ type ListBrowserTestsOptions,
12
14
  color,
13
15
  createCoverageProvider,
16
+ createRunnerEventSink,
17
+ createSilentConsoleController,
14
18
  DEFAULT_TEST_TIMEOUT,
15
19
  type FormattedError,
16
20
  getNoTestFilesMessage,
21
+ getPrettyConsoleName,
17
22
  getSetupFiles,
18
23
  getTestEntries,
24
+ hasUserRstestConfigPlugins,
25
+ importMetaRstestDefine,
26
+ initModifyRstestConfigHooks,
19
27
  isDebug,
20
28
  type ListCommandResult,
21
29
  loadCoverageProvider,
22
30
  logger,
31
+ pluginMockRuntime,
32
+ prepareWatchRerunState,
33
+ projectRuntimeConfig,
23
34
  PhaseTracker,
24
35
  type ProjectContext,
25
36
  type Reporter,
37
+ type RunnerEventSink,
26
38
  type RstestContext,
27
- type RuntimeConfig,
28
39
  resolveProjectBuildCache,
40
+ resolveSnapshotPathDefault,
41
+ resolveShardedEntries,
29
42
  RSTEST_ENV_SYMBOL_KEY,
30
43
  rsbuild,
31
44
  serializableConfig,
@@ -36,7 +49,7 @@ import {
36
49
  } from '@rstest/core/internal/browser';
37
50
  import { type BirpcReturn, createBirpc } from 'birpc';
38
51
  import openEditor from 'open-editor';
39
- import { basename, dirname, join, normalize, relative, resolve } from 'pathe';
52
+ import { dirname, join, normalize, relative, resolve } from 'pathe';
40
53
  import picomatch from 'picomatch';
41
54
  import sirv from 'sirv';
42
55
  import { type WebSocket, WebSocketServer } from 'ws';
@@ -45,6 +58,7 @@ import {
45
58
  createHostDispatchRouter,
46
59
  type HostDispatchRouterOptions,
47
60
  } from './dispatchCapabilities';
61
+ import { validateBrowserConfig } from './configValidation';
48
62
  import { createHeadedSerialTaskQueue } from './headedSerialTaskQueue';
49
63
  import { createHeadlessLatestRerunScheduler } from './headlessLatestRerunScheduler';
50
64
  import { attachHeadlessRunnerTransport } from './headlessTransport';
@@ -97,6 +111,8 @@ import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';
97
111
  const { createRsbuild, rspack } = rsbuild;
98
112
  type RsbuildDevServer = rsbuild.RsbuildDevServer;
99
113
  type RsbuildInstance = rsbuild.RsbuildInstance;
114
+ type RsbuildEnvironmentConfig = rsbuild.EnvironmentConfig &
115
+ Pick<rsbuild.RsbuildConfig, 'root'>;
100
116
 
101
117
  const __dirname = dirname(fileURLToPath(import.meta.url));
102
118
  const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
@@ -200,10 +216,6 @@ const getFileTaskId = (testPath: string): string => {
200
216
  return `file:${testPath}`;
201
217
  };
202
218
 
203
- const getBufferedLogTaskId = (log: UserConsoleLog): string => {
204
- return log.taskId ?? getFileTaskId(log.testPath);
205
- };
206
-
207
219
  const createDeferredPromise = <T>(): DeferredPromise<T> => {
208
220
  let resolve!: DeferredPromise<T>['resolve'];
209
221
  let reject!: DeferredPromise<T>['reject'];
@@ -433,6 +445,7 @@ type BrowserRuntime = {
433
445
  dispatchHandlers: Map<string, BrowserDispatchHandler>;
434
446
  wss: WebSocketServer;
435
447
  rpcManager?: ContainerRpcManager;
448
+ projectEntries: BrowserProjectEntries[];
436
449
  };
437
450
 
438
451
  // ============================================================================
@@ -973,82 +986,25 @@ const getAffectedTestFiles = (
973
986
  return Array.from(affectedFiles);
974
987
  };
975
988
 
976
- const getRuntimeConfigFromProject = (
977
- project: ProjectContext,
978
- ): RuntimeConfig => {
979
- const {
980
- testNamePattern,
981
- testTimeout,
982
- passWithNoTests,
983
- retry,
984
- globals,
985
- clearMocks,
986
- resetMocks,
987
- restoreMocks,
988
- unstubEnvs,
989
- unstubGlobals,
990
- maxConcurrency,
991
- printConsoleTrace,
992
- disableConsoleIntercept,
993
- testEnvironment,
994
- hookTimeout,
995
- isolate,
996
- coverage,
997
- snapshotFormat,
998
- env,
999
- bail,
1000
- logHeapUsage,
1001
- detectAsyncLeaks,
1002
- chaiConfig,
1003
- includeTaskLocation,
1004
- silent,
1005
- } = project.normalizedConfig;
989
+ const getBrowserProjects = (context: RstestContext): ProjectContext[] =>
990
+ context.projects.filter(
991
+ (project) => project.normalizedConfig.browser.enabled,
992
+ );
1006
993
 
1007
- return {
1008
- // Propagate NODE_ENV and the RSTEST flag from the host so
1009
- // `process.env.NODE_ENV` / `process.env.RSTEST` (rewritten to the
1010
- // `RSTEST_ENV_SYMBOL_KEY` symbol store) resolve in browser tests the same
1011
- // way they do in Node mode, where `prepare.ts` sets them on the real
1012
- // `process.env`.
1013
- // User-supplied `env` wins so explicit overrides still take effect.
1014
- // See https://github.com/web-infra-dev/rstest/issues/1351
1015
- env: {
1016
- NODE_ENV: process.env.NODE_ENV,
1017
- RSTEST: 'true',
1018
- ...env,
1019
- },
1020
- testNamePattern,
1021
- testTimeout,
1022
- hookTimeout,
1023
- passWithNoTests,
1024
- retry,
1025
- globals,
1026
- clearMocks,
1027
- resetMocks,
1028
- restoreMocks,
1029
- unstubEnvs,
1030
- unstubGlobals,
1031
- maxConcurrency,
1032
- printConsoleTrace,
1033
- disableConsoleIntercept,
1034
- testEnvironment,
1035
- isolate,
1036
- coverage,
1037
- snapshotFormat,
1038
- bail,
1039
- logHeapUsage,
1040
- detectAsyncLeaks,
1041
- chaiConfig,
1042
- includeTaskLocation,
1043
- silent,
1044
- };
1045
- };
994
+ const getBrowserRsbuildEnvironmentConfig = (
995
+ project: ProjectContext,
996
+ ): RsbuildEnvironmentConfig => ({
997
+ plugins: project.normalizedConfig.plugins,
998
+ root: project.rootPath,
999
+ });
1046
1000
 
1047
- const getBrowserProjects = (context: RstestContext): ProjectContext[] => {
1048
- return context.projects.filter(
1049
- (project) => project.normalizedConfig.browser.enabled,
1001
+ // Max testTimeout across browser projects, used as the host->client RPC timeout.
1002
+ const getMaxTestTimeoutForRpc = (projects: ProjectContext[]): number =>
1003
+ Math.max(
1004
+ ...projects.map(
1005
+ (p) => p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT,
1006
+ ),
1050
1007
  );
1051
- };
1052
1008
 
1053
1009
  const getBrowserLaunchOptions = (
1054
1010
  project: ProjectContext,
@@ -1118,10 +1074,11 @@ const resolveProviderForTestPath = ({
1118
1074
 
1119
1075
  const collectProjectEntries = async (
1120
1076
  context: RstestContext,
1077
+ // The explicit browser-project subset the executor was constructed with. Falls
1078
+ // back to re-deriving from `context` for internal callers (e.g. the watch
1079
+ // plugin) that do not carry the plan's project list.
1080
+ browserProjects: ProjectContext[] = getBrowserProjects(context),
1121
1081
  ): Promise<BrowserProjectEntries[]> => {
1122
- // Only collect entries for browser mode projects
1123
- const browserProjects = getBrowserProjects(context);
1124
-
1125
1082
  return Promise.all(
1126
1083
  browserProjects.map(async (project) => {
1127
1084
  const {
@@ -1289,16 +1246,51 @@ const generateManifestModule = ({
1289
1246
  project.normalizedConfig.exclude.patterns,
1290
1247
  projectRootPosix,
1291
1248
  );
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()},`);
1249
+ const { includeSource } = project.normalizedConfig;
1250
+ const emitContext = (contextVarName: string, regExp: RegExp): void => {
1251
+ lines.push(
1252
+ `const ${contextVarName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
1253
+ );
1254
+ lines.push(' recursive: true,');
1255
+ lines.push(` regExp: ${regExp.toString()},`);
1256
+ if (excludeRegExp) {
1257
+ lines.push(` exclude: ${excludeRegExp.toString()},`);
1258
+ }
1259
+ lines.push(" mode: 'lazy',");
1260
+ lines.push('});');
1261
+ };
1262
+
1263
+ if (includeSource.length === 0) {
1264
+ emitContext(varName, includeRegExp);
1265
+ } else {
1266
+ // In-source test files (`includeSource`) carry an
1267
+ // `if (import.meta.rstest)` block. The include context can't see them,
1268
+ // so a second context over the `includeSource` globs backs
1269
+ // host-scheduled loads, while `keys()` only unions the entry-probed
1270
+ // in-source files (the probe does not apply inside the bundle, so raw
1271
+ // source-context keys would execute never-probed files and fail with
1272
+ // "No test suites found"). The probed list can go stale until a
1273
+ // manifest refresh; scheduled-by-path loading never does.
1274
+ emitContext(`${varName}_include`, includeRegExp);
1275
+ emitContext(`${varName}_source`, globPatternsToRegExp(includeSource));
1276
+ const probedKeys = testFiles.map((filePath) =>
1277
+ toContextKey(filePath, projectRootPosix),
1278
+ );
1279
+ lines.push(`const ${varName}_probed = ${JSON.stringify(probedKeys)};`);
1280
+ lines.push(
1281
+ `const ${varName}_includeKeys = new Set(${varName}_include.keys());`,
1282
+ );
1283
+ lines.push(`const ${varName} = Object.assign(`);
1284
+ lines.push(
1285
+ ` (key) => ${varName}_includeKeys.has(key) ? ${varName}_include(key) : ${varName}_source(key),`,
1286
+ );
1287
+ lines.push(' {');
1288
+ lines.push(
1289
+ ` keys: () => Array.from(new Set([...${varName}_includeKeys, ...${varName}_probed])),`,
1290
+ );
1291
+ lines.push(' },');
1292
+ lines.push(');');
1299
1293
  }
1300
- lines.push(" mode: 'lazy',");
1301
- lines.push('});');
1302
1294
  } else {
1303
1295
  // One-shot runs: the file set is fixed and already filtered, so emit an
1304
1296
  // explicit lazy-import map (one chunk per literal `import()`, like the
@@ -1455,16 +1447,28 @@ const registerWatchCleanup = (): void => {
1455
1447
 
1456
1448
  const createBrowserRuntime = async ({
1457
1449
  context,
1458
- projectEntries,
1450
+ projectEntries: initialProjectEntries,
1451
+ browserProjects,
1452
+ shardedEntries,
1453
+ freezeShardedEntries,
1459
1454
  tempDir,
1460
1455
  isWatchMode,
1461
1456
  onTriggerRerun,
1462
1457
  containerDistPath,
1463
1458
  containerDevServer,
1464
1459
  forceHeadless,
1460
+ skipProviderLaunch,
1461
+ appliedModifyRstestConfigEnvironments,
1465
1462
  }: {
1466
1463
  context: RstestContext;
1467
1464
  projectEntries: BrowserProjectEntries[];
1465
+ /**
1466
+ * The explicit browser-project subset (plan output). Drives launch-option
1467
+ * consistency and the container origin (`browserProjects[0]`).
1468
+ */
1469
+ browserProjects: ProjectContext[];
1470
+ shardedEntries?: Map<string, { entries: Record<string, string> }>;
1471
+ freezeShardedEntries?: boolean;
1468
1472
  tempDir: string;
1469
1473
  isWatchMode: boolean;
1470
1474
  onTriggerRerun?: () => Promise<void>;
@@ -1472,6 +1476,8 @@ const createBrowserRuntime = async ({
1472
1476
  containerDevServer?: string;
1473
1477
  /** Force headless mode regardless of user config (used for list command) */
1474
1478
  forceHeadless?: boolean;
1479
+ skipProviderLaunch?: boolean;
1480
+ appliedModifyRstestConfigEnvironments?: Set<string>;
1475
1481
  }): Promise<BrowserRuntime> => {
1476
1482
  // ---- Shared singletons (created once, wired into every project server) ----
1477
1483
  const containerHtmlTemplate = containerDistPath
@@ -1493,9 +1499,83 @@ const createBrowserRuntime = async ({
1493
1499
  }
1494
1500
  };
1495
1501
 
1496
- const browserProjects = getBrowserProjects(context);
1497
- const browserLaunchOptions =
1502
+ let browserLaunchOptions =
1498
1503
  ensureConsistentBrowserLaunchOptions(browserProjects);
1504
+ let projectEntries = initialProjectEntries;
1505
+ const manifestModules: Array<{
1506
+ manifestPath: string;
1507
+ project: ProjectContext;
1508
+ modules: Record<string, string>;
1509
+ }> = [];
1510
+
1511
+ const createRuntimeWithoutProvider = (): BrowserRuntime => {
1512
+ const firstProject = browserProjects[0]!;
1513
+ return {
1514
+ projectServers: new Map(),
1515
+ containerServer: {
1516
+ projectName: firstProject.name,
1517
+ environmentName: firstProject.environmentName,
1518
+ rsbuildInstance: undefined as unknown as RsbuildInstance,
1519
+ devServer: {
1520
+ close: async () => undefined,
1521
+ } as RsbuildDevServer,
1522
+ port: 0,
1523
+ manifestPath: '',
1524
+ },
1525
+ browser: undefined as unknown as BrowserProviderBrowser,
1526
+ browserLaunchOptions,
1527
+ wsPort: 0,
1528
+ tempDir,
1529
+ setContainerOptions,
1530
+ dispatchHandlers,
1531
+ wss: undefined as unknown as WebSocketServer,
1532
+ projectEntries,
1533
+ };
1534
+ };
1535
+
1536
+ const getProjectEntry = (project: ProjectContext) =>
1537
+ projectEntries.find(
1538
+ (item) => item.project.environmentName === project.environmentName,
1539
+ );
1540
+
1541
+ const refreshManifestModule = (manifestModule: {
1542
+ manifestPath: string;
1543
+ project: ProjectContext;
1544
+ modules: Record<string, string>;
1545
+ }): void => {
1546
+ const entry = getProjectEntry(manifestModule.project);
1547
+ manifestModule.modules[manifestModule.manifestPath] =
1548
+ generateManifestModule({
1549
+ manifestPath: manifestModule.manifestPath,
1550
+ entries: [
1551
+ {
1552
+ project: manifestModule.project,
1553
+ testFiles: entry?.testFiles ?? [],
1554
+ setupFiles: entry?.setupFiles ?? [],
1555
+ },
1556
+ ],
1557
+ isWatchMode,
1558
+ });
1559
+ };
1560
+
1561
+ const refreshProjectEntries = async (): Promise<void> => {
1562
+ validateBrowserConfig(context);
1563
+ browserLaunchOptions =
1564
+ ensureConsistentBrowserLaunchOptions(browserProjects);
1565
+ const updatedShardedEntries = freezeShardedEntries
1566
+ ? shardedEntries
1567
+ : context.normalizedConfig.shard
1568
+ ? await resolveShardedEntries(context, { silent: true })
1569
+ : shardedEntries;
1570
+ projectEntries = await resolveProjectEntries(
1571
+ context,
1572
+ updatedShardedEntries,
1573
+ browserProjects,
1574
+ );
1575
+ for (const manifestModule of manifestModules) {
1576
+ refreshManifestModule(manifestModule);
1577
+ }
1578
+ };
1499
1579
 
1500
1580
  // Rstest internal aliases that must not be overridden by user config
1501
1581
  const browserRuntimePath = fileURLToPath(
@@ -1505,11 +1585,13 @@ const createBrowserRuntime = async ({
1505
1585
  // Shared by every project — only the per-project `@rstest/browser-manifest`
1506
1586
  // alias varies (one virtual manifest per server).
1507
1587
  const staticRstestAliases = {
1508
- // User test code: import { describe, it } from '@rstest/core'
1509
- '@rstest/core': resolveBrowserFile('client/public.ts'),
1588
+ // User test code `import { describe, it } from '@rstest/core'` is NOT
1589
+ // aliased: `applyWebMockRspackConfig` keeps the request external against
1590
+ // `globalThis['@rstest/core']` (node parity), which also keeps the mock
1591
+ // hoister's provider-import ordering correct for `rs.hoisted` callbacks.
1510
1592
  // User test code: import { page } from '@rstest/browser'
1511
1593
  '@rstest/browser': resolveBrowserFile('browser.ts'),
1512
- // Browser runtime APIs for entry.ts and public.ts
1594
+ // Browser runtime APIs for entry.ts
1513
1595
  // Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
1514
1596
  '@rstest/core/internal/browser-runtime': browserRuntimePath,
1515
1597
  };
@@ -1591,10 +1673,6 @@ const createBrowserRuntime = async ({
1591
1673
  }
1592
1674
  };
1593
1675
 
1594
- const entryByEnvironmentName = new Map(
1595
- projectEntries.map((entry) => [entry.project.environmentName, entry]),
1596
- );
1597
-
1598
1676
  // ---- Build one isolated rsbuild instance + dev server per project ----
1599
1677
  const buildProjectServer = async (
1600
1678
  project: ProjectContext,
@@ -1605,20 +1683,27 @@ const createBrowserRuntime = async ({
1605
1683
  toSafeVarName(project.environmentName),
1606
1684
  VIRTUAL_MANIFEST_FILENAME,
1607
1685
  );
1608
- const entry = entryByEnvironmentName.get(project.environmentName);
1609
- const manifestSource = generateManifestModule({
1686
+ const entry = getProjectEntry(project);
1687
+ const virtualManifestModules = {
1688
+ [manifestPath]: generateManifestModule({
1689
+ manifestPath,
1690
+ entries: [
1691
+ {
1692
+ project,
1693
+ testFiles: entry?.testFiles ?? [],
1694
+ setupFiles: entry?.setupFiles ?? [],
1695
+ },
1696
+ ],
1697
+ isWatchMode,
1698
+ }),
1699
+ };
1700
+ const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin(
1701
+ virtualManifestModules,
1702
+ );
1703
+ manifestModules.push({
1610
1704
  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,
1705
+ project,
1706
+ modules: virtualManifestModules,
1622
1707
  });
1623
1708
 
1624
1709
  const rstestInternalAliases = {
@@ -1631,11 +1716,10 @@ const createBrowserRuntime = async ({
1631
1716
  const enableHmr = shouldEnableBrowserHmr(isWatchMode, isHeadless);
1632
1717
 
1633
1718
  const rsbuildInstance = await createRsbuild({
1634
- callerName: 'rstest-browser',
1719
+ callerName: 'rstest',
1635
1720
  rsbuildConfig: {
1636
1721
  root: context.rootPath,
1637
1722
  mode: 'development',
1638
- plugins: project.normalizedConfig.plugins || [],
1639
1723
  server: {
1640
1724
  printUrls: false,
1641
1725
  // Each project gets its own dev server. Honor an explicitly
@@ -1649,13 +1733,29 @@ const createBrowserRuntime = async ({
1649
1733
  },
1650
1734
  dev: createBrowserRsbuildDevConfig(enableHmr),
1651
1735
  environments: {
1652
- [project.environmentName]: {},
1736
+ [project.environmentName]:
1737
+ getBrowserRsbuildEnvironmentConfig(project),
1653
1738
  },
1654
1739
  },
1655
1740
  });
1656
1741
 
1742
+ initModifyRstestConfigHooks(
1743
+ context,
1744
+ rsbuildInstance,
1745
+ [project],
1746
+ [project],
1747
+ {
1748
+ getEnvironmentConfig: getBrowserRsbuildEnvironmentConfig,
1749
+ onModifyRstestConfigApplied: refreshProjectEntries,
1750
+ appliedEnvironmentNames: appliedModifyRstestConfigEnvironments,
1751
+ },
1752
+ );
1753
+
1657
1754
  // Add plugin to merge user Rsbuild config with rstest required config
1658
1755
  rsbuildInstance.addPlugins([
1756
+ // Same mock runtime as the node build (importActual doppelganger rule +
1757
+ // mock webpack runtime module); order-insensitive and self-contained.
1758
+ pluginMockRuntime,
1659
1759
  {
1660
1760
  name: 'rstest:browser-user-config',
1661
1761
  setup(api) {
@@ -1710,6 +1810,10 @@ const createBrowserRuntime = async ({
1710
1810
  define: {
1711
1811
  'process.env': rstestEnvDefine,
1712
1812
  'import.meta.env': rstestEnvDefine,
1813
+ // In-source `if (import.meta.rstest)` blocks read the
1814
+ // per-file runtime API the client entry publishes on
1815
+ // `globalThis` (node parity: `global['@rstest/core']`).
1816
+ 'import.meta.rstest': importMetaRstestDefine('web'),
1713
1817
  },
1714
1818
  },
1715
1819
  output: {
@@ -1722,6 +1826,14 @@ const createBrowserRuntime = async ({
1722
1826
  tools: {
1723
1827
  rspack: (rspackConfig) => {
1724
1828
  rspackConfig.mode = 'development';
1829
+ // Web parameterization of the node mock transform:
1830
+ // RstestPlugin (hoist + path injection), the
1831
+ // `@rstest/core` global external, and
1832
+ // `exportsPresence: 'warn'`.
1833
+ applyWebMockRspackConfig(rspackConfig, {
1834
+ rspack,
1835
+ rootPath: project.rootPath,
1836
+ });
1725
1837
  // lazyCompilation's only delivery transport is the HMR
1726
1838
  // runtime, so it follows the same gate as HMR (see
1727
1839
  // `shouldEnableBrowserHmr`): headed watch only, everything
@@ -1736,7 +1848,7 @@ const createBrowserRuntime = async ({
1736
1848
 
1737
1849
  // Extract and merge sourcemaps from pre-built @rstest/core files
1738
1850
  // This preserves the sourcemap chain for inline snapshot support
1739
- // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
1851
+ // See: https://rspack.rs/config/module-rules#rulesextractsourcemap
1740
1852
  const browserRuntimeDir = dirname(browserRuntimePath);
1741
1853
  rspackConfig.module = rspackConfig.module || {};
1742
1854
  rspackConfig.module.rules =
@@ -1822,6 +1934,20 @@ const createBrowserRuntime = async ({
1822
1934
  ]);
1823
1935
  }
1824
1936
 
1937
+ if (skipProviderLaunch) {
1938
+ await rsbuildInstance.initConfigs({ action: 'dev' });
1939
+ return {
1940
+ projectName: project.name,
1941
+ environmentName: project.environmentName,
1942
+ rsbuildInstance,
1943
+ devServer: {
1944
+ close: async () => undefined,
1945
+ } as RsbuildDevServer,
1946
+ port: 0,
1947
+ manifestPath,
1948
+ };
1949
+ }
1950
+
1825
1951
  // Register coverage plugin if this project enables coverage
1826
1952
  const coverage = project.normalizedConfig.coverage;
1827
1953
  if (coverage?.enabled && context.command !== 'list') {
@@ -1948,6 +2074,10 @@ const createBrowserRuntime = async ({
1948
2074
  throw error;
1949
2075
  }
1950
2076
 
2077
+ if (skipProviderLaunch) {
2078
+ return createRuntimeWithoutProvider();
2079
+ }
2080
+
1951
2081
  // browserProjects is non-empty (ensureConsistentBrowserLaunchOptions throws
1952
2082
  // otherwise) and index 0 is the designated container origin.
1953
2083
  const containerServer = projectServers.get(browserProjects[0]!.name)!;
@@ -1983,6 +2113,7 @@ const createBrowserRuntime = async ({
1983
2113
  setContainerOptions,
1984
2114
  dispatchHandlers,
1985
2115
  wss,
2116
+ projectEntries,
1986
2117
  };
1987
2118
  } catch (error) {
1988
2119
  wss.close();
@@ -1993,10 +2124,10 @@ const createBrowserRuntime = async ({
1993
2124
 
1994
2125
  async function resolveProjectEntries(
1995
2126
  context: RstestContext,
1996
- shardedEntries?: Map<string, { entries: Record<string, string> }>,
2127
+ shardedEntries: Map<string, { entries: Record<string, string> }> | undefined,
2128
+ browserProjects: ProjectContext[],
1997
2129
  ): Promise<BrowserProjectEntries[]> {
1998
2130
  if (shardedEntries) {
1999
- const browserProjects = getBrowserProjects(context);
2000
2131
  const projectEntries: BrowserProjectEntries[] = [];
2001
2132
  for (const project of browserProjects) {
2002
2133
  const entryInfo = shardedEntries.get(project.environmentName);
@@ -2014,7 +2145,7 @@ async function resolveProjectEntries(
2014
2145
  }
2015
2146
  return projectEntries;
2016
2147
  }
2017
- return collectProjectEntries(context);
2148
+ return collectProjectEntries(context, browserProjects);
2018
2149
  }
2019
2150
 
2020
2151
  // ============================================================================
@@ -2026,11 +2157,18 @@ export const runBrowserController = async (
2026
2157
  options?: BrowserTestRunOptions,
2027
2158
  ): Promise<BrowserTestRunResult | void> => {
2028
2159
  const {
2029
- skipOnTestRunEnd = false,
2030
2160
  allowEmptyWatchRun = false,
2161
+ allowEmptyRun = false,
2162
+ filesOnly = false,
2031
2163
  onTraceEvents,
2164
+ env,
2032
2165
  } = options ?? {};
2033
2166
  const buildStart = Date.now();
2167
+ // Non-watch vs watch is the live switch for self-finalize: in non-watch runs
2168
+ // core owns the unified finalize (reporters, exit code, coverage) through
2169
+ // `finalizeRunCycle`, so the host never self-finalizes and always returns a
2170
+ // fully-populated result with `close`. Watch reruns keep their host-driven
2171
+ // per-rerun finalize.
2034
2172
  const isWatchMode = context.command === 'watch';
2035
2173
 
2036
2174
  // Per-file PhaseTrackers, populated only when `--trace` is on (caller
@@ -2041,7 +2179,11 @@ export const runBrowserController = async (
2041
2179
  const phaseTrackers = onTraceEvents
2042
2180
  ? new Map<string, PhaseTracker>()
2043
2181
  : undefined;
2044
- const browserProjects = getBrowserProjects(context);
2182
+ // Explicit projects input (plan output) replaces re-deriving `browser.enabled`
2183
+ // projects from `context`, whose `projects` array is mutated during planning.
2184
+ // Falls back to re-derivation only when the caller passes no list at all —
2185
+ // an explicit empty subset must stay empty, not widen to every project.
2186
+ const browserProjects = options?.projects ?? getBrowserProjects(context);
2045
2187
  const useHeadlessDirect = browserProjects.every(
2046
2188
  (project) => project.normalizedConfig.browser.headless,
2047
2189
  );
@@ -2109,7 +2251,7 @@ export const runBrowserController = async (
2109
2251
  close,
2110
2252
  };
2111
2253
 
2112
- if (!skipOnTestRunEnd) {
2254
+ if (isWatchMode) {
2113
2255
  for (const reporter of context.reporters) {
2114
2256
  await (reporter as Reporter).onTestRunEnd?.({
2115
2257
  results: [],
@@ -2133,11 +2275,16 @@ export const runBrowserController = async (
2133
2275
  error: unknown,
2134
2276
  cleanup?: () => Promise<void>,
2135
2277
  ): Promise<BrowserTestRunResult> => {
2136
- ensureProcessExitCode(1);
2278
+ // Non-watch runs defer the exit code to core's `finalizeRunCycle`, which
2279
+ // raises it from the returned outcome's `errors`. Watch reruns keep owning
2280
+ // their own exit code.
2281
+ if (isWatchMode) {
2282
+ ensureProcessExitCode(1);
2283
+ }
2137
2284
 
2138
2285
  const normalizedError = toError(error);
2139
2286
 
2140
- if (cleanup && skipOnTestRunEnd) {
2287
+ if (cleanup && !isWatchMode) {
2141
2288
  return buildErrorResult(normalizedError, cleanup);
2142
2289
  }
2143
2290
 
@@ -2159,7 +2306,7 @@ export const runBrowserController = async (
2159
2306
  };
2160
2307
 
2161
2308
  const notifyTestRunStart = async (): Promise<void> => {
2162
- if (skipOnTestRunEnd) {
2309
+ if (!isWatchMode) {
2163
2310
  return;
2164
2311
  }
2165
2312
 
@@ -2188,7 +2335,7 @@ export const runBrowserController = async (
2188
2335
  unhandledErrors?: Error[];
2189
2336
  filterRerunTestPaths?: string[];
2190
2337
  }): Promise<void> => {
2191
- if (skipOnTestRunEnd) {
2338
+ if (!isWatchMode) {
2192
2339
  return;
2193
2340
  }
2194
2341
 
@@ -2249,19 +2396,38 @@ export const runBrowserController = async (
2249
2396
  }
2250
2397
  }
2251
2398
 
2252
- const projectEntries = await resolveProjectEntries(
2399
+ let projectEntries = await resolveProjectEntries(
2253
2400
  context,
2254
2401
  options?.shardedEntries,
2402
+ browserProjects,
2255
2403
  );
2256
- const totalTests = projectEntries.reduce(
2404
+ let totalTests = projectEntries.reduce(
2257
2405
  (total, item) => total + item.testFiles.length,
2258
2406
  0,
2259
2407
  );
2260
2408
  const shouldKeepWatchingWithEmptySet = isWatchMode && allowEmptyWatchRun;
2409
+ const shouldInitializeEmptyBrowserHooks =
2410
+ totalTests === 0 && hasUserRstestConfigPlugins(browserProjects);
2411
+
2412
+ const createEmptyRunResult = (): BrowserTestRunResult => {
2413
+ const elapsed = Math.max(0, Date.now() - buildStart);
2414
+ return {
2415
+ results: [],
2416
+ testResults: [],
2417
+ duration: {
2418
+ totalTime: elapsed,
2419
+ buildTime: elapsed,
2420
+ testTime: 0,
2421
+ },
2422
+ hasFailure: false,
2423
+ getSourcemap: getBrowserSourcemap,
2424
+ resolveSourcemap: resolveBrowserSourcemap,
2425
+ };
2426
+ };
2261
2427
 
2262
- if (totalTests === 0) {
2428
+ const reportEmptyTestSet = (): boolean => {
2263
2429
  const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
2264
- if (!skipOnTestRunEnd) {
2430
+ if (isWatchMode || !allowEmptyRun) {
2265
2431
  const message = shouldKeepWatchingWithEmptySet
2266
2432
  ? 'No test files found.'
2267
2433
  : getNoTestFilesMessage({
@@ -2288,15 +2454,30 @@ export const runBrowserController = async (
2288
2454
  }
2289
2455
  }
2290
2456
 
2291
- if (code !== 0 && !shouldKeepWatchingWithEmptySet) {
2457
+ // In non-watch runs the host returns a void outcome and core's
2458
+ // `reportNoTestFiles` owns the exit code and the no-test reporter lifecycle;
2459
+ // the host must not set the code itself. Watch keeps its own exit code.
2460
+ if (
2461
+ isWatchMode &&
2462
+ code !== 0 &&
2463
+ !shouldKeepWatchingWithEmptySet &&
2464
+ !allowEmptyRun
2465
+ ) {
2292
2466
  ensureProcessExitCode(code);
2293
2467
  }
2294
- if (!shouldKeepWatchingWithEmptySet) {
2295
- return;
2468
+
2469
+ return !shouldKeepWatchingWithEmptySet;
2470
+ };
2471
+
2472
+ if (totalTests === 0 && !shouldInitializeEmptyBrowserHooks) {
2473
+ if (reportEmptyTestSet()) {
2474
+ return allowEmptyRun ? createEmptyRunResult() : undefined;
2296
2475
  }
2297
2476
  }
2298
2477
 
2299
- await notifyTestRunStart();
2478
+ if (!filesOnly) {
2479
+ await notifyTestRunStart();
2480
+ }
2300
2481
 
2301
2482
  const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
2302
2483
  const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
@@ -2327,6 +2508,9 @@ export const runBrowserController = async (
2327
2508
  runtime = await createBrowserRuntime({
2328
2509
  context,
2329
2510
  projectEntries,
2511
+ browserProjects,
2512
+ shardedEntries: options?.shardedEntries,
2513
+ freezeShardedEntries: options?.freezeShardedEntries,
2330
2514
  tempDir,
2331
2515
  isWatchMode,
2332
2516
  onTriggerRerun: isWatchMode
@@ -2336,6 +2520,9 @@ export const runBrowserController = async (
2336
2520
  : undefined,
2337
2521
  containerDistPath,
2338
2522
  containerDevServer,
2523
+ skipProviderLaunch: filesOnly,
2524
+ appliedModifyRstestConfigEnvironments:
2525
+ options?.appliedModifyRstestConfigEnvironments,
2339
2526
  });
2340
2527
  } catch (error) {
2341
2528
  return failWithError(error, async () => {
@@ -2355,9 +2542,37 @@ export const runBrowserController = async (
2355
2542
  }
2356
2543
  }
2357
2544
 
2358
- const { browser, browserLaunchOptions, wsPort, wss } = runtime;
2545
+ projectEntries = runtime.projectEntries;
2546
+ totalTests = projectEntries.reduce(
2547
+ (total, item) => total + item.testFiles.length,
2548
+ 0,
2549
+ );
2550
+
2359
2551
  const buildTime = Date.now() - buildStart;
2360
2552
 
2553
+ if (filesOnly) {
2554
+ return {
2555
+ results: [],
2556
+ testResults: [],
2557
+ duration: {
2558
+ totalTime: buildTime,
2559
+ buildTime,
2560
+ testTime: 0,
2561
+ },
2562
+ hasFailure: false,
2563
+ getSourcemap: getBrowserSourcemap,
2564
+ resolveSourcemap: resolveBrowserSourcemap,
2565
+ close: () => destroyBrowserRuntime(runtime),
2566
+ };
2567
+ }
2568
+
2569
+ if (totalTests === 0 && reportEmptyTestSet()) {
2570
+ await destroyBrowserRuntime(runtime);
2571
+ return allowEmptyRun ? createEmptyRunResult() : undefined;
2572
+ }
2573
+
2574
+ const { browser, browserLaunchOptions, wsPort, wss } = runtime;
2575
+
2361
2576
  // Collect all test files from project entries with project info
2362
2577
  // Normalize paths to posix format for cross-platform compatibility
2363
2578
  const allTestFiles: TestFileInfo[] = projectEntries.flatMap((entry) =>
@@ -2374,17 +2589,17 @@ export const runBrowserController = async (
2374
2589
  name: project.name,
2375
2590
  environmentName: project.environmentName,
2376
2591
  projectRoot: normalize(project.rootPath),
2377
- runtimeConfig: serializableConfig(getRuntimeConfigFromProject(project)),
2592
+ runtimeConfig: serializableConfig(
2593
+ // `env` is the post-globalSetup change-set from the core pre-cycle
2594
+ // stage; the projection layers it between the static base and the
2595
+ // user `test.env` config.
2596
+ projectRuntimeConfig(project, { envMode: 'static', envOverlay: env }),
2597
+ ),
2378
2598
  viewport: project.normalizedConfig.browser.viewport,
2379
2599
  }),
2380
2600
  );
2381
2601
 
2382
- // Get max testTimeout from all browser projects for RPC timeout
2383
- const maxTestTimeoutForRpc = Math.max(
2384
- ...browserProjects.map(
2385
- (p) => p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT,
2386
- ),
2387
- );
2602
+ const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
2388
2603
 
2389
2604
  const projectRunnerUrls = Object.fromEntries(
2390
2605
  [...runtime.projectServers].map(([name, server]) => [
@@ -2497,18 +2712,64 @@ export const runBrowserController = async (
2497
2712
  const caseResults: TestResult[] = [];
2498
2713
  let fatalError: Error | null = null;
2499
2714
 
2715
+ // Runner lifecycle events flow through the shared RunnerEventSink (the same
2716
+ // pump the node pool uses), so browser mode feeds stateManager and fans out to
2717
+ // reporters via one implementation. One sink is bound per browser project up
2718
+ // front from the executor's own project plan (`browserProjects`) — never from
2719
+ // `context.projects`, which planning mutates to also contain node projects. The
2720
+ // previous lazy resolver fell back to `context.projects[0]`, so a browser event
2721
+ // could be attributed to a *node* project's config; binding per browser project
2722
+ // here removes that fallback and keeps per-project `onConsoleLog` filtering and
2723
+ // `resolveSnapshotPath` correct across browser projects that share a relative
2724
+ // test path.
2725
+ const runnerSinks = new Map<string, RunnerEventSink>(
2726
+ browserProjects.map((project) => [
2727
+ project.name,
2728
+ createRunnerEventSink(context, project.normalizedConfig),
2729
+ ]),
2730
+ );
2731
+ const firstBrowserSink = runnerSinks.get(browserProjects[0]!.name)!;
2732
+
2733
+ // testPath -> owning project name, stamped from the authoritative client
2734
+ // file-start event (it carries the manifest-resolved projectName) before any
2735
+ // other per-file event for that path fires — including on watch reruns, so the
2736
+ // mapping stays correct when a rerun adds a file. Fully eliminating this map in
2737
+ // favor of a project stamp on every wire event is deferred (it would add
2738
+ // `project` to the shared `TestResult`/`TestFileResult` payloads).
2739
+ const projectNameByTestPath = new Map<string, string>();
2740
+
2741
+ const sinkForProjectName = (projectName: string): RunnerEventSink =>
2742
+ runnerSinks.get(projectName) ?? firstBrowserSink;
2743
+
2744
+ const sinkForTestPath = (testPath: string): RunnerEventSink => {
2745
+ const projectName = projectNameByTestPath.get(testPath);
2746
+ return projectName ? sinkForProjectName(projectName) : firstBrowserSink;
2747
+ };
2748
+
2749
+ // Silent-console buffering runs through the shared controller — the same
2750
+ // engine the node worker uses — so `silent: 'passed-only'` buffers logs and
2751
+ // replays only the failing tasks'. Intercepted replays route through the
2752
+ // owning project's sink, so they honor per-project `onConsoleLog` and
2753
+ // `disableConsoleIntercept` (the browser host previously flushed straight to
2754
+ // reporters, bypassing both). `writeOriginalLog` is a host-side no-op: page
2755
+ // logs have no host "original stream" — the page console and headed terminal
2756
+ // forwarding already show them, so re-emitting here would double-print.
2757
+ const silentConsoleController = createSilentConsoleController({
2758
+ runtimeConfig: {
2759
+ silent: context.normalizedConfig.silent,
2760
+ disableConsoleIntercept: context.normalizedConfig.disableConsoleIntercept,
2761
+ },
2762
+ emitInterceptedLog: (log) =>
2763
+ sinkForTestPath(log.testPath).onConsoleLog(log),
2764
+ writeOriginalLog: () => {},
2765
+ });
2766
+
2500
2767
  const snapshotRpcMethods = {
2501
2768
  async resolveSnapshotPath(testPath: string): Promise<string> {
2502
- const snapExtension = '.snap';
2503
- const resolver =
2504
- context.normalizedConfig.resolveSnapshotPath ||
2505
- (() =>
2506
- join(
2507
- dirname(testPath),
2508
- '__snapshots__',
2509
- `${basename(testPath)}${snapExtension}`,
2510
- ));
2511
- return resolver(testPath, snapExtension);
2769
+ return resolveSnapshotPathDefault(
2770
+ testPath,
2771
+ context.normalizedConfig.resolveSnapshotPath,
2772
+ );
2512
2773
  },
2513
2774
  async readSnapshotFile(filepath: string): Promise<string | null> {
2514
2775
  try {
@@ -2534,6 +2795,7 @@ export const runBrowserController = async (
2534
2795
  const handleTestFileStart = async (
2535
2796
  payload: TestFileStartPayload,
2536
2797
  ): Promise<void> => {
2798
+ projectNameByTestPath.set(payload.testPath, payload.projectName);
2537
2799
  if (phaseTrackers) {
2538
2800
  const tracker = new PhaseTracker({
2539
2801
  trace: {
@@ -2545,51 +2807,37 @@ export const runBrowserController = async (
2545
2807
  tracker.transition('prepare');
2546
2808
  phaseTrackers.set(payload.testPath, tracker);
2547
2809
  }
2548
- await Promise.all(
2549
- context.reporters.map((reporter) =>
2550
- (reporter as Reporter).onTestFileStart?.({
2551
- testId: getFileTaskId(payload.testPath),
2552
- testPath: payload.testPath,
2553
- tests: [],
2554
- }),
2555
- ),
2556
- );
2810
+ // The client sends `{ testPath, projectName }`; the sink adapter builds the
2811
+ // `TestFileInfo` the reporters and stateManager expect.
2812
+ await sinkForProjectName(payload.projectName).onTestFileStart({
2813
+ testId: getFileTaskId(payload.testPath),
2814
+ testPath: payload.testPath,
2815
+ tests: [],
2816
+ });
2557
2817
  };
2558
2818
 
2559
2819
  const handleTestFileReady = async (
2560
2820
  payload: TestFileReadyPayload,
2561
2821
  ): Promise<void> => {
2562
2822
  phaseTrackers?.get(payload.testPath)?.transition('tests');
2563
- await Promise.all(
2564
- context.reporters.map((reporter) =>
2565
- (reporter as Reporter).onTestFileReady?.(payload),
2566
- ),
2567
- );
2823
+ await sinkForTestPath(payload.testPath).onTestFileReady(payload);
2568
2824
  };
2569
2825
 
2570
2826
  const handleTestSuiteStart = async (
2571
2827
  payload: TestSuiteStartPayload,
2572
2828
  ): Promise<void> => {
2573
2829
  phaseTrackers?.get(payload.testPath)?.recordSuiteStart(payload);
2574
- await Promise.all(
2575
- context.reporters.map((reporter) =>
2576
- (reporter as Reporter).onTestSuiteStart?.(payload),
2577
- ),
2578
- );
2830
+ await sinkForTestPath(payload.testPath).onTestSuiteStart(payload);
2579
2831
  };
2580
2832
 
2581
2833
  const handleTestSuiteResult = async (
2582
2834
  payload: TestSuiteResultPayload,
2583
2835
  ): Promise<void> => {
2584
2836
  phaseTrackers?.get(payload.testPath)?.recordSuiteResult(payload);
2585
- await Promise.all(
2586
- context.reporters.map((reporter) =>
2587
- (reporter as Reporter).onTestSuiteResult?.(payload),
2588
- ),
2589
- );
2837
+ await sinkForTestPath(payload.testPath).onTestSuiteResult(payload);
2590
2838
 
2591
2839
  if (context.normalizedConfig.silent === 'passed-only') {
2592
- await flushBufferedLogsForTask({
2840
+ silentConsoleController.flushBufferedLogsForTask({
2593
2841
  taskId: payload.testId,
2594
2842
  status: payload.status,
2595
2843
  taskParentNames: payload.parentNames,
@@ -2603,24 +2851,17 @@ export const runBrowserController = async (
2603
2851
  payload: TestCaseStartPayload,
2604
2852
  ): Promise<void> => {
2605
2853
  phaseTrackers?.get(payload.testPath)?.recordCaseStart(payload);
2606
- await Promise.all(
2607
- context.reporters.map((reporter) =>
2608
- (reporter as Reporter).onTestCaseStart?.(payload),
2609
- ),
2610
- );
2854
+ // Fire-and-forget on both transports (the sink does not await case-start).
2855
+ sinkForTestPath(payload.testPath).onTestCaseStart(payload);
2611
2856
  };
2612
2857
 
2613
2858
  const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
2614
2859
  caseResults.push(payload);
2615
2860
  phaseTrackers?.get(payload.testPath)?.recordCaseResult(payload);
2616
- await Promise.all(
2617
- context.reporters.map((reporter) =>
2618
- (reporter as Reporter).onTestCaseResult?.(payload),
2619
- ),
2620
- );
2861
+ await sinkForTestPath(payload.testPath).onTestCaseResult(payload);
2621
2862
 
2622
2863
  if (context.normalizedConfig.silent === 'passed-only') {
2623
- await flushBufferedLogsForTask({
2864
+ silentConsoleController.flushBufferedLogsForTask({
2624
2865
  taskId: payload.testId,
2625
2866
  status: payload.status,
2626
2867
  taskParentNames: payload.parentNames,
@@ -2635,9 +2876,6 @@ export const runBrowserController = async (
2635
2876
  ): Promise<void> => {
2636
2877
  reporterResults.push(payload);
2637
2878
  context.updateReporterResultState([payload], payload.results);
2638
- if (payload.snapshotResult) {
2639
- context.snapshotManager.add(payload.snapshotResult);
2640
- }
2641
2879
 
2642
2880
  if (phaseTrackers) {
2643
2881
  const tracker = phaseTrackers.get(payload.testPath);
@@ -2650,7 +2888,7 @@ export const runBrowserController = async (
2650
2888
  }
2651
2889
 
2652
2890
  if (context.normalizedConfig.silent === 'passed-only') {
2653
- await flushBufferedLogsForTask({
2891
+ silentConsoleController.flushBufferedLogsForTask({
2654
2892
  taskId: payload.testId,
2655
2893
  status: payload.status,
2656
2894
  taskParentNames: payload.parentNames,
@@ -2659,12 +2897,12 @@ export const runBrowserController = async (
2659
2897
  });
2660
2898
  }
2661
2899
 
2662
- await Promise.all(
2663
- context.reporters.map((reporter) =>
2664
- (reporter as Reporter).onTestFileResult?.(payload),
2665
- ),
2666
- );
2667
- if (payload.status === 'fail') {
2900
+ // Feeds stateManager, fans out onTestFileResult to reporters, and ingests
2901
+ // payload.snapshotResult (the snapshotManager.add moved into the sink).
2902
+ await sinkForTestPath(payload.testPath).onTestFileResult(payload);
2903
+ // In non-watch runs core owns the exit code via `finalizeRunCycle` (the
2904
+ // failing file rides the returned outcome); watch reruns set it here.
2905
+ if (isWatchMode && payload.status === 'fail') {
2668
2906
  ensureProcessExitCode(1);
2669
2907
  }
2670
2908
  };
@@ -2672,7 +2910,8 @@ export const runBrowserController = async (
2672
2910
  const handleLog = async (payload: LogPayload): Promise<void> => {
2673
2911
  const log: UserConsoleLog = {
2674
2912
  content: payload.content,
2675
- name: payload.level,
2913
+ // Same colored level label as the node worker's CustomConsole.
2914
+ name: getPrettyConsoleName(payload.level),
2676
2915
  taskId: payload.taskId,
2677
2916
  taskName: payload.taskName,
2678
2917
  taskParentNames: payload.taskParentNames,
@@ -2681,135 +2920,17 @@ export const runBrowserController = async (
2681
2920
  type: payload.type,
2682
2921
  trace: payload.trace,
2683
2922
  };
2684
- if (context.normalizedConfig.silent === true) {
2685
- return;
2686
- }
2687
-
2688
- if (context.normalizedConfig.silent === 'passed-only') {
2689
- bufferConsoleLog(log);
2690
- return;
2691
- }
2692
-
2693
- if (context.normalizedConfig.disableConsoleIntercept) {
2694
- return;
2695
- }
2696
-
2697
- await emitUserConsoleLog(log);
2923
+ silentConsoleController.onConsoleLog(log);
2698
2924
  };
2699
2925
 
2700
2926
  const handleFatal = async (payload: FatalPayload): Promise<void> => {
2701
2927
  const error = new Error(payload.message);
2702
2928
  error.stack = payload.stack;
2703
2929
  fatalError = error;
2704
- ensureProcessExitCode(1);
2705
- };
2706
-
2707
- const bufferedConsoleLogs = new Map<string, UserConsoleLog[]>();
2708
- const suiteIdsByChain = new Map<string, string>();
2709
-
2710
- const getSuiteChainKey = (names: string[]): string => {
2711
- return names.join('\u0000');
2712
- };
2713
-
2714
- const pushTaskId = (taskIds: string[], taskId: string): void => {
2715
- if (!taskIds.includes(taskId)) {
2716
- taskIds.push(taskId);
2717
- }
2718
- };
2719
-
2720
- const shouldEmitUserConsoleLog = (log: UserConsoleLog): boolean => {
2721
- return (
2722
- context.normalizedConfig.onConsoleLog?.(log.content, log.type) !== false
2723
- );
2724
- };
2725
-
2726
- const emitUserConsoleLog = async (log: UserConsoleLog): Promise<void> => {
2727
- if (!shouldEmitUserConsoleLog(log)) {
2728
- return;
2729
- }
2730
-
2731
- await Promise.all(
2732
- context.reporters.map((reporter) =>
2733
- (reporter as Reporter).onUserConsoleLog?.(log),
2734
- ),
2735
- );
2736
- };
2737
-
2738
- const bufferConsoleLog = (log: UserConsoleLog): void => {
2739
- const taskId = getBufferedLogTaskId(log);
2740
- const logs = bufferedConsoleLogs.get(taskId) || [];
2741
- logs.push(log);
2742
- bufferedConsoleLogs.set(taskId, logs);
2743
-
2744
- if (log.taskType === 'suite' && log.taskId) {
2745
- suiteIdsByChain.set(
2746
- getSuiteChainKey([...(log.taskParentNames || []), log.taskName || '']),
2747
- log.taskId,
2748
- );
2749
- }
2750
- };
2751
-
2752
- const flushBufferedLogsForTask = async ({
2753
- taskId,
2754
- status,
2755
- taskParentNames,
2756
- taskType,
2757
- testPath,
2758
- }: {
2759
- taskId: string;
2760
- status: TestResult['status'];
2761
- taskParentNames?: string[];
2762
- taskType?: 'file' | 'suite' | 'case';
2763
- testPath: string;
2764
- }): Promise<void> => {
2765
- if (status !== 'fail') {
2766
- bufferedConsoleLogs.delete(taskId);
2767
- return;
2768
- }
2769
-
2770
- const taskIdsToFlush: string[] = [];
2771
-
2772
- if (taskType === 'case') {
2773
- pushTaskId(taskIdsToFlush, getFileTaskId(testPath));
2774
-
2775
- const suiteNames = taskParentNames || [];
2776
- for (let i = 0; i < suiteNames.length; i++) {
2777
- const suiteId = suiteIdsByChain.get(
2778
- getSuiteChainKey(suiteNames.slice(0, i + 1)),
2779
- );
2780
-
2781
- if (suiteId) {
2782
- pushTaskId(taskIdsToFlush, suiteId);
2783
- }
2784
- }
2785
-
2786
- pushTaskId(taskIdsToFlush, taskId);
2787
- }
2788
-
2789
- if (taskType === 'suite') {
2790
- pushTaskId(taskIdsToFlush, getFileTaskId(testPath));
2791
- pushTaskId(taskIdsToFlush, taskId);
2792
- }
2793
-
2794
- if (taskType === 'file') {
2795
- pushTaskId(taskIdsToFlush, taskId);
2796
- }
2797
-
2798
- for (const bufferedTaskId of taskIdsToFlush) {
2799
- const logs = bufferedConsoleLogs.get(bufferedTaskId);
2800
- if (!logs) {
2801
- continue;
2802
- }
2803
-
2804
- bufferedConsoleLogs.delete(bufferedTaskId);
2805
-
2806
- for (const log of logs) {
2807
- await Promise.all(
2808
- context.reporters.map((reporter) =>
2809
- (reporter as Reporter).onUserConsoleLog?.(log),
2810
- ),
2811
- );
2812
- }
2930
+ // Non-watch runs surface the fatal error through the returned outcome and
2931
+ // let core's `finalizeRunCycle` set the exit code; watch reruns set it here.
2932
+ if (isWatchMode) {
2933
+ ensureProcessExitCode(1);
2813
2934
  }
2814
2935
  };
2815
2936
 
@@ -3117,6 +3238,18 @@ export const runBrowserController = async (
3117
3238
  }
3118
3239
  };
3119
3240
 
3241
+ // Bailed files never run, so they carry no case results — mirror the node
3242
+ // pool's skip result (`runInPool.ts`) so the summary reports them as skipped
3243
+ // rather than dropping them silently.
3244
+ const makeSkippedFileResult = (file: TestFileInfo): TestFileResult => ({
3245
+ testId: getFileTaskId(file.testPath),
3246
+ status: 'skip',
3247
+ name: '',
3248
+ testPath: file.testPath,
3249
+ project: file.projectName,
3250
+ results: [],
3251
+ });
3252
+
3120
3253
  const runFilesWithPool = async (files: TestFileInfo[]): Promise<void> => {
3121
3254
  if (files.length === 0) {
3122
3255
  return;
@@ -3134,6 +3267,7 @@ export const runBrowserController = async (
3134
3267
 
3135
3268
  const queue = [...files];
3136
3269
  const concurrency = getHeadlessConcurrency(context, queue.length);
3270
+ const bail = context.normalizedConfig.bail;
3137
3271
 
3138
3272
  const worker = async (): Promise<void> => {
3139
3273
  while (
@@ -3141,6 +3275,19 @@ export const runBrowserController = async (
3141
3275
  !run.cancelled &&
3142
3276
  runLifecycle.isTokenActive(run.token)
3143
3277
  ) {
3278
+ // Cross-file bail gate (parity with the node pool's pickup-time skip
3279
+ // at `runInPool.ts`): once the cycle-wide failed count reaches `bail`,
3280
+ // drain the remaining files as skipped instead of running them. The
3281
+ // count is cycle-scoped because `stateManager` is reset at the top of
3282
+ // every run/rerun (initial run and `prepareWatchRerunState`).
3283
+ if (bail && context.stateManager.getCountOfFailedTests() >= bail) {
3284
+ let skipped = queue.shift();
3285
+ while (skipped) {
3286
+ await handleTestFileComplete(makeSkippedFileResult(skipped));
3287
+ skipped = queue.shift();
3288
+ }
3289
+ return;
3290
+ }
3144
3291
  const next = queue.shift();
3145
3292
  if (!next) {
3146
3293
  return;
@@ -3173,6 +3320,12 @@ export const runBrowserController = async (
3173
3320
  await cancelRun(run, false);
3174
3321
  },
3175
3322
  runFiles: async (files) => {
3323
+ // Clear the previous cycle's stateManager/snapshotManager before the
3324
+ // rerun streams new events through the shared sink — otherwise failed
3325
+ // counts (bail) and snapshot summaries accumulate across reruns. The
3326
+ // initial run does not reach here (it calls `runFilesWithPool`
3327
+ // directly), so only reruns reset.
3328
+ prepareWatchRerunState(context);
3176
3329
  await notifyTestRunStart();
3177
3330
 
3178
3331
  const rerunStartTime = Date.now();
@@ -3233,7 +3386,7 @@ export const runBrowserController = async (
3233
3386
  hasFailure: false,
3234
3387
  getSourcemap: getBrowserSourcemap,
3235
3388
  resolveSourcemap: resolveBrowserSourcemap,
3236
- close: skipOnTestRunEnd
3389
+ close: !isWatchMode
3237
3390
  ? async () => {
3238
3391
  sessionRegistry.clear();
3239
3392
  await destroyBrowserRuntime(runtime);
@@ -3241,7 +3394,7 @@ export const runBrowserController = async (
3241
3394
  : undefined,
3242
3395
  };
3243
3396
 
3244
- if (!skipOnTestRunEnd) {
3397
+ if (isWatchMode) {
3245
3398
  await notifyTestRunEnd({ duration });
3246
3399
  }
3247
3400
 
@@ -3365,7 +3518,9 @@ export const runBrowserController = async (
3365
3518
  const isFailure = reporterResults.some(
3366
3519
  (result: TestFileResult) => result.status === 'fail',
3367
3520
  );
3368
- if (isFailure) {
3521
+ // Non-watch runs let core's `finalizeRunCycle` own the exit code from the
3522
+ // returned outcome; watch reruns set it here.
3523
+ if (isWatchMode && isFailure) {
3369
3524
  ensureProcessExitCode(1);
3370
3525
  }
3371
3526
 
@@ -3376,10 +3531,12 @@ export const runBrowserController = async (
3376
3531
  hasFailure: isFailure,
3377
3532
  getSourcemap: getBrowserSourcemap,
3378
3533
  resolveSourcemap: resolveBrowserSourcemap,
3379
- close: skipOnTestRunEnd ? closeHeadlessRuntime : undefined,
3534
+ // `closeHeadlessRuntime` is already `undefined` in watch mode, so the
3535
+ // non-watch caller (core) receives the deferred close and watch does not.
3536
+ close: closeHeadlessRuntime,
3380
3537
  };
3381
3538
 
3382
- if (!skipOnTestRunEnd) {
3539
+ if (isWatchMode) {
3383
3540
  try {
3384
3541
  await notifyTestRunEnd({ duration });
3385
3542
  } finally {
@@ -3754,7 +3911,11 @@ export const runBrowserController = async (
3754
3911
  }
3755
3912
  } catch (error) {
3756
3913
  fatalError = fatalError ?? toError(error);
3757
- ensureProcessExitCode(1);
3914
+ // Non-watch: the fatal error rides the returned outcome and core owns the
3915
+ // exit code; watch reruns set it here.
3916
+ if (isWatchMode) {
3917
+ ensureProcessExitCode(1);
3918
+ }
3758
3919
  }
3759
3920
 
3760
3921
  testTime = Date.now() - testStart;
@@ -3800,6 +3961,10 @@ export const runBrowserController = async (
3800
3961
  `Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`,
3801
3962
  ),
3802
3963
  );
3964
+ // Match the headless path: reset per-cycle state before the rerun
3965
+ // streams new events, so bail counts and snapshot summaries do not
3966
+ // accumulate across headed reruns.
3967
+ prepareWatchRerunState(context);
3803
3968
  await notifyTestRunStart();
3804
3969
 
3805
3970
  const rerunStartTime = Date.now();
@@ -3874,7 +4039,9 @@ export const runBrowserController = async (
3874
4039
  const isFailure = reporterResults.some(
3875
4040
  (result: TestFileResult) => result.status === 'fail',
3876
4041
  );
3877
- if (isFailure) {
4042
+ // Non-watch runs let core's `finalizeRunCycle` own the exit code from the
4043
+ // returned outcome; watch reruns set it here.
4044
+ if (isWatchMode && isFailure) {
3878
4045
  ensureProcessExitCode(1);
3879
4046
  }
3880
4047
 
@@ -3885,10 +4052,12 @@ export const runBrowserController = async (
3885
4052
  hasFailure: isFailure,
3886
4053
  getSourcemap: getBrowserSourcemap,
3887
4054
  resolveSourcemap: resolveBrowserSourcemap,
3888
- close: skipOnTestRunEnd ? closeContainerRuntime : undefined,
4055
+ // `closeContainerRuntime` is already `undefined` in watch mode, so the
4056
+ // non-watch caller (core) receives the deferred close and watch does not.
4057
+ close: closeContainerRuntime,
3889
4058
  };
3890
4059
 
3891
- if (!skipOnTestRunEnd) {
4060
+ if (isWatchMode) {
3892
4061
  try {
3893
4062
  await notifyTestRunEnd({ duration });
3894
4063
  } finally {
@@ -3926,20 +4095,20 @@ export type ListBrowserTestsResult = {
3926
4095
  */
3927
4096
  export const listBrowserTests = async (
3928
4097
  context: RstestContext,
3929
- options?: {
3930
- shardedEntries?: Map<string, { entries: Record<string, string> }>;
3931
- },
4098
+ options?: ListBrowserTestsOptions,
3932
4099
  ): Promise<ListBrowserTestsResult> => {
4100
+ const browserProjects = options?.projects ?? getBrowserProjects(context);
3933
4101
  const projectEntries = await resolveProjectEntries(
3934
4102
  context,
3935
4103
  options?.shardedEntries,
4104
+ browserProjects,
3936
4105
  );
3937
4106
  const totalTests = projectEntries.reduce(
3938
4107
  (total, item) => total + item.testFiles.length,
3939
4108
  0,
3940
4109
  );
3941
4110
 
3942
- if (totalTests === 0) {
4111
+ if (totalTests === 0 && !hasUserRstestConfigPlugins(browserProjects)) {
3943
4112
  return {
3944
4113
  list: [],
3945
4114
  close: async () => {},
@@ -3953,19 +4122,23 @@ export const listBrowserTests = async (
3953
4122
  `list-${Date.now()}`,
3954
4123
  );
3955
4124
 
3956
- const browserProjects = getBrowserProjects(context);
3957
-
3958
4125
  // Create a simplified browser runtime for collect mode
3959
4126
  let runtime: BrowserRuntime;
3960
4127
  try {
3961
4128
  runtime = await createBrowserRuntime({
3962
4129
  context,
3963
4130
  projectEntries,
4131
+ browserProjects,
4132
+ shardedEntries: options?.shardedEntries,
4133
+ freezeShardedEntries: options?.freezeShardedEntries,
3964
4134
  tempDir,
3965
4135
  isWatchMode: false,
3966
4136
  containerDistPath: undefined,
3967
4137
  containerDevServer: undefined,
3968
4138
  forceHeadless: true, // Always use headless for list command
4139
+ skipProviderLaunch: options?.filesOnly,
4140
+ appliedModifyRstestConfigEnvironments:
4141
+ options?.appliedModifyRstestConfigEnvironments,
3969
4142
  });
3970
4143
  } catch (error) {
3971
4144
  const providers = [
@@ -3982,6 +4155,29 @@ export const listBrowserTests = async (
3982
4155
  throw error;
3983
4156
  }
3984
4157
 
4158
+ if (options?.filesOnly) {
4159
+ const list = runtime.projectEntries.flatMap((entry) =>
4160
+ entry.testFiles.map((testPath) => ({
4161
+ testPath,
4162
+ project: entry.project.name,
4163
+ tests: [],
4164
+ })),
4165
+ );
4166
+ await destroyBrowserRuntime(runtime);
4167
+ return {
4168
+ list,
4169
+ close: async () => {},
4170
+ };
4171
+ }
4172
+
4173
+ if (!runtime.projectEntries.some((entry) => entry.testFiles.length > 0)) {
4174
+ await destroyBrowserRuntime(runtime);
4175
+ return {
4176
+ list: [],
4177
+ close: async () => {},
4178
+ };
4179
+ }
4180
+
3985
4181
  const { browser, browserLaunchOptions } = runtime;
3986
4182
 
3987
4183
  // Get browser projects for runtime config
@@ -3991,17 +4187,14 @@ export const listBrowserTests = async (
3991
4187
  name: project.name,
3992
4188
  environmentName: project.environmentName,
3993
4189
  projectRoot: normalize(project.rootPath),
3994
- runtimeConfig: serializableConfig(getRuntimeConfigFromProject(project)),
4190
+ runtimeConfig: serializableConfig(
4191
+ projectRuntimeConfig(project, { envMode: 'static' }),
4192
+ ),
3995
4193
  viewport: project.normalizedConfig.browser.viewport,
3996
4194
  }),
3997
4195
  );
3998
4196
 
3999
- // Get max testTimeout from all browser projects for RPC timeout
4000
- const maxTestTimeoutForRpc = Math.max(
4001
- ...browserProjects.map(
4002
- (p) => p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT,
4003
- ),
4004
- );
4197
+ const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
4005
4198
 
4006
4199
  const hostOptions: BrowserHostConfig = {
4007
4200
  rootPath: normalize(context.rootPath),
@@ -4026,6 +4219,10 @@ export const listBrowserTests = async (
4026
4219
 
4027
4220
  const serializedOptions = serializeForInlineScript(hostOptions);
4028
4221
 
4222
+ // Per-page collect watchdog: a test file whose module evaluation stalls must
4223
+ // not hang `rstest list` forever.
4224
+ const collectTimeoutMs = 30_000;
4225
+
4029
4226
  const collectFromServer = async (
4030
4227
  server: BrowserProjectServer,
4031
4228
  ): Promise<{ results: ListCommandResult[]; error: Error | null }> => {
@@ -4092,20 +4289,19 @@ export const listBrowserTests = async (
4092
4289
  waitUntil: 'load',
4093
4290
  });
4094
4291
 
4095
- // Wait for collection to complete with timeout
4096
- const timeoutMs = 30000;
4292
+ // Wait for collection to complete with the shared collect timeout.
4097
4293
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
4098
4294
  const timeoutPromise = new Promise<void>((resolve) => {
4099
4295
  timeoutId = setTimeout(() => {
4100
4296
  if (!collectCompleted) {
4101
4297
  logger.warn(
4102
4298
  color.yellow(
4103
- `[List] Browser test collection timed out after ${timeoutMs}ms`,
4299
+ `[List] Browser test collection timed out after ${collectTimeoutMs}ms`,
4104
4300
  ),
4105
4301
  );
4106
4302
  }
4107
4303
  resolve();
4108
- }, timeoutMs);
4304
+ }, collectTimeoutMs);
4109
4305
  });
4110
4306
 
4111
4307
  await Promise.race([collectPromise, timeoutPromise]);