'use strict'; const logSymbols = require('log-symbols'); const debug = require('debug')('mocha:cli:watch'); const path = require('path'); const chokidar = require('chokidar'); const Context = require('../context'); const collectFiles = require('./collect-files'); /** * 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); // 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); // 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 }); }; /** * 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('ignoring files matching: %s', watchIgnore); let globalFixtureContext; // we handle global fixtures manually mocha.enableGlobalSetup(false).enableGlobalTeardown(false); const watcher = chokidar.watch(watchFiles, { ignored: watchIgnore, ignoreInitial: true }); const rerunner = createRerunner(mocha, watcher, { beforeRun }); watcher.on('ready', async () => { if (!globalFixtureContext) { debug('triggering global setup'); globalFixtureContext = await mocha.runGlobalSetup(); } rerunner.run(); }); watcher.on('all', () => { 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 (e) { console.error(e.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); }; /** * Callback to be run before `mocha.run()` is called. * Optionally, it can return a new `Mocha` instance. * @callback BeforeWatchRun * @private * @param {{mocha: Mocha, watcher: FSWatcher}} options * @returns {Mocha} */ /** * Object containing run control methods * @typedef {Object} Rerunner * @private * @property {Function} run - Calls `mocha.run()` * @property {Function} scheduleRun - Schedules another call to `run` */