UNPKG

mocha

Version:

simple, flexible, fun test framework

627 lines (547 loc) 19.1 kB
"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); };