@rstest/browser 0.11.0 → 0.11.2

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.
@@ -9,23 +9,32 @@ import {
9
9
  type BrowserTestRunOptions,
10
10
  type BrowserTestRunResult,
11
11
  type CoverageMapData,
12
+ type ListBrowserTestsOptions,
12
13
  color,
13
14
  createCoverageProvider,
15
+ createRunnerEventSink,
16
+ createSilentConsoleController,
14
17
  DEFAULT_TEST_TIMEOUT,
15
18
  type FormattedError,
16
19
  getNoTestFilesMessage,
17
20
  getSetupFiles,
18
21
  getTestEntries,
22
+ hasUserRstestConfigPlugins,
23
+ initModifyRstestConfigHooks,
19
24
  isDebug,
20
25
  type ListCommandResult,
21
26
  loadCoverageProvider,
22
27
  logger,
28
+ prepareWatchRerunState,
29
+ projectRuntimeConfig,
23
30
  PhaseTracker,
24
31
  type ProjectContext,
25
32
  type Reporter,
33
+ type RunnerEventSink,
26
34
  type RstestContext,
27
- type RuntimeConfig,
28
35
  resolveProjectBuildCache,
36
+ resolveSnapshotPathDefault,
37
+ resolveShardedEntries,
29
38
  RSTEST_ENV_SYMBOL_KEY,
30
39
  rsbuild,
31
40
  serializableConfig,
@@ -36,7 +45,7 @@ import {
36
45
  } from '@rstest/core/internal/browser';
37
46
  import { type BirpcReturn, createBirpc } from 'birpc';
38
47
  import openEditor from 'open-editor';
39
- import { basename, dirname, join, normalize, relative, resolve } from 'pathe';
48
+ import { dirname, join, normalize, relative, resolve } from 'pathe';
40
49
  import picomatch from 'picomatch';
41
50
  import sirv from 'sirv';
42
51
  import { type WebSocket, WebSocketServer } from 'ws';
@@ -45,6 +54,7 @@ import {
45
54
  createHostDispatchRouter,
46
55
  type HostDispatchRouterOptions,
47
56
  } from './dispatchCapabilities';
57
+ import { validateBrowserConfig } from './configValidation';
48
58
  import { createHeadedSerialTaskQueue } from './headedSerialTaskQueue';
49
59
  import { createHeadlessLatestRerunScheduler } from './headlessLatestRerunScheduler';
50
60
  import { attachHeadlessRunnerTransport } from './headlessTransport';
@@ -97,6 +107,8 @@ import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';
97
107
  const { createRsbuild, rspack } = rsbuild;
98
108
  type RsbuildDevServer = rsbuild.RsbuildDevServer;
99
109
  type RsbuildInstance = rsbuild.RsbuildInstance;
110
+ type RsbuildEnvironmentConfig = rsbuild.EnvironmentConfig &
111
+ Pick<rsbuild.RsbuildConfig, 'root'>;
100
112
 
101
113
  const __dirname = dirname(fileURLToPath(import.meta.url));
102
114
  const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
@@ -200,10 +212,6 @@ const getFileTaskId = (testPath: string): string => {
200
212
  return `file:${testPath}`;
201
213
  };
202
214
 
203
- const getBufferedLogTaskId = (log: UserConsoleLog): string => {
204
- return log.taskId ?? getFileTaskId(log.testPath);
205
- };
206
-
207
215
  const createDeferredPromise = <T>(): DeferredPromise<T> => {
208
216
  let resolve!: DeferredPromise<T>['resolve'];
209
217
  let reject!: DeferredPromise<T>['reject'];
@@ -433,6 +441,7 @@ type BrowserRuntime = {
433
441
  dispatchHandlers: Map<string, BrowserDispatchHandler>;
434
442
  wss: WebSocketServer;
435
443
  rpcManager?: ContainerRpcManager;
444
+ projectEntries: BrowserProjectEntries[];
436
445
  };
437
446
 
438
447
  // ============================================================================
@@ -973,82 +982,25 @@ const getAffectedTestFiles = (
973
982
  return Array.from(affectedFiles);
974
983
  };
975
984
 
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;
985
+ const getBrowserProjects = (context: RstestContext): ProjectContext[] =>
986
+ context.projects.filter(
987
+ (project) => project.normalizedConfig.browser.enabled,
988
+ );
1006
989
 
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
- };
990
+ const getBrowserRsbuildEnvironmentConfig = (
991
+ project: ProjectContext,
992
+ ): RsbuildEnvironmentConfig => ({
993
+ plugins: project.normalizedConfig.plugins,
994
+ root: project.rootPath,
995
+ });
1046
996
 
1047
- const getBrowserProjects = (context: RstestContext): ProjectContext[] => {
1048
- return context.projects.filter(
1049
- (project) => project.normalizedConfig.browser.enabled,
997
+ // Max testTimeout across browser projects, used as the host->client RPC timeout.
998
+ const getMaxTestTimeoutForRpc = (projects: ProjectContext[]): number =>
999
+ Math.max(
1000
+ ...projects.map(
1001
+ (p) => p.normalizedConfig.testTimeout ?? DEFAULT_TEST_TIMEOUT,
1002
+ ),
1050
1003
  );
1051
- };
1052
1004
 
1053
1005
  const getBrowserLaunchOptions = (
1054
1006
  project: ProjectContext,
@@ -1118,10 +1070,11 @@ const resolveProviderForTestPath = ({
1118
1070
 
1119
1071
  const collectProjectEntries = async (
1120
1072
  context: RstestContext,
1073
+ // The explicit browser-project subset the executor was constructed with. Falls
1074
+ // back to re-deriving from `context` for internal callers (e.g. the watch
1075
+ // plugin) that do not carry the plan's project list.
1076
+ browserProjects: ProjectContext[] = getBrowserProjects(context),
1121
1077
  ): Promise<BrowserProjectEntries[]> => {
1122
- // Only collect entries for browser mode projects
1123
- const browserProjects = getBrowserProjects(context);
1124
-
1125
1078
  return Promise.all(
1126
1079
  browserProjects.map(async (project) => {
1127
1080
  const {
@@ -1455,16 +1408,28 @@ const registerWatchCleanup = (): void => {
1455
1408
 
1456
1409
  const createBrowserRuntime = async ({
1457
1410
  context,
1458
- projectEntries,
1411
+ projectEntries: initialProjectEntries,
1412
+ browserProjects,
1413
+ shardedEntries,
1414
+ freezeShardedEntries,
1459
1415
  tempDir,
1460
1416
  isWatchMode,
1461
1417
  onTriggerRerun,
1462
1418
  containerDistPath,
1463
1419
  containerDevServer,
1464
1420
  forceHeadless,
1421
+ skipProviderLaunch,
1422
+ appliedModifyRstestConfigEnvironments,
1465
1423
  }: {
1466
1424
  context: RstestContext;
1467
1425
  projectEntries: BrowserProjectEntries[];
1426
+ /**
1427
+ * The explicit browser-project subset (plan output). Drives launch-option
1428
+ * consistency and the container origin (`browserProjects[0]`).
1429
+ */
1430
+ browserProjects: ProjectContext[];
1431
+ shardedEntries?: Map<string, { entries: Record<string, string> }>;
1432
+ freezeShardedEntries?: boolean;
1468
1433
  tempDir: string;
1469
1434
  isWatchMode: boolean;
1470
1435
  onTriggerRerun?: () => Promise<void>;
@@ -1472,6 +1437,8 @@ const createBrowserRuntime = async ({
1472
1437
  containerDevServer?: string;
1473
1438
  /** Force headless mode regardless of user config (used for list command) */
1474
1439
  forceHeadless?: boolean;
1440
+ skipProviderLaunch?: boolean;
1441
+ appliedModifyRstestConfigEnvironments?: Set<string>;
1475
1442
  }): Promise<BrowserRuntime> => {
1476
1443
  // ---- Shared singletons (created once, wired into every project server) ----
1477
1444
  const containerHtmlTemplate = containerDistPath
@@ -1493,9 +1460,83 @@ const createBrowserRuntime = async ({
1493
1460
  }
1494
1461
  };
1495
1462
 
1496
- const browserProjects = getBrowserProjects(context);
1497
- const browserLaunchOptions =
1463
+ let browserLaunchOptions =
1498
1464
  ensureConsistentBrowserLaunchOptions(browserProjects);
1465
+ let projectEntries = initialProjectEntries;
1466
+ const manifestModules: Array<{
1467
+ manifestPath: string;
1468
+ project: ProjectContext;
1469
+ modules: Record<string, string>;
1470
+ }> = [];
1471
+
1472
+ const createRuntimeWithoutProvider = (): BrowserRuntime => {
1473
+ const firstProject = browserProjects[0]!;
1474
+ return {
1475
+ projectServers: new Map(),
1476
+ containerServer: {
1477
+ projectName: firstProject.name,
1478
+ environmentName: firstProject.environmentName,
1479
+ rsbuildInstance: undefined as unknown as RsbuildInstance,
1480
+ devServer: {
1481
+ close: async () => undefined,
1482
+ } as RsbuildDevServer,
1483
+ port: 0,
1484
+ manifestPath: '',
1485
+ },
1486
+ browser: undefined as unknown as BrowserProviderBrowser,
1487
+ browserLaunchOptions,
1488
+ wsPort: 0,
1489
+ tempDir,
1490
+ setContainerOptions,
1491
+ dispatchHandlers,
1492
+ wss: undefined as unknown as WebSocketServer,
1493
+ projectEntries,
1494
+ };
1495
+ };
1496
+
1497
+ const getProjectEntry = (project: ProjectContext) =>
1498
+ projectEntries.find(
1499
+ (item) => item.project.environmentName === project.environmentName,
1500
+ );
1501
+
1502
+ const refreshManifestModule = (manifestModule: {
1503
+ manifestPath: string;
1504
+ project: ProjectContext;
1505
+ modules: Record<string, string>;
1506
+ }): void => {
1507
+ const entry = getProjectEntry(manifestModule.project);
1508
+ manifestModule.modules[manifestModule.manifestPath] =
1509
+ generateManifestModule({
1510
+ manifestPath: manifestModule.manifestPath,
1511
+ entries: [
1512
+ {
1513
+ project: manifestModule.project,
1514
+ testFiles: entry?.testFiles ?? [],
1515
+ setupFiles: entry?.setupFiles ?? [],
1516
+ },
1517
+ ],
1518
+ isWatchMode,
1519
+ });
1520
+ };
1521
+
1522
+ const refreshProjectEntries = async (): Promise<void> => {
1523
+ validateBrowserConfig(context);
1524
+ browserLaunchOptions =
1525
+ ensureConsistentBrowserLaunchOptions(browserProjects);
1526
+ const updatedShardedEntries = freezeShardedEntries
1527
+ ? shardedEntries
1528
+ : context.normalizedConfig.shard
1529
+ ? await resolveShardedEntries(context, { silent: true })
1530
+ : shardedEntries;
1531
+ projectEntries = await resolveProjectEntries(
1532
+ context,
1533
+ updatedShardedEntries,
1534
+ browserProjects,
1535
+ );
1536
+ for (const manifestModule of manifestModules) {
1537
+ refreshManifestModule(manifestModule);
1538
+ }
1539
+ };
1499
1540
 
1500
1541
  // Rstest internal aliases that must not be overridden by user config
1501
1542
  const browserRuntimePath = fileURLToPath(
@@ -1591,10 +1632,6 @@ const createBrowserRuntime = async ({
1591
1632
  }
1592
1633
  };
1593
1634
 
1594
- const entryByEnvironmentName = new Map(
1595
- projectEntries.map((entry) => [entry.project.environmentName, entry]),
1596
- );
1597
-
1598
1635
  // ---- Build one isolated rsbuild instance + dev server per project ----
1599
1636
  const buildProjectServer = async (
1600
1637
  project: ProjectContext,
@@ -1605,20 +1642,27 @@ const createBrowserRuntime = async ({
1605
1642
  toSafeVarName(project.environmentName),
1606
1643
  VIRTUAL_MANIFEST_FILENAME,
1607
1644
  );
1608
- const entry = entryByEnvironmentName.get(project.environmentName);
1609
- const manifestSource = generateManifestModule({
1645
+ const entry = getProjectEntry(project);
1646
+ const virtualManifestModules = {
1647
+ [manifestPath]: generateManifestModule({
1648
+ manifestPath,
1649
+ entries: [
1650
+ {
1651
+ project,
1652
+ testFiles: entry?.testFiles ?? [],
1653
+ setupFiles: entry?.setupFiles ?? [],
1654
+ },
1655
+ ],
1656
+ isWatchMode,
1657
+ }),
1658
+ };
1659
+ const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin(
1660
+ virtualManifestModules,
1661
+ );
1662
+ manifestModules.push({
1610
1663
  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,
1664
+ project,
1665
+ modules: virtualManifestModules,
1622
1666
  });
1623
1667
 
1624
1668
  const rstestInternalAliases = {
@@ -1631,11 +1675,10 @@ const createBrowserRuntime = async ({
1631
1675
  const enableHmr = shouldEnableBrowserHmr(isWatchMode, isHeadless);
1632
1676
 
1633
1677
  const rsbuildInstance = await createRsbuild({
1634
- callerName: 'rstest-browser',
1678
+ callerName: 'rstest',
1635
1679
  rsbuildConfig: {
1636
1680
  root: context.rootPath,
1637
1681
  mode: 'development',
1638
- plugins: project.normalizedConfig.plugins || [],
1639
1682
  server: {
1640
1683
  printUrls: false,
1641
1684
  // Each project gets its own dev server. Honor an explicitly
@@ -1649,11 +1692,24 @@ const createBrowserRuntime = async ({
1649
1692
  },
1650
1693
  dev: createBrowserRsbuildDevConfig(enableHmr),
1651
1694
  environments: {
1652
- [project.environmentName]: {},
1695
+ [project.environmentName]:
1696
+ getBrowserRsbuildEnvironmentConfig(project),
1653
1697
  },
1654
1698
  },
1655
1699
  });
1656
1700
 
1701
+ initModifyRstestConfigHooks(
1702
+ context,
1703
+ rsbuildInstance,
1704
+ [project],
1705
+ [project],
1706
+ {
1707
+ getEnvironmentConfig: getBrowserRsbuildEnvironmentConfig,
1708
+ onModifyRstestConfigApplied: refreshProjectEntries,
1709
+ appliedEnvironmentNames: appliedModifyRstestConfigEnvironments,
1710
+ },
1711
+ );
1712
+
1657
1713
  // Add plugin to merge user Rsbuild config with rstest required config
1658
1714
  rsbuildInstance.addPlugins([
1659
1715
  {
@@ -1736,7 +1792,7 @@ const createBrowserRuntime = async ({
1736
1792
 
1737
1793
  // Extract and merge sourcemaps from pre-built @rstest/core files
1738
1794
  // This preserves the sourcemap chain for inline snapshot support
1739
- // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
1795
+ // See: https://rspack.rs/config/module-rules#rulesextractsourcemap
1740
1796
  const browserRuntimeDir = dirname(browserRuntimePath);
1741
1797
  rspackConfig.module = rspackConfig.module || {};
1742
1798
  rspackConfig.module.rules =
@@ -1822,6 +1878,20 @@ const createBrowserRuntime = async ({
1822
1878
  ]);
1823
1879
  }
1824
1880
 
1881
+ if (skipProviderLaunch) {
1882
+ await rsbuildInstance.initConfigs({ action: 'dev' });
1883
+ return {
1884
+ projectName: project.name,
1885
+ environmentName: project.environmentName,
1886
+ rsbuildInstance,
1887
+ devServer: {
1888
+ close: async () => undefined,
1889
+ } as RsbuildDevServer,
1890
+ port: 0,
1891
+ manifestPath,
1892
+ };
1893
+ }
1894
+
1825
1895
  // Register coverage plugin if this project enables coverage
1826
1896
  const coverage = project.normalizedConfig.coverage;
1827
1897
  if (coverage?.enabled && context.command !== 'list') {
@@ -1948,6 +2018,10 @@ const createBrowserRuntime = async ({
1948
2018
  throw error;
1949
2019
  }
1950
2020
 
2021
+ if (skipProviderLaunch) {
2022
+ return createRuntimeWithoutProvider();
2023
+ }
2024
+
1951
2025
  // browserProjects is non-empty (ensureConsistentBrowserLaunchOptions throws
1952
2026
  // otherwise) and index 0 is the designated container origin.
1953
2027
  const containerServer = projectServers.get(browserProjects[0]!.name)!;
@@ -1983,6 +2057,7 @@ const createBrowserRuntime = async ({
1983
2057
  setContainerOptions,
1984
2058
  dispatchHandlers,
1985
2059
  wss,
2060
+ projectEntries,
1986
2061
  };
1987
2062
  } catch (error) {
1988
2063
  wss.close();
@@ -1993,10 +2068,10 @@ const createBrowserRuntime = async ({
1993
2068
 
1994
2069
  async function resolveProjectEntries(
1995
2070
  context: RstestContext,
1996
- shardedEntries?: Map<string, { entries: Record<string, string> }>,
2071
+ shardedEntries: Map<string, { entries: Record<string, string> }> | undefined,
2072
+ browserProjects: ProjectContext[],
1997
2073
  ): Promise<BrowserProjectEntries[]> {
1998
2074
  if (shardedEntries) {
1999
- const browserProjects = getBrowserProjects(context);
2000
2075
  const projectEntries: BrowserProjectEntries[] = [];
2001
2076
  for (const project of browserProjects) {
2002
2077
  const entryInfo = shardedEntries.get(project.environmentName);
@@ -2014,7 +2089,7 @@ async function resolveProjectEntries(
2014
2089
  }
2015
2090
  return projectEntries;
2016
2091
  }
2017
- return collectProjectEntries(context);
2092
+ return collectProjectEntries(context, browserProjects);
2018
2093
  }
2019
2094
 
2020
2095
  // ============================================================================
@@ -2026,11 +2101,18 @@ export const runBrowserController = async (
2026
2101
  options?: BrowserTestRunOptions,
2027
2102
  ): Promise<BrowserTestRunResult | void> => {
2028
2103
  const {
2029
- skipOnTestRunEnd = false,
2030
2104
  allowEmptyWatchRun = false,
2105
+ allowEmptyRun = false,
2106
+ filesOnly = false,
2031
2107
  onTraceEvents,
2108
+ env,
2032
2109
  } = options ?? {};
2033
2110
  const buildStart = Date.now();
2111
+ // Non-watch vs watch is the live switch for self-finalize: in non-watch runs
2112
+ // core owns the unified finalize (reporters, exit code, coverage) through
2113
+ // `finalizeRunCycle`, so the host never self-finalizes and always returns a
2114
+ // fully-populated result with `close`. Watch reruns keep their host-driven
2115
+ // per-rerun finalize.
2034
2116
  const isWatchMode = context.command === 'watch';
2035
2117
 
2036
2118
  // Per-file PhaseTrackers, populated only when `--trace` is on (caller
@@ -2041,7 +2123,11 @@ export const runBrowserController = async (
2041
2123
  const phaseTrackers = onTraceEvents
2042
2124
  ? new Map<string, PhaseTracker>()
2043
2125
  : undefined;
2044
- const browserProjects = getBrowserProjects(context);
2126
+ // Explicit projects input (plan output) replaces re-deriving `browser.enabled`
2127
+ // projects from `context`, whose `projects` array is mutated during planning.
2128
+ // Falls back to re-derivation only when the caller passes no list at all —
2129
+ // an explicit empty subset must stay empty, not widen to every project.
2130
+ const browserProjects = options?.projects ?? getBrowserProjects(context);
2045
2131
  const useHeadlessDirect = browserProjects.every(
2046
2132
  (project) => project.normalizedConfig.browser.headless,
2047
2133
  );
@@ -2109,7 +2195,7 @@ export const runBrowserController = async (
2109
2195
  close,
2110
2196
  };
2111
2197
 
2112
- if (!skipOnTestRunEnd) {
2198
+ if (isWatchMode) {
2113
2199
  for (const reporter of context.reporters) {
2114
2200
  await (reporter as Reporter).onTestRunEnd?.({
2115
2201
  results: [],
@@ -2133,11 +2219,16 @@ export const runBrowserController = async (
2133
2219
  error: unknown,
2134
2220
  cleanup?: () => Promise<void>,
2135
2221
  ): Promise<BrowserTestRunResult> => {
2136
- ensureProcessExitCode(1);
2222
+ // Non-watch runs defer the exit code to core's `finalizeRunCycle`, which
2223
+ // raises it from the returned outcome's `errors`. Watch reruns keep owning
2224
+ // their own exit code.
2225
+ if (isWatchMode) {
2226
+ ensureProcessExitCode(1);
2227
+ }
2137
2228
 
2138
2229
  const normalizedError = toError(error);
2139
2230
 
2140
- if (cleanup && skipOnTestRunEnd) {
2231
+ if (cleanup && !isWatchMode) {
2141
2232
  return buildErrorResult(normalizedError, cleanup);
2142
2233
  }
2143
2234
 
@@ -2159,7 +2250,7 @@ export const runBrowserController = async (
2159
2250
  };
2160
2251
 
2161
2252
  const notifyTestRunStart = async (): Promise<void> => {
2162
- if (skipOnTestRunEnd) {
2253
+ if (!isWatchMode) {
2163
2254
  return;
2164
2255
  }
2165
2256
 
@@ -2188,7 +2279,7 @@ export const runBrowserController = async (
2188
2279
  unhandledErrors?: Error[];
2189
2280
  filterRerunTestPaths?: string[];
2190
2281
  }): Promise<void> => {
2191
- if (skipOnTestRunEnd) {
2282
+ if (!isWatchMode) {
2192
2283
  return;
2193
2284
  }
2194
2285
 
@@ -2249,19 +2340,38 @@ export const runBrowserController = async (
2249
2340
  }
2250
2341
  }
2251
2342
 
2252
- const projectEntries = await resolveProjectEntries(
2343
+ let projectEntries = await resolveProjectEntries(
2253
2344
  context,
2254
2345
  options?.shardedEntries,
2346
+ browserProjects,
2255
2347
  );
2256
- const totalTests = projectEntries.reduce(
2348
+ let totalTests = projectEntries.reduce(
2257
2349
  (total, item) => total + item.testFiles.length,
2258
2350
  0,
2259
2351
  );
2260
2352
  const shouldKeepWatchingWithEmptySet = isWatchMode && allowEmptyWatchRun;
2353
+ const shouldInitializeEmptyBrowserHooks =
2354
+ totalTests === 0 && hasUserRstestConfigPlugins(browserProjects);
2261
2355
 
2262
- if (totalTests === 0) {
2356
+ const createEmptyRunResult = (): BrowserTestRunResult => {
2357
+ const elapsed = Math.max(0, Date.now() - buildStart);
2358
+ return {
2359
+ results: [],
2360
+ testResults: [],
2361
+ duration: {
2362
+ totalTime: elapsed,
2363
+ buildTime: elapsed,
2364
+ testTime: 0,
2365
+ },
2366
+ hasFailure: false,
2367
+ getSourcemap: getBrowserSourcemap,
2368
+ resolveSourcemap: resolveBrowserSourcemap,
2369
+ };
2370
+ };
2371
+
2372
+ const reportEmptyTestSet = (): boolean => {
2263
2373
  const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
2264
- if (!skipOnTestRunEnd) {
2374
+ if (isWatchMode || !allowEmptyRun) {
2265
2375
  const message = shouldKeepWatchingWithEmptySet
2266
2376
  ? 'No test files found.'
2267
2377
  : getNoTestFilesMessage({
@@ -2288,15 +2398,30 @@ export const runBrowserController = async (
2288
2398
  }
2289
2399
  }
2290
2400
 
2291
- if (code !== 0 && !shouldKeepWatchingWithEmptySet) {
2401
+ // In non-watch runs the host returns a void outcome and core's
2402
+ // `reportNoTestFiles` owns the exit code and the no-test reporter lifecycle;
2403
+ // the host must not set the code itself. Watch keeps its own exit code.
2404
+ if (
2405
+ isWatchMode &&
2406
+ code !== 0 &&
2407
+ !shouldKeepWatchingWithEmptySet &&
2408
+ !allowEmptyRun
2409
+ ) {
2292
2410
  ensureProcessExitCode(code);
2293
2411
  }
2294
- if (!shouldKeepWatchingWithEmptySet) {
2295
- return;
2412
+
2413
+ return !shouldKeepWatchingWithEmptySet;
2414
+ };
2415
+
2416
+ if (totalTests === 0 && !shouldInitializeEmptyBrowserHooks) {
2417
+ if (reportEmptyTestSet()) {
2418
+ return allowEmptyRun ? createEmptyRunResult() : undefined;
2296
2419
  }
2297
2420
  }
2298
2421
 
2299
- await notifyTestRunStart();
2422
+ if (!filesOnly) {
2423
+ await notifyTestRunStart();
2424
+ }
2300
2425
 
2301
2426
  const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
2302
2427
  const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
@@ -2327,6 +2452,9 @@ export const runBrowserController = async (
2327
2452
  runtime = await createBrowserRuntime({
2328
2453
  context,
2329
2454
  projectEntries,
2455
+ browserProjects,
2456
+ shardedEntries: options?.shardedEntries,
2457
+ freezeShardedEntries: options?.freezeShardedEntries,
2330
2458
  tempDir,
2331
2459
  isWatchMode,
2332
2460
  onTriggerRerun: isWatchMode
@@ -2336,6 +2464,9 @@ export const runBrowserController = async (
2336
2464
  : undefined,
2337
2465
  containerDistPath,
2338
2466
  containerDevServer,
2467
+ skipProviderLaunch: filesOnly,
2468
+ appliedModifyRstestConfigEnvironments:
2469
+ options?.appliedModifyRstestConfigEnvironments,
2339
2470
  });
2340
2471
  } catch (error) {
2341
2472
  return failWithError(error, async () => {
@@ -2355,9 +2486,37 @@ export const runBrowserController = async (
2355
2486
  }
2356
2487
  }
2357
2488
 
2358
- const { browser, browserLaunchOptions, wsPort, wss } = runtime;
2489
+ projectEntries = runtime.projectEntries;
2490
+ totalTests = projectEntries.reduce(
2491
+ (total, item) => total + item.testFiles.length,
2492
+ 0,
2493
+ );
2494
+
2359
2495
  const buildTime = Date.now() - buildStart;
2360
2496
 
2497
+ if (filesOnly) {
2498
+ return {
2499
+ results: [],
2500
+ testResults: [],
2501
+ duration: {
2502
+ totalTime: buildTime,
2503
+ buildTime,
2504
+ testTime: 0,
2505
+ },
2506
+ hasFailure: false,
2507
+ getSourcemap: getBrowserSourcemap,
2508
+ resolveSourcemap: resolveBrowserSourcemap,
2509
+ close: () => destroyBrowserRuntime(runtime),
2510
+ };
2511
+ }
2512
+
2513
+ if (totalTests === 0 && reportEmptyTestSet()) {
2514
+ await destroyBrowserRuntime(runtime);
2515
+ return allowEmptyRun ? createEmptyRunResult() : undefined;
2516
+ }
2517
+
2518
+ const { browser, browserLaunchOptions, wsPort, wss } = runtime;
2519
+
2361
2520
  // Collect all test files from project entries with project info
2362
2521
  // Normalize paths to posix format for cross-platform compatibility
2363
2522
  const allTestFiles: TestFileInfo[] = projectEntries.flatMap((entry) =>
@@ -2374,17 +2533,17 @@ export const runBrowserController = async (
2374
2533
  name: project.name,
2375
2534
  environmentName: project.environmentName,
2376
2535
  projectRoot: normalize(project.rootPath),
2377
- runtimeConfig: serializableConfig(getRuntimeConfigFromProject(project)),
2536
+ runtimeConfig: serializableConfig(
2537
+ // `env` is the post-globalSetup change-set from the core pre-cycle
2538
+ // stage; the projection layers it between the static base and the
2539
+ // user `test.env` config.
2540
+ projectRuntimeConfig(project, { envMode: 'static', envOverlay: env }),
2541
+ ),
2378
2542
  viewport: project.normalizedConfig.browser.viewport,
2379
2543
  }),
2380
2544
  );
2381
2545
 
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
- );
2546
+ const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
2388
2547
 
2389
2548
  const projectRunnerUrls = Object.fromEntries(
2390
2549
  [...runtime.projectServers].map(([name, server]) => [
@@ -2497,18 +2656,64 @@ export const runBrowserController = async (
2497
2656
  const caseResults: TestResult[] = [];
2498
2657
  let fatalError: Error | null = null;
2499
2658
 
2659
+ // Runner lifecycle events flow through the shared RunnerEventSink (the same
2660
+ // pump the node pool uses), so browser mode feeds stateManager and fans out to
2661
+ // reporters via one implementation. One sink is bound per browser project up
2662
+ // front from the executor's own project plan (`browserProjects`) — never from
2663
+ // `context.projects`, which planning mutates to also contain node projects. The
2664
+ // previous lazy resolver fell back to `context.projects[0]`, so a browser event
2665
+ // could be attributed to a *node* project's config; binding per browser project
2666
+ // here removes that fallback and keeps per-project `onConsoleLog` filtering and
2667
+ // `resolveSnapshotPath` correct across browser projects that share a relative
2668
+ // test path.
2669
+ const runnerSinks = new Map<string, RunnerEventSink>(
2670
+ browserProjects.map((project) => [
2671
+ project.name,
2672
+ createRunnerEventSink(context, project.normalizedConfig),
2673
+ ]),
2674
+ );
2675
+ const firstBrowserSink = runnerSinks.get(browserProjects[0]!.name)!;
2676
+
2677
+ // testPath -> owning project name, stamped from the authoritative client
2678
+ // file-start event (it carries the manifest-resolved projectName) before any
2679
+ // other per-file event for that path fires — including on watch reruns, so the
2680
+ // mapping stays correct when a rerun adds a file. Fully eliminating this map in
2681
+ // favor of a project stamp on every wire event is deferred (it would add
2682
+ // `project` to the shared `TestResult`/`TestFileResult` payloads).
2683
+ const projectNameByTestPath = new Map<string, string>();
2684
+
2685
+ const sinkForProjectName = (projectName: string): RunnerEventSink =>
2686
+ runnerSinks.get(projectName) ?? firstBrowserSink;
2687
+
2688
+ const sinkForTestPath = (testPath: string): RunnerEventSink => {
2689
+ const projectName = projectNameByTestPath.get(testPath);
2690
+ return projectName ? sinkForProjectName(projectName) : firstBrowserSink;
2691
+ };
2692
+
2693
+ // Silent-console buffering runs through the shared controller — the same
2694
+ // engine the node worker uses — so `silent: 'passed-only'` buffers logs and
2695
+ // replays only the failing tasks'. Intercepted replays route through the
2696
+ // owning project's sink, so they honor per-project `onConsoleLog` and
2697
+ // `disableConsoleIntercept` (the browser host previously flushed straight to
2698
+ // reporters, bypassing both). `writeOriginalLog` is a host-side no-op: page
2699
+ // logs have no host "original stream" — the page console and headed terminal
2700
+ // forwarding already show them, so re-emitting here would double-print.
2701
+ const silentConsoleController = createSilentConsoleController({
2702
+ runtimeConfig: {
2703
+ silent: context.normalizedConfig.silent,
2704
+ disableConsoleIntercept: context.normalizedConfig.disableConsoleIntercept,
2705
+ },
2706
+ emitInterceptedLog: (log) =>
2707
+ sinkForTestPath(log.testPath).onConsoleLog(log),
2708
+ writeOriginalLog: () => {},
2709
+ });
2710
+
2500
2711
  const snapshotRpcMethods = {
2501
2712
  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);
2713
+ return resolveSnapshotPathDefault(
2714
+ testPath,
2715
+ context.normalizedConfig.resolveSnapshotPath,
2716
+ );
2512
2717
  },
2513
2718
  async readSnapshotFile(filepath: string): Promise<string | null> {
2514
2719
  try {
@@ -2534,6 +2739,7 @@ export const runBrowserController = async (
2534
2739
  const handleTestFileStart = async (
2535
2740
  payload: TestFileStartPayload,
2536
2741
  ): Promise<void> => {
2742
+ projectNameByTestPath.set(payload.testPath, payload.projectName);
2537
2743
  if (phaseTrackers) {
2538
2744
  const tracker = new PhaseTracker({
2539
2745
  trace: {
@@ -2545,51 +2751,37 @@ export const runBrowserController = async (
2545
2751
  tracker.transition('prepare');
2546
2752
  phaseTrackers.set(payload.testPath, tracker);
2547
2753
  }
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
- );
2754
+ // The client sends `{ testPath, projectName }`; the sink adapter builds the
2755
+ // `TestFileInfo` the reporters and stateManager expect.
2756
+ await sinkForProjectName(payload.projectName).onTestFileStart({
2757
+ testId: getFileTaskId(payload.testPath),
2758
+ testPath: payload.testPath,
2759
+ tests: [],
2760
+ });
2557
2761
  };
2558
2762
 
2559
2763
  const handleTestFileReady = async (
2560
2764
  payload: TestFileReadyPayload,
2561
2765
  ): Promise<void> => {
2562
2766
  phaseTrackers?.get(payload.testPath)?.transition('tests');
2563
- await Promise.all(
2564
- context.reporters.map((reporter) =>
2565
- (reporter as Reporter).onTestFileReady?.(payload),
2566
- ),
2567
- );
2767
+ await sinkForTestPath(payload.testPath).onTestFileReady(payload);
2568
2768
  };
2569
2769
 
2570
2770
  const handleTestSuiteStart = async (
2571
2771
  payload: TestSuiteStartPayload,
2572
2772
  ): Promise<void> => {
2573
2773
  phaseTrackers?.get(payload.testPath)?.recordSuiteStart(payload);
2574
- await Promise.all(
2575
- context.reporters.map((reporter) =>
2576
- (reporter as Reporter).onTestSuiteStart?.(payload),
2577
- ),
2578
- );
2774
+ await sinkForTestPath(payload.testPath).onTestSuiteStart(payload);
2579
2775
  };
2580
2776
 
2581
2777
  const handleTestSuiteResult = async (
2582
2778
  payload: TestSuiteResultPayload,
2583
2779
  ): Promise<void> => {
2584
2780
  phaseTrackers?.get(payload.testPath)?.recordSuiteResult(payload);
2585
- await Promise.all(
2586
- context.reporters.map((reporter) =>
2587
- (reporter as Reporter).onTestSuiteResult?.(payload),
2588
- ),
2589
- );
2781
+ await sinkForTestPath(payload.testPath).onTestSuiteResult(payload);
2590
2782
 
2591
2783
  if (context.normalizedConfig.silent === 'passed-only') {
2592
- await flushBufferedLogsForTask({
2784
+ silentConsoleController.flushBufferedLogsForTask({
2593
2785
  taskId: payload.testId,
2594
2786
  status: payload.status,
2595
2787
  taskParentNames: payload.parentNames,
@@ -2603,24 +2795,17 @@ export const runBrowserController = async (
2603
2795
  payload: TestCaseStartPayload,
2604
2796
  ): Promise<void> => {
2605
2797
  phaseTrackers?.get(payload.testPath)?.recordCaseStart(payload);
2606
- await Promise.all(
2607
- context.reporters.map((reporter) =>
2608
- (reporter as Reporter).onTestCaseStart?.(payload),
2609
- ),
2610
- );
2798
+ // Fire-and-forget on both transports (the sink does not await case-start).
2799
+ sinkForTestPath(payload.testPath).onTestCaseStart(payload);
2611
2800
  };
2612
2801
 
2613
2802
  const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
2614
2803
  caseResults.push(payload);
2615
2804
  phaseTrackers?.get(payload.testPath)?.recordCaseResult(payload);
2616
- await Promise.all(
2617
- context.reporters.map((reporter) =>
2618
- (reporter as Reporter).onTestCaseResult?.(payload),
2619
- ),
2620
- );
2805
+ await sinkForTestPath(payload.testPath).onTestCaseResult(payload);
2621
2806
 
2622
2807
  if (context.normalizedConfig.silent === 'passed-only') {
2623
- await flushBufferedLogsForTask({
2808
+ silentConsoleController.flushBufferedLogsForTask({
2624
2809
  taskId: payload.testId,
2625
2810
  status: payload.status,
2626
2811
  taskParentNames: payload.parentNames,
@@ -2635,9 +2820,6 @@ export const runBrowserController = async (
2635
2820
  ): Promise<void> => {
2636
2821
  reporterResults.push(payload);
2637
2822
  context.updateReporterResultState([payload], payload.results);
2638
- if (payload.snapshotResult) {
2639
- context.snapshotManager.add(payload.snapshotResult);
2640
- }
2641
2823
 
2642
2824
  if (phaseTrackers) {
2643
2825
  const tracker = phaseTrackers.get(payload.testPath);
@@ -2650,7 +2832,7 @@ export const runBrowserController = async (
2650
2832
  }
2651
2833
 
2652
2834
  if (context.normalizedConfig.silent === 'passed-only') {
2653
- await flushBufferedLogsForTask({
2835
+ silentConsoleController.flushBufferedLogsForTask({
2654
2836
  taskId: payload.testId,
2655
2837
  status: payload.status,
2656
2838
  taskParentNames: payload.parentNames,
@@ -2659,12 +2841,12 @@ export const runBrowserController = async (
2659
2841
  });
2660
2842
  }
2661
2843
 
2662
- await Promise.all(
2663
- context.reporters.map((reporter) =>
2664
- (reporter as Reporter).onTestFileResult?.(payload),
2665
- ),
2666
- );
2667
- if (payload.status === 'fail') {
2844
+ // Feeds stateManager, fans out onTestFileResult to reporters, and ingests
2845
+ // payload.snapshotResult (the snapshotManager.add moved into the sink).
2846
+ await sinkForTestPath(payload.testPath).onTestFileResult(payload);
2847
+ // In non-watch runs core owns the exit code via `finalizeRunCycle` (the
2848
+ // failing file rides the returned outcome); watch reruns set it here.
2849
+ if (isWatchMode && payload.status === 'fail') {
2668
2850
  ensureProcessExitCode(1);
2669
2851
  }
2670
2852
  };
@@ -2681,135 +2863,17 @@ export const runBrowserController = async (
2681
2863
  type: payload.type,
2682
2864
  trace: payload.trace,
2683
2865
  };
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);
2866
+ silentConsoleController.onConsoleLog(log);
2698
2867
  };
2699
2868
 
2700
2869
  const handleFatal = async (payload: FatalPayload): Promise<void> => {
2701
2870
  const error = new Error(payload.message);
2702
2871
  error.stack = payload.stack;
2703
2872
  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
- }
2873
+ // Non-watch runs surface the fatal error through the returned outcome and
2874
+ // let core's `finalizeRunCycle` set the exit code; watch reruns set it here.
2875
+ if (isWatchMode) {
2876
+ ensureProcessExitCode(1);
2813
2877
  }
2814
2878
  };
2815
2879
 
@@ -3117,6 +3181,18 @@ export const runBrowserController = async (
3117
3181
  }
3118
3182
  };
3119
3183
 
3184
+ // Bailed files never run, so they carry no case results — mirror the node
3185
+ // pool's skip result (`runInPool.ts`) so the summary reports them as skipped
3186
+ // rather than dropping them silently.
3187
+ const makeSkippedFileResult = (file: TestFileInfo): TestFileResult => ({
3188
+ testId: getFileTaskId(file.testPath),
3189
+ status: 'skip',
3190
+ name: '',
3191
+ testPath: file.testPath,
3192
+ project: file.projectName,
3193
+ results: [],
3194
+ });
3195
+
3120
3196
  const runFilesWithPool = async (files: TestFileInfo[]): Promise<void> => {
3121
3197
  if (files.length === 0) {
3122
3198
  return;
@@ -3134,6 +3210,7 @@ export const runBrowserController = async (
3134
3210
 
3135
3211
  const queue = [...files];
3136
3212
  const concurrency = getHeadlessConcurrency(context, queue.length);
3213
+ const bail = context.normalizedConfig.bail;
3137
3214
 
3138
3215
  const worker = async (): Promise<void> => {
3139
3216
  while (
@@ -3141,6 +3218,19 @@ export const runBrowserController = async (
3141
3218
  !run.cancelled &&
3142
3219
  runLifecycle.isTokenActive(run.token)
3143
3220
  ) {
3221
+ // Cross-file bail gate (parity with the node pool's pickup-time skip
3222
+ // at `runInPool.ts`): once the cycle-wide failed count reaches `bail`,
3223
+ // drain the remaining files as skipped instead of running them. The
3224
+ // count is cycle-scoped because `stateManager` is reset at the top of
3225
+ // every run/rerun (initial run and `prepareWatchRerunState`).
3226
+ if (bail && context.stateManager.getCountOfFailedTests() >= bail) {
3227
+ let skipped = queue.shift();
3228
+ while (skipped) {
3229
+ await handleTestFileComplete(makeSkippedFileResult(skipped));
3230
+ skipped = queue.shift();
3231
+ }
3232
+ return;
3233
+ }
3144
3234
  const next = queue.shift();
3145
3235
  if (!next) {
3146
3236
  return;
@@ -3173,6 +3263,12 @@ export const runBrowserController = async (
3173
3263
  await cancelRun(run, false);
3174
3264
  },
3175
3265
  runFiles: async (files) => {
3266
+ // Clear the previous cycle's stateManager/snapshotManager before the
3267
+ // rerun streams new events through the shared sink — otherwise failed
3268
+ // counts (bail) and snapshot summaries accumulate across reruns. The
3269
+ // initial run does not reach here (it calls `runFilesWithPool`
3270
+ // directly), so only reruns reset.
3271
+ prepareWatchRerunState(context);
3176
3272
  await notifyTestRunStart();
3177
3273
 
3178
3274
  const rerunStartTime = Date.now();
@@ -3233,7 +3329,7 @@ export const runBrowserController = async (
3233
3329
  hasFailure: false,
3234
3330
  getSourcemap: getBrowserSourcemap,
3235
3331
  resolveSourcemap: resolveBrowserSourcemap,
3236
- close: skipOnTestRunEnd
3332
+ close: !isWatchMode
3237
3333
  ? async () => {
3238
3334
  sessionRegistry.clear();
3239
3335
  await destroyBrowserRuntime(runtime);
@@ -3241,7 +3337,7 @@ export const runBrowserController = async (
3241
3337
  : undefined,
3242
3338
  };
3243
3339
 
3244
- if (!skipOnTestRunEnd) {
3340
+ if (isWatchMode) {
3245
3341
  await notifyTestRunEnd({ duration });
3246
3342
  }
3247
3343
 
@@ -3365,7 +3461,9 @@ export const runBrowserController = async (
3365
3461
  const isFailure = reporterResults.some(
3366
3462
  (result: TestFileResult) => result.status === 'fail',
3367
3463
  );
3368
- if (isFailure) {
3464
+ // Non-watch runs let core's `finalizeRunCycle` own the exit code from the
3465
+ // returned outcome; watch reruns set it here.
3466
+ if (isWatchMode && isFailure) {
3369
3467
  ensureProcessExitCode(1);
3370
3468
  }
3371
3469
 
@@ -3376,10 +3474,12 @@ export const runBrowserController = async (
3376
3474
  hasFailure: isFailure,
3377
3475
  getSourcemap: getBrowserSourcemap,
3378
3476
  resolveSourcemap: resolveBrowserSourcemap,
3379
- close: skipOnTestRunEnd ? closeHeadlessRuntime : undefined,
3477
+ // `closeHeadlessRuntime` is already `undefined` in watch mode, so the
3478
+ // non-watch caller (core) receives the deferred close and watch does not.
3479
+ close: closeHeadlessRuntime,
3380
3480
  };
3381
3481
 
3382
- if (!skipOnTestRunEnd) {
3482
+ if (isWatchMode) {
3383
3483
  try {
3384
3484
  await notifyTestRunEnd({ duration });
3385
3485
  } finally {
@@ -3754,7 +3854,11 @@ export const runBrowserController = async (
3754
3854
  }
3755
3855
  } catch (error) {
3756
3856
  fatalError = fatalError ?? toError(error);
3757
- ensureProcessExitCode(1);
3857
+ // Non-watch: the fatal error rides the returned outcome and core owns the
3858
+ // exit code; watch reruns set it here.
3859
+ if (isWatchMode) {
3860
+ ensureProcessExitCode(1);
3861
+ }
3758
3862
  }
3759
3863
 
3760
3864
  testTime = Date.now() - testStart;
@@ -3800,6 +3904,10 @@ export const runBrowserController = async (
3800
3904
  `Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`,
3801
3905
  ),
3802
3906
  );
3907
+ // Match the headless path: reset per-cycle state before the rerun
3908
+ // streams new events, so bail counts and snapshot summaries do not
3909
+ // accumulate across headed reruns.
3910
+ prepareWatchRerunState(context);
3803
3911
  await notifyTestRunStart();
3804
3912
 
3805
3913
  const rerunStartTime = Date.now();
@@ -3874,7 +3982,9 @@ export const runBrowserController = async (
3874
3982
  const isFailure = reporterResults.some(
3875
3983
  (result: TestFileResult) => result.status === 'fail',
3876
3984
  );
3877
- if (isFailure) {
3985
+ // Non-watch runs let core's `finalizeRunCycle` own the exit code from the
3986
+ // returned outcome; watch reruns set it here.
3987
+ if (isWatchMode && isFailure) {
3878
3988
  ensureProcessExitCode(1);
3879
3989
  }
3880
3990
 
@@ -3885,10 +3995,12 @@ export const runBrowserController = async (
3885
3995
  hasFailure: isFailure,
3886
3996
  getSourcemap: getBrowserSourcemap,
3887
3997
  resolveSourcemap: resolveBrowserSourcemap,
3888
- close: skipOnTestRunEnd ? closeContainerRuntime : undefined,
3998
+ // `closeContainerRuntime` is already `undefined` in watch mode, so the
3999
+ // non-watch caller (core) receives the deferred close and watch does not.
4000
+ close: closeContainerRuntime,
3889
4001
  };
3890
4002
 
3891
- if (!skipOnTestRunEnd) {
4003
+ if (isWatchMode) {
3892
4004
  try {
3893
4005
  await notifyTestRunEnd({ duration });
3894
4006
  } finally {
@@ -3926,20 +4038,20 @@ export type ListBrowserTestsResult = {
3926
4038
  */
3927
4039
  export const listBrowserTests = async (
3928
4040
  context: RstestContext,
3929
- options?: {
3930
- shardedEntries?: Map<string, { entries: Record<string, string> }>;
3931
- },
4041
+ options?: ListBrowserTestsOptions,
3932
4042
  ): Promise<ListBrowserTestsResult> => {
4043
+ const browserProjects = options?.projects ?? getBrowserProjects(context);
3933
4044
  const projectEntries = await resolveProjectEntries(
3934
4045
  context,
3935
4046
  options?.shardedEntries,
4047
+ browserProjects,
3936
4048
  );
3937
4049
  const totalTests = projectEntries.reduce(
3938
4050
  (total, item) => total + item.testFiles.length,
3939
4051
  0,
3940
4052
  );
3941
4053
 
3942
- if (totalTests === 0) {
4054
+ if (totalTests === 0 && !hasUserRstestConfigPlugins(browserProjects)) {
3943
4055
  return {
3944
4056
  list: [],
3945
4057
  close: async () => {},
@@ -3953,19 +4065,23 @@ export const listBrowserTests = async (
3953
4065
  `list-${Date.now()}`,
3954
4066
  );
3955
4067
 
3956
- const browserProjects = getBrowserProjects(context);
3957
-
3958
4068
  // Create a simplified browser runtime for collect mode
3959
4069
  let runtime: BrowserRuntime;
3960
4070
  try {
3961
4071
  runtime = await createBrowserRuntime({
3962
4072
  context,
3963
4073
  projectEntries,
4074
+ browserProjects,
4075
+ shardedEntries: options?.shardedEntries,
4076
+ freezeShardedEntries: options?.freezeShardedEntries,
3964
4077
  tempDir,
3965
4078
  isWatchMode: false,
3966
4079
  containerDistPath: undefined,
3967
4080
  containerDevServer: undefined,
3968
4081
  forceHeadless: true, // Always use headless for list command
4082
+ skipProviderLaunch: options?.filesOnly,
4083
+ appliedModifyRstestConfigEnvironments:
4084
+ options?.appliedModifyRstestConfigEnvironments,
3969
4085
  });
3970
4086
  } catch (error) {
3971
4087
  const providers = [
@@ -3982,6 +4098,29 @@ export const listBrowserTests = async (
3982
4098
  throw error;
3983
4099
  }
3984
4100
 
4101
+ if (options?.filesOnly) {
4102
+ const list = runtime.projectEntries.flatMap((entry) =>
4103
+ entry.testFiles.map((testPath) => ({
4104
+ testPath,
4105
+ project: entry.project.name,
4106
+ tests: [],
4107
+ })),
4108
+ );
4109
+ await destroyBrowserRuntime(runtime);
4110
+ return {
4111
+ list,
4112
+ close: async () => {},
4113
+ };
4114
+ }
4115
+
4116
+ if (!runtime.projectEntries.some((entry) => entry.testFiles.length > 0)) {
4117
+ await destroyBrowserRuntime(runtime);
4118
+ return {
4119
+ list: [],
4120
+ close: async () => {},
4121
+ };
4122
+ }
4123
+
3985
4124
  const { browser, browserLaunchOptions } = runtime;
3986
4125
 
3987
4126
  // Get browser projects for runtime config
@@ -3991,17 +4130,14 @@ export const listBrowserTests = async (
3991
4130
  name: project.name,
3992
4131
  environmentName: project.environmentName,
3993
4132
  projectRoot: normalize(project.rootPath),
3994
- runtimeConfig: serializableConfig(getRuntimeConfigFromProject(project)),
4133
+ runtimeConfig: serializableConfig(
4134
+ projectRuntimeConfig(project, { envMode: 'static' }),
4135
+ ),
3995
4136
  viewport: project.normalizedConfig.browser.viewport,
3996
4137
  }),
3997
4138
  );
3998
4139
 
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
- );
4140
+ const maxTestTimeoutForRpc = getMaxTestTimeoutForRpc(browserProjects);
4005
4141
 
4006
4142
  const hostOptions: BrowserHostConfig = {
4007
4143
  rootPath: normalize(context.rootPath),
@@ -4026,6 +4162,10 @@ export const listBrowserTests = async (
4026
4162
 
4027
4163
  const serializedOptions = serializeForInlineScript(hostOptions);
4028
4164
 
4165
+ // Per-page collect watchdog: a test file whose module evaluation stalls must
4166
+ // not hang `rstest list` forever.
4167
+ const collectTimeoutMs = 30_000;
4168
+
4029
4169
  const collectFromServer = async (
4030
4170
  server: BrowserProjectServer,
4031
4171
  ): Promise<{ results: ListCommandResult[]; error: Error | null }> => {
@@ -4092,20 +4232,19 @@ export const listBrowserTests = async (
4092
4232
  waitUntil: 'load',
4093
4233
  });
4094
4234
 
4095
- // Wait for collection to complete with timeout
4096
- const timeoutMs = 30000;
4235
+ // Wait for collection to complete with the shared collect timeout.
4097
4236
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
4098
4237
  const timeoutPromise = new Promise<void>((resolve) => {
4099
4238
  timeoutId = setTimeout(() => {
4100
4239
  if (!collectCompleted) {
4101
4240
  logger.warn(
4102
4241
  color.yellow(
4103
- `[List] Browser test collection timed out after ${timeoutMs}ms`,
4242
+ `[List] Browser test collection timed out after ${collectTimeoutMs}ms`,
4104
4243
  ),
4105
4244
  );
4106
4245
  }
4107
4246
  resolve();
4108
- }, timeoutMs);
4247
+ }, collectTimeoutMs);
4109
4248
  });
4110
4249
 
4111
4250
  await Promise.race([collectPromise, timeoutPromise]);