Skip to content

Commit c2667c3

Browse files
ArnesfieldJoshuaKGoldbergmark-wiemer
authored
fix: watch mode using chokidar v4 (#5379)
* fix: watch mode using chokidar v4 (#5355) Glob paths are no longer supported by chokidar starting at v4. This update works around this by resolving `watchFiles` and `watchIgnore` to valid paths and creating a list of matchers to determine if the files should be allowed or ignored through the chokidar `ignored` match function. This commit reverts changes from 8af0f1a so that the watched file changes are not fixed to the current directory. Additional note: when a `watchFile` path is removed while chokidar is watching it, recreating the `watchFile` path does not trigger events from chokidar to rerun the tests. * refactor: fix watch mode using chokidar v4 (#5355) This update fixes #5355 and refactors the previous watch mode fix attempt. Instead of only filtering the paths in the chokidar `ignored` match function, the chokidar `all` event handler now has a guard to ensure that only file paths that match the allowed patterns from `watchFiles` would trigger test reruns. Doing this solves the issue where creating/deleting directories trigger test reruns despite the watched path being a file (e.g., `**/*.xyz`) as the allowed patterns would not match with just the directory paths. * docs: fix jsdoc description for `normalizeGlob()` * test: add tests for watch mode - Test for watched files from outside the current working directory - Test reruns with a thousand watched files * test: fix test for multiple watched files * test: remove test for multiple watched files The test is dependent on how fast the machine could create 1000 watched files, otherwise the test could fail due to a timeout error. Reducing the number of watched files would make the test pass, but doing so would make it more or less similar to already existing tests. At that point, the test does not really test the limits of possibly huge projects with thousands of watched files, so it has been removed instead. * fix: update watch mode implementation This update consolidates the function for retrieving the paths and globs that will be used for the `watchFiles` and `watchIgnore` paths. This should prevent path patterns from being duplicated and should now also handle and watch paths under the provided glob paths. Allowed and ignored file path results are now cached to a map (with a size limit of 1000) to skip re-matching already matched file paths. Additional tests for `watch.spec.js`: - Test file and directory paths under `--watch-files` (no glob pattern) - Test exact file path matches from `--watch-files` - Test `--watch-files` starting and ending with a glob pattern - Test `--watch-files` with a glob pattern in between * Refactor, add debug * Remove verbose debug * Add debugs * Remove castArray, capitalize Chokidar * Replace for-of with Array.some * Add test for false literal matches * Document new test * Remove unused function * Remove createPathFilter and createPathMatcher from exports * refactor: update watch mode - Use array `flatMap` instead of `reduce` for glob path patterns - Revert glob path matching check to a `for` loop (commit caca650) - Update `MAX_CACHE_SIZE` from 1,000 to 10,000 - Update map cache to only delete the first key whenever the limit is reached - Update debug logging and formatting * chore: move watch mode callbacks and object typedefs to the new types.d.ts * Apply suggestion from @JoshuaKGoldberg Co-authored-by: Josh Goldberg ✨ <[email protected]> --------- Co-authored-by: Josh Goldberg ✨ <[email protected]> Co-authored-by: Mark Wiemer <[email protected]> Co-authored-by: Mark Wiemer <[email protected]>
1 parent 7f68e5c commit c2667c3

File tree

5 files changed

+465
-45
lines changed

5 files changed

+465
-45
lines changed

lib/cli/watch-run.js

Lines changed: 255 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,24 @@ const logSymbols = require('log-symbols');
44
const debug = require('debug')('mocha:cli:watch');
55
const path = require('node:path');
66
const chokidar = require('chokidar');
7+
const glob = require('glob');
8+
const isPathInside = require('is-path-inside');
9+
const {minimatch} = require('minimatch');
710
const Context = require('../context');
811
const collectFiles = require('./collect-files');
9-
const glob = require('glob');
1012

1113
/**
1214
* @typedef {import('chokidar').FSWatcher} FSWatcher
15+
* @typedef {import('glob').Glob['patterns'][number]} Pattern
16+
* The `Pattern` class is not exported by the `glob` package.
17+
* Ref [link](../../node_modules/glob/dist/commonjs/pattern.d.ts).
1318
* @typedef {import('../mocha.js')} Mocha
1419
* @typedef {import('../types.d.ts').BeforeWatchRun} BeforeWatchRun
1520
* @typedef {import('../types.d.ts').FileCollectionOptions} FileCollectionOptions
1621
* @typedef {import('../types.d.ts').Rerunner} Rerunner
22+
* @typedef {import('../types.d.ts').PathPattern} PathPattern
23+
* @typedef {import('../types.d.ts').PathFilter} PathFilter
24+
* @typedef {import('../types.d.ts').PathMatcher} PathMatcher
1725
*/
1826

1927
/**
@@ -145,44 +153,240 @@ exports.watchRun = (mocha, {watchFiles, watchIgnore}, fileCollectParams) => {
145153
});
146154
};
147155

148-
class GlobFilesTracker {
149-
constructor(watchFiles, watchIgnore) {
150-
this.watchFilesSet = new Set();
151-
this.watchFiles = watchFiles;
152-
this.watchIgnore = watchIgnore;
153-
}
156+
/**
157+
* Extracts out paths without the glob part, the directory paths,
158+
* and the paths for matching from the provided glob paths.
159+
* @param {string[]} globPaths The list of glob paths to create a filter for.
160+
* @param {string} basePath The path where mocha is run (e.g., current working directory).
161+
* @returns {PathFilter} Object to filter paths.
162+
* @ignore
163+
* @private
164+
*/
165+
function createPathFilter(globPaths, basePath) {
166+
debug('creating path filter from glob paths: %s', globPaths);
167+
168+
/**
169+
* The resulting object to filter paths.
170+
* @type {PathFilter}
171+
*/
172+
const res = {
173+
dir: {paths: new Set(), globs: new Set()},
174+
match: {paths: new Set(), globs: new Set()}
175+
};
154176

155-
regenerate() {
156-
const watchIgnoreSet = new Set();
157-
for (const pattern of this.watchIgnore) {
158-
glob.sync(pattern, { dot: true }).forEach(filePath => watchIgnoreSet.add(filePath));
159-
}
177+
// for checking if a path ends with `/**/*`
178+
const globEnd = path.join(path.sep, '**', '*');
160179

161-
const globOpts = {
180+
/**
181+
* The current glob pattern to check.
182+
* @type {Pattern[]}
183+
*/
184+
const patterns = globPaths.flatMap(globPath => {
185+
return new glob.Glob(globPath, {
162186
dot: true,
163-
ignore: {
164-
childrenIgnored: pathToCheck => watchIgnoreSet.has(pathToCheck.relative())
187+
magicalBraces: true,
188+
windowsPathsNoEscape: true
189+
}).patterns;
190+
}, []);
191+
192+
// each pattern will have its own path because of the `magicalBraces` option
193+
for (const pattern of patterns) {
194+
debug('processing glob pattern: %s', pattern.globString());
195+
196+
/**
197+
* Path segments before the glob pattern.
198+
* @type {string[]}
199+
*/
200+
const segments = [];
201+
202+
/**
203+
* The current glob pattern to check.
204+
* @type {Pattern | null}
205+
*/
206+
let currentPattern = pattern;
207+
let isGlob = false;
208+
209+
do {
210+
// save string patterns until a non-string (glob or regexp) is matched
211+
const entry = currentPattern.pattern();
212+
const isString = typeof entry === 'string';
213+
debug(
214+
'found %s pattern: %s',
215+
isString ? 'string' : 'glob or regexp',
216+
entry
217+
);
218+
if (!isString) {
219+
// if the entry is a glob
220+
isGlob = true;
221+
break;
165222
}
166-
};
167223

168-
this.watchFilesSet.clear();
169-
for (const pattern of this.watchFiles) {
170-
glob.sync(pattern, globOpts).forEach(pathToCheck => {
171-
if (watchIgnoreSet.has(pathToCheck)) {
172-
return;
173-
}
174-
this.watchFilesSet.add(pathToCheck);
175-
});
224+
segments.push(entry);
225+
226+
// go to next pattern
227+
} while ((currentPattern = currentPattern.rest()));
228+
if (!isGlob) {
229+
debug('all subpatterns of %j processed', pattern.globString());
230+
}
231+
232+
// match `cleanPath` (path without the glob part) and its subdirectories
233+
const cleanPath = path.resolve(basePath, ...segments);
234+
debug('clean path: %s', cleanPath);
235+
res.dir.paths.add(cleanPath);
236+
res.dir.globs.add(path.resolve(cleanPath, '**', '*'));
237+
238+
// match `absPath` and all of its contents
239+
const absPath = path.resolve(basePath, pattern.globString());
240+
debug('absolute path: %s', absPath);
241+
(isGlob ? res.match.globs : res.match.paths).add(absPath);
242+
243+
// always include `/**/*` to the full pattern for matching
244+
// since it's possible for the last path segment to be a directory
245+
if (!absPath.endsWith(globEnd)) {
246+
res.match.globs.add(path.resolve(absPath, '**', '*'));
176247
}
177248
}
178249

179-
has(filePath) {
180-
return this.watchFilesSet.has(filePath)
250+
debug('returning path filter: %o', res);
251+
return res;
252+
}
253+
254+
/**
255+
* Checks if the provided path matches with the path pattern.
256+
* @param {string} filePath The path to match.
257+
* @param {PathPattern} pattern The path pattern for matching.
258+
* @param {boolean} [matchParent] Treats the provided path as a match if it's a valid parent directory from the list of paths.
259+
* @returns {boolean} Determines if the provided path matches the pattern.
260+
* @ignore
261+
* @private
262+
*/
263+
function matchPattern(filePath, pattern, matchParent) {
264+
if (pattern.paths.has(filePath)) {
265+
return true;
266+
}
267+
268+
if (matchParent) {
269+
for (const childPath of pattern.paths) {
270+
if (isPathInside(childPath, filePath)) {
271+
return true;
272+
}
273+
}
274+
}
275+
276+
// loop through the set of glob paths instead of converting it into an array
277+
for (const globPath of pattern.globs) {
278+
if (
279+
minimatch(filePath, globPath, {dot: true, windowsPathsNoEscape: true})
280+
) {
281+
return true;
282+
}
181283
}
284+
285+
return false;
286+
}
287+
288+
/**
289+
* Creates an object for matching allowed or ignored file paths.
290+
* @param {PathFilter} allowed The filter for allowed paths.
291+
* @param {PathFilter} ignored The filter for ignored paths.
292+
* @param {string} basePath The path where mocha is run (e.g., current working directory).
293+
* @returns {PathMatcher} The object for matching paths.
294+
* @ignore
295+
* @private
296+
*/
297+
function createPathMatcher(allowed, ignored, basePath) {
298+
debug(
299+
'creating path matcher from allowed: %o, ignored: %o',
300+
allowed,
301+
ignored
302+
);
303+
304+
/**
305+
* Cache of known file paths processed by `matcher.allow()`.
306+
* @type {Map<string, boolean>}
307+
*/
308+
const allowCache = new Map();
309+
310+
/**
311+
* Cache of known file paths processed by `matcher.ignore()`.
312+
* @type {Map<string, boolean>}
313+
*/
314+
const ignoreCache = new Map();
315+
316+
const MAX_CACHE_SIZE = 10000;
317+
318+
/**
319+
* Performs a `map.set()` but will delete the first key
320+
* for new key-value pairs whenever the limit is reached.
321+
* @param {Map<string, boolean>} map The map to use.
322+
* @param {string} key The key to use.
323+
* @param {boolean} value The value to set.
324+
*/
325+
function cache(map, key, value) {
326+
// only delete the first key if the key doesn't exist in the map
327+
if (map.size >= MAX_CACHE_SIZE && !map.has(key)) {
328+
map.delete(map.keys().next().value);
329+
}
330+
map.set(key, value);
331+
}
332+
333+
/**
334+
* @type {PathMatcher}
335+
*/
336+
const matcher = {
337+
allow(filePath) {
338+
let allow = allowCache.get(filePath);
339+
if (allow !== undefined) {
340+
return allow;
341+
}
342+
343+
allow = matchPattern(filePath, allowed.match);
344+
cache(allowCache, filePath, allow);
345+
return allow;
346+
},
347+
348+
ignore(filePath, stats) {
349+
// Chokidar calls the ignore match function twice:
350+
// once without `stats` and again with `stats`
351+
// see `ignored` under https://github.com/paulmillr/chokidar?tab=readme-ov-file#path-filtering
352+
// note that the second call can also have no `stats` if the `filePath` does not exist
353+
// in which case, allow the nonexistent path since it may be created later
354+
if (!stats) {
355+
return false;
356+
}
357+
358+
// resolve to ensure correct absolute path since, for some reason,
359+
// Chokidar paths for the ignore match function use slashes `/` even for Windows
360+
filePath = path.resolve(basePath, filePath);
361+
362+
let ignore = ignoreCache.get(filePath);
363+
if (ignore !== undefined) {
364+
return ignore;
365+
}
366+
367+
// `filePath` ignore conditions:
368+
// - check if it's ignored from the `ignored` path patterns
369+
// - otherwise, check if it's not ignored via `matcher.allow()` to also cache the result
370+
// - if no match was found and `filePath` is a directory,
371+
// check from the allowed directory paths if it's a valid
372+
// parent directory or if it matches any of the allowed patterns
373+
// since ignoring directories will have Chokidar ignore their contents
374+
// which we may need to watch changes for
375+
ignore =
376+
matchPattern(filePath, ignored.match) ||
377+
(!matcher.allow(filePath) &&
378+
(!stats.isDirectory() || !matchPattern(filePath, allowed.dir, true)));
379+
380+
cache(ignoreCache, filePath, ignore);
381+
return ignore;
382+
}
383+
};
384+
385+
return matcher;
182386
}
183387

184388
/**
185-
* Bootstraps a chokidar watcher. Handles keyboard input & signals
389+
* Bootstraps a Chokidar watcher. Handles keyboard input & signals
186390
* @param {Mocha} mocha - Mocha instance
187391
* @param {Object} opts
188392
* @param {BeforeWatchRun} [opts.beforeRun] - Function to call before
@@ -206,36 +410,46 @@ const createWatcher = (
206410
watchFiles = fileCollectParams.extension.map(ext => `**/*.${ext}`);
207411
}
208412

413+
debug('watching files: %s', watchFiles);
209414
debug('ignoring files matching: %s', watchIgnore);
210415
let globalFixtureContext;
211416

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

215-
const tracker = new GlobFilesTracker(watchFiles, watchIgnore);
216-
tracker.regenerate();
217-
218-
const watcher = chokidar.watch('.', {
219-
ignoreInitial: true
420+
// glob file paths are no longer supported by Chokidar since v4
421+
// first, strip the glob paths from `watchFiles` for Chokidar to watch
422+
// then, create path patterns from `watchFiles` and `watchIgnore`
423+
// to determine if the files should be allowed or ignored
424+
// by the Chokidar `ignored` match function
425+
426+
const basePath = process.cwd();
427+
const allowed = createPathFilter(watchFiles, basePath);
428+
const ignored = createPathFilter(watchIgnore, basePath);
429+
const matcher = createPathMatcher(allowed, ignored, basePath);
430+
431+
// Chokidar has to watch the directory paths in case new files are created
432+
const watcher = chokidar.watch(Array.from(allowed.dir.paths), {
433+
ignoreInitial: true,
434+
ignored: matcher.ignore
220435
});
221436

222437
const rerunner = createRerunner(mocha, watcher, {
223438
beforeRun
224439
});
225440

226441
watcher.on('ready', async () => {
442+
debug('watcher ready');
227443
if (!globalFixtureContext) {
228444
debug('triggering global setup');
229445
globalFixtureContext = await mocha.runGlobalSetup();
230446
}
231447
rerunner.run();
232448
});
233449

234-
watcher.on('all', (event, filePath) => {
235-
if (event === 'add') {
236-
tracker.regenerate();
237-
}
238-
if (tracker.has(filePath)) {
450+
watcher.on('all', (_event, filePath) => {
451+
// only allow file paths that match the allowed patterns
452+
if (matcher.allow(filePath)) {
239453
rerunner.scheduleRun();
240454
}
241455
});
@@ -294,7 +508,7 @@ const createWatcher = (
294508
* Create an object that allows you to rerun tests on the mocha instance.
295509
*
296510
* @param {Mocha} mocha - Mocha instance
297-
* @param {FSWatcher} watcher - chokidar `FSWatcher` instance
511+
* @param {FSWatcher} watcher - Chokidar `FSWatcher` instance
298512
* @param {Object} [opts] - Options!
299513
* @param {BeforeWatchRun} [opts.beforeRun] - Function to call before `mocha.run()`
300514
* @returns {Rerunner}
@@ -353,9 +567,9 @@ const createRerunner = (mocha, watcher, {beforeRun} = {}) => {
353567
};
354568

355569
/**
356-
* Return the list of absolute paths watched by a chokidar watcher.
570+
* Return the list of absolute paths watched by a Chokidar watcher.
357571
*
358-
* @param watcher - Instance of a chokidar watcher
572+
* @param watcher - Instance of a Chokidar watcher
359573
* @return {string[]} - List of absolute paths
360574
* @ignore
361575
* @private
@@ -399,7 +613,7 @@ const eraseLine = () => {
399613

400614
/**
401615
* Blast all of the watched files out of `require.cache`
402-
* @param {FSWatcher} watcher - chokidar FSWatcher
616+
* @param {FSWatcher} watcher - Chokidar FSWatcher
403617
* @ignore
404618
* @private
405619
*/

0 commit comments

Comments
 (0)