Skip to content

Instantly share code, notes, and snippets.

@mark-wiemer
Last active January 8, 2026 05:38
Show Gist options
  • Select an option

  • Save mark-wiemer/e98f4aa9e49fd749519e6789d6ae564d to your computer and use it in GitHub Desktop.

Select an option

Save mark-wiemer/e98f4aa9e49fd749519e6789d6ae564d to your computer and use it in GitHub Desktop.
Mocha architecture

Startup

  1. package.json bin prop defines mocha as bin/mocha.js

bin/mocha.js

  1. bin/mocha.js uses lib/cli/options.js to parse command line args
  2. parses v8 options
  3. conditionally disables test timeout
  4. does some more stuff {/* todo */}
  5. If args are provided, calls lib/cli/cli.js in a separate process
  6. Else calls lib/cli/cli.js in the same process, printing help and exiting

We assume non-help args are provided for this example

cli.js

cli.js does more parsing and then goes to lib/cli/run.js via yargs().command(commands.run). See also commands.js.

run.js and run-helpers.js

  1. run.js exports a yargs object with metadata, specifically a builder and handler property
    1. This includes middleware calling run-helpers#handleRequires for the --require arg values
  2. builder handles options: mutually exclusive options, deprecated options, etc.
  3. handler creates a new instance of Mocha and calls lib/cli/run-helpers.js#runMocha with it
  4. run-helpers.js runMocha reads options and delegates to one of four run functions
  5. watchParallelRun, watchRun, parallelRun, and singleRun are documented in source
    • We will go to watchRun for this example

watch-run.js

watchRun

  1. watch-run.js exports the watchRun function
  2. watchRun returns a call to createWatcher with a beforeRun callback, which
    1. calls mocha.unloadFile()
    2. creates rootSuite = mocha.suite.clone()
    3. calls mocha.dispose()
    4. creates Mocha = require("../mocha")
    5. PR 5409: creates plugins = runHelpers.handleRequires(...)
    6. creates newMocha = new Mocha(...)

createWatcher

  1. watches all files if there was no watchFiles arg passed in
    1. else use watchFiles
  2. createWatcher creates allowed via createPathFilter on the watch globs with the current working directory as the base path
  3. createPathFilter creates a PathPattern object, which has the following structure:
    {
      "dir": {
        // raw directories
        "paths": "Set<string>", // treat the provided paths as literals
        "glob": "Set<`${string}/**/*`>" // if a dir, watch all its children
      },
      "match": {
        // match globs to check against
        "paths": "Set<string>", // treat the provided paths as literals
        "glob": "Set<`${string}/**/*`>" // if a dir, watch all its children
      }
    }
  4. createPathFilter reduces the globs to their Glob.patterns via the glob package
  5. For each pattern, createPathFilter:
    1. finds the first glob entry in the pattern (or null if there isn't one)
    2. adds the non-glob segments/entries to the base path, then adds that to dir.paths
    3. does the same with /**/* to dir.globs
    4. adds absolute path to either match.globs (if it found a glob entry) or match.paths (otherwise)
    5. ensure match.globs includes absolute path + /**/*
  6. createPathFilter returns to createWatcher
  7. createWatcher creates ignored via createPathFilter on the ignore globs with cwd as the base path
    • See above for details of createPathFilter
  8. createWatcher calls createPathMatcher on the return values of the previous createPathFilter calls with cwd
  9. createPathMatcher returns a PathMatcher object:
    /**
     * Object for matching paths to either allow or ignore them.
     * @private
     * @typedef {Object} PathMatcher
     * @property {AllowMatchFunction} allow Checks if the file path matches the allowed patterns.
     * @property {chokidar.MatchFunction} ignore The chokidar `ignored` match function.
     */
  10. createPathMatcher().allow checks a cache, then calls matchPattern(filePath, allowed.match) if needed
  11. matchPattern short-circuits to true if the file is one of the directories (likely very rare)
  12. matchPattern skips the isPathInside (is-path-inside) check because its third param matchParent was not truthy
    • This block checks of the pattern entries for one inside the filePath entry
  13. matchPattern then uses (minimatch) to check if the file matches
    • Chokidar 3 uses anymatch, which uses picomatch for globs. Both mini and pico are popular, we could consider pico for bug 5374 if needed
  14. createPathMatcher().ignore calls matchPattern on filePath
  15. createWatcher creates a watcher object
    1. main: chokidar.watch
    2. PR 5409: if TEST_MODE use EventEmitter, else copy main
      1. use globalSetupBarrier to ensure test watcher doesn't fire too early? (left comment)
    3. chokidar.watch instantly starts readying the watcher
      1. tracking all files to be matched
      2. watcher emits ready event when its ready to watch for changes
  16. creates rerunner = createRerunner(...)
    1. creates run callback
      1. calls beforeRun callback
      2. creates runner via mocha.run(...)
        1. sets runner to null
        2. calls blastCache(watcher)
        3. calls rerun() if rerunScheduled
          1. else logs "waiting for changes"
      3. catches errors and logs the stack to console.error
    2. creates scheduleRun callback
      1. short-circuits if rerunScheduled
      2. sets rerunScheduled to true
      3. if there is an existing runner, calls runner.abort()
        1. else calls rerun()
    3. creates rerun callback
      1. sets rerunScheduled to false
      2. calls eraseLine()
      3. calls run()
  17. PR 5409: creates ready = false (main does not have ready value)
  18. calls watcher.on("ready", ...)
    1. main:
      1. if globalFixtureContext is falsy, sets it to the result of calling mocha.runGlobalSetup()
      2. calls rerunner.run()
    2. PR 5409:
      1. sets ready = true
      2. if TEST_MODE, calls sendMessage({ received: ["ready" ]})
  19. calls watcher.on("all", ...)
    1. main:
      watcher.on('all', (_event, filePath) => {
      // only allow file paths that match the allowed patterns
      if (matcher.allow(filePath)) {
      	rerunner.scheduleRun();
      }
      });
    2. PR 5409:
      1. if test mode, call sendMessage and continue
      2. if matcher does not allow file path, debug and return
      3. call doHandle (which might call itself recursively)
        1. if exiting (based on SIGINT received), return
        2. if unlink event and we should handle the event
          1. (should handle == stat available and file has been modified since we started, or stat not available but watcher is ready)
            1. 🔑 note that we can now schedule a run before the watcher is ready, this is a key change!
          2. if globalFixtureContext is created, call rerunner.scheduleRun
            1. else do nothing
          3. return
        3. if not ready and stat not available
          1. call fs.stat (async) and if it returns a stat, call doHandle recursively with the returned stat
  20. PR 5409: calls sendMessage({ listening: true }) if TEST_MODE
  21. PR 5409: calls mocha.runGlobalSetup()
    1. .then call through globalSetupBarrier
    2. .then call through to set globalFixtureContext
      1. and return rerunner.run()
  22. calls hideCursor()
  23. calls process.on("exit", () => { showCursor(); })
  24. createWatcher returns the watcher object

watcher.on("ready", ...) callback

  1. watcher.on("ready", ...) is called once Chokidar readies the watcher
  2. calls mocha.runGlobalSetup() (no args provided)
    1. If there is anything to do, lib/mocha.js runGlobalSetup awaits _runGlobalFixtures (context is empty obj in this case)
    2. _runGlobalFixtures loops through each fixture function defined in options.globalSetup (ref https://mochajs.org/next/features/global-fixtures/#global-setup-fixtures)
    3. runGlobalSetup returns the updated context object, which the on-ready handler saves to ensure it doesn't call runGlobalSetup twice
  3. calls rerunner.run()
    1. calls options.beforeRun({mocha, watcher})
    2. calls mocha.run(...) (Mocha.prototype.run) with an anonymous done-callback

lib/mocha.js

  1. Top-level calls require('./interfaces') which includes lib/interfaces/exports.js (see EVENT_FILE_REQUIRE, {/* todo confirm with debug */})
  2. run calls _guardRunningStateTransition to ensure a new test run is valid
  3. run sets state to running
  4. If previous runner exists, run disposes it and resets the suite
  5. If there are files and it's not set to lazy load, it eagerly loads files with loadFiles
    • This emits the EVENT_FILE_PRE_REQUIRE, EVENT_FILE_REQUIRE, and EVENT_FILE_POST_REQUIRE events
    • EVENT_FILE_REQUIRE has a listener defined in lib/interfaces/exports.js to populate the Suite and Test objects via visit
  6. run calls _runnerClass,
    • which is either
      • a lib/nodejs/parallel-buffered-runner.js ParallelBufferedRunner or
      • a lib/runner.js Runner
    • we use Runner for this example since we have assumed non-parallel earlier (watchRun instead of watchParallelRun)
    • creating the runner just returns an object
  7. run calls createStatsCollector which listens to various events:
    • EVENT_RUN_BEGIN
    • EVENT_SUITE_BEGIN
    • EVENT_TEST_PASS
    • EVENT_TEST_FAIL
    • EVENT_TEST_PENDING
    • EVENT_TEST_END
    • EVENT_RUN_END
  8. run calls _reporter which creates a new reporter based on options (done in Mocha constructor)
  9. run sets some properties on the runner:
    • checkLeaks
    • fullStackTrace
    • asyncOnly
    • allowUncaught
    • forbidOnly
    • forbidPending
  10. If options.grep, run calls runner.grep which grep each test title
  11. If options.global, run calls runner.globals which appends the provided globals to the runner's _globals array
  12. run sets up reporter options:
    • useColors
    • inlineDiffs
    • hideDiff
  13. run defines done which wraps around the passed-in done-callback
  14. run defines runAsync: 3. calls context = runGlobalSetup(runner) if appropriate 4. calls runner.runAsync 5. calls runGlobalTeardown(runner, {context}) if appropriate
  15. run calls runAsync(runner).then(done)
  16. run returns runner

lib/runner.js

  1. runAsync calls run
  2. If options.cleanReferencesAfterRun, run sets up an on(EVENT_SUITE_END, ...) handler to call suite.cleanReferences
  3. run sets an on(EVENT_RUN_END, ...) handler to set state to stopped and call the done-callback with failures
  4. run calls _removeEventListener on uncaughtException and unhandledRejection
  5. run calls _addEventListener on the same events
    • These two steps are likely deduping and fixing bookkeeping
  6. run waits for options.delay if needed (we'll skip these details for now) {/* todo */}
  7. run calls inner function prepare
  8. prepare filters for any only suites
  9. prepare calls begin, another function inner to run
  10. begin emits EVENT_RUN_BEGIN
  11. begin calls runSuite(rootSuite, end), where end is another function inner to run
  12. runSuite calls grepTotal on the suite
  13. If no tests to run (or there was a failure and options.bail is truthy), runSuite returns a call to its fn (end)
  14. Else, runSuite emits EVENT_SUITE_BEGIN
  15. runSuite defines next, a function that determines the next suite
  16. runSuite defines done, a function that starts executing the next suite
  17. runSuite calls hook with HOOK_TYPE_BEFORE_ALL and a function that calls runTests
  18. If options.dryRun is truthy, hook short-circuits and calls its fn
  19. hook gets the hooks of the given name (HOOK_TYPE_BEFORE_ALL) using suite.getHooks
  20. hook executes all the hooks and calls fn (we are skipping these details too) {/* todo */}
  21. hook#fn (defined in runSuite) calls runTests with runSuite#next as its fn
  22. runTests defines next which handles hooks, errors, and running the next test
  23. runTests calls next
  24. If self._abort is truthy, next returns a call to runTests#fn (in this case it's runSuite#next)
  25. If there's an error, next returns a call to hookErr
  26. If there are no more tests, next returns a call to runTests#fn
  27. If the current test doesn't match the grep, next calls itself, then returns
  28. If test.isPending(), next handles that, calls itself, then returns {/* todo */}
  29. next emits EVENT_TEST_BEGIN
  30. next calls hookDown on HOOK_TYPE_BEFORE_EACH with a function that calls runTest and handles edge cases
  31. hookDown calls all hooks matching that type, then calls its fn
  32. hookDown#fn (defined in runTests#next):
  33. If the test is pending, handles that, emits events, and returns a call to hookUp with a function that calls runTests#next
  34. If there's an error, returns a call to hookErr
  35. Calls runTest with a big function
  36. runTest calls _addEventListener for the error event to fail the test
  37. If allowUncaught is truthy, runTest returns test.run(fn)
  38. Else runTest calls test.run(fn) in a try and returns fn(err) in a catch

lib/test.js

  1. Test inherits from Runnable via utils.inherits (yuck!)
  2. Runnable defines run

lib/runnable.js

  1. run calls fn.call(ctx), which actually runs the test!

Open questions

  1. How does a delayed run start?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment