@rstest/browser 0.11.3 → 0.11.5

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,10 +6,17 @@ import { fileURLToPath } from 'node:url';
6
6
  import { isDeepStrictEqual } from 'node:util';
7
7
  import type { Rspack } from '@rstest/core';
8
8
  import {
9
+ applyWatchInvalidation,
9
10
  applyWebMockRspackConfig,
10
11
  type BrowserTestRunOptions,
11
12
  type BrowserTestRunResult,
13
+ type BrowserWatchHandles,
14
+ buildBrowserCoverageMap,
12
15
  type CoverageMapData,
16
+ type EntryHashSnapshot,
17
+ type ExecutorCycleOutcome,
18
+ FATAL_SIGNALS,
19
+ finalizeRunCycle,
13
20
  type ListBrowserTestsOptions,
14
21
  color,
15
22
  createCoverageProvider,
@@ -25,9 +32,11 @@ import {
25
32
  importMetaRstestDefine,
26
33
  initModifyRstestConfigHooks,
27
34
  isDebug,
35
+ isTTY,
28
36
  type ListCommandResult,
29
37
  loadCoverageProvider,
30
38
  logger,
39
+ logWatchReadyMessage,
31
40
  pluginMockRuntime,
32
41
  prepareWatchRerunState,
33
42
  projectRuntimeConfig,
@@ -46,6 +55,7 @@ import {
46
55
  type TestFileResult,
47
56
  type TestResult,
48
57
  type UserConsoleLog,
58
+ type WatchInvalidationState,
49
59
  } from '@rstest/core/internal/browser';
50
60
  import { type BirpcReturn, createBirpc } from 'birpc';
51
61
  import openEditor from 'open-editor';
@@ -101,11 +111,6 @@ import {
101
111
  type SourceMapPayload,
102
112
  } from './sourceMap/sourceMapLoader';
103
113
  import { resolveBrowserViewportPreset } from './viewportPresets';
104
- import {
105
- isBrowserWatchCliShortcutsEnabled,
106
- logBrowserWatchReadyMessage,
107
- setupBrowserWatchCliShortcuts,
108
- } from './watchCliShortcuts';
109
114
  import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';
110
115
 
111
116
  const { createRsbuild, rspack } = rsbuild;
@@ -255,6 +260,12 @@ type ContainerRpcMethods = {
255
260
  testFile: string,
256
261
  testNamePattern?: string,
257
262
  ) => Promise<ReloadTestFileAck>;
263
+ /**
264
+ * Replace the container's copy of the host config so runner iframes loaded
265
+ * from now on receive fresh values (e.g. the 'u' shortcut flipping
266
+ * `snapshot.updateSnapshot` between watch reruns).
267
+ */
268
+ onHostConfigUpdate: (config: BrowserHostConfig) => Promise<void>;
258
269
  };
259
270
 
260
271
  type ContainerRpc = BirpcReturn<ContainerRpcMethods, HostRpcMethods>;
@@ -394,6 +405,11 @@ class ContainerRpcManager {
394
405
  await this.rpc?.onTestFileUpdate(files);
395
406
  }
396
407
 
408
+ /** Push a refreshed host config to the container (watch reruns) */
409
+ async updateHostConfig(config: BrowserHostConfig): Promise<void> {
410
+ await this.rpc?.onHostConfigUpdate(config);
411
+ }
412
+
397
413
  /** Request container to reload a specific test file */
398
414
  async reloadTestFile(
399
415
  testFile: string,
@@ -428,6 +444,56 @@ type BrowserProjectServer = {
428
444
  manifestPath: string;
429
445
  };
430
446
 
447
+ // Watch diff/rerun state. Lives on the BrowserRuntime (one per set of
448
+ // per-project compilers, surviving controller re-entry that reuses the
449
+ // runtime) instead of module scope, so its lifetime always matches the
450
+ // compilers whose baselines it holds.
451
+ type BrowserWatchState = {
452
+ lastTestFiles: TestFileInfo[];
453
+ hooksEnabled: boolean;
454
+ // Diff baselines keyed per project: sibling projects have isolated
455
+ // compilers, so a shared flat baseline would let one project's compile
456
+ // clobber another's (missed reruns) and collide on compiler-local chunk
457
+ // keys.
458
+ invalidation: Map<string, WatchInvalidationState>;
459
+ // Affected files accumulated per project until a rerun drains them, so a
460
+ // compile finishing while another project's rerun is being planned cannot
461
+ // drop pending work.
462
+ pendingAffectedTestFiles: Map<string, Set<string>>;
463
+ // Per-project compile start times and the accumulated compile duration of
464
+ // the pending rerun, so the rerun's finalize reports the real buildTime.
465
+ compileStartTimes: Map<string, number>;
466
+ pendingBuildTimeMs: number;
467
+ };
468
+
469
+ const createBrowserWatchState = (): BrowserWatchState => ({
470
+ lastTestFiles: [],
471
+ hooksEnabled: false,
472
+ invalidation: new Map(),
473
+ pendingAffectedTestFiles: new Map(),
474
+ compileStartTimes: new Map(),
475
+ pendingBuildTimeMs: 0,
476
+ });
477
+
478
+ const drainPendingBuildTime = (watchState: BrowserWatchState): number => {
479
+ const buildTime = watchState.pendingBuildTimeMs;
480
+ watchState.pendingBuildTimeMs = 0;
481
+ return buildTime;
482
+ };
483
+
484
+ const drainPendingAffectedTestFiles = (
485
+ watchState: BrowserWatchState,
486
+ ): string[] => {
487
+ const affected = new Set<string>();
488
+ for (const files of watchState.pendingAffectedTestFiles.values()) {
489
+ for (const file of files) {
490
+ affected.add(file);
491
+ }
492
+ }
493
+ watchState.pendingAffectedTestFiles.clear();
494
+ return Array.from(affected);
495
+ };
496
+
431
497
  type BrowserRuntime = {
432
498
  // Per-project servers, keyed by project name.
433
499
  projectServers: Map<string, BrowserProjectServer>;
@@ -446,32 +512,27 @@ type BrowserRuntime = {
446
512
  wss: WebSocketServer;
447
513
  rpcManager?: ContainerRpcManager;
448
514
  projectEntries: BrowserProjectEntries[];
515
+ watchState: BrowserWatchState;
449
516
  };
450
517
 
451
518
  // ============================================================================
452
- // Watch Mode Context - Encapsulates all watch mode state
519
+ // Watch Mode Context - Process-lifecycle watch state
453
520
  // ============================================================================
454
521
 
522
+ // Only process-wide concerns stay module-level: the runtime handle reused
523
+ // across controller re-entry (config-change restarts), and the signal/exit
524
+ // cleanup that must run once per process. Diff/rerun state lives on
525
+ // `BrowserRuntime.watchState`.
455
526
  type WatchContext = {
456
527
  runtime: BrowserRuntime | null;
457
- lastTestFiles: TestFileInfo[];
458
- hooksEnabled: boolean;
459
528
  cleanupRegistered: boolean;
460
529
  cleanupPromise: Promise<void> | null;
461
- closeCliShortcuts: (() => void) | null;
462
- chunkHashes: Map<string, string>;
463
- affectedTestFiles: string[];
464
530
  };
465
531
 
466
532
  const watchContext: WatchContext = {
467
533
  runtime: null,
468
- lastTestFiles: [],
469
- hooksEnabled: false,
470
534
  cleanupRegistered: false,
471
535
  cleanupPromise: null,
472
- closeCliShortcuts: null,
473
- chunkHashes: new Map(),
474
- affectedTestFiles: [],
475
536
  };
476
537
 
477
538
  // ============================================================================
@@ -948,42 +1009,87 @@ const getChunkKey = (chunk: StatsChunk): string | null => {
948
1009
  };
949
1010
 
950
1011
  /**
951
- * Compare chunk hashes and find affected test files for watch mode re-runs.
952
- * Uses chunk.id/names as stable keys instead of relying on file path patterns.
1012
+ * Fold one project compile's chunks into per-entry hash snapshots and apply
1013
+ * the shared watch-invalidation policy against that project's baseline.
1014
+ * Chunks are attributed to a test/setup file by scanning their modules; the
1015
+ * chunk.id/names key is only the hash-record key, never a cross-project one.
953
1016
  */
954
- const getAffectedTestFiles = (
955
- chunks: StatsChunk[] | undefined,
956
- entryTestFiles: Set<string>,
957
- ): string[] => {
958
- if (!chunks) return [];
959
-
960
- const affectedFiles = new Set<string>();
961
- const currentHashes = new Map<string, string>();
1017
+ const getAffectedTestFiles = ({
1018
+ chunks,
1019
+ entryTestFiles,
1020
+ setupFiles,
1021
+ state,
1022
+ }: {
1023
+ chunks: StatsChunk[] | undefined;
1024
+ entryTestFiles: Set<string>;
1025
+ setupFiles: Set<string>;
1026
+ state: WatchInvalidationState;
1027
+ }): string[] => {
1028
+ const entryHashes: EntryHashSnapshot = new Map();
1029
+ const setupHashes: EntryHashSnapshot = new Map();
1030
+
1031
+ const recordChunk = (
1032
+ snapshot: EntryHashSnapshot,
1033
+ entryPath: string,
1034
+ chunkKey: string,
1035
+ hash: string,
1036
+ ) => {
1037
+ const record = snapshot.get(entryPath) ?? {};
1038
+ record[chunkKey] = hash;
1039
+ snapshot.set(entryPath, record);
1040
+ };
962
1041
 
963
- for (const chunk of chunks) {
1042
+ for (const chunk of chunks || []) {
964
1043
  if (!chunk.hash) continue;
965
1044
 
966
- // First check if this chunk contains a test entry file
967
- const testFile = findTestFileInModules(chunk.modules, entryTestFiles);
968
- if (!testFile) continue;
969
-
970
- // Get a stable key for this chunk
971
1045
  const chunkKey = getChunkKey(chunk);
972
1046
  if (!chunkKey) continue;
973
1047
 
974
- const prevHash = watchContext.chunkHashes.get(chunkKey);
975
- currentHashes.set(chunkKey, chunk.hash);
1048
+ const testFile = findTestFileInModules(chunk.modules, entryTestFiles);
1049
+ if (testFile) {
1050
+ recordChunk(entryHashes, testFile, chunkKey, chunk.hash);
1051
+ continue;
1052
+ }
976
1053
 
977
- if (prevHash !== undefined && prevHash !== chunk.hash) {
978
- affectedFiles.add(testFile);
979
- logger.debug(
980
- `[Watch] Chunk hash changed for ${chunkKey}: ${prevHash} -> ${chunk.hash} (test: ${testFile})`,
981
- );
1054
+ const setupFile = findTestFileInModules(chunk.modules, setupFiles);
1055
+ if (setupFile) {
1056
+ recordChunk(setupHashes, setupFile, chunkKey, chunk.hash);
1057
+ }
1058
+ }
1059
+
1060
+ // Headed watch compiles chunks on demand (lazyCompilation), so an entry's
1061
+ // first appearance in stats means "just loaded", not "just added": its first
1062
+ // sighting establishes the baseline instead of marking a change. Genuinely
1063
+ // new and deleted test files are owned by the test-file-set diff in
1064
+ // `planWatchRerun` / `collectDeletedTestPaths`.
1065
+ const seedFirstSeen = (
1066
+ baseline: EntryHashSnapshot | undefined,
1067
+ current: EntryHashSnapshot,
1068
+ ) => {
1069
+ if (!baseline) return;
1070
+ for (const [entryPath, record] of current) {
1071
+ if (!baseline.has(entryPath)) {
1072
+ baseline.set(entryPath, record);
1073
+ }
982
1074
  }
1075
+ };
1076
+ seedFirstSeen(state.entryHashes, entryHashes);
1077
+ seedFirstSeen(state.setupHashes, setupHashes);
1078
+
1079
+ const outcome = applyWatchInvalidation(state, { entryHashes, setupHashes });
1080
+
1081
+ if (outcome.rerunAll) {
1082
+ logger.debug(
1083
+ '[Watch] Setup file changed, re-running all test files of the project',
1084
+ );
1085
+ return Array.from(entryTestFiles);
983
1086
  }
984
1087
 
985
- watchContext.chunkHashes = currentHashes;
986
- return Array.from(affectedFiles);
1088
+ for (const affected of outcome.affectedPaths) {
1089
+ logger.debug(`[Watch] Chunk hash changed for test: ${affected}`);
1090
+ }
1091
+
1092
+ return outcome.affectedPaths;
987
1093
  };
988
1094
 
989
1095
  const getBrowserProjects = (context: RstestContext): ProjectContext[] =>
@@ -1413,9 +1519,6 @@ const cleanupWatchRuntime = (): Promise<void> => {
1413
1519
  }
1414
1520
 
1415
1521
  watchContext.cleanupPromise = (async () => {
1416
- watchContext.closeCliShortcuts?.();
1417
- watchContext.closeCliShortcuts = null;
1418
-
1419
1522
  if (!watchContext.runtime) {
1420
1523
  return;
1421
1524
  }
@@ -1427,12 +1530,22 @@ const cleanupWatchRuntime = (): Promise<void> => {
1427
1530
  return watchContext.cleanupPromise;
1428
1531
  };
1429
1532
 
1430
- const registerWatchCleanup = (): void => {
1533
+ const registerWatchCleanup = (embedded: boolean): void => {
1431
1534
  if (watchContext.cleanupRegistered) {
1432
1535
  return;
1433
1536
  }
1537
+ watchContext.cleanupRegistered = true;
1538
+
1539
+ // Embedded (programmatic) hosts own the process lifecycle; they tear the
1540
+ // session down through the watch handles' `close` instead of signals.
1541
+ if (embedded) {
1542
+ return;
1543
+ }
1434
1544
 
1435
- for (const signal of ['SIGINT', 'SIGTERM', 'SIGTSTP'] as const) {
1545
+ // Cleanup-only nets: core's watch loop owns the signal → exit-code path
1546
+ // (`registerBrowserWatchSignalExit` / the mixed watch handler) and awaits
1547
+ // the same idempotent `cleanupWatchRuntime` promise through `watch.close`.
1548
+ for (const signal of FATAL_SIGNALS) {
1436
1549
  process.once(signal, () => {
1437
1550
  void cleanupWatchRuntime();
1438
1551
  });
@@ -1441,8 +1554,6 @@ const registerWatchCleanup = (): void => {
1441
1554
  process.once('exit', () => {
1442
1555
  void cleanupWatchRuntime();
1443
1556
  });
1444
-
1445
- watchContext.cleanupRegistered = true;
1446
1557
  };
1447
1558
 
1448
1559
  const createBrowserRuntime = async ({
@@ -1502,6 +1613,10 @@ const createBrowserRuntime = async ({
1502
1613
  let browserLaunchOptions =
1503
1614
  ensureConsistentBrowserLaunchOptions(browserProjects);
1504
1615
  let projectEntries = initialProjectEntries;
1616
+ // Created with the runtime so the per-project watch plugins and the
1617
+ // controller's rerun closures share one state whose lifetime matches the
1618
+ // compilers holding the diffed chunks.
1619
+ const watchState = createBrowserWatchState();
1505
1620
  const manifestModules: Array<{
1506
1621
  manifestPath: string;
1507
1622
  project: ProjectContext;
@@ -1530,6 +1645,7 @@ const createBrowserRuntime = async ({
1530
1645
  dispatchHandlers,
1531
1646
  wss: undefined as unknown as WebSocketServer,
1532
1647
  projectEntries,
1648
+ watchState,
1533
1649
  };
1534
1650
  };
1535
1651
 
@@ -1673,6 +1789,55 @@ const createBrowserRuntime = async ({
1673
1789
  }
1674
1790
  };
1675
1791
 
1792
+ const serveContainerRoute = async (
1793
+ req: IncomingMessage,
1794
+ res: ServerResponse,
1795
+ next: () => void,
1796
+ ): Promise<void> => {
1797
+ if (!req.url) {
1798
+ next();
1799
+ return;
1800
+ }
1801
+
1802
+ const url = new URL(req.url, 'http://localhost');
1803
+ if (url.pathname === '/') {
1804
+ if (await respondWithDevServerHtml(url, res)) {
1805
+ return;
1806
+ }
1807
+
1808
+ const html =
1809
+ injectedContainerHtml ||
1810
+ containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
1811
+
1812
+ if (html) {
1813
+ res.setHeader('Content-Type', 'text/html');
1814
+ res.end(html);
1815
+ return;
1816
+ }
1817
+
1818
+ res.statusCode = 502;
1819
+ res.end('Container UI is not available.');
1820
+ return;
1821
+ }
1822
+
1823
+ if (url.pathname.startsWith('/container-static/')) {
1824
+ if (await proxyDevServerAsset(req, res)) {
1825
+ return;
1826
+ }
1827
+
1828
+ if (serveContainer) {
1829
+ serveContainer(req, res, next);
1830
+ return;
1831
+ }
1832
+
1833
+ res.statusCode = 502;
1834
+ res.end('Container assets are not available.');
1835
+ return;
1836
+ }
1837
+
1838
+ next();
1839
+ };
1840
+
1676
1841
  // ---- Build one isolated rsbuild instance + dev server per project ----
1677
1842
  const buildProjectServer = async (
1678
1843
  project: ProjectContext,
@@ -1730,6 +1895,13 @@ const createBrowserRuntime = async ({
1730
1895
  project.normalizedConfig.browser.port ??
1731
1896
  (isContainerServer ? 4000 : 0),
1732
1897
  strictPort: project.normalizedConfig.browser.strictPort,
1898
+ // User plugins may emit index.html; register before Rsbuild's HTML
1899
+ // completion middleware so `/` remains owned by the Browser UI.
1900
+ setup: isContainerServer
1901
+ ? ({ server }) => {
1902
+ server.middlewares.use(serveContainerRoute);
1903
+ }
1904
+ : undefined,
1733
1905
  },
1734
1906
  dev: createBrowserRsbuildDevConfig(enableHmr),
1735
1907
  environments: {
@@ -1822,8 +1994,35 @@ const createBrowserRuntime = async ({
1822
1994
  sourceMap: {
1823
1995
  js: 'source-map',
1824
1996
  },
1997
+ // Every project server compiles the same asset names
1998
+ // (`static/js/runner.js`, ...). With `dev.writeToDisk`
1999
+ // (debug mode) the middleware serves from disk, so a
2000
+ // shared dist dir would be last-writer-wins and one
2001
+ // project's server would deliver another project's
2002
+ // bundle — keep each project's output isolated, inside
2003
+ // the run's temp dir so teardown removes it.
2004
+ distPath: {
2005
+ root: join(
2006
+ tempDir,
2007
+ 'server',
2008
+ toSafeVarName(project.environmentName),
2009
+ ),
2010
+ },
1825
2011
  },
1826
2012
  tools: {
2013
+ swc: (swcConfig) => {
2014
+ // Fixture dependency discovery reads callback parameters
2015
+ // through Function#toString(). Playwright's supported
2016
+ // browsers all support parameter destructuring, so keep
2017
+ // that syntax intact in the browser test bundle.
2018
+ swcConfig.env ??= {};
2019
+ swcConfig.env.exclude = Array.from(
2020
+ new Set([
2021
+ ...(swcConfig.env.exclude ?? []),
2022
+ 'transform-parameters',
2023
+ ]),
2024
+ );
2025
+ },
1827
2026
  rspack: (rspackConfig) => {
1828
2027
  rspackConfig.mode = 'development';
1829
2028
  // Web parameterization of the node mock transform:
@@ -1893,37 +2092,80 @@ const createBrowserRuntime = async ({
1893
2092
  name: 'rstest:browser-watch',
1894
2093
  setup(api) {
1895
2094
  api.onBeforeDevCompile(() => {
1896
- if (!watchContext.hooksEnabled) {
2095
+ watchState.compileStartTimes.set(project.name, Date.now());
2096
+ if (!watchState.hooksEnabled) {
1897
2097
  return;
1898
2098
  }
1899
2099
  logger.log(color.cyan('\nFile changed, re-running tests...\n'));
1900
2100
  });
1901
2101
 
1902
2102
  api.onAfterDevCompile(async ({ stats }) => {
2103
+ const compileStart = watchState.compileStartTimes.get(
2104
+ project.name,
2105
+ );
2106
+ if (compileStart !== undefined) {
2107
+ watchState.compileStartTimes.delete(project.name);
2108
+ // Only change-triggered compiles feed the pending rerun's
2109
+ // build phase (the initial build is the initial run's
2110
+ // buildTime). Parallel project compiles overlap; the longest
2111
+ // one bounds the rerun's build phase.
2112
+ if (watchState.hooksEnabled) {
2113
+ watchState.pendingBuildTimeMs = Math.max(
2114
+ watchState.pendingBuildTimeMs,
2115
+ Date.now() - compileStart,
2116
+ );
2117
+ }
2118
+ }
1903
2119
  // Collect hashes even during initial build to establish baseline
1904
2120
  if (stats) {
1905
- const allProjectEntries = await collectProjectEntries(context);
2121
+ // This compiler only ever holds this project's entries; the
2122
+ // diff baseline is keyed per project accordingly.
2123
+ const [projectEntry] = await collectProjectEntries(context, [
2124
+ project,
2125
+ ]);
1906
2126
  const entryTestFiles = new Set<string>(
1907
- collectWatchTestFiles(allProjectEntries).map(
2127
+ collectWatchTestFiles(projectEntry ? [projectEntry] : []).map(
1908
2128
  (file) => file.testPath,
1909
2129
  ),
1910
2130
  );
2131
+ const setupFiles = new Set<string>(
2132
+ (projectEntry?.setupFiles ?? []).map((file) =>
2133
+ normalize(file),
2134
+ ),
2135
+ );
2136
+
2137
+ let state = watchState.invalidation.get(project.name);
2138
+ if (!state) {
2139
+ state = {};
2140
+ watchState.invalidation.set(project.name, state);
2141
+ }
1911
2142
 
1912
2143
  const statsJson = stats.toJson({ all: true });
1913
- const affected = getAffectedTestFiles(
1914
- statsJson.chunks,
2144
+ const affected = getAffectedTestFiles({
2145
+ chunks: statsJson.chunks,
1915
2146
  entryTestFiles,
1916
- );
1917
- watchContext.affectedTestFiles = affected;
2147
+ setupFiles,
2148
+ state,
2149
+ });
1918
2150
 
1919
2151
  if (affected.length > 0) {
2152
+ const pending =
2153
+ watchState.pendingAffectedTestFiles.get(project.name) ??
2154
+ new Set<string>();
2155
+ for (const file of affected) {
2156
+ pending.add(file);
2157
+ }
2158
+ watchState.pendingAffectedTestFiles.set(
2159
+ project.name,
2160
+ pending,
2161
+ );
1920
2162
  logger.debug(
1921
2163
  `[Watch] Affected test files: ${affected.join(', ')}`,
1922
2164
  );
1923
2165
  }
1924
2166
  }
1925
2167
 
1926
- if (!watchContext.hooksEnabled) {
2168
+ if (!watchState.hooksEnabled) {
1927
2169
  return;
1928
2170
  }
1929
2171
 
@@ -1965,6 +2207,15 @@ const createBrowserRuntime = async ({
1965
2207
  if (isDebug()) {
1966
2208
  await rsbuildInstance.inspectConfig({
1967
2209
  writeToDisk: true,
2210
+ // The server's own distPath is isolated per project inside the run's
2211
+ // temp dir (removed at teardown); keep the debug artifacts at the
2212
+ // project's stable dist root so they survive the run and stay where
2213
+ // the docs point users to.
2214
+ outputPath: resolve(
2215
+ context.rootPath,
2216
+ context.normalizedConfig.output.distPath.root,
2217
+ '.rsbuild',
2218
+ ),
1968
2219
  extraConfigs: {
1969
2220
  rstest: {
1970
2221
  ...context.normalizedConfig,
@@ -2001,43 +2252,6 @@ const createBrowserRuntime = async ({
2001
2252
  }
2002
2253
  return;
2003
2254
  }
2004
- // Container UI HTML + static assets are served by the container origin
2005
- // only. Per-project runner servers expose just /runner.html + assets.
2006
- if (isContainerServer) {
2007
- if (url.pathname === '/') {
2008
- if (await respondWithDevServerHtml(url, res)) {
2009
- return;
2010
- }
2011
-
2012
- const html =
2013
- injectedContainerHtml ||
2014
- containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
2015
-
2016
- if (html) {
2017
- res.setHeader('Content-Type', 'text/html');
2018
- res.end(html);
2019
- return;
2020
- }
2021
-
2022
- res.statusCode = 502;
2023
- res.end('Container UI is not available.');
2024
- return;
2025
- }
2026
- if (url.pathname.startsWith('/container-static/')) {
2027
- if (await proxyDevServerAsset(req, res)) {
2028
- return;
2029
- }
2030
-
2031
- if (serveContainer) {
2032
- serveContainer(req, res, next);
2033
- return;
2034
- }
2035
-
2036
- res.statusCode = 502;
2037
- res.end('Container assets are not available.');
2038
- return;
2039
- }
2040
- }
2041
2255
  if (url.pathname === '/runner.html') {
2042
2256
  res.setHeader('Content-Type', 'text/html');
2043
2257
  res.end(htmlTemplate);
@@ -2114,6 +2328,7 @@ const createBrowserRuntime = async ({
2114
2328
  dispatchHandlers,
2115
2329
  wss,
2116
2330
  projectEntries,
2331
+ watchState,
2117
2332
  };
2118
2333
  } catch (error) {
2119
2334
  wss.close();
@@ -2157,7 +2372,6 @@ export const runBrowserController = async (
2157
2372
  options?: BrowserTestRunOptions,
2158
2373
  ): Promise<BrowserTestRunResult | void> => {
2159
2374
  const {
2160
- allowEmptyWatchRun = false,
2161
2375
  allowEmptyRun = false,
2162
2376
  filesOnly = false,
2163
2377
  onTraceEvents,
@@ -2175,10 +2389,13 @@ export const runBrowserController = async (
2175
2389
  // passes `onTraceEvents`). The browser host shares one Node process across
2176
2390
  // every test file, so each tracker is assigned a synthetic per-file pid
2177
2391
  // (`nextBrowserFilePid`) that lets Perfetto render each file as its own
2178
- // process track with the file path as the title.
2392
+ // process track with the file path as the title. Keyed by project + path so
2393
+ // concurrent projects running the same file keep separate trackers.
2179
2394
  const phaseTrackers = onTraceEvents
2180
2395
  ? new Map<string, PhaseTracker>()
2181
2396
  : undefined;
2397
+ const trackerKey = (project: string, testPath: string) =>
2398
+ `${project}\u0000${testPath}`;
2182
2399
  // Explicit projects input (plan output) replaces re-deriving `browser.enabled`
2183
2400
  // projects from `context`, whose `projects` array is mutated during planning.
2184
2401
  // Falls back to re-derivation only when the caller passes no list at all —
@@ -2324,47 +2541,27 @@ export const runBrowserController = async (
2324
2541
 
2325
2542
  const notifyTestRunEnd = async ({
2326
2543
  duration,
2327
- unhandledErrors,
2328
- filterRerunTestPaths,
2544
+ coverage,
2329
2545
  }: {
2330
2546
  duration: {
2331
2547
  totalTime: number;
2332
2548
  buildTime: number;
2333
2549
  testTime: number;
2334
2550
  };
2335
- unhandledErrors?: Error[];
2336
- filterRerunTestPaths?: string[];
2551
+ coverage?: CoverageMapData;
2337
2552
  }): Promise<void> => {
2338
2553
  if (!isWatchMode) {
2339
2554
  return;
2340
2555
  }
2341
2556
 
2342
- // Merge per-file coverage into a single CoverageMapData for reporters
2343
- let mergedCoverage: CoverageMapData | undefined;
2344
- if (coverageProvider) {
2345
- const coverageMap = coverageProvider.createCoverageMap();
2346
- let hasCoverage = false;
2347
- for (const result of context.reporterResults.results) {
2348
- if (result.coverage) {
2349
- coverageMap.merge(result.coverage);
2350
- hasCoverage = true;
2351
- }
2352
- }
2353
- if (hasCoverage) {
2354
- mergedCoverage = coverageMap.toJSON();
2355
- }
2356
- }
2357
-
2358
2557
  for (const reporter of context.reporters) {
2359
2558
  await reporter.onTestRunEnd?.({
2360
2559
  results: context.reporterResults.results,
2361
- coverage: mergedCoverage,
2560
+ coverage,
2362
2561
  testResults: context.reporterResults.testResults,
2363
2562
  duration,
2364
2563
  snapshotSummary: context.snapshotManager.summary,
2365
2564
  getSourcemap: getBrowserSourcemap,
2366
- unhandledErrors,
2367
- filterRerunTestPaths,
2368
2565
  });
2369
2566
  }
2370
2567
  };
@@ -2405,7 +2602,6 @@ export const runBrowserController = async (
2405
2602
  (total, item) => total + item.testFiles.length,
2406
2603
  0,
2407
2604
  );
2408
- const shouldKeepWatchingWithEmptySet = isWatchMode && allowEmptyWatchRun;
2409
2605
  const shouldInitializeEmptyBrowserHooks =
2410
2606
  totalTests === 0 && hasUserRstestConfigPlugins(browserProjects);
2411
2607
 
@@ -2425,16 +2621,14 @@ export const runBrowserController = async (
2425
2621
  };
2426
2622
  };
2427
2623
 
2428
- const reportEmptyTestSet = (): boolean => {
2624
+ const reportEmptyTestSet = (): void => {
2429
2625
  const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
2430
2626
  if (isWatchMode || !allowEmptyRun) {
2431
- const message = shouldKeepWatchingWithEmptySet
2432
- ? 'No test files found.'
2433
- : getNoTestFilesMessage({
2434
- context,
2435
- code,
2436
- defaultMessage: `No test files found, exiting with code ${code}.`,
2437
- });
2627
+ const message = getNoTestFilesMessage({
2628
+ context,
2629
+ code,
2630
+ defaultMessage: `No test files found, exiting with code ${code}.`,
2631
+ });
2438
2632
  if (code === 0) {
2439
2633
  logger.log(color.yellow(message));
2440
2634
  } else {
@@ -2457,29 +2651,21 @@ export const runBrowserController = async (
2457
2651
  // In non-watch runs the host returns a void outcome and core's
2458
2652
  // `reportNoTestFiles` owns the exit code and the no-test reporter lifecycle;
2459
2653
  // 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
- ) {
2654
+ if (isWatchMode && code !== 0 && !allowEmptyRun) {
2466
2655
  ensureProcessExitCode(code);
2467
2656
  }
2468
-
2469
- return !shouldKeepWatchingWithEmptySet;
2470
2657
  };
2471
2658
 
2472
2659
  if (totalTests === 0 && !shouldInitializeEmptyBrowserHooks) {
2473
- if (reportEmptyTestSet()) {
2474
- return allowEmptyRun ? createEmptyRunResult() : undefined;
2475
- }
2660
+ reportEmptyTestSet();
2661
+ return allowEmptyRun ? createEmptyRunResult() : undefined;
2476
2662
  }
2477
2663
 
2478
2664
  if (!filesOnly) {
2479
2665
  await notifyTestRunStart();
2480
2666
  }
2481
2667
 
2482
- const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
2668
+ const enableCliShortcuts = isWatchMode && isTTY('stdin');
2483
2669
  const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
2484
2670
  const tempDir =
2485
2671
  isWatchMode && watchContext.runtime
@@ -2493,15 +2679,13 @@ export const runBrowserController = async (
2493
2679
  Date.now().toString(),
2494
2680
  );
2495
2681
 
2496
- // Track initial test files for watch mode
2497
- if (isWatchMode) {
2498
- watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
2499
- }
2500
-
2501
2682
  let runtime = isWatchMode ? watchContext.runtime : null;
2502
2683
 
2503
2684
  // Define rerun callback for watch mode (will be populated later)
2504
2685
  let triggerRerun: (() => Promise<void>) | undefined;
2686
+ // Headless reruns complete asynchronously in the scheduler's drain loop;
2687
+ // the watch handles await this so callers observe rerun completion.
2688
+ let awaitHeadlessRerunIdle: (() => Promise<void>) | undefined;
2505
2689
 
2506
2690
  if (!runtime) {
2507
2691
  try {
@@ -2532,15 +2716,109 @@ export const runBrowserController = async (
2532
2716
 
2533
2717
  if (isWatchMode) {
2534
2718
  watchContext.runtime = runtime;
2535
- registerWatchCleanup();
2719
+ registerWatchCleanup(context.embedded);
2720
+ }
2721
+ }
2536
2722
 
2537
- if (enableCliShortcuts && !watchContext.closeCliShortcuts) {
2538
- watchContext.closeCliShortcuts = await setupBrowserWatchCliShortcuts({
2539
- close: cleanupWatchRuntime,
2540
- });
2723
+ const watchState = runtime.watchState;
2724
+
2725
+ // Track initial test files for watch mode (from this controller's freshly
2726
+ // collected entries, before adopting the runtime's entry snapshot below).
2727
+ if (isWatchMode) {
2728
+ watchState.lastTestFiles = collectWatchTestFiles(projectEntries);
2729
+ }
2730
+
2731
+ // Mark files as pending-affected so the next `triggerRerun` reruns them
2732
+ // through the normal plan/schedule/finalize pipeline (used by the watch
2733
+ // handles' explicit reruns; omitted paths = all current files). Returns the
2734
+ // number of seeded files so callers can skip the rerun when a path-scoped
2735
+ // request matches no browser test file (mixed watch 'u' with node-only
2736
+ // snapshot updates).
2737
+ const seedPendingRerun = (testPaths?: string[]): number => {
2738
+ const wanted = testPaths
2739
+ ? new Set(testPaths.map((testPath) => normalize(testPath)))
2740
+ : null;
2741
+ let seeded = 0;
2742
+ for (const file of watchState.lastTestFiles) {
2743
+ if (wanted && !wanted.has(file.testPath)) {
2744
+ continue;
2541
2745
  }
2746
+ const pending =
2747
+ watchState.pendingAffectedTestFiles.get(file.projectName) ??
2748
+ new Set<string>();
2749
+ pending.add(file.testPath);
2750
+ watchState.pendingAffectedTestFiles.set(file.projectName, pending);
2751
+ seeded += 1;
2542
2752
  }
2543
- }
2753
+ return seeded;
2754
+ };
2755
+
2756
+ const watchHandles: BrowserWatchHandles | undefined = isWatchMode
2757
+ ? {
2758
+ rerun: async (testPaths) => {
2759
+ const seeded = seedPendingRerun(testPaths);
2760
+ if (testPaths && seeded === 0) {
2761
+ return;
2762
+ }
2763
+ await triggerRerun?.();
2764
+ await awaitHeadlessRerunIdle?.();
2765
+ },
2766
+ close: cleanupWatchRuntime,
2767
+ }
2768
+ : undefined;
2769
+
2770
+ /**
2771
+ * Per-rerun finalize for watch mode: fold the rerun into a synthetic
2772
+ * `ExecutorCycleOutcome` and hand it to core's `finalizeRunCycle`, so
2773
+ * reporter payloads, exit-code never-downgrade semantics, and coverage
2774
+ * reports match the node watch cycle. The trace buffer stays session-owned
2775
+ * (no `traceRun` here); `buildTime` is the drained duration of the
2776
+ * change-triggered compile(s), not a hardcoded zero.
2777
+ */
2778
+ const finalizeWatchRerun = async ({
2779
+ rerunTestPaths,
2780
+ testTime,
2781
+ unhandledErrors,
2782
+ }: {
2783
+ rerunTestPaths: string[];
2784
+ testTime: number;
2785
+ unhandledErrors?: Error[];
2786
+ }): Promise<void> => {
2787
+ const rerunPathSet = new Set(rerunTestPaths);
2788
+ const rerunResults = context.reporterResults.results.filter((result) =>
2789
+ rerunPathSet.has(result.testPath),
2790
+ );
2791
+ // Watch coverage is per-cycle on both transports: only the files this
2792
+ // rerun executed are reported.
2793
+ let rerunCoverage: CoverageMapData | undefined;
2794
+ const coverageMap = buildBrowserCoverageMap(rerunResults, coverageProvider);
2795
+ if (coverageMap && coverageMap.files().length > 0) {
2796
+ rerunCoverage = coverageMap.toJSON();
2797
+ }
2798
+
2799
+ const outcome: ExecutorCycleOutcome = {
2800
+ results: rerunResults,
2801
+ testResults: context.reporterResults.testResults.filter((result) =>
2802
+ rerunPathSet.has(result.testPath),
2803
+ ),
2804
+ errors: unhandledErrors ?? [],
2805
+ testPaths: rerunTestPaths,
2806
+ duration: {
2807
+ buildTime: drainPendingBuildTime(watchState),
2808
+ testTime,
2809
+ },
2810
+ coverage: rerunCoverage ? { map: rerunCoverage } : undefined,
2811
+ resolveSourcemap: resolveBrowserSourcemap,
2812
+ };
2813
+
2814
+ await finalizeRunCycle(context, {
2815
+ outcomes: [outcome],
2816
+ mode: 'on-demand',
2817
+ isWatchMode: true,
2818
+ coverageProvider,
2819
+ reportOnFailure: coverageConfig?.reportOnFailure ?? false,
2820
+ });
2821
+ };
2544
2822
 
2545
2823
  projectEntries = runtime.projectEntries;
2546
2824
  totalTests = projectEntries.reduce(
@@ -2566,7 +2844,8 @@ export const runBrowserController = async (
2566
2844
  };
2567
2845
  }
2568
2846
 
2569
- if (totalTests === 0 && reportEmptyTestSet()) {
2847
+ if (totalTests === 0) {
2848
+ reportEmptyTestSet();
2570
2849
  await destroyBrowserRuntime(runtime);
2571
2850
  return allowEmptyRun ? createEmptyRunResult() : undefined;
2572
2851
  }
@@ -2612,7 +2891,9 @@ export const runBrowserController = async (
2612
2891
  rootPath: normalize(context.rootPath),
2613
2892
  projects: projectRuntimeConfigs,
2614
2893
  snapshot: {
2615
- updateSnapshot: context.snapshotManager.options.updateSnapshot,
2894
+ updateSnapshot:
2895
+ options?.updateSnapshot ??
2896
+ context.snapshotManager.options.updateSnapshot,
2616
2897
  },
2617
2898
  // Container origin (fallback). Per-project runner origins below.
2618
2899
  runnerUrl: `http://localhost:${runtime.containerServer.port}`,
@@ -2728,22 +3009,18 @@ export const runBrowserController = async (
2728
3009
  createRunnerEventSink(context, project.normalizedConfig),
2729
3010
  ]),
2730
3011
  );
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;
3012
+ // Every per-file wire payload carries its owning project name, so routing
3013
+ // never derives a project from a test path — concurrent projects can run the
3014
+ // same file, and a path-keyed lookup would attribute events to the wrong one.
3015
+ // The client resolves its project from the host's own manifest, so a miss is
3016
+ // a protocol bug; fail loudly rather than route through another project's
3017
+ // config.
3018
+ const sinkForProjectName = (projectName: string): RunnerEventSink => {
3019
+ const sink = runnerSinks.get(projectName);
3020
+ if (!sink) {
3021
+ throw new Error(`No runner event sink for project "${projectName}"`);
3022
+ }
3023
+ return sink;
2747
3024
  };
2748
3025
 
2749
3026
  // Silent-console buffering runs through the shared controller — the same
@@ -2760,7 +3037,7 @@ export const runBrowserController = async (
2760
3037
  disableConsoleIntercept: context.normalizedConfig.disableConsoleIntercept,
2761
3038
  },
2762
3039
  emitInterceptedLog: (log) =>
2763
- sinkForTestPath(log.testPath).onConsoleLog(log),
3040
+ sinkForProjectName(log.project).onConsoleLog(log),
2764
3041
  writeOriginalLog: () => {},
2765
3042
  });
2766
3043
 
@@ -2795,7 +3072,6 @@ export const runBrowserController = async (
2795
3072
  const handleTestFileStart = async (
2796
3073
  payload: TestFileStartPayload,
2797
3074
  ): Promise<void> => {
2798
- projectNameByTestPath.set(payload.testPath, payload.projectName);
2799
3075
  if (phaseTrackers) {
2800
3076
  const tracker = new PhaseTracker({
2801
3077
  trace: {
@@ -2805,13 +3081,17 @@ export const runBrowserController = async (
2805
3081
  pid: nextBrowserFilePid++,
2806
3082
  });
2807
3083
  tracker.transition('prepare');
2808
- phaseTrackers.set(payload.testPath, tracker);
3084
+ phaseTrackers.set(
3085
+ trackerKey(payload.projectName, payload.testPath),
3086
+ tracker,
3087
+ );
2809
3088
  }
2810
3089
  // The client sends `{ testPath, projectName }`; the sink adapter builds the
2811
3090
  // `TestFileInfo` the reporters and stateManager expect.
2812
3091
  await sinkForProjectName(payload.projectName).onTestFileStart({
2813
3092
  testId: getFileTaskId(payload.testPath),
2814
3093
  testPath: payload.testPath,
3094
+ project: payload.projectName,
2815
3095
  tests: [],
2816
3096
  });
2817
3097
  };
@@ -2819,22 +3099,28 @@ export const runBrowserController = async (
2819
3099
  const handleTestFileReady = async (
2820
3100
  payload: TestFileReadyPayload,
2821
3101
  ): Promise<void> => {
2822
- phaseTrackers?.get(payload.testPath)?.transition('tests');
2823
- await sinkForTestPath(payload.testPath).onTestFileReady(payload);
3102
+ phaseTrackers
3103
+ ?.get(trackerKey(payload.project, payload.testPath))
3104
+ ?.transition('tests');
3105
+ await sinkForProjectName(payload.project).onTestFileReady(payload);
2824
3106
  };
2825
3107
 
2826
3108
  const handleTestSuiteStart = async (
2827
3109
  payload: TestSuiteStartPayload,
2828
3110
  ): Promise<void> => {
2829
- phaseTrackers?.get(payload.testPath)?.recordSuiteStart(payload);
2830
- await sinkForTestPath(payload.testPath).onTestSuiteStart(payload);
3111
+ phaseTrackers
3112
+ ?.get(trackerKey(payload.project, payload.testPath))
3113
+ ?.recordSuiteStart(payload);
3114
+ await sinkForProjectName(payload.project).onTestSuiteStart(payload);
2831
3115
  };
2832
3116
 
2833
3117
  const handleTestSuiteResult = async (
2834
3118
  payload: TestSuiteResultPayload,
2835
3119
  ): Promise<void> => {
2836
- phaseTrackers?.get(payload.testPath)?.recordSuiteResult(payload);
2837
- await sinkForTestPath(payload.testPath).onTestSuiteResult(payload);
3120
+ phaseTrackers
3121
+ ?.get(trackerKey(payload.project, payload.testPath))
3122
+ ?.recordSuiteResult(payload);
3123
+ await sinkForProjectName(payload.project).onTestSuiteResult(payload);
2838
3124
 
2839
3125
  if (context.normalizedConfig.silent === 'passed-only') {
2840
3126
  silentConsoleController.flushBufferedLogsForTask({
@@ -2850,15 +3136,19 @@ export const runBrowserController = async (
2850
3136
  const handleTestCaseStart = async (
2851
3137
  payload: TestCaseStartPayload,
2852
3138
  ): Promise<void> => {
2853
- phaseTrackers?.get(payload.testPath)?.recordCaseStart(payload);
3139
+ phaseTrackers
3140
+ ?.get(trackerKey(payload.project, payload.testPath))
3141
+ ?.recordCaseStart(payload);
2854
3142
  // Fire-and-forget on both transports (the sink does not await case-start).
2855
- sinkForTestPath(payload.testPath).onTestCaseStart(payload);
3143
+ sinkForProjectName(payload.project).onTestCaseStart(payload);
2856
3144
  };
2857
3145
 
2858
3146
  const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
2859
3147
  caseResults.push(payload);
2860
- phaseTrackers?.get(payload.testPath)?.recordCaseResult(payload);
2861
- await sinkForTestPath(payload.testPath).onTestCaseResult(payload);
3148
+ phaseTrackers
3149
+ ?.get(trackerKey(payload.project, payload.testPath))
3150
+ ?.recordCaseResult(payload);
3151
+ await sinkForProjectName(payload.project).onTestCaseResult(payload);
2862
3152
 
2863
3153
  if (context.normalizedConfig.silent === 'passed-only') {
2864
3154
  silentConsoleController.flushBufferedLogsForTask({
@@ -2878,12 +3168,13 @@ export const runBrowserController = async (
2878
3168
  context.updateReporterResultState([payload], payload.results);
2879
3169
 
2880
3170
  if (phaseTrackers) {
2881
- const tracker = phaseTrackers.get(payload.testPath);
3171
+ const key = trackerKey(payload.project, payload.testPath);
3172
+ const tracker = phaseTrackers.get(key);
2882
3173
  if (tracker) {
2883
3174
  tracker.end();
2884
3175
  const events = tracker.getTraceEvents();
2885
3176
  if (events) onTraceEvents?.(events);
2886
- phaseTrackers.delete(payload.testPath);
3177
+ phaseTrackers.delete(key);
2887
3178
  }
2888
3179
  }
2889
3180
 
@@ -2899,7 +3190,7 @@ export const runBrowserController = async (
2899
3190
 
2900
3191
  // Feeds stateManager, fans out onTestFileResult to reporters, and ingests
2901
3192
  // payload.snapshotResult (the snapshotManager.add moved into the sink).
2902
- await sinkForTestPath(payload.testPath).onTestFileResult(payload);
3193
+ await sinkForProjectName(payload.project).onTestFileResult(payload);
2903
3194
  // In non-watch runs core owns the exit code via `finalizeRunCycle` (the
2904
3195
  // failing file rides the returned outcome); watch reruns set it here.
2905
3196
  if (isWatchMode && payload.status === 'fail') {
@@ -2917,6 +3208,7 @@ export const runBrowserController = async (
2917
3208
  taskParentNames: payload.taskParentNames,
2918
3209
  taskType: payload.taskType,
2919
3210
  testPath: payload.testPath,
3211
+ project: payload.projectName,
2920
3212
  type: payload.type,
2921
3213
  trace: payload.trace,
2922
3214
  };
@@ -3174,6 +3466,12 @@ export const runBrowserController = async (
3174
3466
 
3175
3467
  const inlineOptions: BrowserHostConfig = {
3176
3468
  ...hostOptions,
3469
+ // Read live per page load, not from the construction-time
3470
+ // `hostOptions` value: the 'u' shortcut flips
3471
+ // `snapshotManager.options` between reruns.
3472
+ snapshot: {
3473
+ updateSnapshot: context.snapshotManager.options.updateSnapshot,
3474
+ },
3177
3475
  testFile: file.testPath,
3178
3476
  runId: `${run.token}:${session.id}`,
3179
3477
  };
@@ -3343,20 +3641,16 @@ export const runBrowserController = async (
3343
3641
  fatalError && fatalError !== fatalErrorBeforeRun
3344
3642
  ? fatalError
3345
3643
  : undefined;
3346
- await notifyTestRunEnd({
3347
- duration: {
3348
- totalTime: testTime,
3349
- buildTime: 0,
3350
- testTime,
3351
- },
3352
- filterRerunTestPaths: files.map((file) => file.testPath),
3644
+ await finalizeWatchRerun({
3645
+ rerunTestPaths: files.map((file) => file.testPath),
3646
+ testTime,
3353
3647
  unhandledErrors: rerunError
3354
3648
  ? [rerunError]
3355
3649
  : rerunFatalError
3356
3650
  ? [rerunFatalError]
3357
3651
  : undefined,
3358
3652
  });
3359
- logBrowserWatchReadyMessage(enableCliShortcuts);
3653
+ logWatchReadyMessage(context, enableCliShortcuts);
3360
3654
  }
3361
3655
  },
3362
3656
  onError: async (error) => {
@@ -3373,68 +3667,7 @@ export const runBrowserController = async (
3373
3667
  },
3374
3668
  });
3375
3669
 
3376
- if (allTestFiles.length === 0) {
3377
- const duration = {
3378
- totalTime: buildTime,
3379
- buildTime,
3380
- testTime: 0,
3381
- };
3382
- const result = {
3383
- results: reporterResults,
3384
- testResults: caseResults,
3385
- duration,
3386
- hasFailure: false,
3387
- getSourcemap: getBrowserSourcemap,
3388
- resolveSourcemap: resolveBrowserSourcemap,
3389
- close: !isWatchMode
3390
- ? async () => {
3391
- sessionRegistry.clear();
3392
- await destroyBrowserRuntime(runtime);
3393
- }
3394
- : undefined,
3395
- };
3396
-
3397
- if (isWatchMode) {
3398
- await notifyTestRunEnd({ duration });
3399
- }
3400
-
3401
- if (isWatchMode) {
3402
- triggerRerun = async () => {
3403
- const newProjectEntries = await collectProjectEntries(context);
3404
- const rerunPlan = planWatchRerun({
3405
- projectEntries: newProjectEntries,
3406
- previousTestFiles: watchContext.lastTestFiles,
3407
- affectedTestFiles: watchContext.affectedTestFiles,
3408
- });
3409
- watchContext.affectedTestFiles = [];
3410
-
3411
- if (rerunPlan.filesChanged) {
3412
- watchContext.lastTestFiles = rerunPlan.currentTestFiles;
3413
- if (rerunPlan.currentTestFiles.length === 0) {
3414
- logger.log(
3415
- color.cyan('No browser test files remain after update.\n'),
3416
- );
3417
- logBrowserWatchReadyMessage(enableCliShortcuts);
3418
- return;
3419
- }
3420
-
3421
- logger.log(
3422
- color.cyan(
3423
- `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
3424
- ),
3425
- );
3426
- void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
3427
- return;
3428
- }
3429
-
3430
- logBrowserWatchReadyMessage(enableCliShortcuts);
3431
- };
3432
- watchContext.hooksEnabled = true;
3433
- logBrowserWatchReadyMessage(enableCliShortcuts);
3434
- }
3435
-
3436
- return result;
3437
- }
3670
+ awaitHeadlessRerunIdle = () => latestRerunScheduler.whenIdle();
3438
3671
 
3439
3672
  const testStart = Date.now();
3440
3673
  await runFilesWithPool(allTestFiles);
@@ -3445,26 +3678,25 @@ export const runBrowserController = async (
3445
3678
  const newProjectEntries = await collectProjectEntries(context);
3446
3679
  const rerunPlan = planWatchRerun({
3447
3680
  projectEntries: newProjectEntries,
3448
- previousTestFiles: watchContext.lastTestFiles,
3449
- affectedTestFiles: watchContext.affectedTestFiles,
3681
+ previousTestFiles: watchState.lastTestFiles,
3682
+ affectedTestFiles: drainPendingAffectedTestFiles(watchState),
3450
3683
  });
3451
- watchContext.affectedTestFiles = [];
3452
3684
 
3453
3685
  if (rerunPlan.filesChanged) {
3454
3686
  const deletedTestPaths = collectDeletedTestPaths(
3455
- watchContext.lastTestFiles,
3687
+ watchState.lastTestFiles,
3456
3688
  rerunPlan.currentTestFiles,
3457
3689
  );
3458
3690
  if (deletedTestPaths.length > 0) {
3459
3691
  context.updateReporterResultState([], [], deletedTestPaths);
3460
3692
  }
3461
- watchContext.lastTestFiles = rerunPlan.currentTestFiles;
3693
+ watchState.lastTestFiles = rerunPlan.currentTestFiles;
3462
3694
  if (rerunPlan.currentTestFiles.length === 0) {
3463
3695
  await latestRerunScheduler.enqueueLatest([]);
3464
3696
  logger.log(
3465
3697
  color.cyan('No browser test files remain after update.\n'),
3466
3698
  );
3467
- logBrowserWatchReadyMessage(enableCliShortcuts);
3699
+ logWatchReadyMessage(context, enableCliShortcuts);
3468
3700
  return;
3469
3701
  }
3470
3702
 
@@ -3473,7 +3705,7 @@ export const runBrowserController = async (
3473
3705
  `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
3474
3706
  ),
3475
3707
  );
3476
- void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
3708
+ await latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
3477
3709
  return;
3478
3710
  }
3479
3711
 
@@ -3483,7 +3715,7 @@ export const runBrowserController = async (
3483
3715
  'No affected browser test files detected, skipping re-run.\n',
3484
3716
  ),
3485
3717
  );
3486
- logBrowserWatchReadyMessage(enableCliShortcuts);
3718
+ logWatchReadyMessage(context, enableCliShortcuts);
3487
3719
  return;
3488
3720
  }
3489
3721
 
@@ -3492,7 +3724,7 @@ export const runBrowserController = async (
3492
3724
  `Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`,
3493
3725
  ),
3494
3726
  );
3495
- void latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
3727
+ await latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
3496
3728
  };
3497
3729
  }
3498
3730
 
@@ -3524,6 +3756,15 @@ export const runBrowserController = async (
3524
3756
  ensureProcessExitCode(1);
3525
3757
  }
3526
3758
 
3759
+ // Fold and strip the initial cycle's coverage (the same per-cycle fold
3760
+ // reruns get in `finalizeWatchRerun`); the map rides on the result so the
3761
+ // browser-only watch path can report it without re-merging. Non-watch runs
3762
+ // keep `result.coverage` intact for the executor's outcome fold.
3763
+ const cycleCoverageMap =
3764
+ isWatchMode && coverageProvider
3765
+ ? buildBrowserCoverageMap(reporterResults, coverageProvider)
3766
+ : undefined;
3767
+
3527
3768
  const result = {
3528
3769
  results: reporterResults,
3529
3770
  testResults: caseResults,
@@ -3534,19 +3775,26 @@ export const runBrowserController = async (
3534
3775
  // `closeHeadlessRuntime` is already `undefined` in watch mode, so the
3535
3776
  // non-watch caller (core) receives the deferred close and watch does not.
3536
3777
  close: closeHeadlessRuntime,
3778
+ coverage: cycleCoverageMap,
3779
+ watch: watchHandles,
3537
3780
  };
3538
3781
 
3539
3782
  if (isWatchMode) {
3540
3783
  try {
3541
- await notifyTestRunEnd({ duration });
3784
+ await notifyTestRunEnd({
3785
+ duration,
3786
+ coverage: cycleCoverageMap?.files().length
3787
+ ? cycleCoverageMap.toJSON()
3788
+ : undefined,
3789
+ });
3542
3790
  } finally {
3543
3791
  await closeHeadlessRuntime?.();
3544
3792
  }
3545
3793
  }
3546
3794
 
3547
3795
  if (isWatchMode && triggerRerun) {
3548
- watchContext.hooksEnabled = true;
3549
- logBrowserWatchReadyMessage(enableCliShortcuts);
3796
+ watchState.hooksEnabled = true;
3797
+ logWatchReadyMessage(context, enableCliShortcuts);
3550
3798
  }
3551
3799
 
3552
3800
  return result;
@@ -3924,30 +4172,43 @@ export const runBrowserController = async (
3924
4172
  // Define rerun logic for watch mode
3925
4173
  if (isWatchMode) {
3926
4174
  triggerRerun = async () => {
3927
- const newProjectEntries = await collectProjectEntries(context);
4175
+ // Re-deliver the host config so runner iframes reloaded by this rerun
4176
+ // observe live per-rerun values ('u' flips updateSnapshot between
4177
+ // reruns); `setContainerOptions` keeps full container reloads in sync.
4178
+ const refreshedHostOptions: BrowserHostConfig = {
4179
+ ...hostOptions,
4180
+ snapshot: {
4181
+ updateSnapshot: context.snapshotManager.options.updateSnapshot,
4182
+ },
4183
+ };
4184
+ runtime.setContainerOptions(refreshedHostOptions);
4185
+ // Independent: config push to the container vs. local entry collection.
4186
+ const [, newProjectEntries] = await Promise.all([
4187
+ rpcManager.updateHostConfig(refreshedHostOptions),
4188
+ collectProjectEntries(context),
4189
+ ]);
3928
4190
  const rerunPlan = planWatchRerun({
3929
4191
  projectEntries: newProjectEntries,
3930
- previousTestFiles: watchContext.lastTestFiles,
3931
- affectedTestFiles: watchContext.affectedTestFiles,
4192
+ previousTestFiles: watchState.lastTestFiles,
4193
+ affectedTestFiles: drainPendingAffectedTestFiles(watchState),
3932
4194
  });
3933
- watchContext.affectedTestFiles = [];
3934
4195
 
3935
4196
  if (rerunPlan.filesChanged) {
3936
4197
  const deletedTestPaths = collectDeletedTestPaths(
3937
- watchContext.lastTestFiles,
4198
+ watchState.lastTestFiles,
3938
4199
  rerunPlan.currentTestFiles,
3939
4200
  );
3940
4201
  if (deletedTestPaths.length > 0) {
3941
4202
  context.updateReporterResultState([], [], deletedTestPaths);
3942
4203
  }
3943
- watchContext.lastTestFiles = rerunPlan.currentTestFiles;
4204
+ watchState.lastTestFiles = rerunPlan.currentTestFiles;
3944
4205
  currentTestFiles = rerunPlan.currentTestFiles;
3945
4206
  await rpcManager.notifyTestFileUpdate(currentTestFiles);
3946
4207
  if (currentTestFiles.length === 0) {
3947
4208
  logger.log(
3948
4209
  color.cyan('No browser test files remain after update.\n'),
3949
4210
  );
3950
- logBrowserWatchReadyMessage(enableCliShortcuts);
4211
+ logWatchReadyMessage(context, enableCliShortcuts);
3951
4212
  return;
3952
4213
  }
3953
4214
  await waitForRunnerFramesReady(
@@ -3984,26 +4245,22 @@ export const runBrowserController = async (
3984
4245
  fatalError && fatalError !== fatalErrorBeforeRun
3985
4246
  ? fatalError
3986
4247
  : undefined;
3987
- await notifyTestRunEnd({
3988
- duration: {
3989
- totalTime: testTime,
3990
- buildTime: 0,
3991
- testTime,
3992
- },
3993
- filterRerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
4248
+ await finalizeWatchRerun({
4249
+ rerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
4250
+ testTime,
3994
4251
  unhandledErrors: rerunError
3995
4252
  ? [rerunError]
3996
4253
  : rerunFatalError
3997
4254
  ? [rerunFatalError]
3998
4255
  : undefined,
3999
4256
  });
4000
- logBrowserWatchReadyMessage(enableCliShortcuts);
4257
+ logWatchReadyMessage(context, enableCliShortcuts);
4001
4258
  }
4002
4259
  } else if (!rerunPlan.filesChanged) {
4003
4260
  logger.log(color.cyan('Tests will be re-executed automatically\n'));
4004
- logBrowserWatchReadyMessage(enableCliShortcuts);
4261
+ logWatchReadyMessage(context, enableCliShortcuts);
4005
4262
  } else {
4006
- logBrowserWatchReadyMessage(enableCliShortcuts);
4263
+ logWatchReadyMessage(context, enableCliShortcuts);
4007
4264
  }
4008
4265
  };
4009
4266
  }
@@ -4045,6 +4302,12 @@ export const runBrowserController = async (
4045
4302
  ensureProcessExitCode(1);
4046
4303
  }
4047
4304
 
4305
+ // Same per-cycle fold-and-strip as the headless path above.
4306
+ const cycleCoverageMap =
4307
+ isWatchMode && coverageProvider
4308
+ ? buildBrowserCoverageMap(reporterResults, coverageProvider)
4309
+ : undefined;
4310
+
4048
4311
  const result = {
4049
4312
  results: reporterResults,
4050
4313
  testResults: caseResults,
@@ -4055,11 +4318,18 @@ export const runBrowserController = async (
4055
4318
  // `closeContainerRuntime` is already `undefined` in watch mode, so the
4056
4319
  // non-watch caller (core) receives the deferred close and watch does not.
4057
4320
  close: closeContainerRuntime,
4321
+ coverage: cycleCoverageMap,
4322
+ watch: watchHandles,
4058
4323
  };
4059
4324
 
4060
4325
  if (isWatchMode) {
4061
4326
  try {
4062
- await notifyTestRunEnd({ duration });
4327
+ await notifyTestRunEnd({
4328
+ duration,
4329
+ coverage: cycleCoverageMap?.files().length
4330
+ ? cycleCoverageMap.toJSON()
4331
+ : undefined,
4332
+ });
4063
4333
  } finally {
4064
4334
  await closeContainerRuntime?.();
4065
4335
  }
@@ -4067,8 +4337,8 @@ export const runBrowserController = async (
4067
4337
 
4068
4338
  // Enable watch hooks AFTER initial test run to avoid duplicate runs
4069
4339
  if (isWatchMode && triggerRerun) {
4070
- watchContext.hooksEnabled = true;
4071
- logBrowserWatchReadyMessage(enableCliShortcuts);
4340
+ watchState.hooksEnabled = true;
4341
+ logWatchReadyMessage(context, enableCliShortcuts);
4072
4342
  }
4073
4343
 
4074
4344
  return result;