@rstest/browser 0.11.2 → 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,9 +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,
10
+ applyWebMockRspackConfig,
9
11
  type BrowserTestRunOptions,
10
12
  type BrowserTestRunResult,
13
+ type BrowserWatchHandles,
14
+ buildBrowserCoverageMap,
11
15
  type CoverageMapData,
16
+ type EntryHashSnapshot,
17
+ type ExecutorCycleOutcome,
18
+ FATAL_SIGNALS,
19
+ finalizeRunCycle,
12
20
  type ListBrowserTestsOptions,
13
21
  color,
14
22
  createCoverageProvider,
@@ -17,14 +25,19 @@ import {
17
25
  DEFAULT_TEST_TIMEOUT,
18
26
  type FormattedError,
19
27
  getNoTestFilesMessage,
28
+ getPrettyConsoleName,
20
29
  getSetupFiles,
21
30
  getTestEntries,
22
31
  hasUserRstestConfigPlugins,
32
+ importMetaRstestDefine,
23
33
  initModifyRstestConfigHooks,
24
34
  isDebug,
35
+ isTTY,
25
36
  type ListCommandResult,
26
37
  loadCoverageProvider,
27
38
  logger,
39
+ logWatchReadyMessage,
40
+ pluginMockRuntime,
28
41
  prepareWatchRerunState,
29
42
  projectRuntimeConfig,
30
43
  PhaseTracker,
@@ -42,6 +55,7 @@ import {
42
55
  type TestFileResult,
43
56
  type TestResult,
44
57
  type UserConsoleLog,
58
+ type WatchInvalidationState,
45
59
  } from '@rstest/core/internal/browser';
46
60
  import { type BirpcReturn, createBirpc } from 'birpc';
47
61
  import openEditor from 'open-editor';
@@ -97,11 +111,6 @@ import {
97
111
  type SourceMapPayload,
98
112
  } from './sourceMap/sourceMapLoader';
99
113
  import { resolveBrowserViewportPreset } from './viewportPresets';
100
- import {
101
- isBrowserWatchCliShortcutsEnabled,
102
- logBrowserWatchReadyMessage,
103
- setupBrowserWatchCliShortcuts,
104
- } from './watchCliShortcuts';
105
114
  import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';
106
115
 
107
116
  const { createRsbuild, rspack } = rsbuild;
@@ -251,6 +260,12 @@ type ContainerRpcMethods = {
251
260
  testFile: string,
252
261
  testNamePattern?: string,
253
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>;
254
269
  };
255
270
 
256
271
  type ContainerRpc = BirpcReturn<ContainerRpcMethods, HostRpcMethods>;
@@ -390,6 +405,11 @@ class ContainerRpcManager {
390
405
  await this.rpc?.onTestFileUpdate(files);
391
406
  }
392
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
+
393
413
  /** Request container to reload a specific test file */
394
414
  async reloadTestFile(
395
415
  testFile: string,
@@ -424,6 +444,56 @@ type BrowserProjectServer = {
424
444
  manifestPath: string;
425
445
  };
426
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
+
427
497
  type BrowserRuntime = {
428
498
  // Per-project servers, keyed by project name.
429
499
  projectServers: Map<string, BrowserProjectServer>;
@@ -442,32 +512,27 @@ type BrowserRuntime = {
442
512
  wss: WebSocketServer;
443
513
  rpcManager?: ContainerRpcManager;
444
514
  projectEntries: BrowserProjectEntries[];
515
+ watchState: BrowserWatchState;
445
516
  };
446
517
 
447
518
  // ============================================================================
448
- // Watch Mode Context - Encapsulates all watch mode state
519
+ // Watch Mode Context - Process-lifecycle watch state
449
520
  // ============================================================================
450
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`.
451
526
  type WatchContext = {
452
527
  runtime: BrowserRuntime | null;
453
- lastTestFiles: TestFileInfo[];
454
- hooksEnabled: boolean;
455
528
  cleanupRegistered: boolean;
456
529
  cleanupPromise: Promise<void> | null;
457
- closeCliShortcuts: (() => void) | null;
458
- chunkHashes: Map<string, string>;
459
- affectedTestFiles: string[];
460
530
  };
461
531
 
462
532
  const watchContext: WatchContext = {
463
533
  runtime: null,
464
- lastTestFiles: [],
465
- hooksEnabled: false,
466
534
  cleanupRegistered: false,
467
535
  cleanupPromise: null,
468
- closeCliShortcuts: null,
469
- chunkHashes: new Map(),
470
- affectedTestFiles: [],
471
536
  };
472
537
 
473
538
  // ============================================================================
@@ -944,42 +1009,87 @@ const getChunkKey = (chunk: StatsChunk): string | null => {
944
1009
  };
945
1010
 
946
1011
  /**
947
- * Compare chunk hashes and find affected test files for watch mode re-runs.
948
- * 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.
949
1016
  */
950
- const getAffectedTestFiles = (
951
- chunks: StatsChunk[] | undefined,
952
- entryTestFiles: Set<string>,
953
- ): string[] => {
954
- if (!chunks) return [];
955
-
956
- const affectedFiles = new Set<string>();
957
- 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
+ };
958
1041
 
959
- for (const chunk of chunks) {
1042
+ for (const chunk of chunks || []) {
960
1043
  if (!chunk.hash) continue;
961
1044
 
962
- // First check if this chunk contains a test entry file
963
- const testFile = findTestFileInModules(chunk.modules, entryTestFiles);
964
- if (!testFile) continue;
965
-
966
- // Get a stable key for this chunk
967
1045
  const chunkKey = getChunkKey(chunk);
968
1046
  if (!chunkKey) continue;
969
1047
 
970
- const prevHash = watchContext.chunkHashes.get(chunkKey);
971
- 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
+ }
1053
+
1054
+ const setupFile = findTestFileInModules(chunk.modules, setupFiles);
1055
+ if (setupFile) {
1056
+ recordChunk(setupHashes, setupFile, chunkKey, chunk.hash);
1057
+ }
1058
+ }
972
1059
 
973
- if (prevHash !== undefined && prevHash !== chunk.hash) {
974
- affectedFiles.add(testFile);
975
- logger.debug(
976
- `[Watch] Chunk hash changed for ${chunkKey}: ${prevHash} -> ${chunk.hash} (test: ${testFile})`,
977
- );
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
+ }
978
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}`);
979
1090
  }
980
1091
 
981
- watchContext.chunkHashes = currentHashes;
982
- return Array.from(affectedFiles);
1092
+ return outcome.affectedPaths;
983
1093
  };
984
1094
 
985
1095
  const getBrowserProjects = (context: RstestContext): ProjectContext[] =>
@@ -1242,16 +1352,51 @@ const generateManifestModule = ({
1242
1352
  project.normalizedConfig.exclude.patterns,
1243
1353
  projectRootPosix,
1244
1354
  );
1245
- lines.push(
1246
- `const ${varName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
1247
- );
1248
- lines.push(' recursive: true,');
1249
- lines.push(` regExp: ${includeRegExp.toString()},`);
1250
- if (excludeRegExp) {
1251
- lines.push(` exclude: ${excludeRegExp.toString()},`);
1355
+ const { includeSource } = project.normalizedConfig;
1356
+ const emitContext = (contextVarName: string, regExp: RegExp): void => {
1357
+ lines.push(
1358
+ `const ${contextVarName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
1359
+ );
1360
+ lines.push(' recursive: true,');
1361
+ lines.push(` regExp: ${regExp.toString()},`);
1362
+ if (excludeRegExp) {
1363
+ lines.push(` exclude: ${excludeRegExp.toString()},`);
1364
+ }
1365
+ lines.push(" mode: 'lazy',");
1366
+ lines.push('});');
1367
+ };
1368
+
1369
+ if (includeSource.length === 0) {
1370
+ emitContext(varName, includeRegExp);
1371
+ } else {
1372
+ // In-source test files (`includeSource`) carry an
1373
+ // `if (import.meta.rstest)` block. The include context can't see them,
1374
+ // so a second context over the `includeSource` globs backs
1375
+ // host-scheduled loads, while `keys()` only unions the entry-probed
1376
+ // in-source files (the probe does not apply inside the bundle, so raw
1377
+ // source-context keys would execute never-probed files and fail with
1378
+ // "No test suites found"). The probed list can go stale until a
1379
+ // manifest refresh; scheduled-by-path loading never does.
1380
+ emitContext(`${varName}_include`, includeRegExp);
1381
+ emitContext(`${varName}_source`, globPatternsToRegExp(includeSource));
1382
+ const probedKeys = testFiles.map((filePath) =>
1383
+ toContextKey(filePath, projectRootPosix),
1384
+ );
1385
+ lines.push(`const ${varName}_probed = ${JSON.stringify(probedKeys)};`);
1386
+ lines.push(
1387
+ `const ${varName}_includeKeys = new Set(${varName}_include.keys());`,
1388
+ );
1389
+ lines.push(`const ${varName} = Object.assign(`);
1390
+ lines.push(
1391
+ ` (key) => ${varName}_includeKeys.has(key) ? ${varName}_include(key) : ${varName}_source(key),`,
1392
+ );
1393
+ lines.push(' {');
1394
+ lines.push(
1395
+ ` keys: () => Array.from(new Set([...${varName}_includeKeys, ...${varName}_probed])),`,
1396
+ );
1397
+ lines.push(' },');
1398
+ lines.push(');');
1252
1399
  }
1253
- lines.push(" mode: 'lazy',");
1254
- lines.push('});');
1255
1400
  } else {
1256
1401
  // One-shot runs: the file set is fixed and already filtered, so emit an
1257
1402
  // explicit lazy-import map (one chunk per literal `import()`, like the
@@ -1374,9 +1519,6 @@ const cleanupWatchRuntime = (): Promise<void> => {
1374
1519
  }
1375
1520
 
1376
1521
  watchContext.cleanupPromise = (async () => {
1377
- watchContext.closeCliShortcuts?.();
1378
- watchContext.closeCliShortcuts = null;
1379
-
1380
1522
  if (!watchContext.runtime) {
1381
1523
  return;
1382
1524
  }
@@ -1388,12 +1530,22 @@ const cleanupWatchRuntime = (): Promise<void> => {
1388
1530
  return watchContext.cleanupPromise;
1389
1531
  };
1390
1532
 
1391
- const registerWatchCleanup = (): void => {
1533
+ const registerWatchCleanup = (embedded: boolean): void => {
1392
1534
  if (watchContext.cleanupRegistered) {
1393
1535
  return;
1394
1536
  }
1537
+ watchContext.cleanupRegistered = true;
1395
1538
 
1396
- for (const signal of ['SIGINT', 'SIGTERM', 'SIGTSTP'] as const) {
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
+ }
1544
+
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) {
1397
1549
  process.once(signal, () => {
1398
1550
  void cleanupWatchRuntime();
1399
1551
  });
@@ -1402,8 +1554,6 @@ const registerWatchCleanup = (): void => {
1402
1554
  process.once('exit', () => {
1403
1555
  void cleanupWatchRuntime();
1404
1556
  });
1405
-
1406
- watchContext.cleanupRegistered = true;
1407
1557
  };
1408
1558
 
1409
1559
  const createBrowserRuntime = async ({
@@ -1463,6 +1613,10 @@ const createBrowserRuntime = async ({
1463
1613
  let browserLaunchOptions =
1464
1614
  ensureConsistentBrowserLaunchOptions(browserProjects);
1465
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();
1466
1620
  const manifestModules: Array<{
1467
1621
  manifestPath: string;
1468
1622
  project: ProjectContext;
@@ -1491,6 +1645,7 @@ const createBrowserRuntime = async ({
1491
1645
  dispatchHandlers,
1492
1646
  wss: undefined as unknown as WebSocketServer,
1493
1647
  projectEntries,
1648
+ watchState,
1494
1649
  };
1495
1650
  };
1496
1651
 
@@ -1546,11 +1701,13 @@ const createBrowserRuntime = async ({
1546
1701
  // Shared by every project — only the per-project `@rstest/browser-manifest`
1547
1702
  // alias varies (one virtual manifest per server).
1548
1703
  const staticRstestAliases = {
1549
- // User test code: import { describe, it } from '@rstest/core'
1550
- '@rstest/core': resolveBrowserFile('client/public.ts'),
1704
+ // User test code `import { describe, it } from '@rstest/core'` is NOT
1705
+ // aliased: `applyWebMockRspackConfig` keeps the request external against
1706
+ // `globalThis['@rstest/core']` (node parity), which also keeps the mock
1707
+ // hoister's provider-import ordering correct for `rs.hoisted` callbacks.
1551
1708
  // User test code: import { page } from '@rstest/browser'
1552
1709
  '@rstest/browser': resolveBrowserFile('browser.ts'),
1553
- // Browser runtime APIs for entry.ts and public.ts
1710
+ // Browser runtime APIs for entry.ts
1554
1711
  // Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
1555
1712
  '@rstest/core/internal/browser-runtime': browserRuntimePath,
1556
1713
  };
@@ -1712,6 +1869,9 @@ const createBrowserRuntime = async ({
1712
1869
 
1713
1870
  // Add plugin to merge user Rsbuild config with rstest required config
1714
1871
  rsbuildInstance.addPlugins([
1872
+ // Same mock runtime as the node build (importActual doppelganger rule +
1873
+ // mock webpack runtime module); order-insensitive and self-contained.
1874
+ pluginMockRuntime,
1715
1875
  {
1716
1876
  name: 'rstest:browser-user-config',
1717
1877
  setup(api) {
@@ -1766,6 +1926,10 @@ const createBrowserRuntime = async ({
1766
1926
  define: {
1767
1927
  'process.env': rstestEnvDefine,
1768
1928
  'import.meta.env': rstestEnvDefine,
1929
+ // In-source `if (import.meta.rstest)` blocks read the
1930
+ // per-file runtime API the client entry publishes on
1931
+ // `globalThis` (node parity: `global['@rstest/core']`).
1932
+ 'import.meta.rstest': importMetaRstestDefine('web'),
1769
1933
  },
1770
1934
  },
1771
1935
  output: {
@@ -1774,10 +1938,45 @@ const createBrowserRuntime = async ({
1774
1938
  sourceMap: {
1775
1939
  js: 'source-map',
1776
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
+ },
1777
1955
  },
1778
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
+ },
1779
1970
  rspack: (rspackConfig) => {
1780
1971
  rspackConfig.mode = 'development';
1972
+ // Web parameterization of the node mock transform:
1973
+ // RstestPlugin (hoist + path injection), the
1974
+ // `@rstest/core` global external, and
1975
+ // `exportsPresence: 'warn'`.
1976
+ applyWebMockRspackConfig(rspackConfig, {
1977
+ rspack,
1978
+ rootPath: project.rootPath,
1979
+ });
1781
1980
  // lazyCompilation's only delivery transport is the HMR
1782
1981
  // runtime, so it follows the same gate as HMR (see
1783
1982
  // `shouldEnableBrowserHmr`): headed watch only, everything
@@ -1837,37 +2036,80 @@ const createBrowserRuntime = async ({
1837
2036
  name: 'rstest:browser-watch',
1838
2037
  setup(api) {
1839
2038
  api.onBeforeDevCompile(() => {
1840
- if (!watchContext.hooksEnabled) {
2039
+ watchState.compileStartTimes.set(project.name, Date.now());
2040
+ if (!watchState.hooksEnabled) {
1841
2041
  return;
1842
2042
  }
1843
2043
  logger.log(color.cyan('\nFile changed, re-running tests...\n'));
1844
2044
  });
1845
2045
 
1846
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
+ }
1847
2063
  // Collect hashes even during initial build to establish baseline
1848
2064
  if (stats) {
1849
- 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
+ ]);
1850
2070
  const entryTestFiles = new Set<string>(
1851
- collectWatchTestFiles(allProjectEntries).map(
2071
+ collectWatchTestFiles(projectEntry ? [projectEntry] : []).map(
1852
2072
  (file) => file.testPath,
1853
2073
  ),
1854
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
+ }
1855
2086
 
1856
2087
  const statsJson = stats.toJson({ all: true });
1857
- const affected = getAffectedTestFiles(
1858
- statsJson.chunks,
2088
+ const affected = getAffectedTestFiles({
2089
+ chunks: statsJson.chunks,
1859
2090
  entryTestFiles,
1860
- );
1861
- watchContext.affectedTestFiles = affected;
2091
+ setupFiles,
2092
+ state,
2093
+ });
1862
2094
 
1863
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
+ );
1864
2106
  logger.debug(
1865
2107
  `[Watch] Affected test files: ${affected.join(', ')}`,
1866
2108
  );
1867
2109
  }
1868
2110
  }
1869
2111
 
1870
- if (!watchContext.hooksEnabled) {
2112
+ if (!watchState.hooksEnabled) {
1871
2113
  return;
1872
2114
  }
1873
2115
 
@@ -1909,6 +2151,15 @@ const createBrowserRuntime = async ({
1909
2151
  if (isDebug()) {
1910
2152
  await rsbuildInstance.inspectConfig({
1911
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
+ ),
1912
2163
  extraConfigs: {
1913
2164
  rstest: {
1914
2165
  ...context.normalizedConfig,
@@ -2058,6 +2309,7 @@ const createBrowserRuntime = async ({
2058
2309
  dispatchHandlers,
2059
2310
  wss,
2060
2311
  projectEntries,
2312
+ watchState,
2061
2313
  };
2062
2314
  } catch (error) {
2063
2315
  wss.close();
@@ -2423,7 +2675,7 @@ export const runBrowserController = async (
2423
2675
  await notifyTestRunStart();
2424
2676
  }
2425
2677
 
2426
- const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
2678
+ const enableCliShortcuts = isWatchMode && isTTY('stdin');
2427
2679
  const browserTempOutputRoot = context.normalizedConfig.output.distPath.root;
2428
2680
  const tempDir =
2429
2681
  isWatchMode && watchContext.runtime
@@ -2437,15 +2689,13 @@ export const runBrowserController = async (
2437
2689
  Date.now().toString(),
2438
2690
  );
2439
2691
 
2440
- // Track initial test files for watch mode
2441
- if (isWatchMode) {
2442
- watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
2443
- }
2444
-
2445
2692
  let runtime = isWatchMode ? watchContext.runtime : null;
2446
2693
 
2447
2694
  // Define rerun callback for watch mode (will be populated later)
2448
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;
2449
2699
 
2450
2700
  if (!runtime) {
2451
2701
  try {
@@ -2476,15 +2726,113 @@ export const runBrowserController = async (
2476
2726
 
2477
2727
  if (isWatchMode) {
2478
2728
  watchContext.runtime = runtime;
2479
- registerWatchCleanup();
2729
+ registerWatchCleanup(context.embedded);
2730
+ }
2731
+ }
2480
2732
 
2481
- if (enableCliShortcuts && !watchContext.closeCliShortcuts) {
2482
- watchContext.closeCliShortcuts = await setupBrowserWatchCliShortcuts({
2483
- close: cleanupWatchRuntime,
2484
- });
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;
2485
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;
2486
2762
  }
2487
- }
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
+ };
2488
2836
 
2489
2837
  projectEntries = runtime.projectEntries;
2490
2838
  totalTests = projectEntries.reduce(
@@ -2556,7 +2904,9 @@ export const runBrowserController = async (
2556
2904
  rootPath: normalize(context.rootPath),
2557
2905
  projects: projectRuntimeConfigs,
2558
2906
  snapshot: {
2559
- updateSnapshot: context.snapshotManager.options.updateSnapshot,
2907
+ updateSnapshot:
2908
+ options?.updateSnapshot ??
2909
+ context.snapshotManager.options.updateSnapshot,
2560
2910
  },
2561
2911
  // Container origin (fallback). Per-project runner origins below.
2562
2912
  runnerUrl: `http://localhost:${runtime.containerServer.port}`,
@@ -2854,7 +3204,8 @@ export const runBrowserController = async (
2854
3204
  const handleLog = async (payload: LogPayload): Promise<void> => {
2855
3205
  const log: UserConsoleLog = {
2856
3206
  content: payload.content,
2857
- name: payload.level,
3207
+ // Same colored level label as the node worker's CustomConsole.
3208
+ name: getPrettyConsoleName(payload.level),
2858
3209
  taskId: payload.taskId,
2859
3210
  taskName: payload.taskName,
2860
3211
  taskParentNames: payload.taskParentNames,
@@ -3117,6 +3468,12 @@ export const runBrowserController = async (
3117
3468
 
3118
3469
  const inlineOptions: BrowserHostConfig = {
3119
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
+ },
3120
3477
  testFile: file.testPath,
3121
3478
  runId: `${run.token}:${session.id}`,
3122
3479
  };
@@ -3286,20 +3643,16 @@ export const runBrowserController = async (
3286
3643
  fatalError && fatalError !== fatalErrorBeforeRun
3287
3644
  ? fatalError
3288
3645
  : undefined;
3289
- await notifyTestRunEnd({
3290
- duration: {
3291
- totalTime: testTime,
3292
- buildTime: 0,
3293
- testTime,
3294
- },
3295
- filterRerunTestPaths: files.map((file) => file.testPath),
3646
+ await finalizeWatchRerun({
3647
+ rerunTestPaths: files.map((file) => file.testPath),
3648
+ testTime,
3296
3649
  unhandledErrors: rerunError
3297
3650
  ? [rerunError]
3298
3651
  : rerunFatalError
3299
3652
  ? [rerunFatalError]
3300
3653
  : undefined,
3301
3654
  });
3302
- logBrowserWatchReadyMessage(enableCliShortcuts);
3655
+ logWatchReadyMessage(context, enableCliShortcuts);
3303
3656
  }
3304
3657
  },
3305
3658
  onError: async (error) => {
@@ -3316,6 +3669,8 @@ export const runBrowserController = async (
3316
3669
  },
3317
3670
  });
3318
3671
 
3672
+ awaitHeadlessRerunIdle = () => latestRerunScheduler.whenIdle();
3673
+
3319
3674
  if (allTestFiles.length === 0) {
3320
3675
  const duration = {
3321
3676
  totalTime: buildTime,
@@ -3335,6 +3690,7 @@ export const runBrowserController = async (
3335
3690
  await destroyBrowserRuntime(runtime);
3336
3691
  }
3337
3692
  : undefined,
3693
+ watch: watchHandles,
3338
3694
  };
3339
3695
 
3340
3696
  if (isWatchMode) {
@@ -3346,18 +3702,17 @@ export const runBrowserController = async (
3346
3702
  const newProjectEntries = await collectProjectEntries(context);
3347
3703
  const rerunPlan = planWatchRerun({
3348
3704
  projectEntries: newProjectEntries,
3349
- previousTestFiles: watchContext.lastTestFiles,
3350
- affectedTestFiles: watchContext.affectedTestFiles,
3705
+ previousTestFiles: watchState.lastTestFiles,
3706
+ affectedTestFiles: drainPendingAffectedTestFiles(watchState),
3351
3707
  });
3352
- watchContext.affectedTestFiles = [];
3353
3708
 
3354
3709
  if (rerunPlan.filesChanged) {
3355
- watchContext.lastTestFiles = rerunPlan.currentTestFiles;
3710
+ watchState.lastTestFiles = rerunPlan.currentTestFiles;
3356
3711
  if (rerunPlan.currentTestFiles.length === 0) {
3357
3712
  logger.log(
3358
3713
  color.cyan('No browser test files remain after update.\n'),
3359
3714
  );
3360
- logBrowserWatchReadyMessage(enableCliShortcuts);
3715
+ logWatchReadyMessage(context, enableCliShortcuts);
3361
3716
  return;
3362
3717
  }
3363
3718
 
@@ -3366,14 +3721,16 @@ export const runBrowserController = async (
3366
3721
  `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
3367
3722
  ),
3368
3723
  );
3369
- void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
3724
+ await latestRerunScheduler.enqueueLatest(
3725
+ rerunPlan.currentTestFiles,
3726
+ );
3370
3727
  return;
3371
3728
  }
3372
3729
 
3373
- logBrowserWatchReadyMessage(enableCliShortcuts);
3730
+ logWatchReadyMessage(context, enableCliShortcuts);
3374
3731
  };
3375
- watchContext.hooksEnabled = true;
3376
- logBrowserWatchReadyMessage(enableCliShortcuts);
3732
+ watchState.hooksEnabled = true;
3733
+ logWatchReadyMessage(context, enableCliShortcuts);
3377
3734
  }
3378
3735
 
3379
3736
  return result;
@@ -3388,26 +3745,25 @@ export const runBrowserController = async (
3388
3745
  const newProjectEntries = await collectProjectEntries(context);
3389
3746
  const rerunPlan = planWatchRerun({
3390
3747
  projectEntries: newProjectEntries,
3391
- previousTestFiles: watchContext.lastTestFiles,
3392
- affectedTestFiles: watchContext.affectedTestFiles,
3748
+ previousTestFiles: watchState.lastTestFiles,
3749
+ affectedTestFiles: drainPendingAffectedTestFiles(watchState),
3393
3750
  });
3394
- watchContext.affectedTestFiles = [];
3395
3751
 
3396
3752
  if (rerunPlan.filesChanged) {
3397
3753
  const deletedTestPaths = collectDeletedTestPaths(
3398
- watchContext.lastTestFiles,
3754
+ watchState.lastTestFiles,
3399
3755
  rerunPlan.currentTestFiles,
3400
3756
  );
3401
3757
  if (deletedTestPaths.length > 0) {
3402
3758
  context.updateReporterResultState([], [], deletedTestPaths);
3403
3759
  }
3404
- watchContext.lastTestFiles = rerunPlan.currentTestFiles;
3760
+ watchState.lastTestFiles = rerunPlan.currentTestFiles;
3405
3761
  if (rerunPlan.currentTestFiles.length === 0) {
3406
3762
  await latestRerunScheduler.enqueueLatest([]);
3407
3763
  logger.log(
3408
3764
  color.cyan('No browser test files remain after update.\n'),
3409
3765
  );
3410
- logBrowserWatchReadyMessage(enableCliShortcuts);
3766
+ logWatchReadyMessage(context, enableCliShortcuts);
3411
3767
  return;
3412
3768
  }
3413
3769
 
@@ -3416,7 +3772,7 @@ export const runBrowserController = async (
3416
3772
  `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
3417
3773
  ),
3418
3774
  );
3419
- void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
3775
+ await latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
3420
3776
  return;
3421
3777
  }
3422
3778
 
@@ -3426,7 +3782,7 @@ export const runBrowserController = async (
3426
3782
  'No affected browser test files detected, skipping re-run.\n',
3427
3783
  ),
3428
3784
  );
3429
- logBrowserWatchReadyMessage(enableCliShortcuts);
3785
+ logWatchReadyMessage(context, enableCliShortcuts);
3430
3786
  return;
3431
3787
  }
3432
3788
 
@@ -3435,7 +3791,7 @@ export const runBrowserController = async (
3435
3791
  `Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`,
3436
3792
  ),
3437
3793
  );
3438
- void latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
3794
+ await latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
3439
3795
  };
3440
3796
  }
3441
3797
 
@@ -3477,6 +3833,7 @@ export const runBrowserController = async (
3477
3833
  // `closeHeadlessRuntime` is already `undefined` in watch mode, so the
3478
3834
  // non-watch caller (core) receives the deferred close and watch does not.
3479
3835
  close: closeHeadlessRuntime,
3836
+ watch: watchHandles,
3480
3837
  };
3481
3838
 
3482
3839
  if (isWatchMode) {
@@ -3488,8 +3845,8 @@ export const runBrowserController = async (
3488
3845
  }
3489
3846
 
3490
3847
  if (isWatchMode && triggerRerun) {
3491
- watchContext.hooksEnabled = true;
3492
- logBrowserWatchReadyMessage(enableCliShortcuts);
3848
+ watchState.hooksEnabled = true;
3849
+ logWatchReadyMessage(context, enableCliShortcuts);
3493
3850
  }
3494
3851
 
3495
3852
  return result;
@@ -3867,30 +4224,43 @@ export const runBrowserController = async (
3867
4224
  // Define rerun logic for watch mode
3868
4225
  if (isWatchMode) {
3869
4226
  triggerRerun = async () => {
3870
- 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
+ ]);
3871
4242
  const rerunPlan = planWatchRerun({
3872
4243
  projectEntries: newProjectEntries,
3873
- previousTestFiles: watchContext.lastTestFiles,
3874
- affectedTestFiles: watchContext.affectedTestFiles,
4244
+ previousTestFiles: watchState.lastTestFiles,
4245
+ affectedTestFiles: drainPendingAffectedTestFiles(watchState),
3875
4246
  });
3876
- watchContext.affectedTestFiles = [];
3877
4247
 
3878
4248
  if (rerunPlan.filesChanged) {
3879
4249
  const deletedTestPaths = collectDeletedTestPaths(
3880
- watchContext.lastTestFiles,
4250
+ watchState.lastTestFiles,
3881
4251
  rerunPlan.currentTestFiles,
3882
4252
  );
3883
4253
  if (deletedTestPaths.length > 0) {
3884
4254
  context.updateReporterResultState([], [], deletedTestPaths);
3885
4255
  }
3886
- watchContext.lastTestFiles = rerunPlan.currentTestFiles;
4256
+ watchState.lastTestFiles = rerunPlan.currentTestFiles;
3887
4257
  currentTestFiles = rerunPlan.currentTestFiles;
3888
4258
  await rpcManager.notifyTestFileUpdate(currentTestFiles);
3889
4259
  if (currentTestFiles.length === 0) {
3890
4260
  logger.log(
3891
4261
  color.cyan('No browser test files remain after update.\n'),
3892
4262
  );
3893
- logBrowserWatchReadyMessage(enableCliShortcuts);
4263
+ logWatchReadyMessage(context, enableCliShortcuts);
3894
4264
  return;
3895
4265
  }
3896
4266
  await waitForRunnerFramesReady(
@@ -3927,26 +4297,22 @@ export const runBrowserController = async (
3927
4297
  fatalError && fatalError !== fatalErrorBeforeRun
3928
4298
  ? fatalError
3929
4299
  : undefined;
3930
- await notifyTestRunEnd({
3931
- duration: {
3932
- totalTime: testTime,
3933
- buildTime: 0,
3934
- testTime,
3935
- },
3936
- filterRerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
4300
+ await finalizeWatchRerun({
4301
+ rerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
4302
+ testTime,
3937
4303
  unhandledErrors: rerunError
3938
4304
  ? [rerunError]
3939
4305
  : rerunFatalError
3940
4306
  ? [rerunFatalError]
3941
4307
  : undefined,
3942
4308
  });
3943
- logBrowserWatchReadyMessage(enableCliShortcuts);
4309
+ logWatchReadyMessage(context, enableCliShortcuts);
3944
4310
  }
3945
4311
  } else if (!rerunPlan.filesChanged) {
3946
4312
  logger.log(color.cyan('Tests will be re-executed automatically\n'));
3947
- logBrowserWatchReadyMessage(enableCliShortcuts);
4313
+ logWatchReadyMessage(context, enableCliShortcuts);
3948
4314
  } else {
3949
- logBrowserWatchReadyMessage(enableCliShortcuts);
4315
+ logWatchReadyMessage(context, enableCliShortcuts);
3950
4316
  }
3951
4317
  };
3952
4318
  }
@@ -3998,6 +4364,7 @@ export const runBrowserController = async (
3998
4364
  // `closeContainerRuntime` is already `undefined` in watch mode, so the
3999
4365
  // non-watch caller (core) receives the deferred close and watch does not.
4000
4366
  close: closeContainerRuntime,
4367
+ watch: watchHandles,
4001
4368
  };
4002
4369
 
4003
4370
  if (isWatchMode) {
@@ -4010,8 +4377,8 @@ export const runBrowserController = async (
4010
4377
 
4011
4378
  // Enable watch hooks AFTER initial test run to avoid duplicate runs
4012
4379
  if (isWatchMode && triggerRerun) {
4013
- watchContext.hooksEnabled = true;
4014
- logBrowserWatchReadyMessage(enableCliShortcuts);
4380
+ watchState.hooksEnabled = true;
4381
+ logWatchReadyMessage(context, enableCliShortcuts);
4015
4382
  }
4016
4383
 
4017
4384
  return result;