Skip to content

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

Open
farzaneh-wm wants to merge 1 commit into
microsoft:mainfrom
farzaneh-wm:farzaneh-wm/fix-jest30-watch
Open

farzaneh-wm wants to merge 1 commit into
microsoft:mainfrom
farzaneh-wm:farzaneh-wm/fix-jest30-watch

Conversation

@farzaneh-wm

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. Lists the files under each folder in Jest's roots setting. It reads them through Heft's watchFs, so Heft schedules another incremental run when any of them change.
  2. Compares each file's modification time with the previous run. Added or removed files also count as changes.
  3. Runs the test suite with runCLI if anything changed. Otherwise it prints No pending test runs., as before.

Some notes on the implementation:

  • Modification times are read directly instead of through watchGlobAsync. I tried watchGlobAsync first. 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 first pass after an edit printed No pending test runs., and the tests only ran on a second pass that the watcher triggered afterward. Reading the times through watchFs.statSync runs the tests in the same pass.
  • Jest's own output folders aren't watched. Heft's temp folder (which holds Jest's cacheDirectory) and Jest's coverageDirectory are skipped, along with node_modules. Without this, a config whose roots includes the project folder (Jest's default when roots isn't set) would rerun the tests every time Jest wrote its cache or coverage.
  • <rootDir> in roots is resolved. When disableConfigurationModuleResolution is set, the config isn't token-resolved, so roots can still contain <rootDir>. A small helper resolves it, along with relative paths, against the project folder.
  • 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 change: each rerun now runs the whole test suite. The old implementation used the haste map's change events to rerun only the tests related to the changed files, through findRelatedTests together with Jest's onlyChanged setting. If maintainers want that back, it could be added on top of this change by passing the changed files to runCLI with findRelatedTests. I kept this PR focused on making watch mode work again.

Alternative considered: detecting Jest 30 and failing with a clear error message. I chose to restore the feature instead.

Performance: each incremental run reads 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

In build-tests/heft-jest-preset-test (Jest 30.2.0) on macOS, with Node 22.23.4:

  • Ran heft test-watch --clean. The build ran, then the tests passed, and Heft waited for changes. There was no Cannot find module error.
  • Edited src/index.ts. TypeScript recompiled and the tests reran in the same incremental pass. Heft then stayed idle, with no extra passes or rerun loop.
  • Changed src/index.ts so that the test failed. The run reported 1 Jest test failed, marked the run as failed, and kept watching.
  • Ran heft build and heft test for @rushstack/heft-jest-plugin. The build had no lint warnings, and all 5 existing unit tests passed.

Not tested: Windows.

@farzaneh-wm

Copy link
Copy Markdown
Author

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

this.#jestOutputStream = wrappedStdOut;

// Shim watch so that we can intercept the output stream
const watchModulePath: string = path.resolve(jestCoreDir, 'watch.js');

@philippe-wm Philippe Elsass (philippe-wm) Nov 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It looks like a fairly major change while you might have "just" replaced the loading of the JS modules by calling runCLI with the right parameters. Did you try that?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the review! I did look at that. When watch/watchAll is set, runCLI starts Jest's own watch loop and returns a promise that never resolves (@jest/core 30.3.0, build/index.js: "If in watch mode, return the promise that will never resolve").

// _@jest/core/build/index.js_ 
if (argv.watch || argv.watchAll) {
    // If in watch mode, return the promise that will never resolve.
    // If the watch mode is interrupted, watch should handle the process
    // shutdown.
    // eslint-disable-next-line @typescript-eslint/no-empty-function
    return new Promise(() => {});
  }

That conflicts with how Heft's runIncremental works:

  • The test phase would never finish. Heft couldn't report pass/fail or errors for each run, and later phases would be blocked.
  • Jest's file watcher would trigger reruns by itself, independently of Heft's build, so tests could start while TypeScript is still emitting into lib.
  • Jest would write directly to stdout and take over stdin for its interactive keys, bypassing Heft's terminal.

Working around those three issues is what the old watch.js / runJest.js patches did (wrapping the output stream, and deferring runJest until Heft requested a run). Since Jest 30 no longer exposes those modules, I couldn't find a way to keep Jest's watch loop without patching internals. Calling runCLI once per Heft incremental run, without watch, is the smallest change I found that only uses the public API.

The tradeoff is that each rerun now runs the full suite instead of only the related tests. If you'd like, I can add that back in this PR or a follow-up by passing the changed files to runCLI with findRelatedTests.

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

Comment on lines +234 to +244
const {
roots = [ROOTDIR_TOKEN],
coverageDirectory = `${ROOTDIR_TOKEN}/coverage`
}: IHeftJestConfiguration = JSON.parse(jestArgv.config as string);

// Jest writes its cache and coverage output while running, so those folders must not be watched,
// otherwise every test run would schedule another one.
const ignoredFolderPaths: Set<string> = new Set([
path.resolve(tempFolderPath),
_resolveRootDirPath(coverageDirectory, buildFolderPath)
]);
Comment on lines +262 to +263
if (entry.name !== 'node_modules' && !ignoredFolderPaths.has(entryPath)) {
collectFileTimes(entryPath);

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