@rstest/browser 0.11.3 → 0.11.4

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);
982
1057
  }
983
1058
  }
984
1059
 
985
- watchContext.chunkHashes = currentHashes;
986
- return Array.from(affectedFiles);
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
+ }
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);
1086
+ }
1087
+
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
 
@@ -1822,8 +1938,35 @@ const createBrowserRuntime = async ({
1822
1938
  sourceMap: {
1823
1939
  js: 'source-map',
1824
1940
  },
1941
+ // Every project server compiles the same asset names
1942
+ // (`static/js/runner.js`, ...). With `dev.writeToDisk`
1943
+ // (debug mode) the middleware serves from disk, so a
1944
+ // shared dist dir would be last-writer-wins and one
1945
+ // project's server would deliver another project's
1946
+ // bundle — keep each project's output isolated, inside
1947
+ // the run's temp dir so teardown removes it.
1948
+ distPath: {
1949
+ root: join(
1950
+ tempDir,
1951
+ 'server',
1952
+ toSafeVarName(project.environmentName),
1953
+ ),
1954
+ },
1825
1955
  },
1826
1956
  tools: {
1957
+ swc: (swcConfig) => {
1958
+ // Fixture dependency discovery reads callback parameters
1959
+ // through Function#toString(). Playwright's supported
1960
+ // browsers all support parameter destructuring, so keep
1961
+ // that syntax intact in the browser test bundle.
1962
+ swcConfig.env ??= {};
1963
+ swcConfig.env.exclude = Array.from(
1964
+ new Set([
1965
+ ...(swcConfig.env.exclude ?? []),
1966
+ 'transform-parameters',
1967
+ ]),
1968
+ );
1969
+ },
1827
1970
  rspack: (rspackConfig) => {
1828
1971
  rspackConfig.mode = 'development';
1829
1972
  // Web parameterization of the node mock transform:
@@ -1893,37 +2036,80 @@ const createBrowserRuntime = async ({
1893
2036
  name: 'rstest:browser-watch',
1894
2037
  setup(api) {
1895
2038
  api.onBeforeDevCompile(() => {
1896
- if (!watchContext.hooksEnabled) {
2039
+ watchState.compileStartTimes.set(project.name, Date.now());
2040
+ if (!watchState.hooksEnabled) {
1897
2041
  return;
1898
2042
  }
1899
2043
  logger.log(color.cyan('\nFile changed, re-running tests...\n'));
1900
2044
  });
1901
2045
 
1902
2046
  api.onAfterDevCompile(async ({ stats }) => {
2047
+ const compileStart = watchState.compileStartTimes.get(
2048
+ project.name,
2049
+ );
2050
+ if (compileStart !== undefined) {
2051
+ watchState.compileStartTimes.delete(project.name);
2052
+ // Only change-triggered compiles feed the pending rerun's
2053
+ // build phase (the initial build is the initial run's
2054
+ // buildTime). Parallel project compiles overlap; the longest
2055
+ // one bounds the rerun's build phase.
2056
+ if (watchState.hooksEnabled) {
2057
+ watchState.pendingBuildTimeMs = Math.max(
2058
+ watchState.pendingBuildTimeMs,
2059
+ Date.now() - compileStart,
2060
+ );
2061
+ }
2062
+ }
1903
2063
  // Collect hashes even during initial build to establish baseline
1904
2064
  if (stats) {
1905
- const allProjectEntries = await collectProjectEntries(context);
2065
+ // This compiler only ever holds this project's entries; the
2066
+ // diff baseline is keyed per project accordingly.
2067
+ const [projectEntry] = await collectProjectEntries(context, [
2068
+ project,
2069
+ ]);
1906
2070
  const entryTestFiles = new Set<string>(
1907
- collectWatchTestFiles(allProjectEntries).map(
2071
+ collectWatchTestFiles(projectEntry ? [projectEntry] : []).map(
1908
2072
  (file) => file.testPath,
1909
2073
  ),
1910
2074
  );
2075
+ const setupFiles = new Set<string>(
2076
+ (projectEntry?.setupFiles ?? []).map((file) =>
2077
+ normalize(file),
2078
+ ),
2079
+ );
2080
+
2081
+ let state = watchState.invalidation.get(project.name);
2082
+ if (!state) {
2083
+ state = {};
2084
+ watchState.invalidation.set(project.name, state);
2085
+ }
1911
2086
 
1912
2087
  const statsJson = stats.toJson({ all: true });
1913
- const affected = getAffectedTestFiles(
1914
- statsJson.chunks,
2088
+ const affected = getAffectedTestFiles({
2089
+ chunks: statsJson.chunks,
1915
2090
  entryTestFiles,
1916
- );
1917
- watchContext.affectedTestFiles = affected;
2091
+ setupFiles,
2092
+ state,
2093
+ });
1918
2094
 
1919
2095
  if (affected.length > 0) {
2096
+ const pending =
2097
+ watchState.pendingAffectedTestFiles.get(project.name) ??
2098
+ new Set<string>();
2099
+ for (const file of affected) {
2100
+ pending.add(file);
2101
+ }
2102
+ watchState.pendingAffectedTestFiles.set(
2103
+ project.name,
2104
+ pending,
2105
+ );
1920
2106
  logger.debug(
1921
2107
  `[Watch] Affected test files: ${affected.join(', ')}`,
1922
2108
  );
1923
2109
  }
1924
2110
  }
1925
2111
 
1926
- if (!watchContext.hooksEnabled) {
2112
+ if (!watchState.hooksEnabled) {
1927
2113
  return;
1928
2114
  }
1929
2115
 
@@ -1965,6 +2151,15 @@ const createBrowserRuntime = async ({
1965
2151
  if (isDebug()) {
1966
2152
  await rsbuildInstance.inspectConfig({
1967
2153
  writeToDisk: true,
2154
+ // The server's own distPath is isolated per project inside the run's
2155
+ // temp dir (removed at teardown); keep the debug artifacts at the
2156
+ // project's stable dist root so they survive the run and stay where
2157
+ // the docs point users to.
2158
+ outputPath: resolve(
2159
+ context.rootPath,
2160
+ context.normalizedConfig.output.distPath.root,
2161
+ '.rsbuild',
2162
+ ),
1968
2163
  extraConfigs: {
1969
2164
  rstest: {
1970
2165
  ...context.normalizedConfig,
@@ -2114,6 +2309,7 @@ const createBrowserRuntime = async ({
2114
2309
  dispatchHandlers,
2115
2310
  wss,
2116
2311
  projectEntries,
2312
+ watchState,
2117
2313
  };
2118
2314
  } catch (error) {
2119
2315
  wss.close();
@@ -2479,7 +2675,7 @@ export const runBrowserController = async (
2479
2675
  await notifyTestRunStart();
2480
2676
  }
2481
2677
 
2482
- const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
2678
+ const enableCliShortcuts = isWatchMode && isTTY('stdin');
2483
2679
  const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
2484
2680
  const tempDir =
2485
2681
  isWatchMode && watchContext.runtime
@@ -2493,15 +2689,13 @@ export const runBrowserController = async (
2493
2689
  Date.now().toString(),
2494
2690
  );
2495
2691
 
2496
- // Track initial test files for watch mode
2497
- if (isWatchMode) {
2498
- watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
2499
- }
2500
-
2501
2692
  let runtime = isWatchMode ? watchContext.runtime : null;
2502
2693
 
2503
2694
  // Define rerun callback for watch mode (will be populated later)
2504
2695
  let triggerRerun: (() => Promise<void>) | undefined;
2696
+ // Headless reruns complete asynchronously in the scheduler's drain loop;
2697
+ // the watch handles await this so callers observe rerun completion.
2698
+ let awaitHeadlessRerunIdle: (() => Promise<void>) | undefined;
2505
2699
 
2506
2700
  if (!runtime) {
2507
2701
  try {
@@ -2532,15 +2726,113 @@ export const runBrowserController = async (
2532
2726
 
2533
2727
  if (isWatchMode) {
2534
2728
  watchContext.runtime = runtime;
2535
- registerWatchCleanup();
2729
+ registerWatchCleanup(context.embedded);
2730
+ }
2731
+ }
2536
2732
 
2537
- if (enableCliShortcuts && !watchContext.closeCliShortcuts) {
2538
- watchContext.closeCliShortcuts = await setupBrowserWatchCliShortcuts({
2539
- close: cleanupWatchRuntime,
2540
- });
2733
+ const watchState = runtime.watchState;
2734
+
2735
+ // Track initial test files for watch mode (from this controller's freshly
2736
+ // collected entries, before adopting the runtime's entry snapshot below).
2737
+ if (isWatchMode) {
2738
+ watchState.lastTestFiles = collectWatchTestFiles(projectEntries);
2739
+ }
2740
+
2741
+ // Mark files as pending-affected so the next `triggerRerun` reruns them
2742
+ // through the normal plan/schedule/finalize pipeline (used by the watch
2743
+ // handles' explicit reruns; omitted paths = all current files). Returns the
2744
+ // number of seeded files so callers can skip the rerun when a path-scoped
2745
+ // request matches no browser test file (mixed watch 'u' with node-only
2746
+ // snapshot updates).
2747
+ const seedPendingRerun = (testPaths?: string[]): number => {
2748
+ const wanted = testPaths
2749
+ ? new Set(testPaths.map((testPath) => normalize(testPath)))
2750
+ : null;
2751
+ let seeded = 0;
2752
+ for (const file of watchState.lastTestFiles) {
2753
+ if (wanted && !wanted.has(file.testPath)) {
2754
+ continue;
2541
2755
  }
2756
+ const pending =
2757
+ watchState.pendingAffectedTestFiles.get(file.projectName) ??
2758
+ new Set<string>();
2759
+ pending.add(file.testPath);
2760
+ watchState.pendingAffectedTestFiles.set(file.projectName, pending);
2761
+ seeded += 1;
2542
2762
  }
2543
- }
2763
+ return seeded;
2764
+ };
2765
+
2766
+ const watchHandles: BrowserWatchHandles | undefined = isWatchMode
2767
+ ? {
2768
+ rerun: async (testPaths) => {
2769
+ const seeded = seedPendingRerun(testPaths);
2770
+ if (testPaths && seeded === 0) {
2771
+ return;
2772
+ }
2773
+ await triggerRerun?.();
2774
+ await awaitHeadlessRerunIdle?.();
2775
+ },
2776
+ close: cleanupWatchRuntime,
2777
+ }
2778
+ : undefined;
2779
+
2780
+ /**
2781
+ * Per-rerun finalize for watch mode: fold the rerun into a synthetic
2782
+ * `ExecutorCycleOutcome` and hand it to core's `finalizeRunCycle`, so
2783
+ * reporter payloads, exit-code never-downgrade semantics, and coverage
2784
+ * reports match the node watch cycle. The trace buffer stays session-owned
2785
+ * (no `traceRun` here); `buildTime` is the drained duration of the
2786
+ * change-triggered compile(s), not a hardcoded zero.
2787
+ */
2788
+ const finalizeWatchRerun = async ({
2789
+ rerunTestPaths,
2790
+ testTime,
2791
+ unhandledErrors,
2792
+ }: {
2793
+ rerunTestPaths: string[];
2794
+ testTime: number;
2795
+ unhandledErrors?: Error[];
2796
+ }): Promise<void> => {
2797
+ const rerunPathSet = new Set(rerunTestPaths);
2798
+ // Reporter coverage spans the whole session (unaffected files keep their
2799
+ // last coverage), matching the previous self-finalize payload. The merge
2800
+ // must not strip `result.coverage`, or later reruns would lose it.
2801
+ let sessionCoverage: CoverageMapData | undefined;
2802
+ const coverageMap = buildBrowserCoverageMap(
2803
+ context.reporterResults.results,
2804
+ coverageProvider,
2805
+ { keepResultCoverage: true },
2806
+ );
2807
+ if (coverageMap && coverageMap.files().length > 0) {
2808
+ sessionCoverage = coverageMap.toJSON();
2809
+ }
2810
+
2811
+ const outcome: ExecutorCycleOutcome = {
2812
+ results: context.reporterResults.results.filter((result) =>
2813
+ rerunPathSet.has(result.testPath),
2814
+ ),
2815
+ testResults: context.reporterResults.testResults.filter((result) =>
2816
+ rerunPathSet.has(result.testPath),
2817
+ ),
2818
+ errors: unhandledErrors ?? [],
2819
+ testPaths: rerunTestPaths,
2820
+ duration: {
2821
+ buildTime: drainPendingBuildTime(watchState),
2822
+ testTime,
2823
+ },
2824
+ coverage: sessionCoverage ? { map: sessionCoverage } : undefined,
2825
+ resolveSourcemap: resolveBrowserSourcemap,
2826
+ };
2827
+
2828
+ await finalizeRunCycle(context, {
2829
+ outcomes: [outcome],
2830
+ mode: 'on-demand',
2831
+ isWatchMode: true,
2832
+ coverageProvider,
2833
+ reportOnFailure: coverageConfig?.reportOnFailure ?? false,
2834
+ });
2835
+ };
2544
2836
 
2545
2837
  projectEntries = runtime.projectEntries;
2546
2838
  totalTests = projectEntries.reduce(
@@ -2612,7 +2904,9 @@ export const runBrowserController = async (
2612
2904
  rootPath: normalize(context.rootPath),
2613
2905
  projects: projectRuntimeConfigs,
2614
2906
  snapshot: {
2615
- updateSnapshot: context.snapshotManager.options.updateSnapshot,
2907
+ updateSnapshot:
2908
+ options?.updateSnapshot ??
2909
+ context.snapshotManager.options.updateSnapshot,
2616
2910
  },
2617
2911
  // Container origin (fallback). Per-project runner origins below.
2618
2912
  runnerUrl: `http://localhost:${runtime.containerServer.port}`,
@@ -3174,6 +3468,12 @@ export const runBrowserController = async (
3174
3468
 
3175
3469
  const inlineOptions: BrowserHostConfig = {
3176
3470
  ...hostOptions,
3471
+ // Read live per page load, not from the construction-time
3472
+ // `hostOptions` value: the 'u' shortcut flips
3473
+ // `snapshotManager.options` between reruns.
3474
+ snapshot: {
3475
+ updateSnapshot: context.snapshotManager.options.updateSnapshot,
3476
+ },
3177
3477
  testFile: file.testPath,
3178
3478
  runId: `${run.token}:${session.id}`,
3179
3479
  };
@@ -3343,20 +3643,16 @@ export const runBrowserController = async (
3343
3643
  fatalError && fatalError !== fatalErrorBeforeRun
3344
3644
  ? fatalError
3345
3645
  : undefined;
3346
- await notifyTestRunEnd({
3347
- duration: {
3348
- totalTime: testTime,
3349
- buildTime: 0,
3350
- testTime,
3351
- },
3352
- filterRerunTestPaths: files.map((file) => file.testPath),
3646
+ await finalizeWatchRerun({
3647
+ rerunTestPaths: files.map((file) => file.testPath),
3648
+ testTime,
3353
3649
  unhandledErrors: rerunError
3354
3650
  ? [rerunError]
3355
3651
  : rerunFatalError
3356
3652
  ? [rerunFatalError]
3357
3653
  : undefined,
3358
3654
  });
3359
- logBrowserWatchReadyMessage(enableCliShortcuts);
3655
+ logWatchReadyMessage(context, enableCliShortcuts);
3360
3656
  }
3361
3657
  },
3362
3658
  onError: async (error) => {
@@ -3373,6 +3669,8 @@ export const runBrowserController = async (
3373
3669
  },
3374
3670
  });
3375
3671
 
3672
+ awaitHeadlessRerunIdle = () => latestRerunScheduler.whenIdle();
3673
+
3376
3674
  if (allTestFiles.length === 0) {
3377
3675
  const duration = {
3378
3676
  totalTime: buildTime,
@@ -3392,6 +3690,7 @@ export const runBrowserController = async (
3392
3690
  await destroyBrowserRuntime(runtime);
3393
3691
  }
3394
3692
  : undefined,
3693
+ watch: watchHandles,
3395
3694
  };
3396
3695
 
3397
3696
  if (isWatchMode) {
@@ -3403,18 +3702,17 @@ export const runBrowserController = async (
3403
3702
  const newProjectEntries = await collectProjectEntries(context);
3404
3703
  const rerunPlan = planWatchRerun({
3405
3704
  projectEntries: newProjectEntries,
3406
- previousTestFiles: watchContext.lastTestFiles,
3407
- affectedTestFiles: watchContext.affectedTestFiles,
3705
+ previousTestFiles: watchState.lastTestFiles,
3706
+ affectedTestFiles: drainPendingAffectedTestFiles(watchState),
3408
3707
  });
3409
- watchContext.affectedTestFiles = [];
3410
3708
 
3411
3709
  if (rerunPlan.filesChanged) {
3412
- watchContext.lastTestFiles = rerunPlan.currentTestFiles;
3710
+ watchState.lastTestFiles = rerunPlan.currentTestFiles;
3413
3711
  if (rerunPlan.currentTestFiles.length === 0) {
3414
3712
  logger.log(
3415
3713
  color.cyan('No browser test files remain after update.\n'),
3416
3714
  );
3417
- logBrowserWatchReadyMessage(enableCliShortcuts);
3715
+ logWatchReadyMessage(context, enableCliShortcuts);
3418
3716
  return;
3419
3717
  }
3420
3718
 
@@ -3423,14 +3721,16 @@ export const runBrowserController = async (
3423
3721
  `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
3424
3722
  ),
3425
3723
  );
3426
- void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
3724
+ await latestRerunScheduler.enqueueLatest(
3725
+ rerunPlan.currentTestFiles,
3726
+ );
3427
3727
  return;
3428
3728
  }
3429
3729
 
3430
- logBrowserWatchReadyMessage(enableCliShortcuts);
3730
+ logWatchReadyMessage(context, enableCliShortcuts);
3431
3731
  };
3432
- watchContext.hooksEnabled = true;
3433
- logBrowserWatchReadyMessage(enableCliShortcuts);
3732
+ watchState.hooksEnabled = true;
3733
+ logWatchReadyMessage(context, enableCliShortcuts);
3434
3734
  }
3435
3735
 
3436
3736
  return result;
@@ -3445,26 +3745,25 @@ export const runBrowserController = async (
3445
3745
  const newProjectEntries = await collectProjectEntries(context);
3446
3746
  const rerunPlan = planWatchRerun({
3447
3747
  projectEntries: newProjectEntries,
3448
- previousTestFiles: watchContext.lastTestFiles,
3449
- affectedTestFiles: watchContext.affectedTestFiles,
3748
+ previousTestFiles: watchState.lastTestFiles,
3749
+ affectedTestFiles: drainPendingAffectedTestFiles(watchState),
3450
3750
  });
3451
- watchContext.affectedTestFiles = [];
3452
3751
 
3453
3752
  if (rerunPlan.filesChanged) {
3454
3753
  const deletedTestPaths = collectDeletedTestPaths(
3455
- watchContext.lastTestFiles,
3754
+ watchState.lastTestFiles,
3456
3755
  rerunPlan.currentTestFiles,
3457
3756
  );
3458
3757
  if (deletedTestPaths.length > 0) {
3459
3758
  context.updateReporterResultState([], [], deletedTestPaths);
3460
3759
  }
3461
- watchContext.lastTestFiles = rerunPlan.currentTestFiles;
3760
+ watchState.lastTestFiles = rerunPlan.currentTestFiles;
3462
3761
  if (rerunPlan.currentTestFiles.length === 0) {
3463
3762
  await latestRerunScheduler.enqueueLatest([]);
3464
3763
  logger.log(
3465
3764
  color.cyan('No browser test files remain after update.\n'),
3466
3765
  );
3467
- logBrowserWatchReadyMessage(enableCliShortcuts);
3766
+ logWatchReadyMessage(context, enableCliShortcuts);
3468
3767
  return;
3469
3768
  }
3470
3769
 
@@ -3473,7 +3772,7 @@ export const runBrowserController = async (
3473
3772
  `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
3474
3773
  ),
3475
3774
  );
3476
- void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
3775
+ await latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
3477
3776
  return;
3478
3777
  }
3479
3778
 
@@ -3483,7 +3782,7 @@ export const runBrowserController = async (
3483
3782
  'No affected browser test files detected, skipping re-run.\n',
3484
3783
  ),
3485
3784
  );
3486
- logBrowserWatchReadyMessage(enableCliShortcuts);
3785
+ logWatchReadyMessage(context, enableCliShortcuts);
3487
3786
  return;
3488
3787
  }
3489
3788
 
@@ -3492,7 +3791,7 @@ export const runBrowserController = async (
3492
3791
  `Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`,
3493
3792
  ),
3494
3793
  );
3495
- void latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
3794
+ await latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
3496
3795
  };
3497
3796
  }
3498
3797
 
@@ -3534,6 +3833,7 @@ export const runBrowserController = async (
3534
3833
  // `closeHeadlessRuntime` is already `undefined` in watch mode, so the
3535
3834
  // non-watch caller (core) receives the deferred close and watch does not.
3536
3835
  close: closeHeadlessRuntime,
3836
+ watch: watchHandles,
3537
3837
  };
3538
3838
 
3539
3839
  if (isWatchMode) {
@@ -3545,8 +3845,8 @@ export const runBrowserController = async (
3545
3845
  }
3546
3846
 
3547
3847
  if (isWatchMode && triggerRerun) {
3548
- watchContext.hooksEnabled = true;
3549
- logBrowserWatchReadyMessage(enableCliShortcuts);
3848
+ watchState.hooksEnabled = true;
3849
+ logWatchReadyMessage(context, enableCliShortcuts);
3550
3850
  }
3551
3851
 
3552
3852
  return result;
@@ -3924,30 +4224,43 @@ export const runBrowserController = async (
3924
4224
  // Define rerun logic for watch mode
3925
4225
  if (isWatchMode) {
3926
4226
  triggerRerun = async () => {
3927
- const newProjectEntries = await collectProjectEntries(context);
4227
+ // Re-deliver the host config so runner iframes reloaded by this rerun
4228
+ // observe live per-rerun values ('u' flips updateSnapshot between
4229
+ // reruns); `setContainerOptions` keeps full container reloads in sync.
4230
+ const refreshedHostOptions: BrowserHostConfig = {
4231
+ ...hostOptions,
4232
+ snapshot: {
4233
+ updateSnapshot: context.snapshotManager.options.updateSnapshot,
4234
+ },
4235
+ };
4236
+ runtime.setContainerOptions(refreshedHostOptions);
4237
+ // Independent: config push to the container vs. local entry collection.
4238
+ const [, newProjectEntries] = await Promise.all([
4239
+ rpcManager.updateHostConfig(refreshedHostOptions),
4240
+ collectProjectEntries(context),
4241
+ ]);
3928
4242
  const rerunPlan = planWatchRerun({
3929
4243
  projectEntries: newProjectEntries,
3930
- previousTestFiles: watchContext.lastTestFiles,
3931
- affectedTestFiles: watchContext.affectedTestFiles,
4244
+ previousTestFiles: watchState.lastTestFiles,
4245
+ affectedTestFiles: drainPendingAffectedTestFiles(watchState),
3932
4246
  });
3933
- watchContext.affectedTestFiles = [];
3934
4247
 
3935
4248
  if (rerunPlan.filesChanged) {
3936
4249
  const deletedTestPaths = collectDeletedTestPaths(
3937
- watchContext.lastTestFiles,
4250
+ watchState.lastTestFiles,
3938
4251
  rerunPlan.currentTestFiles,
3939
4252
  );
3940
4253
  if (deletedTestPaths.length > 0) {
3941
4254
  context.updateReporterResultState([], [], deletedTestPaths);
3942
4255
  }
3943
- watchContext.lastTestFiles = rerunPlan.currentTestFiles;
4256
+ watchState.lastTestFiles = rerunPlan.currentTestFiles;
3944
4257
  currentTestFiles = rerunPlan.currentTestFiles;
3945
4258
  await rpcManager.notifyTestFileUpdate(currentTestFiles);
3946
4259
  if (currentTestFiles.length === 0) {
3947
4260
  logger.log(
3948
4261
  color.cyan('No browser test files remain after update.\n'),
3949
4262
  );
3950
- logBrowserWatchReadyMessage(enableCliShortcuts);
4263
+ logWatchReadyMessage(context, enableCliShortcuts);
3951
4264
  return;
3952
4265
  }
3953
4266
  await waitForRunnerFramesReady(
@@ -3984,26 +4297,22 @@ export const runBrowserController = async (
3984
4297
  fatalError && fatalError !== fatalErrorBeforeRun
3985
4298
  ? fatalError
3986
4299
  : undefined;
3987
- await notifyTestRunEnd({
3988
- duration: {
3989
- totalTime: testTime,
3990
- buildTime: 0,
3991
- testTime,
3992
- },
3993
- filterRerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
4300
+ await finalizeWatchRerun({
4301
+ rerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
4302
+ testTime,
3994
4303
  unhandledErrors: rerunError
3995
4304
  ? [rerunError]
3996
4305
  : rerunFatalError
3997
4306
  ? [rerunFatalError]
3998
4307
  : undefined,
3999
4308
  });
4000
- logBrowserWatchReadyMessage(enableCliShortcuts);
4309
+ logWatchReadyMessage(context, enableCliShortcuts);
4001
4310
  }
4002
4311
  } else if (!rerunPlan.filesChanged) {
4003
4312
  logger.log(color.cyan('Tests will be re-executed automatically\n'));
4004
- logBrowserWatchReadyMessage(enableCliShortcuts);
4313
+ logWatchReadyMessage(context, enableCliShortcuts);
4005
4314
  } else {
4006
- logBrowserWatchReadyMessage(enableCliShortcuts);
4315
+ logWatchReadyMessage(context, enableCliShortcuts);
4007
4316
  }
4008
4317
  };
4009
4318
  }
@@ -4055,6 +4364,7 @@ export const runBrowserController = async (
4055
4364
  // `closeContainerRuntime` is already `undefined` in watch mode, so the
4056
4365
  // non-watch caller (core) receives the deferred close and watch does not.
4057
4366
  close: closeContainerRuntime,
4367
+ watch: watchHandles,
4058
4368
  };
4059
4369
 
4060
4370
  if (isWatchMode) {
@@ -4067,8 +4377,8 @@ export const runBrowserController = async (
4067
4377
 
4068
4378
  // Enable watch hooks AFTER initial test run to avoid duplicate runs
4069
4379
  if (isWatchMode && triggerRerun) {
4070
- watchContext.hooksEnabled = true;
4071
- logBrowserWatchReadyMessage(enableCliShortcuts);
4380
+ watchState.hooksEnabled = true;
4381
+ logWatchReadyMessage(context, enableCliShortcuts);
4072
4382
  }
4073
4383
 
4074
4384
  return result;