Skip to content

[heft-jest-plugin] Fix watch mode failing on Jest 30 - #6200

Open
farzaneh-wm wants to merge 3 commits into
microsoft:mainfrom
farzaneh-wm:farzaneh-wm/fix-jest30-watch
Open

farzaneh-wm wants to merge 3 commits into
microsoft:mainfrom
farzaneh-wm:farzaneh-wm/fix-jest30-watch

Conversation

@farzaneh-wm

@farzaneh-wm farzaneh-wm commented Nov 12, 2026 •

Copy link
Copy Markdown

Summary

Fixes #6199

Since @rushstack/heft-jest-plugin moved to Jest 30, heft test-watch (and heft run-watch with a Jest test phase) fails as soon as the test phase starts:

Cannot find module '<...>/node_modules/@jest/core/build/watch.js'

In watch mode, the plugin monkey-patched two internal modules of @jest/core (build/watch.js and build/runJest.js) so that Heft could drive Jest's own watch loop. Jest 30 ships @jest/core as a single bundle, and its exports only expose . and ./package.json, so those modules no longer exist and the patch can't be applied.

This PR removes the patching. Heft now owns the watch loop, and each incremental run calls Jest's public runCLI API, the same entry point that heft test already uses.

Details

On each incremental run, JestPlugin now:

  1. Reads the normalized Jest configuration with readConfigs from jest-config, so that presets and defaults are applied. NODE_ENV is set first, because JavaScript presets may depend on it.
  2. Lists the test input files under each folder in the project's roots, and reads their modification times through Heft's watchFs, so that Heft schedules another incremental run when any of them change. This logic lives in the new src/JestWatchInputs.ts.
  3. Runs the test suite with runCLI if any file was added, deleted or modified since the previous run. Otherwise it prints No pending test runs., as before.

What counts as a test input, matching what Jest's own watch mode ignores:

  • Paths that match watchPathIgnorePatterns or modulePathIgnorePatterns are skipped, as are node_modules and the .git, .hg and .sl folders.
  • Folders that Jest or Heft write to while running are skipped: Heft's temp folder (which contains the Jest cacheDirectory), each project's cacheDirectory, and the coverageDirectory. Without this, a root that contains them, such as the project folder, would rerun the tests every time Jest wrote its cache or coverage.
  • Roots and excluded folders are compared using real paths, because Jest resolves symbolic links in rootDir.

Other implementation notes:

  • Modification times are read directly instead of through watchGlobAsync. Its changed flag comes from the watcher's cached file times, and those don't yet include the lib files that TypeScript emitted a few milliseconds earlier in the build phase. As a result, the tests only ran on a second pass. Reading the times through watchFs.statSync runs the tests in the same pass.
  • Failures aren't hidden by a pass where nothing changed. Heft clears a task's errors on every run, so when nothing changed after a failed run, the plugin reports <N> Jest test(s) failed in the previous run, and no test inputs have changed since instead of reporting success. If Jest throws, the next pass reruns the tests.
  • NODE_ENV is restored in a finally block in both heft test and watch mode. Previously it stayed set if Jest threw.
  • Code removed: the patching code, TerminalWritableStream.ts (only used to capture the patched watch output), and the jestPluginSymbol / getJestPlugin plumbing that let the patches find the plugin instance. JestPlugin is @internal, and none of these appear in an API report.

Behavior changes, also listed in the change file:

  • Each rerun runs the whole test suite. The old implementation reran only the tests related to the changed files. That could be added on top of this change by passing the changed files to runCLI with findRelatedTests.
  • Jest's interactive watch keys and the watchPlugins setting are no longer used in watch mode, because Jest's own watch loop no longer runs.
  • --find-related-tests now works in watch mode. Its description in heft-plugin.json no longer says it's incompatible.

Known limitation: Heft's watcher always watches a folder together with everything under it, and it doesn't support ignored paths. So a folder that contains an excluded or ignored path is not registered as a whole. Its files and its other subfolders are still tracked, but files or subfolders added directly to such a folder don't trigger a run. The default Heft rig roots (<rootDir>/lib) aren't affected. The proper fix is to pass ignored paths through Heft's WatchFileSystemAdapter to watchpack, which supports them. That changes @rushstack/heft's public API, so I'd like to do it in a follow-up PR.

Alternative considered: detecting Jest 30 and failing with a clear error message. I chose to restore the feature instead. Calling runCLI with watch / watchAll isn't an option, because it returns a promise that never resolves (see the discussion in the review thread).

Performance: each incremental run reads the normalized config, plus the folder listing and modification time of every file under the Jest roots. These are usually small output folders such as lib.

How it was tested

New unit tests, run with heft test in heft-plugins/heft-jest-plugin (21 tests in total pass, and the build has no lint warnings):

  • JestWatchInputs.test.ts covers:
    • which folders are watched as a whole
    • skipping node_modules, .git, excluded folders, and ignored files and folders
    • nested excluded folders and their siblings
    • ignore patterns not being applied to a root itself
    • missing roots and excluded roots
    • added, deleted, renamed and modified files
    • symlink resolution, and combining the ignore patterns of several projects
  • JestPluginWatch.test.ts mocks @jest/core and jest-config and covers:
    • tests rerun only when an input changes
    • the previous failure is reported again on a pass where nothing changed
    • NODE_ENV is test while the config is read, and is restored even after Jest throws, after which the tests run again

Manual testing with heft test-watch --clean on macOS, Jest 30.2.0, Node 22.23.4:

  • In build-tests/heft-jest-preset-test with its normal config (roots lib), I checked these cases:
    • The first run passed, with no Cannot find module error.
    • Editing a source file triggered one rerun in the same pass, and Heft then stayed idle.
    • A failing test was reported as an error.
    • A pass where nothing changed (adding an empty .d.ts file) reported the previous failure again.
    • Fixing the test triggered a passing rerun.
  • In temporary copies of that project:
    • With the roots set to the project folder and a coverage folder that are defined only in the Jest preset, Jest's coverage and cache writes didn't trigger extra runs, and an edit triggered exactly one rerun.
    • With a preset that throws unless NODE_ENV is test, and NODE_ENV unset in the shell, the tests ran and passed.
    • With watchPathIgnorePatterns: ["<rootDir>/generated/"] and the roots set to the project folder, writing files into generated/ triggered no run, and an edit triggered exactly one rerun in 75 seconds.

Not tested: Windows, apart from CI.

@farzaneh-wm

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="Warner Bros. Discovery"

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical watch-mode issues remain: exclusions do not filter watcher notifications, and roots and coverage exclusions use unnormalized Jest configuration, risking missed changes, repeated runs, and false success after test failures.

2 open issues
What changed in this PR

This PR addresses #6199 by replacing the Heft Jest plugin’s reliance on private Jest modules with public runCLI calls, restoring watch-mode support for Jest 30.

Changes:

  • Moves watch orchestration to Heft, using file timestamps under Jest roots to trigger full-suite reruns.
  • Adds path resolution and traversal exclusions for dependencies, temporary files, and coverage output.
  • Removes obsolete watch-patching helpers and adds a patch-release change entry.
File Description
heft-plugins/​heft-jest-plugin/​src/​TerminalWritableStream.ts Removes the output stream wrapper used by the old watch patches.
heft-plugins/​heft-jest-plugin/​src/​JestPlugin.ts Replaces internal Jest patches with Heft-managed input tracking and public API execution.
common/​changes/​@rushstack/​heft-jest-plugin/​farzaneh-wm-fix-jest30-watch_2026-11-11-17-11.json Records the Jest 30 watch-mode fix for release.

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Watch mode misses new tests beside excluded folders, and preset normalization can evaluate environment-dependent configuration before NODE_ENV is set to test, causing incorrect roots or failures.

2 open issues
2 resolved since last review

Review effort: Balanced

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs triage

Development

Successfully merging this pull request may close these issues.

[heft] heft-jest-plugin watch mode fails on Jest 30: Cannot find module '@jest/core/build/watch.js'

3 participants