From 74fd719876d6714e4c5129b39d3c3dd118fc4eed Mon Sep 17 00:00:00 2001 From: farzaneh-wm <95686816+farzaneh-wm@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:29:00 +0100 Subject: [PATCH 1/3] [heft-jest-plugin] Fix watch mode on Jest 30 (#6199) --- ...-wm-fix-jest30-watch_2026-11-11-17-11.json | 11 + .../heft-jest-plugin/src/JestPlugin.ts | 437 +++++------------- .../src/TerminalWritableStream.ts | 34 -- 3 files changed, 134 insertions(+), 348 deletions(-) create mode 100644 common/changes/@rushstack/heft-jest-plugin/farzaneh-wm-fix-jest30-watch_2026-11-11-17-11.json delete mode 100644 heft-plugins/heft-jest-plugin/src/TerminalWritableStream.ts diff --git a/common/changes/@rushstack/heft-jest-plugin/farzaneh-wm-fix-jest30-watch_2026-11-11-17-11.json b/common/changes/@rushstack/heft-jest-plugin/farzaneh-wm-fix-jest30-watch_2026-11-11-17-11.json new file mode 100644 index 00000000000..0e34a7ed54d --- /dev/null +++ b/common/changes/@rushstack/heft-jest-plugin/farzaneh-wm-fix-jest30-watch_2026-11-11-17-11.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-jest-plugin", + "comment": "Fix watch mode (`heft test-watch`) failing on Jest 30 with \"Cannot find module '@jest/core/build/watch.js'\". Heft now drives watch mode and reruns the test suite through Jest's public `runCLI` API when files under the Jest `roots` change.", + "type": "patch" + } + ], + "packageName": "@rushstack/heft-jest-plugin", + "email": "95686816+farzaneh-wm@users.noreply.github.com" +} diff --git a/heft-plugins/heft-jest-plugin/src/JestPlugin.ts b/heft-plugins/heft-jest-plugin/src/JestPlugin.ts index 32eefb41729..ca4beac5096 100644 --- a/heft-plugins/heft-jest-plugin/src/JestPlugin.ts +++ b/heft-plugins/heft-jest-plugin/src/JestPlugin.ts @@ -4,10 +4,9 @@ // Load the Jest patches before anything else loads import './patches/jestWorkerPatch'; -import type { EventEmitter } from 'node:events'; +import type { Dirent } from 'node:fs'; import * as path from 'node:path'; -import type { AggregatedResult } from '@jest/reporters'; import type { Config } from '@jest/types'; import { resolveRunner, resolveSequencer, resolveTestEnvironment, resolveWatchPlugin } from 'jest-resolve'; @@ -35,39 +34,8 @@ import type { ITerminal } from '@rushstack/terminal'; import type { IHeftJestReporterOptions } from './HeftJestReporter'; import { jestResolve } from './JestUtils'; -import { TerminalWritableStream } from './TerminalWritableStream'; import anythingSchema from './schemas/anything.schema.json'; -const jestPluginSymbol: unique symbol = Symbol('heft-jest-plugin'); -interface IWithJestPlugin { - [jestPluginSymbol]?: JestPlugin; -} - -interface IReadConfigsResult { - globalConfig: Config.GlobalConfig; -} - -interface IReadConfigs { - (argv: Config.Argv, projectPaths: string[]): Promise; -} - -interface IRunJestParams { - globalConfig: Config.GlobalConfig; - onComplete: (result: AggregatedResult) => void; -} - -interface IJestWatch { - ( - initialGlobalConfig: Config.GlobalConfig, - contexts: unknown, - outputStream: NodeJS.WriteStream, - hasteMapInstances: EventEmitter[], - stdin: NodeJS.ReadStream | undefined, - hooks: unknown, - filter: unknown - ): Promise; -} - type JestReporterConfig = string | Config.ReporterConfig; /** @@ -134,35 +102,16 @@ const JEST_CONFIG_PACKAGE_FOLDER: string = path.dirname(require.resolve('jest-co let _jestConfigurationFileLoader: ProjectConfigurationFile | undefined; -interface IPendingTestRun { - (): Promise; -} - /** * @internal */ export default class JestPlugin implements IHeftTaskPlugin { - #jestPromise: Promise | undefined; - #pendingTestRuns: Set = new Set(); - #executing: boolean = false; - - #jestOutputStream: TerminalWritableStream | undefined; - #changedFiles: Set = new Set(); - #requestRun!: () => void; #nodeEnvSet: boolean | undefined; - - #resolveFirstRunQueued!: () => void; - #firstRunQueuedPromise: Promise; - - public constructor() { - this.#firstRunQueuedPromise = new Promise((resolve) => { - this.#resolveFirstRunQueued = resolve; - }); - } - - public static getJestPlugin(object: object): JestPlugin | undefined { - return (object as IWithJestPlugin)[jestPluginSymbol]; - } + /** + * The modified times of the test input files seen by the previous watch mode run, or `undefined` + * before the first run. + */ + #testInputFileTimes: Map | undefined; /** * Setup the hooks and custom CLI options for the Jest plugin. @@ -234,12 +183,7 @@ export default class JestPlugin implements IHeftTaskPlugin { taskSession.hooks.runIncremental.tapPromise( PLUGIN_NAME, async (runIncrementalOptions: IHeftTaskRunIncrementalHookOptions) => { - await this.#runJestWatchAsync( - taskSession, - heftConfiguration, - options, - runIncrementalOptions.requestRun - ); + await this.#runJestWatchAsync(taskSession, heftConfiguration, options, runIncrementalOptions); } ); } @@ -252,32 +196,125 @@ export default class JestPlugin implements IHeftTaskPlugin { heftConfiguration: HeftConfiguration, options: IJestPluginOptions ): Promise { - const logger: IScopedLogger = taskSession.logger; - const terminal: ITerminal = logger.terminal; - - this.#setNodeEnvIfRequested(options, logger); + const jestArgv: Config.Argv | undefined = await this.#createJestArgvAsync( + taskSession, + heftConfiguration, + options + ); + if (!jestArgv) { + return; + } - const { getVersion, runCLI } = await import(`@jest/core`); - terminal.writeLine(`Using Jest version ${getVersion()}`); + await this.#executeJestAsync(taskSession, heftConfiguration, options, jestArgv); + } - const buildFolderPath: string = heftConfiguration.buildFolderPath; + /** + * Runs Jest in watch mode. Heft owns the watch loop: each incremental run re-executes the test + * suite through the public `runCLI` API if any file under the Jest `roots` has changed since + * the previous run. Jest 30 bundles `@jest/core` into a single module, so its internal `watch` + * and `runJest` implementations can no longer be patched to let Heft orchestrate Jest's own + * watch mode. + */ + async #runJestWatchAsync( + taskSession: IHeftTaskSession, + heftConfiguration: HeftConfiguration, + options: IJestPluginOptions, + runIncrementalOptions: IHeftTaskRunIncrementalHookOptions + ): Promise { const jestArgv: Config.Argv | undefined = await this.#createJestArgvAsync( taskSession, heftConfiguration, - options, - false + options ); if (!jestArgv) { return; } + const { buildFolderPath, tempFolderPath } = heftConfiguration; + 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 = new Set([ + path.resolve(tempFolderPath), + _resolveRootDirPath(coverageDirectory, buildFolderPath) + ]); + + // Read the test inputs through watchFs so that Heft schedules another incremental run when any of + // them change. The modified times are read directly rather than through watchGlobAsync, because + // its change detection relies on the watcher's cached times, which may not yet include files that + // the build phase wrote just before this run. + const { watchFs } = runIncrementalOptions; + const testInputFileTimes: Map = new Map(); + const collectFileTimes = (folderPath: string): void => { + let entries: Dirent[]; + try { + entries = watchFs.readdirSync(folderPath, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const entryPath: string = path.join(folderPath, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'node_modules' && !ignoredFolderPaths.has(entryPath)) { + collectFileTimes(entryPath); + } + } else { + try { + testInputFileTimes.set(entryPath, watchFs.statSync(entryPath).mtimeMs); + } catch { + // The file was deleted while reading the folder + } + } + } + }; + for (const root of roots) { + collectFileTimes(_resolveRootDirPath(root, buildFolderPath)); + } + + const previousTestInputFileTimes: Map | undefined = this.#testInputFileTimes; + let hasChanges: boolean = + !previousTestInputFileTimes || previousTestInputFileTimes.size !== testInputFileTimes.size; + for (const [filePath, time] of testInputFileTimes) { + if (hasChanges) { + break; + } + hasChanges = previousTestInputFileTimes?.get(filePath) !== time; + } + this.#testInputFileTimes = testInputFileTimes; + + if (!hasChanges) { + taskSession.logger.terminal.writeLine(`No pending test runs.`); + return; + } + + await this.#executeJestAsync(taskSession, heftConfiguration, options, jestArgv); + } + + async #executeJestAsync( + taskSession: IHeftTaskSession, + heftConfiguration: HeftConfiguration, + options: IJestPluginOptions, + jestArgv: Config.Argv + ): Promise { + const logger: IScopedLogger = taskSession.logger; + const terminal: ITerminal = logger.terminal; + + this.#setNodeEnvIfRequested(options, logger); + + const { getVersion, runCLI } = await import(`@jest/core`); + terminal.writeLine(`Using Jest version ${getVersion()}`); + const { // Config.Argv is weakly typed. After updating the jestArgv object, it's a good idea to inspect "globalConfig" // in the debugger to validate that your changes are being applied as expected. // eslint-disable-next-line @typescript-eslint/no-unused-vars globalConfig, results: jestResults - } = await runCLI(jestArgv, [buildFolderPath]); + } = await runCLI(jestArgv, [heftConfiguration.buildFolderPath]); this.#resetNodeEnv(); @@ -298,245 +335,10 @@ export default class JestPlugin implements IHeftTaskPlugin { } } - /** - * Runs Jest using the provided options. - */ - async #runJestWatchAsync( - taskSession: IHeftTaskSession, - heftConfiguration: HeftConfiguration, - options: IJestPluginOptions, - requestRun: () => void - ): Promise { - const logger: IScopedLogger = taskSession.logger; - const terminal: ITerminal = logger.terminal; - - const pendingTestRuns: Set = this.#pendingTestRuns; - this.#requestRun = requestRun; - - if (!this.#jestPromise) { - // Monkey-patch Jest's watch mode so that we can orchestrate it. - const jestCoreDir: string = path.dirname(require.resolve('@jest/core')); - - const jestConfigPath: string = require.resolve('jest-config', { - paths: [jestCoreDir] - }); - const jestConfigModule: { - readConfigs: IReadConfigs; - } = require(jestConfigPath); - const { readConfigs: originalReadConfigs } = jestConfigModule; - // eslint-disable-next-line func-style - const readConfigs: typeof originalReadConfigs = async function wrappedReadConfigs( - this: void, - argv: Config.Argv, - projectPaths: string[] - ): Promise { - const host: JestPlugin | undefined = JestPlugin.getJestPlugin(argv); - const result: IReadConfigsResult = await originalReadConfigs(argv, projectPaths); - // Forward the JestPlugin instance from argv. - const extendedGlobalConfig: Config.GlobalConfig & IWithJestPlugin = Object.freeze({ - ...result.globalConfig, - [jestPluginSymbol]: host - }); - - return { - // There are other properties on this object - ...result, - globalConfig: extendedGlobalConfig - }; - }; - Object.defineProperty(jestConfigModule, 'readConfigs', { - get: () => readConfigs - }); - - const wrappedStdOut: TerminalWritableStream = new TerminalWritableStream(terminal); - this.#jestOutputStream = wrappedStdOut; - - // Shim watch so that we can intercept the output stream - const watchModulePath: string = path.resolve(jestCoreDir, 'watch.js'); - const watchModule: { - default: IJestWatch; - } = require(watchModulePath); - const { default: originalWatch } = watchModule; - - // eslint-disable-next-line func-style - const watch: IJestWatch = function patchedWatch( - this: void, - initialGlobalConfig: Config.GlobalConfig, - contexts: unknown, - outputStream: NodeJS.WriteStream, - hasteMapInstances: EventEmitter[], - stdin: NodeJS.ReadStream | undefined, - hooks: unknown, - filter: unknown - ): Promise { - // Extract the host JestPlugin. This is plumbed through on the config rather than captured - // in a closure to avoid binding global state to the current plugin instance. - const host: JestPlugin | undefined = JestPlugin.getJestPlugin(initialGlobalConfig); - if (!host || !(host instanceof JestPlugin)) { - throw new Error(`Patched Jest expected JestPlugin on globalConfig`); - } - - // Listen to the haste maps directly to get the list of touched files - hasteMapInstances.forEach((hasteMap: EventEmitter, index: number) => { - hasteMap.on('change', ({ eventsQueue }: { eventsQueue: { filePath: string }[] }) => { - for (const file of eventsQueue) { - // Record all changed files for the next test run - host.#changedFiles.add(file.filePath); - } - }); - }); - - return originalWatch( - initialGlobalConfig, - contexts, - host.#jestOutputStream as unknown as NodeJS.WriteStream, - hasteMapInstances, - stdin, - hooks, - filter - ); - }; - Object.defineProperty(watchModule, 'default', { - get(): typeof originalWatch { - return watch; - } - }); - - // Shim runJest so that we can defer test execution - const runJestModulePath: string = path.resolve(jestCoreDir, 'runJest.js'); - const runJestModule: { - default: (params: IRunJestParams) => Promise; - } = require(runJestModulePath); - const { default: originalRunJest } = runJestModule; - // eslint-disable-next-line func-style - const runJest: typeof originalRunJest = function patchedRunJest( - this: void, - params: IRunJestParams - ): Promise { - const host: JestPlugin | undefined = JestPlugin.getJestPlugin(params.globalConfig); - if (!host || !(host instanceof JestPlugin)) { - throw new Error(`Patched Jest expected JestPlugin on globalConfig`); - } - - if (!host.#executing) { - host.#requestRun(); - } - - return new Promise((resolve: () => void, reject: (err: Error) => void) => { - host.#pendingTestRuns.add(async (): Promise => { - let result: AggregatedResult | undefined; - const { onComplete } = params; - - const findRelatedTests: boolean = params.globalConfig.onlyChanged && host.#changedFiles.size > 0; - - const globalConfig: IRunJestParams['globalConfig'] = Object.freeze({ - ...params.globalConfig, - // Use the knowledge of changed files to implement the "onlyChanged" behavior via - // findRelatedTests and the list of changed files - findRelatedTests, - nonFlagArgs: findRelatedTests ? Array.from(host.#changedFiles) : [], - // This property can only be true when the files are tracked directly by Git - // Since we run tests on compiled files, this is not the case. - onlyChanged: false - }); - - try { - await originalRunJest({ - ...params, - globalConfig, - onComplete: (testResults: AggregatedResult) => { - result = testResults; - onComplete(testResults); - } - }); - resolve(); - } catch (err) { - reject(err); - throw err; - } - - return result; - }); - host.#resolveFirstRunQueued(); - }); - }; - - Object.defineProperty(runJestModule, 'default', { - get(): typeof originalRunJest { - return runJest; - } - }); - - const { getVersion, runCLI } = await import(`@jest/core`); - terminal.writeLine(`Using Jest version ${getVersion()}`); - - const buildFolderPath: string = heftConfiguration.buildFolderPath; - const jestArgv: Config.Argv | undefined = await this.#createJestArgvAsync( - taskSession, - heftConfiguration, - options, - true - ); - if (!jestArgv) { - this.#jestPromise = Promise.resolve(); - return; - } - - this.#jestPromise = runCLI(jestArgv, [buildFolderPath]); - } - - // Wait for the initial run to be queued. - await this.#firstRunQueuedPromise; - // Explicitly wait an async tick for any file watchers - await Promise.resolve(); - - if (pendingTestRuns.size > 0) { - this.#setNodeEnvIfRequested(options, logger); - - this.#executing = true; - for (const pendingTestRun of pendingTestRuns) { - pendingTestRuns.delete(pendingTestRun); - const jestResults: AggregatedResult | undefined = await pendingTestRun(); - if (jestResults) { - if (jestResults.numFailedTests > 0) { - logger.emitError( - new Error( - `${jestResults.numFailedTests} Jest test${jestResults.numFailedTests > 1 ? 's' : ''} failed` - ) - ); - } else if (jestResults.numFailedTestSuites > 0) { - logger.emitError( - new Error( - `${jestResults.numFailedTestSuites} Jest test suite${ - jestResults.numFailedTestSuites > 1 ? 's' : '' - } failed` - ) - ); - } - } else { - terminal.writeLine(`No tests were executed.`); - } - } - - this.#resetNodeEnv(); - - if (!logger.hasErrors) { - // If we ran tests and they succeeded, consider the files to no longer be changed. - // This might be overly-permissive, but there isn't a great way to identify if the changes - // are no longer relevant, unfortunately. - this.#changedFiles.clear(); - } - this.#executing = false; - } else { - terminal.writeLine(`No pending test runs.`); - } - } - async #createJestArgvAsync( taskSession: IHeftTaskSession, heftConfiguration: HeftConfiguration, - options: IJestPluginOptions, - watch: boolean + options: IJestPluginOptions ): Promise { const logger: IScopedLogger = taskSession.logger; const { terminal } = logger; @@ -631,12 +433,8 @@ export default class JestPlugin implements IHeftTaskPlugin { passWithNoTests: options.passWithNoTests, - [jestPluginSymbol]: this, - $0: process.argv0, - _: [], - - watchAll: watch + _: [] }; if (!options.debugHeftReporter) { @@ -885,6 +683,17 @@ function _getHeftJestReporterConfig(reporterOptions: IHeftJestReporterOptions): ]; } +/** + * Resolves a Jest config path to an absolute path. The config loader already replaces `` tokens, + * except when "disableConfigurationModuleResolution" is set. + */ +function _resolveRootDirPath(configPath: string, rootDir: string): string { + if (configPath.startsWith(ROOTDIR_TOKEN)) { + configPath = path.normalize('./' + configPath.slice(ROOTDIR_TOKEN.length)); + } + return path.resolve(rootDir, configPath); +} + /** * Resolve all specified properties to an absolute path using Jest resolution. In addition, the following * transforms will be applied to the provided propertyValue before resolution: diff --git a/heft-plugins/heft-jest-plugin/src/TerminalWritableStream.ts b/heft-plugins/heft-jest-plugin/src/TerminalWritableStream.ts deleted file mode 100644 index 75acf1f6040..00000000000 --- a/heft-plugins/heft-jest-plugin/src/TerminalWritableStream.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -import { Writable } from 'node:stream'; - -import type { ITerminal } from '@rushstack/terminal'; - -// Regex to filter out screen clearing directives -// Can't use the AnsiEscape.removeCodes() function from node-core-library because we are only -// removing the clear screen directives, but want to preserve coloring. -// eslint-disable-next-line no-control-regex -const FILTER_REGEX: RegExp = /\x1B\[2J\x1B\[0f|\x1B\[2J\x1B\[3J\x1B\[H/g; - -export class TerminalWritableStream extends Writable { - readonly #terminal: ITerminal; - - public constructor(terminal: ITerminal) { - super({ - objectMode: false, - decodeStrings: false, - defaultEncoding: 'utf-8' - }); - - this.#terminal = terminal; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public _write(chunk: any, encoding: string, callback: (error?: Error | undefined) => void): void { - const stringified: string = typeof chunk === 'string' ? chunk : chunk.toString(encoding); - const filtered: string = stringified.replace(FILTER_REGEX, ''); - this.#terminal.write(filtered); - callback(); - } -} From 1af1107a9076516728b8805527d13b3c5b49c823 Mon Sep 17 00:00:00 2001 From: farzaneh-wm <95686816+farzaneh-wm@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:54:40 +0100 Subject: [PATCH 2/3] [heft-jest-plugin] Use normalized Jest config for watch inputs and keep excluded folders unwatched --- .../heft-jest-plugin/src/JestPlugin.ts | 200 ++++++++++++------ 1 file changed, 138 insertions(+), 62 deletions(-) diff --git a/heft-plugins/heft-jest-plugin/src/JestPlugin.ts b/heft-plugins/heft-jest-plugin/src/JestPlugin.ts index ca4beac5096..fbd4bb31e95 100644 --- a/heft-plugins/heft-jest-plugin/src/JestPlugin.ts +++ b/heft-plugins/heft-jest-plugin/src/JestPlugin.ts @@ -4,7 +4,6 @@ // Load the Jest patches before anything else loads import './patches/jestWorkerPatch'; -import type { Dirent } from 'node:fs'; import * as path from 'node:path'; import type { Config } from '@jest/types'; @@ -17,6 +16,7 @@ import type { IHeftTaskSession, IHeftTaskRunHookOptions, IHeftTaskRunIncrementalHookOptions, + IWatchFileSystem, CommandLineFlagParameter, CommandLineIntegerParameter, CommandLineStringParameter, @@ -29,13 +29,32 @@ import { InheritanceType, PathResolutionMethod } from '@rushstack/heft-config-file'; -import { FileSystem, Path, Import, JsonFile, Objects, PackageName } from '@rushstack/node-core-library'; +import { + FileSystem, + type FolderItem, + Path, + Import, + JsonFile, + Objects, + PackageName +} from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import type { IHeftJestReporterOptions } from './HeftJestReporter'; import { jestResolve } from './JestUtils'; import anythingSchema from './schemas/anything.schema.json'; +/** + * The subset of the "jest-config" API that is used by this plugin. Its type declarations are not imported, + * because they depend on types that this package does not install. + */ +interface IJestConfigModule { + readConfigs( + argv: Config.Argv, + projectPaths: string[] + ): Promise<{ globalConfig: Config.GlobalConfig; configs: Config.ProjectConfig[] }>; +} + type JestReporterConfig = string | Config.ReporterConfig; /** @@ -112,6 +131,10 @@ export default class JestPlugin implements IHeftTaskPlugin { * before the first run. */ #testInputFileTimes: Map | undefined; + /** + * The error reported by the previous watch mode test run, if it failed. + */ + #lastTestRunError: Error | undefined; /** * Setup the hooks and custom CLI options for the Jest plugin. @@ -230,50 +253,31 @@ export default class JestPlugin implements IHeftTaskPlugin { return; } - const { buildFolderPath, tempFolderPath } = heftConfiguration; - const { - roots = [ROOTDIR_TOKEN], - coverageDirectory = `${ROOTDIR_TOKEN}/coverage` - }: IHeftJestConfiguration = JSON.parse(jestArgv.config as string); + // Use the normalized configuration, so that the roots and output folders match the ones that Jest + // uses after applying presets and defaults. + const { readConfigs }: IJestConfigModule = require('jest-config'); + const { globalConfig, configs } = await readConfigs(jestArgv, [heftConfiguration.buildFolderPath]); // 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 = new Set([ - path.resolve(tempFolderPath), - _resolveRootDirPath(coverageDirectory, buildFolderPath) - ]); - - // Read the test inputs through watchFs so that Heft schedules another incremental run when any of - // them change. The modified times are read directly rather than through watchGlobAsync, because - // its change detection relies on the watcher's cached times, which may not yet include files that - // the build phase wrote just before this run. - const { watchFs } = runIncrementalOptions; - const testInputFileTimes: Map = new Map(); - const collectFileTimes = (folderPath: string): void => { - let entries: Dirent[]; - try { - entries = watchFs.readdirSync(folderPath, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const entryPath: string = path.join(folderPath, entry.name); - if (entry.isDirectory()) { - if (entry.name !== 'node_modules' && !ignoredFolderPaths.has(entryPath)) { - collectFileTimes(entryPath); - } - } else { - try { - testInputFileTimes.set(entryPath, watchFs.statSync(entryPath).mtimeMs); - } catch { - // The file was deleted while reading the folder - } - } - } - }; - for (const root of roots) { - collectFileTimes(_resolveRootDirPath(root, buildFolderPath)); - } + const excludedFolderPaths: Set = new Set( + [ + heftConfiguration.tempFolderPath, + globalConfig.coverageDirectory, + ...configs.map((config: Config.ProjectConfig) => config.cacheDirectory) + ].map((folderPath: string) => path.resolve(folderPath)) + ); + const rootPaths: Set = new Set( + configs.flatMap((config: Config.ProjectConfig) => + config.roots.map((root: string) => path.resolve(root)) + ) + ); + + const testInputFileTimes: Map = _collectTestInputFileTimes( + runIncrementalOptions.watchFs, + rootPaths, + excludedFolderPaths + ); const previousTestInputFileTimes: Map | undefined = this.#testInputFileTimes; let hasChanges: boolean = @@ -284,14 +288,20 @@ export default class JestPlugin implements IHeftTaskPlugin { } hasChanges = previousTestInputFileTimes?.get(filePath) !== time; } - this.#testInputFileTimes = testInputFileTimes; if (!hasChanges) { taskSession.logger.terminal.writeLine(`No pending test runs.`); + // Heft clears the task's errors on every run, so report the failure of the previous run again + if (this.#lastTestRunError) { + taskSession.logger.emitError(this.#lastTestRunError); + } return; } - await this.#executeJestAsync(taskSession, heftConfiguration, options, jestArgv); + // If Jest throws, the next incremental run must run the tests again + this.#testInputFileTimes = undefined; + this.#lastTestRunError = await this.#executeJestAsync(taskSession, heftConfiguration, options, jestArgv); + this.#testInputFileTimes = testInputFileTimes; } async #executeJestAsync( @@ -299,7 +309,7 @@ export default class JestPlugin implements IHeftTaskPlugin { heftConfiguration: HeftConfiguration, options: IJestPluginOptions, jestArgv: Config.Argv - ): Promise { + ): Promise { const logger: IScopedLogger = taskSession.logger; const terminal: ITerminal = logger.terminal; @@ -318,21 +328,23 @@ export default class JestPlugin implements IHeftTaskPlugin { this.#resetNodeEnv(); + let testRunError: Error | undefined; if (jestResults.numFailedTests > 0) { - logger.emitError( - new Error( - `${jestResults.numFailedTests} Jest test${jestResults.numFailedTests > 1 ? 's' : ''} failed` - ) + testRunError = new Error( + `${jestResults.numFailedTests} Jest test${jestResults.numFailedTests > 1 ? 's' : ''} failed` ); } else if (jestResults.numFailedTestSuites > 0) { - logger.emitError( - new Error( - `${jestResults.numFailedTestSuites} Jest test suite${ - jestResults.numFailedTestSuites > 1 ? 's' : '' - } failed` - ) + testRunError = new Error( + `${jestResults.numFailedTestSuites} Jest test suite${ + jestResults.numFailedTestSuites > 1 ? 's' : '' + } failed` ); } + + if (testRunError) { + logger.emitError(testRunError); + } + return testRunError; } async #createJestArgvAsync( @@ -684,14 +696,78 @@ function _getHeftJestReporterConfig(reporterOptions: IHeftJestReporterOptions): } /** - * Resolves a Jest config path to an absolute path. The config loader already replaces `` tokens, - * except when "disableConfigurationModuleResolution" is set. + * Returns the modified time of each file under the root folders, and tracks the files with `watchFs` so that + * Heft schedules another incremental run when any of them change. The times are read directly rather than + * through `watchGlobAsync`, because its change detection relies on the watcher's cached times, which may not + * yet include files that the build phase wrote just before this run. + * + * Heft watches a folder that was read with `watchFs` together with everything under it. A folder that contains + * an excluded folder is therefore listed without tracking, and only its files and its other subfolders are + * tracked. New files that are added directly to such a folder do not trigger a run. */ -function _resolveRootDirPath(configPath: string, rootDir: string): string { - if (configPath.startsWith(ROOTDIR_TOKEN)) { - configPath = path.normalize('./' + configPath.slice(ROOTDIR_TOKEN.length)); +function _collectTestInputFileTimes( + watchFs: IWatchFileSystem, + rootPaths: Iterable, + excludedFolderPaths: ReadonlySet +): Map { + const fileTimes: Map = new Map(); + + const trackFolder = (folderPath: string): void => { + try { + watchFs.readdirSync(folderPath); + } catch { + // A missing folder is watched for its creation + } + }; + + // Returns true if the folder contains no excluded folders, so that it can be tracked as a whole + const collectFolder = (folderPath: string): boolean => { + let folderItems: FolderItem[]; + try { + folderItems = FileSystem.readFolderItems(folderPath); + } catch { + return true; + } + + const filePaths: string[] = []; + const subfolderPaths: string[] = []; + let containsExcludedFolder: boolean = false; + for (const folderItem of folderItems) { + const itemPath: string = path.join(folderPath, folderItem.name); + if (!folderItem.isDirectory()) { + filePaths.push(itemPath); + } else if (folderItem.name === 'node_modules' || excludedFolderPaths.has(itemPath)) { + containsExcludedFolder = true; + } else if (collectFolder(itemPath)) { + subfolderPaths.push(itemPath); + } else { + containsExcludedFolder = true; + } + } + + for (const filePath of filePaths) { + try { + fileTimes.set(filePath, watchFs.statSync(filePath).mtimeMs); + } catch { + // The file was deleted while reading the folder + } + } + + if (containsExcludedFolder) { + for (const subfolderPath of subfolderPaths) { + trackFolder(subfolderPath); + } + } + return !containsExcludedFolder; + }; + + for (const rootPath of rootPaths) { + if (!excludedFolderPaths.has(rootPath) && collectFolder(rootPath)) { + trackFolder(rootPath); + } } - return path.resolve(rootDir, configPath); + + return fileTimes; } /** From 8bb4d5f2529936d9f405e87bfa2c79a46a34b7e2 Mon Sep 17 00:00:00 2001 From: farzaneh-wm <95686816+farzaneh-wm@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:00:29 +0100 Subject: [PATCH 3/3] [heft-jest-plugin] Respect Jest watch ignore patterns, resolve symlinks, skip VCS folders, and add tests --- ...-wm-fix-jest30-watch_2026-11-11-17-11.json | 2 +- .../heft-jest-plugin/heft-plugin.json | 2 +- .../heft-jest-plugin/src/JestPlugin.ts | 164 ++++-------- .../heft-jest-plugin/src/JestWatchInputs.ts | 192 ++++++++++++++ .../src/test/JestPluginWatch.test.ts | 198 +++++++++++++++ .../src/test/JestWatchInputs.test.ts | 237 ++++++++++++++++++ 6 files changed, 673 insertions(+), 122 deletions(-) create mode 100644 heft-plugins/heft-jest-plugin/src/JestWatchInputs.ts create mode 100644 heft-plugins/heft-jest-plugin/src/test/JestPluginWatch.test.ts create mode 100644 heft-plugins/heft-jest-plugin/src/test/JestWatchInputs.test.ts diff --git a/common/changes/@rushstack/heft-jest-plugin/farzaneh-wm-fix-jest30-watch_2026-11-11-17-11.json b/common/changes/@rushstack/heft-jest-plugin/farzaneh-wm-fix-jest30-watch_2026-11-11-17-11.json index 0e34a7ed54d..90ee7850838 100644 --- a/common/changes/@rushstack/heft-jest-plugin/farzaneh-wm-fix-jest30-watch_2026-11-11-17-11.json +++ b/common/changes/@rushstack/heft-jest-plugin/farzaneh-wm-fix-jest30-watch_2026-11-11-17-11.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/heft-jest-plugin", - "comment": "Fix watch mode (`heft test-watch`) failing on Jest 30 with \"Cannot find module '@jest/core/build/watch.js'\". Heft now drives watch mode and reruns the test suite through Jest's public `runCLI` API when files under the Jest `roots` change.", + "comment": "Fix watch mode (`heft test-watch`) failing on Jest 30 with \"Cannot find module '@jest/core/build/watch.js'\". Heft now drives watch mode and reruns the test suite through Jest's public `runCLI` API when files under the Jest `roots` change, ignoring paths that match `modulePathIgnorePatterns` or `watchPathIgnorePatterns`. Each rerun runs the full test suite, and Jest's interactive watch keys and `watchPlugins` are no longer used in watch mode.", "type": "patch" } ], diff --git a/heft-plugins/heft-jest-plugin/heft-plugin.json b/heft-plugins/heft-jest-plugin/heft-plugin.json index 52c65540566..dc15e842b11 100644 --- a/heft-plugins/heft-jest-plugin/heft-plugin.json +++ b/heft-plugins/heft-jest-plugin/heft-plugin.json @@ -34,7 +34,7 @@ "longName": "--find-related-tests", "parameterKind": "stringList", "argumentName": "SOURCE_FILE", - "description": "Find and run the tests that cover a source file that was passed in as an argument. This corresponds to the \"--findRelatedTests\" parameter in Jest's documentation. This parameter is not compatible with watch mode." + "description": "Find and run the tests that cover a source file that was passed in as an argument. This corresponds to the \"--findRelatedTests\" parameter in Jest's documentation. In watch mode, the same related tests are run again whenever the test inputs change." }, { "longName": "--log-heap-usage", diff --git a/heft-plugins/heft-jest-plugin/src/JestPlugin.ts b/heft-plugins/heft-jest-plugin/src/JestPlugin.ts index fbd4bb31e95..eb3a388096e 100644 --- a/heft-plugins/heft-jest-plugin/src/JestPlugin.ts +++ b/heft-plugins/heft-jest-plugin/src/JestPlugin.ts @@ -16,7 +16,6 @@ import type { IHeftTaskSession, IHeftTaskRunHookOptions, IHeftTaskRunIncrementalHookOptions, - IWatchFileSystem, CommandLineFlagParameter, CommandLineIntegerParameter, CommandLineStringParameter, @@ -29,19 +28,16 @@ import { InheritanceType, PathResolutionMethod } from '@rushstack/heft-config-file'; -import { - FileSystem, - type FolderItem, - Path, - Import, - JsonFile, - Objects, - PackageName -} from '@rushstack/node-core-library'; +import { FileSystem, Path, Import, JsonFile, Objects, PackageName } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import type { IHeftJestReporterOptions } from './HeftJestReporter'; import { jestResolve } from './JestUtils'; +import { + collectTestInputFileTimes, + getJestWatchInputOptions, + haveTestInputsChanged +} from './JestWatchInputs'; import anythingSchema from './schemas/anything.schema.json'; /** @@ -228,7 +224,9 @@ export default class JestPlugin implements IHeftTaskPlugin { return; } - await this.#executeJestAsync(taskSession, heftConfiguration, options, jestArgv); + await this.#runWithNodeEnvAsync(options, taskSession.logger, () => + this.#executeJestAsync(taskSession, heftConfiguration, jestArgv) + ); } /** @@ -253,6 +251,18 @@ export default class JestPlugin implements IHeftTaskPlugin { return; } + // Reading the configuration evaluates JavaScript presets, which may depend on NODE_ENV + await this.#runWithNodeEnvAsync(options, taskSession.logger, () => + this.#runJestWatchIterationAsync(taskSession, heftConfiguration, runIncrementalOptions, jestArgv) + ); + } + + async #runJestWatchIterationAsync( + taskSession: IHeftTaskSession, + heftConfiguration: HeftConfiguration, + runIncrementalOptions: IHeftTaskRunIncrementalHookOptions, + jestArgv: Config.Argv + ): Promise { // Use the normalized configuration, so that the roots and output folders match the ones that Jest // uses after applying presets and defaults. const { readConfigs }: IJestConfigModule = require('jest-config'); @@ -260,61 +270,38 @@ export default class JestPlugin implements IHeftTaskPlugin { // 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 excludedFolderPaths: Set = new Set( - [ - heftConfiguration.tempFolderPath, - globalConfig.coverageDirectory, - ...configs.map((config: Config.ProjectConfig) => config.cacheDirectory) - ].map((folderPath: string) => path.resolve(folderPath)) - ); - const rootPaths: Set = new Set( - configs.flatMap((config: Config.ProjectConfig) => - config.roots.map((root: string) => path.resolve(root)) - ) - ); - - const testInputFileTimes: Map = _collectTestInputFileTimes( + const testInputFileTimes: Map = collectTestInputFileTimes( runIncrementalOptions.watchFs, - rootPaths, - excludedFolderPaths + getJestWatchInputOptions(globalConfig, configs, heftConfiguration.tempFolderPath) ); - const previousTestInputFileTimes: Map | undefined = this.#testInputFileTimes; - let hasChanges: boolean = - !previousTestInputFileTimes || previousTestInputFileTimes.size !== testInputFileTimes.size; - for (const [filePath, time] of testInputFileTimes) { - if (hasChanges) { - break; - } - hasChanges = previousTestInputFileTimes?.get(filePath) !== time; - } - - if (!hasChanges) { + if (!haveTestInputsChanged(this.#testInputFileTimes, testInputFileTimes)) { taskSession.logger.terminal.writeLine(`No pending test runs.`); // Heft clears the task's errors on every run, so report the failure of the previous run again if (this.#lastTestRunError) { - taskSession.logger.emitError(this.#lastTestRunError); + taskSession.logger.emitError( + new Error( + `${this.#lastTestRunError.message} in the previous run, and no test inputs have changed since` + ) + ); } return; } // If Jest throws, the next incremental run must run the tests again this.#testInputFileTimes = undefined; - this.#lastTestRunError = await this.#executeJestAsync(taskSession, heftConfiguration, options, jestArgv); + this.#lastTestRunError = await this.#executeJestAsync(taskSession, heftConfiguration, jestArgv); this.#testInputFileTimes = testInputFileTimes; } async #executeJestAsync( taskSession: IHeftTaskSession, heftConfiguration: HeftConfiguration, - options: IJestPluginOptions, jestArgv: Config.Argv ): Promise { const logger: IScopedLogger = taskSession.logger; const terminal: ITerminal = logger.terminal; - this.#setNodeEnvIfRequested(options, logger); - const { getVersion, runCLI } = await import(`@jest/core`); terminal.writeLine(`Using Jest version ${getVersion()}`); @@ -326,8 +313,6 @@ export default class JestPlugin implements IHeftTaskPlugin { results: jestResults } = await runCLI(jestArgv, [heftConfiguration.buildFolderPath]); - this.#resetNodeEnv(); - let testRunError: Error | undefined; if (jestResults.numFailedTests > 0) { testRunError = new Error( @@ -626,6 +611,20 @@ export default class JestPlugin implements IHeftTaskPlugin { // unset the NODE_ENV only if we have set it if (this.#nodeEnvSet) { delete process.env.NODE_ENV; + this.#nodeEnvSet = false; + } + } + + async #runWithNodeEnvAsync( + options: IJestPluginOptions, + logger: IScopedLogger, + action: () => Promise + ): Promise { + this.#setNodeEnvIfRequested(options, logger); + try { + return await action(); + } finally { + this.#resetNodeEnv(); } } } @@ -695,81 +694,6 @@ function _getHeftJestReporterConfig(reporterOptions: IHeftJestReporterOptions): ]; } -/** - * Returns the modified time of each file under the root folders, and tracks the files with `watchFs` so that - * Heft schedules another incremental run when any of them change. The times are read directly rather than - * through `watchGlobAsync`, because its change detection relies on the watcher's cached times, which may not - * yet include files that the build phase wrote just before this run. - * - * Heft watches a folder that was read with `watchFs` together with everything under it. A folder that contains - * an excluded folder is therefore listed without tracking, and only its files and its other subfolders are - * tracked. New files that are added directly to such a folder do not trigger a run. - */ -function _collectTestInputFileTimes( - watchFs: IWatchFileSystem, - rootPaths: Iterable, - excludedFolderPaths: ReadonlySet -): Map { - const fileTimes: Map = new Map(); - - const trackFolder = (folderPath: string): void => { - try { - watchFs.readdirSync(folderPath); - } catch { - // A missing folder is watched for its creation - } - }; - - // Returns true if the folder contains no excluded folders, so that it can be tracked as a whole - const collectFolder = (folderPath: string): boolean => { - let folderItems: FolderItem[]; - try { - folderItems = FileSystem.readFolderItems(folderPath); - } catch { - return true; - } - - const filePaths: string[] = []; - const subfolderPaths: string[] = []; - let containsExcludedFolder: boolean = false; - for (const folderItem of folderItems) { - const itemPath: string = path.join(folderPath, folderItem.name); - if (!folderItem.isDirectory()) { - filePaths.push(itemPath); - } else if (folderItem.name === 'node_modules' || excludedFolderPaths.has(itemPath)) { - containsExcludedFolder = true; - } else if (collectFolder(itemPath)) { - subfolderPaths.push(itemPath); - } else { - containsExcludedFolder = true; - } - } - - for (const filePath of filePaths) { - try { - fileTimes.set(filePath, watchFs.statSync(filePath).mtimeMs); - } catch { - // The file was deleted while reading the folder - } - } - - if (containsExcludedFolder) { - for (const subfolderPath of subfolderPaths) { - trackFolder(subfolderPath); - } - } - return !containsExcludedFolder; - }; - - for (const rootPath of rootPaths) { - if (!excludedFolderPaths.has(rootPath) && collectFolder(rootPath)) { - trackFolder(rootPath); - } - } - - return fileTimes; -} - /** * Resolve all specified properties to an absolute path using Jest resolution. In addition, the following * transforms will be applied to the provided propertyValue before resolution: diff --git a/heft-plugins/heft-jest-plugin/src/JestWatchInputs.ts b/heft-plugins/heft-jest-plugin/src/JestWatchInputs.ts new file mode 100644 index 00000000000..5d90bd49f1d --- /dev/null +++ b/heft-plugins/heft-jest-plugin/src/JestWatchInputs.ts @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import type { Config } from '@jest/types'; + +import type { IWatchFileSystem } from '@rushstack/heft'; +import { FileSystem, type FolderItem } from '@rushstack/node-core-library'; + +/** + * Folder names that are never test inputs. `jest-haste-map` always ignores the version control folders. + */ +const IGNORED_FOLDER_NAMES: ReadonlySet = new Set(['node_modules', '.git', '.hg', '.sl']); + +/** + * Describes which files are test inputs in watch mode. + */ +export interface IJestWatchInputOptions { + /** + * The folders that contain the test inputs + */ + rootPaths: ReadonlySet; + /** + * Folders that are not test inputs, because Jest or Heft write to them while running + */ + excludedFolderPaths: ReadonlySet; + /** + * Patterns for paths that are not test inputs, matched against absolute paths + */ + ignorePatterns: readonly RegExp[]; +} + +/** + * Determines the test inputs from the normalized Jest configuration. Like Jest's own watch mode, this ignores + * paths that match `modulePathIgnorePatterns` or `watchPathIgnorePatterns`, and the cache folder. + */ +export function getJestWatchInputOptions( + globalConfig: Config.GlobalConfig, + configs: readonly Config.ProjectConfig[], + tempFolderPath: string +): IJestWatchInputOptions { + const rootPaths: Set = new Set(); + const excludedFolderPaths: Set = new Set([ + _getRealPath(tempFolderPath), + _getRealPath(globalConfig.coverageDirectory) + ]); + const ignorePatterns: RegExp[] = []; + for (const config of configs) { + for (const root of config.roots) { + rootPaths.add(_getRealPath(root)); + } + excludedFolderPaths.add(_getRealPath(config.cacheDirectory)); + for (const pattern of [...config.modulePathIgnorePatterns, ...config.watchPathIgnorePatterns]) { + ignorePatterns.push(new RegExp(pattern)); + } + } + + return { rootPaths, excludedFolderPaths, ignorePatterns }; +} + +/** + * Returns the modified time of each test input file, and tracks the files with `watchFs` so that Heft schedules + * another incremental run when any of them change. The times are read directly rather than through + * `watchGlobAsync`, because its change detection relies on the watcher's cached times, which may not yet include + * files that the build phase wrote just before this run. + * + * Heft watches a folder that was read with `watchFs` together with everything under it, and its watcher does + * not support ignored paths. A folder that contains an excluded or ignored path is therefore listed without + * tracking, and only its test input files and its other subfolders are tracked. Files and subfolders that are + * later added directly to such a folder do not trigger a run. This affects roots that contain `node_modules`, + * ignored paths or Jest's output folders, such as the project folder. + */ +export function collectTestInputFileTimes( + watchFs: IWatchFileSystem, + options: IJestWatchInputOptions +): Map { + const { rootPaths, excludedFolderPaths, ignorePatterns } = options; + const fileTimes: Map = new Map(); + + const isIgnored = (itemPath: string, isFolder: boolean): boolean => + ignorePatterns.some( + (pattern: RegExp) => pattern.test(itemPath) || (isFolder && pattern.test(itemPath + path.sep)) + ); + + const isExcludedFolder = (folderPath: string, folderName: string): boolean => + IGNORED_FOLDER_NAMES.has(folderName) || + excludedFolderPaths.has(folderPath) || + isIgnored(folderPath, true); + + const trackFolder = (folderPath: string): void => { + try { + watchFs.readdirSync(folderPath); + } catch { + // A missing folder is watched for its creation + } + }; + + // Returns true if the folder contains no excluded or ignored paths, so that it can be tracked as a whole + const collectFolder = (folderPath: string): boolean => { + let folderItems: FolderItem[]; + try { + folderItems = FileSystem.readFolderItems(folderPath); + } catch { + return true; + } + + const filePaths: string[] = []; + const subfolderPaths: string[] = []; + let containsExcludedPath: boolean = false; + for (const folderItem of folderItems) { + const itemPath: string = path.join(folderPath, folderItem.name); + if (!folderItem.isDirectory()) { + if (isIgnored(itemPath, false)) { + containsExcludedPath = true; + } else { + filePaths.push(itemPath); + } + } else if (isExcludedFolder(itemPath, folderItem.name)) { + containsExcludedPath = true; + } else if (collectFolder(itemPath)) { + subfolderPaths.push(itemPath); + } else { + containsExcludedPath = true; + } + } + + for (const filePath of filePaths) { + try { + fileTimes.set(filePath, watchFs.statSync(filePath).mtimeMs); + } catch { + // The file was deleted while reading the folder + } + } + + if (containsExcludedPath) { + for (const subfolderPath of subfolderPaths) { + trackFolder(subfolderPath); + } + } + return !containsExcludedPath; + }; + + // Like Jest's crawler, apply the ignore patterns only to the contents of the roots + for (const rootPath of rootPaths) { + if (!excludedFolderPaths.has(rootPath) && collectFolder(rootPath)) { + trackFolder(rootPath); + } + } + + return fileTimes; +} + +/** + * Returns true if any test input file was added, deleted or modified, or if there was no previous run. + */ +export function haveTestInputsChanged( + previousFileTimes: ReadonlyMap | undefined, + fileTimes: ReadonlyMap +): boolean { + if (!previousFileTimes || previousFileTimes.size !== fileTimes.size) { + return true; + } + for (const [filePath, time] of fileTimes) { + if (previousFileTimes.get(filePath) !== time) { + return true; + } + } + return false; +} + +/** + * Jest resolves symbolic links in `rootDir`, so paths that are compared with the paths that Jest uses must be + * resolved too. For a path that does not exist yet, the closest existing parent folder is resolved. + */ +function _getRealPath(fileOrFolderPath: string): string { + const resolvedPath: string = path.resolve(fileOrFolderPath); + const missingSegments: string[] = []; + let existingPath: string = resolvedPath; + for (;;) { + try { + return path.join(FileSystem.getRealPath(existingPath), ...missingSegments); + } catch { + const parentPath: string = path.dirname(existingPath); + if (parentPath === existingPath) { + return resolvedPath; + } + missingSegments.unshift(path.basename(existingPath)); + existingPath = parentPath; + } + } +} diff --git a/heft-plugins/heft-jest-plugin/src/test/JestPluginWatch.test.ts b/heft-plugins/heft-jest-plugin/src/test/JestPluginWatch.test.ts new file mode 100644 index 00000000000..605d67f7187 --- /dev/null +++ b/heft-plugins/heft-jest-plugin/src/test/JestPluginWatch.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +jest.mock('@jest/core', () => ({ + getVersion: () => '30.0.0', + runCLI: jest.fn() +})); +jest.mock('jest-config', () => ({ + readConfigs: jest.fn() +})); + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { + CommandLineParameter, + HeftConfiguration, + IHeftTaskRunIncrementalHookOptions, + IHeftTaskSession, + IScopedLogger +} from '@rushstack/heft'; +import { FileSystem } from '@rushstack/node-core-library'; + +import JestPlugin from '../JestPlugin'; + +const TEST_FOLDER_PATH: string = path.resolve(__dirname, '../../temp/jest-plugin-watch-test'); +const TEST_FILE_PATH: string = path.join(TEST_FOLDER_PATH, 'lib', 'a.test.js'); + +const { runCLI: mockRunCLI } = jest.requireMock('@jest/core') as { runCLI: jest.Mock }; +const { readConfigs: mockReadConfigs } = jest.requireMock('jest-config') as { readConfigs: jest.Mock }; + +interface IWatchTestContext { + runIncrementalAsync: () => Promise; + logger: { terminal: { writeLine: jest.Mock }; emitError: jest.Mock; emitWarning: jest.Mock }; +} + +function createWatchTestContext(): IWatchTestContext { + let runIncrementalHook: ((options: IHeftTaskRunIncrementalHookOptions) => Promise) | undefined; + const logger: IWatchTestContext['logger'] = { + terminal: { writeLine: jest.fn() }, + emitError: jest.fn(), + emitWarning: jest.fn() + }; + + function getParameter(): T { + return { value: undefined, values: [] } as unknown as T; + } + const taskSession: IHeftTaskSession = { + logger: logger as unknown as IScopedLogger, + tempFolderPath: path.join(TEST_FOLDER_PATH, 'temp', 'test'), + parameters: { + verbose: false, + debug: false, + getChoiceParameter: getParameter, + getChoiceListParameter: getParameter, + getFlagParameter: getParameter, + getIntegerParameter: getParameter, + getIntegerListParameter: getParameter, + getStringParameter: getParameter, + getStringListParameter: getParameter + }, + hooks: { + run: { tapPromise: () => {} }, + runIncremental: { + tapPromise: (name: string, hook: (options: IHeftTaskRunIncrementalHookOptions) => Promise) => { + runIncrementalHook = hook; + } + } + } + } as unknown as IHeftTaskSession; + const heftConfiguration: HeftConfiguration = { + buildFolderPath: TEST_FOLDER_PATH, + tempFolderPath: path.join(TEST_FOLDER_PATH, 'temp'), + projectPackageJson: { name: 'test-project' } + } as unknown as HeftConfiguration; + + new JestPlugin().apply(taskSession, heftConfiguration, { + disableConfigurationModuleResolution: true, + debugHeftReporter: true + }); + + const runIncrementalOptions: IHeftTaskRunIncrementalHookOptions = { + watchFs: { + readdirSync: (folderPath: string) => fs.readdirSync(folderPath), + statSync: (filePath: string) => fs.statSync(filePath) + }, + requestRun: () => {} + } as unknown as IHeftTaskRunIncrementalHookOptions; + + return { + runIncrementalAsync: () => runIncrementalHook!(runIncrementalOptions), + logger + }; +} + +function mockJestResults(numFailedTests: number): void { + mockRunCLI.mockImplementation(async () => ({ + globalConfig: {}, + results: { numFailedTests, numFailedTestSuites: 0 } + })); +} + +function getErrorMessages(context: IWatchTestContext): string[] { + return context.logger.emitError.mock.calls.map(([error]: [Error]) => error.message); +} + +describe('JestPlugin watch mode', () => { + const originalNodeEnv: string | undefined = process.env.NODE_ENV; + + beforeEach(() => { + FileSystem.ensureEmptyFolder(TEST_FOLDER_PATH); + FileSystem.writeFile(path.join(TEST_FOLDER_PATH, 'config', 'jest.config.json'), '{}', { + ensureFolderExists: true + }); + FileSystem.writeFile(TEST_FILE_PATH, '', { ensureFolderExists: true }); + + mockRunCLI.mockReset(); + mockReadConfigs.mockReset(); + mockReadConfigs.mockImplementation(async () => ({ + globalConfig: { coverageDirectory: path.join(TEST_FOLDER_PATH, 'coverage') }, + configs: [ + { + roots: [path.join(TEST_FOLDER_PATH, 'lib')], + cacheDirectory: path.join(TEST_FOLDER_PATH, 'temp', 'test'), + modulePathIgnorePatterns: [], + watchPathIgnorePatterns: [] + } + ] + })); + }); + + afterEach(() => { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + }); + + afterAll(() => { + FileSystem.deleteFolder(TEST_FOLDER_PATH); + }); + + it('runs the tests only when a test input changed', async () => { + mockJestResults(0); + const context: IWatchTestContext = createWatchTestContext(); + + await context.runIncrementalAsync(); + expect(mockRunCLI).toHaveBeenCalledTimes(1); + + await context.runIncrementalAsync(); + expect(mockRunCLI).toHaveBeenCalledTimes(1); + expect(context.logger.terminal.writeLine).toHaveBeenCalledWith('No pending test runs.'); + + const futureTime: Date = new Date(Date.now() + 60000); + fs.utimesSync(TEST_FILE_PATH, futureTime, futureTime); + await context.runIncrementalAsync(); + expect(mockRunCLI).toHaveBeenCalledTimes(2); + expect(getErrorMessages(context)).toEqual([]); + }); + + it('reports the failure of the previous run again when no test inputs changed', async () => { + mockJestResults(1); + const context: IWatchTestContext = createWatchTestContext(); + + await context.runIncrementalAsync(); + await context.runIncrementalAsync(); + + expect(mockRunCLI).toHaveBeenCalledTimes(1); + expect(getErrorMessages(context)).toEqual([ + '1 Jest test failed', + '1 Jest test failed in the previous run, and no test inputs have changed since' + ]); + }); + + it('sets NODE_ENV while reading the configuration, and restores it and reruns after Jest throws', async () => { + delete process.env.NODE_ENV; + const nodeEnvValues: (string | undefined)[] = []; + const readConfigsImplementation: (...args: unknown[]) => unknown = + mockReadConfigs.getMockImplementation()!; + mockReadConfigs.mockImplementation(async (...args: unknown[]) => { + nodeEnvValues.push(process.env.NODE_ENV); + return readConfigsImplementation(...args); + }); + mockRunCLI.mockRejectedValueOnce(new Error('Jest failed to start')); + const context: IWatchTestContext = createWatchTestContext(); + + await expect(context.runIncrementalAsync()).rejects.toThrow('Jest failed to start'); + expect(process.env.NODE_ENV).toBeUndefined(); + + mockJestResults(0); + await context.runIncrementalAsync(); + + expect(mockRunCLI).toHaveBeenCalledTimes(2); + expect(nodeEnvValues).toEqual(['test', 'test']); + expect(process.env.NODE_ENV).toBeUndefined(); + }); +}); diff --git a/heft-plugins/heft-jest-plugin/src/test/JestWatchInputs.test.ts b/heft-plugins/heft-jest-plugin/src/test/JestWatchInputs.test.ts new file mode 100644 index 00000000000..d5467c21d5b --- /dev/null +++ b/heft-plugins/heft-jest-plugin/src/test/JestWatchInputs.test.ts @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { Config } from '@jest/types'; + +import type { IWatchFileSystem } from '@rushstack/heft'; +import { FileSystem } from '@rushstack/node-core-library'; + +import { + collectTestInputFileTimes, + getJestWatchInputOptions, + haveTestInputsChanged, + type IJestWatchInputOptions +} from '../JestWatchInputs'; + +const TEST_FOLDER_PATH: string = path.resolve(__dirname, '../../temp/jest-watch-inputs-test'); + +class RecordingWatchFileSystem { + public readonly trackedFolderPaths: string[] = []; + + public readdirSync(folderPath: string): string[] { + this.trackedFolderPaths.push(folderPath); + return fs.readdirSync(folderPath); + } + + public statSync(filePath: string): fs.Stats { + return fs.statSync(filePath); + } +} + +function createFiles(rootPath: string, relativePaths: string[]): void { + for (const relativePath of relativePaths) { + FileSystem.writeFile(path.join(rootPath, relativePath), '', { ensureFolderExists: true }); + } +} + +function collect(options: Partial & Pick): { + fileTimes: Map; + trackedFolderPaths: string[]; +} { + const watchFs: RecordingWatchFileSystem = new RecordingWatchFileSystem(); + const fileTimes: Map = collectTestInputFileTimes(watchFs as unknown as IWatchFileSystem, { + excludedFolderPaths: new Set(), + ignorePatterns: [], + ...options + }); + return { fileTimes, trackedFolderPaths: watchFs.trackedFolderPaths.sort() }; +} + +describe('JestWatchInputs', () => { + let rootPath: string; + + beforeEach(() => { + FileSystem.ensureEmptyFolder(TEST_FOLDER_PATH); + rootPath = path.join(TEST_FOLDER_PATH, 'root'); + }); + + afterAll(() => { + FileSystem.deleteFolder(TEST_FOLDER_PATH); + }); + + describe(collectTestInputFileTimes.name, () => { + it('tracks a root that contains only test inputs as a whole', () => { + createFiles(rootPath, ['a.js', 'sub/b.js']); + + const { fileTimes, trackedFolderPaths } = collect({ rootPaths: new Set([rootPath]) }); + + expect(Array.from(fileTimes.keys()).sort()).toEqual([ + path.join(rootPath, 'a.js'), + path.join(rootPath, 'sub', 'b.js') + ]); + expect(trackedFolderPaths).toEqual([rootPath]); + }); + + it('skips excluded and ignored paths, and does not track the folders that contain them', () => { + createFiles(rootPath, [ + 'a.js', + 'sub/b.js', + 'node_modules/x.js', + '.git/HEAD', + 'coverage/lcov.info', + 'e2e/c.js', + 'report.xml' + ]); + + const { fileTimes, trackedFolderPaths } = collect({ + rootPaths: new Set([rootPath]), + excludedFolderPaths: new Set([path.join(rootPath, 'coverage')]), + ignorePatterns: [/[\\/]e2e[\\/]/, /report\.xml$/] + }); + + expect(Array.from(fileTimes.keys()).sort()).toEqual([ + path.join(rootPath, 'a.js'), + path.join(rootPath, 'sub', 'b.js') + ]); + expect(trackedFolderPaths).toEqual([path.join(rootPath, 'sub')]); + }); + + it('tracks the siblings of a nested excluded folder', () => { + createFiles(rootPath, ['lib/x.js', 'lib/inner/y.js', 'lib/coverage/lcov.info', 'src/z.js']); + + const { fileTimes, trackedFolderPaths } = collect({ + rootPaths: new Set([rootPath]), + excludedFolderPaths: new Set([path.join(rootPath, 'lib', 'coverage')]) + }); + + expect(Array.from(fileTimes.keys()).sort()).toEqual([ + path.join(rootPath, 'lib', 'inner', 'y.js'), + path.join(rootPath, 'lib', 'x.js'), + path.join(rootPath, 'src', 'z.js') + ]); + expect(trackedFolderPaths).toEqual([path.join(rootPath, 'lib', 'inner'), path.join(rootPath, 'src')]); + }); + + it('does not apply the ignore patterns to a root itself', () => { + createFiles(rootPath, ['a.js']); + + const { fileTimes, trackedFolderPaths } = collect({ + rootPaths: new Set([rootPath]), + ignorePatterns: [/[\\/]root[\\/]?$/] + }); + + expect(Array.from(fileTimes.keys())).toEqual([path.join(rootPath, 'a.js')]); + expect(trackedFolderPaths).toEqual([rootPath]); + }); + + it('watches a missing root for its creation', () => { + const { fileTimes, trackedFolderPaths } = collect({ rootPaths: new Set([rootPath]) }); + + expect(fileTimes.size).toBe(0); + expect(trackedFolderPaths).toEqual([rootPath]); + }); + + it('skips a root that is an excluded folder', () => { + createFiles(rootPath, ['a.js']); + + const { fileTimes, trackedFolderPaths } = collect({ + rootPaths: new Set([rootPath]), + excludedFolderPaths: new Set([rootPath]) + }); + + expect(fileTimes.size).toBe(0); + expect(trackedFolderPaths).toEqual([]); + }); + }); + + describe(haveTestInputsChanged.name, () => { + const fileTimes: Map = new Map([ + ['a.js', 1], + ['b.js', 2] + ]); + + it('returns true if there was no previous run', () => { + expect(haveTestInputsChanged(undefined, fileTimes)).toBe(true); + }); + + it('returns false if no file changed', () => { + expect(haveTestInputsChanged(new Map(fileTimes), fileTimes)).toBe(false); + }); + + it.each([ + [ + 'modified', + [ + ['a.js', 1], + ['b.js', 3] + ] + ], + [ + 'added', + [ + ['a.js', 1], + ['b.js', 2], + ['c.js', 3] + ] + ], + ['deleted', [['a.js', 1]]], + [ + 'renamed', + [ + ['a.js', 1], + ['c.js', 2] + ] + ] + ] as [string, [string, number][]][])('returns true if a file was %s', (change, entries) => { + expect(haveTestInputsChanged(fileTimes, new Map(entries))).toBe(true); + }); + }); + + describe(getJestWatchInputOptions.name, () => { + it('resolves symbolic links and combines the ignore patterns of all projects', () => { + const realFolderPath: string = path.join(TEST_FOLDER_PATH, 'real'); + const linkFolderPath: string = path.join(TEST_FOLDER_PATH, 'link'); + FileSystem.ensureFolder(path.join(realFolderPath, 'lib')); + fs.symlinkSync(realFolderPath, linkFolderPath, 'junction'); + const resolvedFolderPath: string = fs.realpathSync(realFolderPath); + + const globalConfig: Partial = { + coverageDirectory: path.join(linkFolderPath, 'coverage') + }; + const configs: Partial[] = [ + { + roots: [path.join(linkFolderPath, 'lib')], + cacheDirectory: path.join(linkFolderPath, 'temp', 'test', 'jest'), + modulePathIgnorePatterns: ['a'], + watchPathIgnorePatterns: ['b'] + }, + { + roots: [path.join(linkFolderPath, 'missing')], + cacheDirectory: path.join(linkFolderPath, 'temp', 'test', 'jest'), + modulePathIgnorePatterns: [], + watchPathIgnorePatterns: ['c'] + } + ]; + + const options: IJestWatchInputOptions = getJestWatchInputOptions( + globalConfig as Config.GlobalConfig, + configs as Config.ProjectConfig[], + path.join(linkFolderPath, 'temp') + ); + + expect(Array.from(options.rootPaths)).toEqual([ + path.join(resolvedFolderPath, 'lib'), + path.join(resolvedFolderPath, 'missing') + ]); + expect(Array.from(options.excludedFolderPaths)).toEqual([ + path.join(resolvedFolderPath, 'temp'), + path.join(resolvedFolderPath, 'coverage'), + path.join(resolvedFolderPath, 'temp', 'test', 'jest') + ]); + expect(options.ignorePatterns.map((pattern: RegExp) => pattern.source)).toEqual(['a', 'b', 'c']); + }); + }); +});