Repository navigation
[heft-jest-plugin] Fix watch mode failing on Jest 30 - #6200
farzaneh-wm wants to merge 1 commit into
Conversation
|
@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'); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🟡 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
| 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) | ||
| ]); |
| if (entry.name !== 'node_modules' && !ignoredFolderPaths.has(entryPath)) { | ||
| collectFileTimes(entryPath); |

Summary
Fixes #6199
Since
@rushstack/heft-jest-pluginmoved to Jest 30,heft test-watch(andheft run-watchwith a Jest test phase) fails as soon as the test phase starts:In watch mode, the plugin monkey-patched two internal modules of
@jest/core(build/watch.jsandbuild/runJest.js) so that Heft could drive Jest's own watch loop. Jest 30 ships@jest/coreas a single bundle, and itsexportsonly 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
runCLIAPI, the same entry point thatheft testalready uses.Details
On each incremental run,
JestPluginnow:rootssetting. It reads them through Heft'swatchFs, so Heft schedules another incremental run when any of them change.runCLIif anything changed. Otherwise it printsNo pending test runs., as before.Some notes on the implementation:
watchGlobAsync. I triedwatchGlobAsyncfirst. Itschangedflag comes from the watcher's cached file times, and those don't yet include thelibfiles that TypeScript emitted a few milliseconds earlier in the build phase. As a result, the first pass after an edit printedNo pending test runs., and the tests only ran on a second pass that the watcher triggered afterward. Reading the times throughwatchFs.statSyncruns the tests in the same pass.cacheDirectory) and Jest'scoverageDirectoryare skipped, along withnode_modules. Without this, a config whoserootsincludes the project folder (Jest's default whenrootsisn't set) would rerun the tests every time Jest wrote its cache or coverage.<rootDir>inrootsis resolved. WhendisableConfigurationModuleResolutionis set, the config isn't token-resolved, sorootscan still contain<rootDir>. A small helper resolves it, along with relative paths, against the project folder.TerminalWritableStream.ts(only used to capture the patched watch output), and thejestPluginSymbol/getJestPluginplumbing that let the patches find the plugin instance.JestPluginis@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
findRelatedTeststogether with Jest'sonlyChangedsetting. If maintainers want that back, it could be added on top of this change by passing the changed files torunCLIwithfindRelatedTests. 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 aslib.How it was tested
In
build-tests/heft-jest-preset-test(Jest 30.2.0) on macOS, with Node 22.23.4:heft test-watch --clean. The build ran, then the tests passed, and Heft waited for changes. There was noCannot find moduleerror.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.src/index.tsso that the test failed. The run reported1 Jest test failed, marked the run as failed, and kept watching.heft buildandheft testfor@rushstack/heft-jest-plugin. The build had no lint warnings, and all 5 existing unit tests passed.Not tested: Windows.