You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
627 lines
19 KiB
627 lines
19 KiB
|
2 months ago
|
'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);
|
||
|
|
};
|