watch-run.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. 'use strict';
  2. const logSymbols = require('log-symbols');
  3. const debug = require('debug')('mocha:cli:watch');
  4. const path = require('node:path');
  5. const chokidar = require('chokidar');
  6. const glob = require('glob');
  7. const isPathInside = require('is-path-inside');
  8. const {minimatch} = require('minimatch');
  9. const Context = require('../context');
  10. const collectFiles = require('./collect-files');
  11. /**
  12. * @typedef {import('chokidar').FSWatcher} FSWatcher
  13. * @typedef {import('glob').Glob['patterns'][number]} Pattern
  14. * The `Pattern` class is not exported by the `glob` package.
  15. * Ref [link](../../node_modules/glob/dist/commonjs/pattern.d.ts).
  16. * @typedef {import('../mocha.js')} Mocha
  17. * @typedef {import('../types.d.ts').BeforeWatchRun} BeforeWatchRun
  18. * @typedef {import('../types.d.ts').FileCollectionOptions} FileCollectionOptions
  19. * @typedef {import('../types.d.ts').Rerunner} Rerunner
  20. * @typedef {import('../types.d.ts').PathPattern} PathPattern
  21. * @typedef {import('../types.d.ts').PathFilter} PathFilter
  22. * @typedef {import('../types.d.ts').PathMatcher} PathMatcher
  23. */
  24. /**
  25. * Exports the `watchRun` function that runs mocha in "watch" mode.
  26. * @see module:lib/cli/run-helpers
  27. * @module
  28. * @private
  29. */
  30. /**
  31. * Run Mocha in parallel "watch" mode
  32. * @param {Mocha} mocha - Mocha instance
  33. * @param {Object} opts - Options
  34. * @param {string[]} [opts.watchFiles] - List of paths and patterns to
  35. * watch. If not provided all files with an extension included in
  36. * `fileCollectionParams.extension` are watched. See first argument of
  37. * `chokidar.watch`.
  38. * @param {string[]} opts.watchIgnore - List of paths and patterns to
  39. * exclude from watching. See `ignored` option of `chokidar`.
  40. * @param {FileCollectionOptions} fileCollectParams - Parameters that control test
  41. * @private
  42. */
  43. exports.watchParallelRun = (
  44. mocha,
  45. {watchFiles, watchIgnore},
  46. fileCollectParams
  47. ) => {
  48. debug('creating parallel watcher');
  49. return createWatcher(mocha, {
  50. watchFiles,
  51. watchIgnore,
  52. beforeRun({mocha}) {
  53. // I don't know why we're cloning the root suite.
  54. const rootSuite = mocha.suite.clone();
  55. // ensure we aren't leaking event listeners
  56. mocha.dispose();
  57. // this `require` is needed because the require cache has been cleared. the dynamic
  58. // exports set via the below call to `mocha.ui()` won't work properly if a
  59. // test depends on this module.
  60. const Mocha = require('../mocha');
  61. // ... and now that we've gotten a new module, we need to use it again due
  62. // to `mocha.ui()` call
  63. const newMocha = new Mocha(mocha.options);
  64. // don't know why this is needed
  65. newMocha.suite = rootSuite;
  66. // nor this
  67. newMocha.suite.ctx = new Context();
  68. // reset the list of files
  69. newMocha.files = collectFiles(fileCollectParams).files;
  70. // because we've swapped out the root suite (see the `run` inner function
  71. // in `createRerunner`), we need to call `mocha.ui()` again to set up the context/globals.
  72. newMocha.ui(newMocha.options.ui);
  73. // we need to call `newMocha.rootHooks` to set up rootHooks for the new
  74. // suite
  75. newMocha.rootHooks(newMocha.options.rootHooks);
  76. // in parallel mode, the main Mocha process doesn't actually load the
  77. // files. this flag prevents `mocha.run()` from autoloading.
  78. newMocha.lazyLoadFiles(true);
  79. return newMocha;
  80. },
  81. fileCollectParams
  82. });
  83. };
  84. /**
  85. * Run Mocha in "watch" mode
  86. * @param {Mocha} mocha - Mocha instance
  87. * @param {Object} opts - Options
  88. * @param {string[]} [opts.watchFiles] - List of paths and patterns to
  89. * watch. If not provided all files with an extension included in
  90. * `fileCollectionParams.extension` are watched. See first argument of
  91. * `chokidar.watch`.
  92. * @param {string[]} opts.watchIgnore - List of paths and patterns to
  93. * exclude from watching. See `ignored` option of `chokidar`.
  94. * @param {FileCollectionOptions} fileCollectParams - Parameters that control test
  95. * file collection. See `lib/cli/collect-files.js`.
  96. * @private
  97. */
  98. exports.watchRun = (mocha, {watchFiles, watchIgnore}, fileCollectParams) => {
  99. debug('creating serial watcher');
  100. return createWatcher(mocha, {
  101. watchFiles,
  102. watchIgnore,
  103. beforeRun({mocha}) {
  104. mocha.unloadFiles();
  105. // I don't know why we're cloning the root suite.
  106. const rootSuite = mocha.suite.clone();
  107. // ensure we aren't leaking event listeners
  108. mocha.dispose();
  109. // this `require` is needed because the require cache has been cleared. the dynamic
  110. // exports set via the below call to `mocha.ui()` won't work properly if a
  111. // test depends on this module.
  112. const Mocha = require('../mocha');
  113. // ... and now that we've gotten a new module, we need to use it again due
  114. // to `mocha.ui()` call
  115. const newMocha = new Mocha(mocha.options);
  116. // don't know why this is needed
  117. newMocha.suite = rootSuite;
  118. // nor this
  119. newMocha.suite.ctx = new Context();
  120. // reset the list of files
  121. newMocha.files = collectFiles(fileCollectParams).files;
  122. // because we've swapped out the root suite (see the `run` inner function
  123. // in `createRerunner`), we need to call `mocha.ui()` again to set up the context/globals.
  124. newMocha.ui(newMocha.options.ui);
  125. // we need to call `newMocha.rootHooks` to set up rootHooks for the new
  126. // suite
  127. newMocha.rootHooks(newMocha.options.rootHooks);
  128. return newMocha;
  129. },
  130. fileCollectParams
  131. });
  132. };
  133. /**
  134. * Extracts out paths without the glob part, the directory paths,
  135. * and the paths for matching from the provided glob paths.
  136. * @param {string[]} globPaths The list of glob paths to create a filter for.
  137. * @param {string} basePath The path where mocha is run (e.g., current working directory).
  138. * @returns {PathFilter} Object to filter paths.
  139. * @ignore
  140. * @private
  141. */
  142. function createPathFilter(globPaths, basePath) {
  143. debug('creating path filter from glob paths: %s', globPaths);
  144. /**
  145. * The resulting object to filter paths.
  146. * @type {PathFilter}
  147. */
  148. const res = {
  149. dir: {paths: new Set(), globs: new Set()},
  150. match: {paths: new Set(), globs: new Set()}
  151. };
  152. // for checking if a path ends with `/**/*`
  153. const globEnd = path.join(path.sep, '**', '*');
  154. /**
  155. * The current glob pattern to check.
  156. * @type {Pattern[]}
  157. */
  158. const patterns = globPaths.flatMap(globPath => {
  159. return new glob.Glob(globPath, {
  160. dot: true,
  161. magicalBraces: true,
  162. windowsPathsNoEscape: true
  163. }).patterns;
  164. }, []);
  165. // each pattern will have its own path because of the `magicalBraces` option
  166. for (const pattern of patterns) {
  167. debug('processing glob pattern: %s', pattern.globString());
  168. /**
  169. * Path segments before the glob pattern.
  170. * @type {string[]}
  171. */
  172. const segments = [];
  173. /**
  174. * The current glob pattern to check.
  175. * @type {Pattern | null}
  176. */
  177. let currentPattern = pattern;
  178. let isGlob = false;
  179. do {
  180. // save string patterns until a non-string (glob or regexp) is matched
  181. const entry = currentPattern.pattern();
  182. const isString = typeof entry === 'string';
  183. debug(
  184. 'found %s pattern: %s',
  185. isString ? 'string' : 'glob or regexp',
  186. entry
  187. );
  188. if (!isString) {
  189. // if the entry is a glob
  190. isGlob = true;
  191. break;
  192. }
  193. segments.push(entry);
  194. // go to next pattern
  195. } while ((currentPattern = currentPattern.rest()));
  196. if (!isGlob) {
  197. debug('all subpatterns of %j processed', pattern.globString());
  198. }
  199. // match `cleanPath` (path without the glob part) and its subdirectories
  200. const cleanPath = path.resolve(basePath, ...segments);
  201. debug('clean path: %s', cleanPath);
  202. res.dir.paths.add(cleanPath);
  203. res.dir.globs.add(path.resolve(cleanPath, '**', '*'));
  204. // match `absPath` and all of its contents
  205. const absPath = path.resolve(basePath, pattern.globString());
  206. debug('absolute path: %s', absPath);
  207. (isGlob ? res.match.globs : res.match.paths).add(absPath);
  208. // always include `/**/*` to the full pattern for matching
  209. // since it's possible for the last path segment to be a directory
  210. if (!absPath.endsWith(globEnd)) {
  211. res.match.globs.add(path.resolve(absPath, '**', '*'));
  212. }
  213. }
  214. debug('returning path filter: %o', res);
  215. return res;
  216. }
  217. /**
  218. * Checks if the provided path matches with the path pattern.
  219. * @param {string} filePath The path to match.
  220. * @param {PathPattern} pattern The path pattern for matching.
  221. * @param {boolean} [matchParent] Treats the provided path as a match if it's a valid parent directory from the list of paths.
  222. * @returns {boolean} Determines if the provided path matches the pattern.
  223. * @ignore
  224. * @private
  225. */
  226. function matchPattern(filePath, pattern, matchParent) {
  227. if (pattern.paths.has(filePath)) {
  228. return true;
  229. }
  230. if (matchParent) {
  231. for (const childPath of pattern.paths) {
  232. if (isPathInside(childPath, filePath)) {
  233. return true;
  234. }
  235. }
  236. }
  237. // loop through the set of glob paths instead of converting it into an array
  238. for (const globPath of pattern.globs) {
  239. if (
  240. minimatch(filePath, globPath, {dot: true, windowsPathsNoEscape: true})
  241. ) {
  242. return true;
  243. }
  244. }
  245. return false;
  246. }
  247. /**
  248. * Creates an object for matching allowed or ignored file paths.
  249. * @param {PathFilter} allowed The filter for allowed paths.
  250. * @param {PathFilter} ignored The filter for ignored paths.
  251. * @param {string} basePath The path where mocha is run (e.g., current working directory).
  252. * @returns {PathMatcher} The object for matching paths.
  253. * @ignore
  254. * @private
  255. */
  256. function createPathMatcher(allowed, ignored, basePath) {
  257. debug(
  258. 'creating path matcher from allowed: %o, ignored: %o',
  259. allowed,
  260. ignored
  261. );
  262. /**
  263. * Cache of known file paths processed by `matcher.allow()`.
  264. * @type {Map<string, boolean>}
  265. */
  266. const allowCache = new Map();
  267. /**
  268. * Cache of known file paths processed by `matcher.ignore()`.
  269. * @type {Map<string, boolean>}
  270. */
  271. const ignoreCache = new Map();
  272. const MAX_CACHE_SIZE = 10000;
  273. /**
  274. * Performs a `map.set()` but will delete the first key
  275. * for new key-value pairs whenever the limit is reached.
  276. * @param {Map<string, boolean>} map The map to use.
  277. * @param {string} key The key to use.
  278. * @param {boolean} value The value to set.
  279. */
  280. function cache(map, key, value) {
  281. // only delete the first key if the key doesn't exist in the map
  282. if (map.size >= MAX_CACHE_SIZE && !map.has(key)) {
  283. map.delete(map.keys().next().value);
  284. }
  285. map.set(key, value);
  286. }
  287. /**
  288. * @type {PathMatcher}
  289. */
  290. const matcher = {
  291. allow(filePath) {
  292. let allow = allowCache.get(filePath);
  293. if (allow !== undefined) {
  294. return allow;
  295. }
  296. allow = matchPattern(filePath, allowed.match);
  297. cache(allowCache, filePath, allow);
  298. return allow;
  299. },
  300. ignore(filePath, stats) {
  301. // Chokidar calls the ignore match function twice:
  302. // once without `stats` and again with `stats`
  303. // see `ignored` under https://github.com/paulmillr/chokidar?tab=readme-ov-file#path-filtering
  304. // note that the second call can also have no `stats` if the `filePath` does not exist
  305. // in which case, allow the nonexistent path since it may be created later
  306. if (!stats) {
  307. return false;
  308. }
  309. // resolve to ensure correct absolute path since, for some reason,
  310. // Chokidar paths for the ignore match function use slashes `/` even for Windows
  311. filePath = path.resolve(basePath, filePath);
  312. let ignore = ignoreCache.get(filePath);
  313. if (ignore !== undefined) {
  314. return ignore;
  315. }
  316. // `filePath` ignore conditions:
  317. // - check if it's ignored from the `ignored` path patterns
  318. // - otherwise, check if it's not ignored via `matcher.allow()` to also cache the result
  319. // - if no match was found and `filePath` is a directory,
  320. // check from the allowed directory paths if it's a valid
  321. // parent directory or if it matches any of the allowed patterns
  322. // since ignoring directories will have Chokidar ignore their contents
  323. // which we may need to watch changes for
  324. ignore =
  325. matchPattern(filePath, ignored.match) ||
  326. (!matcher.allow(filePath) &&
  327. (!stats.isDirectory() || !matchPattern(filePath, allowed.dir, true)));
  328. cache(ignoreCache, filePath, ignore);
  329. return ignore;
  330. }
  331. };
  332. return matcher;
  333. }
  334. /**
  335. * Bootstraps a Chokidar watcher. Handles keyboard input & signals
  336. * @param {Mocha} mocha - Mocha instance
  337. * @param {Object} opts
  338. * @param {BeforeWatchRun} [opts.beforeRun] - Function to call before
  339. * `mocha.run()`
  340. * @param {string[]} [opts.watchFiles] - List of paths and patterns to watch. If
  341. * not provided all files with an extension included in
  342. * `fileCollectionParams.extension` are watched. See first argument of
  343. * `chokidar.watch`.
  344. * @param {string[]} [opts.watchIgnore] - List of paths and patterns to exclude
  345. * from watching. See `ignored` option of `chokidar`.
  346. * @param {FileCollectionOptions} opts.fileCollectParams - List of extensions to watch if `opts.watchFiles` is not given.
  347. * @returns {FSWatcher}
  348. * @ignore
  349. * @private
  350. */
  351. const createWatcher = (
  352. mocha,
  353. {watchFiles, watchIgnore, beforeRun, fileCollectParams}
  354. ) => {
  355. if (!watchFiles) {
  356. watchFiles = fileCollectParams.extension.map(ext => `**/*.${ext}`);
  357. }
  358. debug('watching files: %s', watchFiles);
  359. debug('ignoring files matching: %s', watchIgnore);
  360. let globalFixtureContext;
  361. // we handle global fixtures manually
  362. mocha.enableGlobalSetup(false).enableGlobalTeardown(false);
  363. // glob file paths are no longer supported by Chokidar since v4
  364. // first, strip the glob paths from `watchFiles` for Chokidar to watch
  365. // then, create path patterns from `watchFiles` and `watchIgnore`
  366. // to determine if the files should be allowed or ignored
  367. // by the Chokidar `ignored` match function
  368. const basePath = process.cwd();
  369. const allowed = createPathFilter(watchFiles, basePath);
  370. const ignored = createPathFilter(watchIgnore, basePath);
  371. const matcher = createPathMatcher(allowed, ignored, basePath);
  372. // Chokidar has to watch the directory paths in case new files are created
  373. const watcher = chokidar.watch(Array.from(allowed.dir.paths), {
  374. ignoreInitial: true,
  375. ignored: matcher.ignore
  376. });
  377. const rerunner = createRerunner(mocha, watcher, {
  378. beforeRun
  379. });
  380. watcher.on('ready', async () => {
  381. debug('watcher ready');
  382. if (!globalFixtureContext) {
  383. debug('triggering global setup');
  384. globalFixtureContext = await mocha.runGlobalSetup();
  385. }
  386. rerunner.run();
  387. });
  388. watcher.on('all', (_event, filePath) => {
  389. // only allow file paths that match the allowed patterns
  390. if (matcher.allow(filePath)) {
  391. rerunner.scheduleRun();
  392. }
  393. });
  394. hideCursor();
  395. process.on('exit', () => {
  396. showCursor();
  397. });
  398. // this is for testing.
  399. // win32 cannot gracefully shutdown via a signal from a parent
  400. // process; a `SIGINT` from a parent will cause the process
  401. // to immediately exit. during normal course of operation, a user
  402. // will type Ctrl-C and the listener will be invoked, but this
  403. // is not possible in automated testing.
  404. // there may be another way to solve this, but it too will be a hack.
  405. // for our watch tests on win32 we must _fork_ mocha with an IPC channel
  406. if (process.connected) {
  407. process.on('message', msg => {
  408. if (msg === 'SIGINT') {
  409. process.emit('SIGINT');
  410. }
  411. });
  412. }
  413. let exiting = false;
  414. process.on('SIGINT', async () => {
  415. showCursor();
  416. console.error(`${logSymbols.warning} [mocha] cleaning up, please wait...`);
  417. if (!exiting) {
  418. exiting = true;
  419. if (mocha.hasGlobalTeardownFixtures()) {
  420. debug('running global teardown');
  421. try {
  422. await mocha.runGlobalTeardown(globalFixtureContext);
  423. } catch (err) {
  424. console.error(err);
  425. }
  426. }
  427. process.exit(130);
  428. }
  429. });
  430. // Keyboard shortcut for restarting when "rs\n" is typed (ala Nodemon)
  431. process.stdin.resume();
  432. process.stdin.setEncoding('utf8');
  433. process.stdin.on('data', data => {
  434. const str = data.toString().trim().toLowerCase();
  435. if (str === 'rs') rerunner.scheduleRun();
  436. });
  437. return watcher;
  438. };
  439. /**
  440. * Create an object that allows you to rerun tests on the mocha instance.
  441. *
  442. * @param {Mocha} mocha - Mocha instance
  443. * @param {FSWatcher} watcher - Chokidar `FSWatcher` instance
  444. * @param {Object} [opts] - Options!
  445. * @param {BeforeWatchRun} [opts.beforeRun] - Function to call before `mocha.run()`
  446. * @returns {Rerunner}
  447. * @ignore
  448. * @private
  449. */
  450. const createRerunner = (mocha, watcher, {beforeRun} = {}) => {
  451. // Set to a `Runner` when mocha is running. Set to `null` when mocha is not
  452. // running.
  453. let runner = null;
  454. // true if a file has changed during a test run
  455. let rerunScheduled = false;
  456. const run = () => {
  457. try {
  458. mocha = beforeRun ? beforeRun({mocha, watcher}) || mocha : mocha;
  459. runner = mocha.run(() => {
  460. debug('finished watch run');
  461. runner = null;
  462. blastCache(watcher);
  463. if (rerunScheduled) {
  464. rerun();
  465. } else {
  466. console.error(`${logSymbols.info} [mocha] waiting for changes...`);
  467. }
  468. });
  469. } catch (err) {
  470. console.error(err.stack);
  471. }
  472. };
  473. const scheduleRun = () => {
  474. if (rerunScheduled) {
  475. return;
  476. }
  477. rerunScheduled = true;
  478. if (runner) {
  479. runner.abort();
  480. } else {
  481. rerun();
  482. }
  483. };
  484. const rerun = () => {
  485. rerunScheduled = false;
  486. eraseLine();
  487. run();
  488. };
  489. return {
  490. scheduleRun,
  491. run
  492. };
  493. };
  494. /**
  495. * Return the list of absolute paths watched by a Chokidar watcher.
  496. *
  497. * @param watcher - Instance of a Chokidar watcher
  498. * @return {string[]} - List of absolute paths
  499. * @ignore
  500. * @private
  501. */
  502. const getWatchedFiles = watcher => {
  503. const watchedDirs = watcher.getWatched();
  504. return Object.keys(watchedDirs).reduce(
  505. (acc, dir) => [
  506. ...acc,
  507. ...watchedDirs[dir].map(file => path.join(dir, file))
  508. ],
  509. []
  510. );
  511. };
  512. /**
  513. * Hide the cursor.
  514. * @ignore
  515. * @private
  516. */
  517. const hideCursor = () => {
  518. process.stdout.write('\u001b[?25l');
  519. };
  520. /**
  521. * Show the cursor.
  522. * @ignore
  523. * @private
  524. */
  525. const showCursor = () => {
  526. process.stdout.write('\u001b[?25h');
  527. };
  528. /**
  529. * Erases the line on stdout
  530. * @private
  531. */
  532. const eraseLine = () => {
  533. process.stdout.write('\u001b[2K');
  534. };
  535. /**
  536. * Blast all of the watched files out of `require.cache`
  537. * @param {FSWatcher} watcher - Chokidar FSWatcher
  538. * @ignore
  539. * @private
  540. */
  541. const blastCache = watcher => {
  542. const files = getWatchedFiles(watcher);
  543. files.forEach(file => {
  544. delete require.cache[file];
  545. });
  546. debug('deleted %d file(s) from the require cache', files.length);
  547. };