| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626 |
- 'use strict';
- 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');
- /**
- * @typedef {import('chokidar').FSWatcher} FSWatcher
- * @typedef {import('glob').Glob['patterns'][number]} Pattern
- * The `Pattern` class is not exported by the `glob` package.
- * Ref [link](../../node_modules/glob/dist/commonjs/pattern.d.ts).
- * @typedef {import('../mocha.js')} Mocha
- * @typedef {import('../types.d.ts').BeforeWatchRun} BeforeWatchRun
- * @typedef {import('../types.d.ts').FileCollectionOptions} FileCollectionOptions
- * @typedef {import('../types.d.ts').Rerunner} Rerunner
- * @typedef {import('../types.d.ts').PathPattern} PathPattern
- * @typedef {import('../types.d.ts').PathFilter} PathFilter
- * @typedef {import('../types.d.ts').PathMatcher} PathMatcher
- */
- /**
- * Exports the `watchRun` function that runs mocha in "watch" mode.
- * @see module:lib/cli/run-helpers
- * @module
- * @private
- */
- /**
- * Run Mocha in parallel "watch" mode
- * @param {Mocha} mocha - Mocha instance
- * @param {Object} opts - Options
- * @param {string[]} [opts.watchFiles] - List of paths and patterns to
- * watch. If not provided all files with an extension included in
- * `fileCollectionParams.extension` are watched. See first argument of
- * `chokidar.watch`.
- * @param {string[]} opts.watchIgnore - List of paths and patterns to
- * exclude from watching. See `ignored` option of `chokidar`.
- * @param {FileCollectionOptions} fileCollectParams - Parameters that control test
- * @private
- */
- exports.watchParallelRun = (
- mocha,
- {watchFiles, watchIgnore},
- fileCollectParams
- ) => {
- debug('creating parallel watcher');
- return createWatcher(mocha, {
- watchFiles,
- watchIgnore,
- beforeRun({mocha}) {
- // I don't know why we're cloning the root suite.
- const rootSuite = mocha.suite.clone();
- // ensure we aren't leaking event listeners
- mocha.dispose();
- // this `require` is needed because the require cache has been cleared. the dynamic
- // exports set via the below call to `mocha.ui()` won't work properly if a
- // test depends on this module.
- const Mocha = require('../mocha');
- // ... and now that we've gotten a new module, we need to use it again due
- // to `mocha.ui()` call
- const newMocha = new Mocha(mocha.options);
- // don't know why this is needed
- newMocha.suite = rootSuite;
- // nor this
- newMocha.suite.ctx = new Context();
- // reset the list of files
- newMocha.files = collectFiles(fileCollectParams).files;
- // because we've swapped out the root suite (see the `run` inner function
- // in `createRerunner`), we need to call `mocha.ui()` again to set up the context/globals.
- newMocha.ui(newMocha.options.ui);
- // we need to call `newMocha.rootHooks` to set up rootHooks for the new
- // suite
- newMocha.rootHooks(newMocha.options.rootHooks);
- // in parallel mode, the main Mocha process doesn't actually load the
- // files. this flag prevents `mocha.run()` from autoloading.
- newMocha.lazyLoadFiles(true);
- return newMocha;
- },
- fileCollectParams
- });
- };
- /**
- * Run Mocha in "watch" mode
- * @param {Mocha} mocha - Mocha instance
- * @param {Object} opts - Options
- * @param {string[]} [opts.watchFiles] - List of paths and patterns to
- * watch. If not provided all files with an extension included in
- * `fileCollectionParams.extension` are watched. See first argument of
- * `chokidar.watch`.
- * @param {string[]} opts.watchIgnore - List of paths and patterns to
- * exclude from watching. See `ignored` option of `chokidar`.
- * @param {FileCollectionOptions} fileCollectParams - Parameters that control test
- * file collection. See `lib/cli/collect-files.js`.
- * @private
- */
- exports.watchRun = (mocha, {watchFiles, watchIgnore}, fileCollectParams) => {
- debug('creating serial watcher');
- return createWatcher(mocha, {
- watchFiles,
- watchIgnore,
- beforeRun({mocha}) {
- mocha.unloadFiles();
- // I don't know why we're cloning the root suite.
- const rootSuite = mocha.suite.clone();
- // ensure we aren't leaking event listeners
- mocha.dispose();
- // this `require` is needed because the require cache has been cleared. the dynamic
- // exports set via the below call to `mocha.ui()` won't work properly if a
- // test depends on this module.
- const Mocha = require('../mocha');
- // ... and now that we've gotten a new module, we need to use it again due
- // to `mocha.ui()` call
- const newMocha = new Mocha(mocha.options);
- // don't know why this is needed
- newMocha.suite = rootSuite;
- // nor this
- newMocha.suite.ctx = new Context();
- // reset the list of files
- newMocha.files = collectFiles(fileCollectParams).files;
- // because we've swapped out the root suite (see the `run` inner function
- // in `createRerunner`), we need to call `mocha.ui()` again to set up the context/globals.
- newMocha.ui(newMocha.options.ui);
- // we need to call `newMocha.rootHooks` to set up rootHooks for the new
- // suite
- newMocha.rootHooks(newMocha.options.rootHooks);
- return newMocha;
- },
- fileCollectParams
- });
- };
- /**
- * 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
- */
- function 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()}
- };
- // for checking if a path ends with `/**/*`
- const globEnd = path.join(path.sep, '**', '*');
- /**
- * The current glob pattern to check.
- * @type {Pattern[]}
- */
- const patterns = globPaths.flatMap(globPath => {
- return new glob.Glob(globPath, {
- dot: true,
- magicalBraces: true,
- windowsPathsNoEscape: true
- }).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 {Pattern | null}
- */
- let currentPattern = pattern;
- let isGlob = false;
- do {
- // save string patterns until a non-string (glob or regexp) is matched
- const entry = currentPattern.pattern();
- const isString = typeof entry === 'string';
- debug(
- 'found %s pattern: %s',
- isString ? 'string' : 'glob or regexp',
- entry
- );
- if (!isString) {
- // if the entry is a glob
- isGlob = true;
- break;
- }
- segments.push(entry);
- // go to next pattern
- } while ((currentPattern = currentPattern.rest()));
- if (!isGlob) {
- debug('all subpatterns of %j processed', 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, '**', '*'));
- }
- }
- 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;
- }
- }
- }
- // loop through the set of glob paths instead of converting it into an array
- for (const globPath of pattern.globs) {
- if (
- minimatch(filePath, globPath, {dot: true, windowsPathsNoEscape: true})
- ) {
- return true;
- }
- }
- return false;
- }
- /**
- * 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
- */
- function 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 = 10000;
- /**
- * Performs a `map.set()` but will delete the first key
- * for new key-value pairs 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) {
- // only delete the first key if the key doesn't exist in the map
- if (map.size >= MAX_CACHE_SIZE && !map.has(key)) {
- map.delete(map.keys().next().value);
- }
- 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
- * `mocha.run()`
- * @param {string[]} [opts.watchFiles] - List of paths and patterns to watch. If
- * not provided all files with an extension included in
- * `fileCollectionParams.extension` are watched. See first argument of
- * `chokidar.watch`.
- * @param {string[]} [opts.watchIgnore] - List of paths and patterns to exclude
- * from watching. See `ignored` option of `chokidar`.
- * @param {FileCollectionOptions} opts.fileCollectParams - List of extensions to watch if `opts.watchFiles` is not given.
- * @returns {FSWatcher}
- * @ignore
- * @private
- */
- const createWatcher = (
- mocha,
- {watchFiles, watchIgnore, beforeRun, fileCollectParams}
- ) => {
- if (!watchFiles) {
- 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);
- // 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 = createPathFilter(watchFiles, basePath);
- const ignored = createPathFilter(watchIgnore, basePath);
- const matcher = 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) => {
- // only allow file paths that match the allowed patterns
- if (matcher.allow(filePath)) {
- rerunner.scheduleRun();
- }
- });
- hideCursor();
- process.on('exit', () => {
- showCursor();
- });
- // this is for testing.
- // win32 cannot gracefully shutdown via a signal from a parent
- // process; a `SIGINT` from a parent will cause the process
- // to immediately exit. during normal course of operation, a user
- // will type Ctrl-C and the listener will be invoked, but this
- // is not possible in automated testing.
- // there may be another way to solve this, but it too will be a hack.
- // for our watch tests on win32 we must _fork_ mocha with an IPC channel
- if (process.connected) {
- process.on('message', msg => {
- if (msg === 'SIGINT') {
- process.emit('SIGINT');
- }
- });
- }
- let exiting = false;
- process.on('SIGINT', async () => {
- showCursor();
- console.error(`${logSymbols.warning} [mocha] cleaning up, please wait...`);
- if (!exiting) {
- exiting = true;
- if (mocha.hasGlobalTeardownFixtures()) {
- debug('running global teardown');
- try {
- await mocha.runGlobalTeardown(globalFixtureContext);
- } catch (err) {
- console.error(err);
- }
- }
- process.exit(130);
- }
- });
- // Keyboard shortcut for restarting when "rs\n" is typed (ala Nodemon)
- process.stdin.resume();
- process.stdin.setEncoding('utf8');
- process.stdin.on('data', data => {
- const str = data.toString().trim().toLowerCase();
- if (str === 'rs') rerunner.scheduleRun();
- });
- return watcher;
- };
- /**
- * 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 {Object} [opts] - Options!
- * @param {BeforeWatchRun} [opts.beforeRun] - Function to call before `mocha.run()`
- * @returns {Rerunner}
- * @ignore
- * @private
- */
- const createRerunner = (mocha, watcher, {beforeRun} = {}) => {
- // Set to a `Runner` when mocha is running. Set to `null` when mocha is not
- // running.
- let runner = null;
- // true if a file has changed during a test run
- let rerunScheduled = false;
- const run = () => {
- try {
- mocha = beforeRun ? beforeRun({mocha, watcher}) || mocha : mocha;
- runner = mocha.run(() => {
- debug('finished watch run');
- runner = null;
- blastCache(watcher);
- if (rerunScheduled) {
- rerun();
- } else {
- console.error(`${logSymbols.info} [mocha] waiting for changes...`);
- }
- });
- } catch (err) {
- console.error(err.stack);
- }
- };
- const scheduleRun = () => {
- if (rerunScheduled) {
- return;
- }
- rerunScheduled = true;
- if (runner) {
- runner.abort();
- } else {
- rerun();
- }
- };
- const rerun = () => {
- rerunScheduled = false;
- eraseLine();
- run();
- };
- return {
- scheduleRun,
- run
- };
- };
- /**
- * Return the list of absolute paths watched by a Chokidar watcher.
- *
- * @param watcher - Instance of a Chokidar watcher
- * @return {string[]} - List of absolute paths
- * @ignore
- * @private
- */
- const getWatchedFiles = watcher => {
- const watchedDirs = watcher.getWatched();
- return Object.keys(watchedDirs).reduce(
- (acc, dir) => [
- ...acc,
- ...watchedDirs[dir].map(file => path.join(dir, file))
- ],
- []
- );
- };
- /**
- * Hide the cursor.
- * @ignore
- * @private
- */
- const hideCursor = () => {
- process.stdout.write('\u001b[?25l');
- };
- /**
- * Show the cursor.
- * @ignore
- * @private
- */
- const showCursor = () => {
- process.stdout.write('\u001b[?25h');
- };
- /**
- * Erases the line on stdout
- * @private
- */
- const eraseLine = () => {
- process.stdout.write('\u001b[2K');
- };
- /**
- * Blast all of the watched files out of `require.cache`
- * @param {FSWatcher} watcher - Chokidar FSWatcher
- * @ignore
- * @private
- */
- const blastCache = watcher => {
- const files = getWatchedFiles(watcher);
- files.forEach(file => {
- delete require.cache[file];
- });
- debug('deleted %d file(s) from the require cache', files.length);
- };
|