Skip to content

fix: watch mode using chokidar v4 (#5355) #5379

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
24988ef
fix: watch mode using chokidar v4 (#5355)
Arnesfield Jun 18, 2025
4f3150d
refactor: fix watch mode using chokidar v4 (#5355)
Arnesfield Jun 19, 2025
5e39fae
docs: fix jsdoc description for `normalizeGlob()`
Arnesfield Jun 19, 2025
7d716db
Merge branch 'main' into fix-5355-watch-mode
JoshuaKGoldberg Jul 3, 2025
3660ed2
test: add tests for watch mode
Arnesfield Jul 3, 2025
bad7b36
test: fix test for multiple watched files
Arnesfield Jul 3, 2025
539d4de
Merge branch 'main' into fix-5355-watch-mode
Arnesfield Jul 12, 2025
fcc839f
test: remove test for multiple watched files
Arnesfield Jul 12, 2025
4860486
Merge branch 'main' into fix-5355-watch-mode
Arnesfield Jul 20, 2025
0b1babc
fix: update watch mode implementation
Arnesfield Jul 17, 2025
00bb46a
Merge branch 'main' into fix-5355-watch-mode
mark-wiemer Jul 22, 2025
5514fe2
Refactor, add debug
mark-wiemer Jul 27, 2025
eb2792c
Remove verbose debug
mark-wiemer Jul 27, 2025
2a9c88c
Add debugs
mark-wiemer Jul 27, 2025
9e2a41e
Remove castArray, capitalize Chokidar
mark-wiemer Jul 27, 2025
caca650
Replace for-of with Array.some
mark-wiemer Jul 27, 2025
f897e65
Add test for false literal matches
mark-wiemer Jul 27, 2025
fea5476
Document new test
mark-wiemer Jul 27, 2025
84b27a9
Merge branch 'main' into fix-5355-watch-mode
mark-wiemer Jul 27, 2025
ca7d630
Remove unused function
mark-wiemer Jul 27, 2025
c3dd839
Merge remote-tracking branch 'arnes/fix-5355-watch-mode' into arnes-f…
mark-wiemer Jul 27, 2025
8af5d0b
Remove createPathFilter and createPathMatcher from exports
mark-wiemer Jul 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 268 additions & 42 deletions lib/cli/watch-run.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ const logSymbols = require('log-symbols');
const debug = require('debug')('mocha:cli:watch');
const path = require('node:path');
const chokidar = require('chokidar');
const glob = require('glob');
const isPathInside = require('is-path-inside');
const {minimatch} = require('minimatch');
const Context = require('../context');
const collectFiles = require('./collect-files');
const glob = require('glob');

/**
* Exports the `watchRun` function that runs mocha in "watch" mode.
Expand Down Expand Up @@ -137,44 +139,226 @@ exports.watchRun = (mocha, {watchFiles, watchIgnore}, fileCollectParams) => {
});
};

class GlobFilesTracker {
constructor(watchFiles, watchIgnore) {
this.watchFilesSet = new Set();
this.watchFiles = watchFiles;
this.watchIgnore = watchIgnore;
}

regenerate() {
const watchIgnoreSet = new Set();
for (const pattern of this.watchIgnore) {
glob.sync(pattern, { dot: true }).forEach(filePath => watchIgnoreSet.add(filePath));
}
/**
* Extracts out paths without the glob part, the directory paths,
* and the paths for matching from the provided glob paths.
* @param {string[]} globPaths The list of glob paths to create a filter for.
* @param {string} basePath The path where mocha is run (e.g., current working directory).
* @returns {PathFilter} Object to filter paths.
* @ignore
* @private
*/
exports.createPathFilter = (globPaths, basePath) => {
debug('creating path filter from glob paths: %s', globPaths);

/**
* The resulting object to filter paths.
* @type {PathFilter}
*/
const res = {
dir: {paths: new Set(), globs: new Set()},
match: {paths: new Set(), globs: new Set()}
};

const globOpts = {
// for checking if a path ends with `/**/*`
const globEnd = path.join(path.sep, '**', '*');

/**
* The current glob pattern to check.
* @type {typeof Pattern[]} The `Pattern` class is not exported by the `glob` package.
* Ref [link](../../node_modules/glob/dist/commonjs/pattern.d.ts)
*/
const patterns = globPaths.reduce((acc, globPath) => {
const globInstance = new glob.Glob(globPath, {
dot: true,
ignore: {
childrenIgnored: pathToCheck => watchIgnoreSet.has(pathToCheck.relative())
magicalBraces: true,
windowsPathsNoEscape: true
});
return acc.concat(globInstance.patterns);
}, []);

// each pattern will have its own path because of the `magicalBraces` option
for (const pattern of patterns) {
debug('processing glob pattern: %s', pattern.globString());

/**
* Path segments before the glob pattern.
* @type {string[]}
*/
const segments = [];

/**
* The current glob pattern to check.
* @type {typeof Pattern | null} The `Pattern` class is not exported by the `glob` package.
*/
let currentPattern = pattern;
let isGlob = false;

do {
// save string patterns until a non-string (glob or regexp) is matched
const entry = currentPattern.pattern();
const serializedEntry = JSON.stringify(entry);
debug('processing glob subpattern: %s', serializedEntry);
if (typeof entry !== 'string') {
debug('found glob or regexp pattern: %s', serializedEntry);
// if the entry is a glob
isGlob = true;
break;
}
};

this.watchFilesSet.clear();
for (const pattern of this.watchFiles) {
glob.sync(pattern, globOpts).forEach(pathToCheck => {
if (watchIgnoreSet.has(pathToCheck)) {
return;
}
this.watchFilesSet.add(pathToCheck);
});
segments.push(entry);

// go to next pattern
} while ((currentPattern = currentPattern.rest()));
if (!isGlob) {
debug('all subpatterns of %s processed', JSON.stringify(pattern.globString()));
}

// match `cleanPath` (path without the glob part) and its subdirectories
const cleanPath = path.resolve(basePath, ...segments);
debug('clean path: %s', cleanPath);
res.dir.paths.add(cleanPath);
res.dir.globs.add(path.resolve(cleanPath, '**', '*'));

// match `absPath` and all of its contents
const absPath = path.resolve(basePath, pattern.globString());
debug('absolute path: %s', absPath);
(isGlob ? res.match.globs : res.match.paths).add(absPath);

// always include `/**/*` to the full pattern for matching
// since it's possible for the last path segment to be a directory
if (!absPath.endsWith(globEnd)) {
res.match.globs.add(path.resolve(absPath, '**', '*'));
}
}

has(filePath) {
return this.watchFilesSet.has(filePath)
debug("returning path filter: %o", res);
return res;
}

/**
* Checks if the provided path matches with the path pattern.
* @param {string} filePath The path to match.
* @param {PathPattern} pattern The path pattern for matching.
* @param {boolean} [matchParent] Treats the provided path as a match if it's a valid parent directory from the list of paths.
* @returns {boolean} Determines if the provided path matches the pattern.
* @ignore
* @private
*/
function matchPattern(filePath, pattern, matchParent) {
if (pattern.paths.has(filePath)) {
return true;
}

if (matchParent) {
for (const childPath of pattern.paths) {
if (isPathInside(childPath, filePath)) {
return true;
}
}
}

return Array.from(pattern.globs).some(globPath =>
minimatch(filePath, globPath, {dot: true, windowsPathsNoEscape: true})
);
}

/**
* Bootstraps a chokidar watcher. Handles keyboard input & signals
* Creates an object for matching allowed or ignored file paths.
* @param {PathFilter} allowed The filter for allowed paths.
* @param {PathFilter} ignored The filter for ignored paths.
* @param {string} basePath The path where mocha is run (e.g., current working directory).
* @returns {PathMatcher} The object for matching paths.
* @ignore
* @private
*/
exports.createPathMatcher = (allowed, ignored, basePath) => {
debug('creating path matcher from allowed: %o, ignored: %o', allowed, ignored);

/**
* Cache of known file paths processed by `matcher.allow()`.
* @type {Map<string, boolean>}
*/
const allowCache = new Map();

/**
* Cache of known file paths processed by `matcher.ignore()`.
* @type {Map<string, boolean>}
*/
const ignoreCache = new Map();

const MAX_CACHE_SIZE = 1000;

/**
* Performs a `map.set()` but will clear the map whenever the limit is reached.
* @param {Map<string, boolean>} map The map to use.
* @param {string} key The key to use.
* @param {boolean} value The value to set.
*/
function cache(map, key, value) {
if (map.size >= MAX_CACHE_SIZE) {
map.clear();
}
map.set(key, value);
}

/**
* @type {PathMatcher}
*/
const matcher = {
allow(filePath) {
let allow = allowCache.get(filePath);
if (allow !== undefined) {
return allow;
}

allow = matchPattern(filePath, allowed.match);
cache(allowCache, filePath, allow);
return allow;
},

ignore(filePath, stats) {
// Chokidar calls the ignore match function twice:
// once without `stats` and again with `stats`
// see `ignored` under https://github.com/paulmillr/chokidar?tab=readme-ov-file#path-filtering
// note that the second call can also have no `stats` if the `filePath` does not exist
// in which case, allow the nonexistent path since it may be created later
if (!stats) {
return false;
}

// resolve to ensure correct absolute path since, for some reason,
// Chokidar paths for the ignore match function use slashes `/` even for Windows
filePath = path.resolve(basePath, filePath);

let ignore = ignoreCache.get(filePath);
if (ignore !== undefined) {
return ignore;
}

// `filePath` ignore conditions:
// - check if it's ignored from the `ignored` path patterns
// - otherwise, check if it's not ignored via `matcher.allow()` to also cache the result
// - if no match was found and `filePath` is a directory,
// check from the allowed directory paths if it's a valid
// parent directory or if it matches any of the allowed patterns
// since ignoring directories will have Chokidar ignore their contents
// which we may need to watch changes for
ignore =
matchPattern(filePath, ignored.match) ||
(!matcher.allow(filePath) &&
(!stats.isDirectory() || !matchPattern(filePath, allowed.dir, true)));

cache(ignoreCache, filePath, ignore);
return ignore;
}
};

return matcher;
}

/**
* Bootstraps a Chokidar watcher. Handles keyboard input & signals
* @param {Mocha} mocha - Mocha instance
* @param {Object} opts
* @param {BeforeWatchRun} [opts.beforeRun] - Function to call before
Expand All @@ -198,36 +382,46 @@ const createWatcher = (
watchFiles = fileCollectParams.extension.map(ext => `**/*.${ext}`);
}

debug('watching files: %s', watchFiles);
debug('ignoring files matching: %s', watchIgnore);
let globalFixtureContext;

// we handle global fixtures manually
mocha.enableGlobalSetup(false).enableGlobalTeardown(false);

const tracker = new GlobFilesTracker(watchFiles, watchIgnore);
tracker.regenerate();

const watcher = chokidar.watch('.', {
ignoreInitial: true
// glob file paths are no longer supported by Chokidar since v4
// first, strip the glob paths from `watchFiles` for Chokidar to watch
// then, create path patterns from `watchFiles` and `watchIgnore`
// to determine if the files should be allowed or ignored
// by the Chokidar `ignored` match function

const basePath = process.cwd();
const allowed = this.createPathFilter(watchFiles, basePath);
const ignored = this.createPathFilter(watchIgnore, basePath);
const matcher = this.createPathMatcher(allowed, ignored, basePath);

// Chokidar has to watch the directory paths in case new files are created
const watcher = chokidar.watch(Array.from(allowed.dir.paths), {
ignoreInitial: true,
ignored: matcher.ignore
});

const rerunner = createRerunner(mocha, watcher, {
beforeRun
});

watcher.on('ready', async () => {
debug('watcher ready');
if (!globalFixtureContext) {
debug('triggering global setup');
globalFixtureContext = await mocha.runGlobalSetup();
}
rerunner.run();
});

watcher.on('all', (event, filePath) => {
if (event === 'add') {
tracker.regenerate();
}
if (tracker.has(filePath)) {
watcher.on('all', (_event, filePath) => {
// only allow file paths that match the allowed patterns
if (matcher.allow(filePath)) {
rerunner.scheduleRun();
}
});
Expand Down Expand Up @@ -286,7 +480,7 @@ const createWatcher = (
* Create an object that allows you to rerun tests on the mocha instance.
*
* @param {Mocha} mocha - Mocha instance
* @param {FSWatcher} watcher - chokidar `FSWatcher` instance
* @param {FSWatcher} watcher - Chokidar `FSWatcher` instance
* @param {Object} [opts] - Options!
* @param {BeforeWatchRun} [opts.beforeRun] - Function to call before `mocha.run()`
* @returns {Rerunner}
Expand Down Expand Up @@ -345,9 +539,9 @@ const createRerunner = (mocha, watcher, {beforeRun} = {}) => {
};

/**
* Return the list of absolute paths watched by a chokidar watcher.
* Return the list of absolute paths watched by a Chokidar watcher.
*
* @param watcher - Instance of a chokidar watcher
* @param watcher - Instance of a Chokidar watcher
* @return {string[]} - List of absolute paths
* @ignore
* @private
Expand Down Expand Up @@ -391,7 +585,7 @@ const eraseLine = () => {

/**
* Blast all of the watched files out of `require.cache`
* @param {FSWatcher} watcher - chokidar FSWatcher
* @param {FSWatcher} watcher - Chokidar FSWatcher
* @ignore
* @private
*/
Expand Down Expand Up @@ -419,3 +613,35 @@ const blastCache = watcher => {
* @property {Function} run - Calls `mocha.run()`
* @property {Function} scheduleRun - Schedules another call to `run`
*/

/**
* Object containing paths (without globs) and glob paths for matching.
* @private
* @typedef {Object} PathPattern
* @property {Set<string>} paths Set of absolute paths without globs.
* @property {Set<string>} globs Set of absolute glob paths.
*/

/**
* Object containing path patterns for filtering from the provided glob paths.
* @private
* @typedef {Object} PathFilter
* @property {PathPattern} dir Path patterns for directories.
* @property {PathPattern} match Path patterns for matching.
*/

/**
* Checks if the file path matches the allowed patterns.
* @private
* @callback AllowMatchFunction
* @param {string} filePath The file path to check.
* @returns {boolean} Determines if there was a match.
*/

/**
* Object for matching paths to either allow or ignore them.
* @private
* @typedef {Object} PathMatcher
* @property {AllowMatchFunction} allow Checks if the file path matches the allowed patterns.
* @property {chokidar.MatchFunction} ignore The Chokidar `ignored` match function.
*/
Loading
Loading