diff --git a/lib/mocha.cjs b/lib/mocha.cjs index aad4cedd5b..9ba15b1f06 100644 --- a/lib/mocha.cjs +++ b/lib/mocha.cjs @@ -1135,7 +1135,7 @@ Mocha.prototype.parallelMode = function parallelMode(enable = true) { // swap Runner class this._runnerClass = parallel - ? require("./nodejs/parallel-buffered-runner.cjs") + ? require("./nodejs/parallel-buffered-runner.mjs").ParallelBufferedRunner : exports.Runner; // lazyLoadFiles may have been set `true` otherwise (for ESM loading), diff --git a/lib/nodejs/buffered-worker-pool.cjs b/lib/nodejs/buffered-worker-pool.cjs deleted file mode 100644 index cd357df9e1..0000000000 --- a/lib/nodejs/buffered-worker-pool.cjs +++ /dev/null @@ -1,194 +0,0 @@ -/** - * A wrapper around a third-party child process worker pool implementation. - * Used by {@link module:buffered-runner}. - * @private - * @module buffered-worker-pool - */ - -"use strict"; - -/** - * @typedef {import('workerpool').WorkerPoolOptions} WorkerPoolOptions - * @typedef {import('../types.d.ts').MochaOptions} MochaOptions - * @typedef {import('../types.d.ts').SerializedWorkerResult} SerializedWorkerResult - */ - -const serializeJavascript = require("serialize-javascript"); -const workerpool = require("workerpool"); -const { deserialize } = require("./serializer.js"); -const debug = require("debug")("mocha:parallel:buffered-worker-pool"); -const { createInvalidArgumentTypeError } = require("../errors.js"); - -const WORKER_PATH = require.resolve("./worker.cjs"); - -/** - * A mapping of Mocha `Options` objects to serialized values. - * - * This is helpful because we tend to same the same options over and over - * over IPC. - * @type {WeakMap} - */ -let optionsCache = new WeakMap(); - -/** - * These options are passed into the [workerpool](https://npm.im/workerpool) module. - * @type {Partial} - */ -const WORKER_POOL_DEFAULT_OPTS = { - // use child processes, not worker threads! - workerType: "process", - // ensure the same flags sent to `node` for this `mocha` invocation are passed - // along to children - forkOpts: { execArgv: process.execArgv }, - maxWorkers: workerpool.cpus - 1, -}; - -/** - * A wrapper around a third-party worker pool implementation. - * @private - */ -class BufferedWorkerPool { - /** - * Creates an underlying worker pool instance; determines max worker count - * @param {Partial} [opts] - Options - */ - constructor(opts = {}) { - const maxWorkers = Math.max( - 1, - typeof opts.maxWorkers === "undefined" - ? WORKER_POOL_DEFAULT_OPTS.maxWorkers - : opts.maxWorkers, - ); - - /* istanbul ignore next */ - if (workerpool.cpus < 2) { - // TODO: decide whether we should warn - debug( - "not enough CPU cores available to run multiple jobs; avoid --parallel on this machine", - ); - } else if (maxWorkers >= workerpool.cpus) { - // TODO: decide whether we should warn - debug( - "%d concurrent job(s) requested, but only %d core(s) available", - maxWorkers, - workerpool.cpus, - ); - } - /* istanbul ignore next */ - debug( - "run(): starting worker pool of max size %d, using node args: %s", - maxWorkers, - process.execArgv.join(" "), - ); - - let counter = 0; - const onCreateWorker = ({ forkOpts }) => { - return { - forkOpts: { - ...forkOpts, - // adds an incremental id to all workers, which can be useful to allocate resources for each process - env: { ...process.env, MOCHA_WORKER_ID: counter++ }, - }, - }; - }; - - this.options = { - ...WORKER_POOL_DEFAULT_OPTS, - ...opts, - maxWorkers, - onCreateWorker, - }; - this._pool = workerpool.pool(WORKER_PATH, this.options); - } - - /** - * Terminates all workers in the pool. - * @param {boolean} [force] - Whether to force-kill workers. By default, lets workers finish their current task before termination. - * @private - * @returns {Promise} - */ - async terminate(force = false) { - /* istanbul ignore next */ - debug("terminate(): terminating with force = %s", force); - return this._pool.terminate(force); - } - - /** - * Adds a test file run to the worker pool queue for execution by a worker process. - * - * Handles serialization/deserialization. - * - * @param {string} filepath - Filepath of test - * @param {MochaOptions} [options] - Options for Mocha instance - * @private - * @returns {Promise} - */ - async run(filepath, options = {}) { - if (!filepath || typeof filepath !== "string") { - throw createInvalidArgumentTypeError( - "Expected a non-empty filepath", - "filepath", - "string", - ); - } - const serializedOptions = BufferedWorkerPool.serializeOptions(options); - const result = await this._pool.exec("run", [filepath, serializedOptions]); - return deserialize(result); - } - - /** - * Returns stats about the state of the worker processes in the pool. - * - * Used for debugging. - * - * @private - */ - stats() { - return this._pool.stats(); - } - - /** - * Instantiates a {@link WorkerPool}. - * @private - */ - static create(...args) { - return new BufferedWorkerPool(...args); - } - - /** - * Given Mocha options object `opts`, serialize into a format suitable for - * transmission over IPC. - * - * @param {MochaOptions} [opts] - Mocha options - * @private - * @returns {string} Serialized options - */ - static serializeOptions(opts = {}) { - if (!optionsCache.has(opts)) { - const serialized = serializeJavascript(opts, { - unsafe: true, // this means we don't care about XSS - ignoreFunction: true, // do not serialize functions - }); - optionsCache.set(opts, serialized); - /* istanbul ignore next */ - debug( - "serializeOptions(): serialized options %O to: %s", - opts, - serialized, - ); - } - return optionsCache.get(opts); - } - - /** - * Resets internal cache of serialized options objects. - * - * For testing/debugging - * @private - */ - static resetOptionsCache() { - optionsCache = new WeakMap(); - } -} - -exports.BufferedWorkerPool = BufferedWorkerPool; diff --git a/lib/nodejs/buffered-worker-pool.mjs b/lib/nodejs/buffered-worker-pool.mjs new file mode 100644 index 0000000000..5421327f2a --- /dev/null +++ b/lib/nodejs/buffered-worker-pool.mjs @@ -0,0 +1,208 @@ +/** + * A wrapper around a third-party child process worker pool implementation. + * Used by {@link module:buffered-runner}. + * @private + * @module buffered-worker-pool + */ + +import { fileURLToPath } from "node:url"; +import debugFactory from "debug"; +import serializeJavascript from "serialize-javascript"; +import workerpool from "workerpool"; +import { createInvalidArgumentTypeError } from "../errors.js"; +import { deserialize } from "./serializer.js"; + +/** + * @typedef {import('workerpool').WorkerPoolOptions} WorkerPoolOptions + * @typedef {import('../types.d.ts').MochaOptions} MochaOptions + * @typedef {import('../types.d.ts').SerializedWorkerResult} SerializedWorkerResult + */ + +const debug = debugFactory("mocha:parallel:buffered-worker-pool"); + +const WORKER_PATH = fileURLToPath(new URL("./worker.mjs", import.meta.url)); + +/** + * A mapping of Mocha `Options` objects to serialized values. + * + * This is helpful because we tend to same the same options over and over + * over IPC. + * @type {WeakMap} + */ +const createOptionsCache = () => new WeakMap(); + +/** + * These options are passed into the [workerpool](https://npm.im/workerpool) module. + * @type {Partial} + */ +const createWorkerPoolDefaultOpts = (workerpoolImpl) => ({ + // use child processes, not worker threads! + workerType: "process", + // ensure the same flags sent to `node` for this `mocha` invocation are passed + // along to children + forkOpts: { execArgv: process.execArgv }, + maxWorkers: workerpoolImpl.cpus - 1, +}); + +/** + * A wrapper around a third-party worker pool implementation. + * @private + */ +export const createBufferedWorkerPoolClass = ({ + deserialize: deserializeImpl = deserialize, + serializeJavascript: serializeJavascriptImpl = serializeJavascript, + workerPath = WORKER_PATH, + workerpool: workerpoolImpl = workerpool, +} = {}) => { + let optionsCache = createOptionsCache(); + const WORKER_POOL_DEFAULT_OPTS = createWorkerPoolDefaultOpts(workerpoolImpl); + + return class BufferedWorkerPool { + /** + * Creates an underlying worker pool instance; determines max worker count + * @param {Partial} [opts] - Options + */ + constructor(opts = {}) { + const maxWorkers = Math.max( + 1, + typeof opts.maxWorkers === "undefined" + ? WORKER_POOL_DEFAULT_OPTS.maxWorkers + : opts.maxWorkers, + ); + + /* istanbul ignore next */ + if (workerpoolImpl.cpus < 2) { + // TODO: decide whether we should warn + debug( + "not enough CPU cores available to run multiple jobs; avoid --parallel on this machine", + ); + } else if (maxWorkers >= workerpoolImpl.cpus) { + // TODO: decide whether we should warn + debug( + "%d concurrent job(s) requested, but only %d core(s) available", + maxWorkers, + workerpoolImpl.cpus, + ); + } + /* istanbul ignore next */ + debug( + "run(): starting worker pool of max size %d, using node args: %s", + maxWorkers, + process.execArgv.join(" "), + ); + + let counter = 0; + const onCreateWorker = ({ forkOpts }) => { + return { + forkOpts: { + ...forkOpts, + // adds an incremental id to all workers, which can be useful to allocate resources for each process + env: { ...process.env, MOCHA_WORKER_ID: counter++ }, + }, + }; + }; + + this.options = { + ...WORKER_POOL_DEFAULT_OPTS, + ...opts, + maxWorkers, + onCreateWorker, + }; + this._pool = workerpoolImpl.pool(workerPath, this.options); + } + + /** + * Terminates all workers in the pool. + * @param {boolean} [force] - Whether to force-kill workers. By default, lets workers finish their current task before termination. + * @private + * @returns {Promise} + */ + async terminate(force = false) { + /* istanbul ignore next */ + debug("terminate(): terminating with force = %s", force); + return this._pool.terminate(force); + } + + /** + * Adds a test file run to the worker pool queue for execution by a worker process. + * + * Handles serialization/deserialization. + * + * @param {string} filepath - Filepath of test + * @param {MochaOptions} [options] - Options for Mocha instance + * @private + * @returns {Promise} + */ + async run(filepath, options = {}) { + if (!filepath || typeof filepath !== "string") { + throw createInvalidArgumentTypeError( + "Expected a non-empty filepath", + "filepath", + "string", + ); + } + const serializedOptions = BufferedWorkerPool.serializeOptions(options); + const result = await this._pool.exec("run", [ + filepath, + serializedOptions, + ]); + return deserializeImpl(result); + } + + /** + * Returns stats about the state of the worker processes in the pool. + * + * Used for debugging. + * + * @private + */ + stats() { + return this._pool.stats(); + } + + /** + * Instantiates a {@link WorkerPool}. + * @private + */ + static create(...args) { + return new BufferedWorkerPool(...args); + } + + /** + * Given Mocha options object `opts`, serialize into a format suitable for + * transmission over IPC. + * + * @param {MochaOptions} [opts] - Mocha options + * @private + * @returns {string} Serialized options + */ + static serializeOptions(opts = {}) { + if (!optionsCache.has(opts)) { + const serialized = serializeJavascriptImpl(opts, { + unsafe: true, // this means we don't care about XSS + ignoreFunction: true, // do not serialize functions + }); + optionsCache.set(opts, serialized); + /* istanbul ignore next */ + debug( + "serializeOptions(): serialized options %O to: %s", + opts, + serialized, + ); + } + return optionsCache.get(opts); + } + + /** + * Resets internal cache of serialized options objects. + * + * For testing/debugging + * @private + */ + static resetOptionsCache() { + optionsCache = createOptionsCache(); + } + }; +}; + +export const BufferedWorkerPool = createBufferedWorkerPoolClass(); diff --git a/lib/nodejs/parallel-buffered-runner.cjs b/lib/nodejs/parallel-buffered-runner.mjs similarity index 95% rename from lib/nodejs/parallel-buffered-runner.cjs rename to lib/nodejs/parallel-buffered-runner.mjs index de2d8128b5..90265bc4f5 100644 --- a/lib/nodejs/parallel-buffered-runner.cjs +++ b/lib/nodejs/parallel-buffered-runner.mjs @@ -4,7 +4,12 @@ * @private */ -"use strict"; +import { fileURLToPath } from "node:url"; +import debugFactory from "debug"; +import Runner from "../runner.cjs"; +import { createFatalError } from "../errors.js"; +import utils from "../utils.cjs"; +import { BufferedWorkerPool } from "./buffered-worker-pool.mjs"; /** * @typedef {import('../types.d.ts').FileRunner} FileRunner @@ -13,17 +18,15 @@ * @typedef {import('../types.d.ts').SigIntListener} SigIntListener */ -const Runner = require("../runner.cjs"); const { EVENT_RUN_BEGIN, EVENT_RUN_END } = Runner.constants; -const debug = require("debug")("mocha:parallel:parallel-buffered-runner"); -const { BufferedWorkerPool } = require("./buffered-worker-pool.cjs"); +const debug = debugFactory("mocha:parallel:parallel-buffered-runner"); const { setInterval, clearInterval } = global; -const { createMap, constants } = require("../utils.cjs"); +const { createMap, constants } = utils; const { MOCHA_ID_PROP_NAME } = constants; -const { createFatalError } = require("../errors.js"); -const DEFAULT_WORKER_REPORTER = - require.resolve("./reporters/parallel-buffered.cjs"); +const DEFAULT_WORKER_REPORTER = fileURLToPath( + new URL("./reporters/parallel-buffered.mjs", import.meta.url), +); /** * List of options to _not_ serialize for transmission to workers @@ -81,7 +84,7 @@ const states = createMap({ * {@link Runnable}s by itself! * @public */ -class ParallelBufferedRunner extends Runner { +export class ParallelBufferedRunner extends Runner { constructor(...args) { super(...args); @@ -418,5 +421,3 @@ class ParallelBufferedRunner extends Runner { return this; } } - -module.exports = ParallelBufferedRunner; diff --git a/lib/nodejs/reporters/parallel-buffered.cjs b/lib/nodejs/reporters/parallel-buffered.cjs deleted file mode 100644 index 1fb0c0ca98..0000000000 --- a/lib/nodejs/reporters/parallel-buffered.cjs +++ /dev/null @@ -1,164 +0,0 @@ -/** - * "Buffered" reporter used internally by a worker process when running in parallel mode. - * @module nodejs/reporters/parallel-buffered - * @public - */ - -"use strict"; - -/** - * @typedef {import('../../types.d.ts').BufferedEvent} BufferedEvent - * @typedef {import('../../runner.cjs')} Runner - */ - -/** - * Module dependencies. - */ - -const { - EVENT_SUITE_BEGIN, - EVENT_SUITE_END, - EVENT_TEST_FAIL, - EVENT_TEST_PASS, - EVENT_TEST_PENDING, - EVENT_TEST_BEGIN, - EVENT_TEST_END, - EVENT_TEST_RETRY, - EVENT_DELAY_BEGIN, - EVENT_DELAY_END, - EVENT_HOOK_BEGIN, - EVENT_HOOK_END, - EVENT_RUN_END, -} = require("../../runner.cjs").constants; -const { - SerializableEvent, - SerializableWorkerResult, -} = require("../serializer.js"); -const debug = require("debug")("mocha:reporters:buffered"); -const { Base } = require("../../reporters/base.js"); - -/** - * List of events to listen to; these will be buffered and sent - * when `Mocha#run` is complete (via {@link ParallelBuffered#done}). - */ -const EVENT_NAMES = [ - EVENT_SUITE_BEGIN, - EVENT_SUITE_END, - EVENT_TEST_BEGIN, - EVENT_TEST_PENDING, - EVENT_TEST_FAIL, - EVENT_TEST_PASS, - EVENT_TEST_RETRY, - EVENT_TEST_END, - EVENT_HOOK_BEGIN, - EVENT_HOOK_END, -]; - -/** - * Like {@link EVENT_NAMES}, except we expect these events to only be emitted - * by the `Runner` once. - */ -const ONCE_EVENT_NAMES = [EVENT_DELAY_BEGIN, EVENT_DELAY_END]; - -/** - * The `ParallelBuffered` reporter is used by each worker process in "parallel" - * mode, by default. Instead of reporting to `STDOUT`, etc., it retains a - * list of events it receives and hands these off to the callback passed into - * {@link Mocha#run}. That callback will then return the data to the main - * process. - * @public - */ -class ParallelBuffered extends Base { - /** - * Calls {@link ParallelBuffered#createListeners} - * @param {Runner} runner - */ - constructor(runner, opts) { - super(runner, opts); - - /** - * Retained list of events emitted from the {@link Runner} instance. - * @type {BufferedEvent[]} - * @public - */ - this.events = []; - - /** - * Map of `Runner` event names to listeners (for later teardown) - * @public - * @type {Map} - */ - this.listeners = new Map(); - - this.createListeners(runner); - } - - /** - * Returns a new listener which saves event data in memory to - * {@link ParallelBuffered#events}. Listeners are indexed by `eventName` and stored - * in {@link ParallelBuffered#listeners}. This is a defensive measure, so that we - * don't a) leak memory or b) remove _other_ listeners that may not be - * associated with this reporter. - * - * Subclasses could override this behavior. - * - * @public - * @param {string} eventName - Name of event to create listener for - * @returns {EventListener} - */ - createListener(eventName) { - const listener = (runnable, err) => { - this.events.push(SerializableEvent.create(eventName, runnable, err)); - }; - return this.listeners.set(eventName, listener).get(eventName); - } - - /** - * Creates event listeners (using {@link ParallelBuffered#createListener}) for each - * reporter-relevant event emitted by a {@link Runner}. This array is drained when - * {@link ParallelBuffered#done} is called by {@link Runner#run}. - * - * Subclasses could override this behavior. - * @public - * @param {Runner} runner - Runner instance - * @returns {ParallelBuffered} - * @chainable - */ - createListeners(runner) { - EVENT_NAMES.forEach((evt) => { - runner.on(evt, this.createListener(evt)); - }); - ONCE_EVENT_NAMES.forEach((evt) => { - runner.once(evt, this.createListener(evt)); - }); - - runner.once(EVENT_RUN_END, () => { - debug("received EVENT_RUN_END"); - this.listeners.forEach((listener, evt) => { - runner.removeListener(evt, listener); - this.listeners.delete(evt); - }); - }); - - return this; - } - - /** - * Calls the {@link Mocha#run} callback (`callback`) with the test failure - * count and the array of {@link BufferedEvent} objects. Resets the array. - * - * This is called directly by `Runner#run` and should not be called by any other consumer. - * - * Subclasses could override this. - * - * @param {number} failures - Number of failed tests - * @param {Function} callback - The callback passed to {@link Mocha#run}. - * @public - */ - done(failures, callback) { - callback(SerializableWorkerResult.create(this.events, failures)); - this.events = []; // defensive - } -} - -module.exports = ParallelBuffered; diff --git a/lib/nodejs/reporters/parallel-buffered.mjs b/lib/nodejs/reporters/parallel-buffered.mjs new file mode 100644 index 0000000000..12544b9af1 --- /dev/null +++ b/lib/nodejs/reporters/parallel-buffered.mjs @@ -0,0 +1,171 @@ +/** + * "Buffered" reporter used internally by a worker process when running in parallel mode. + * @module nodejs/reporters/parallel-buffered + * @public + */ + +import debugFactory from "debug"; +import Runner from "../../runner.cjs"; +import { Base } from "../../reporters/base.js"; +import { SerializableEvent, SerializableWorkerResult } from "../serializer.js"; + +/** + * @typedef {import('../../types.d.ts').BufferedEvent} BufferedEvent + * @typedef {import('../../runner.cjs')} Runner + */ + +/** + * Module dependencies. + */ + +const { + EVENT_SUITE_BEGIN, + EVENT_SUITE_END, + EVENT_TEST_FAIL, + EVENT_TEST_PASS, + EVENT_TEST_PENDING, + EVENT_TEST_BEGIN, + EVENT_TEST_END, + EVENT_TEST_RETRY, + EVENT_DELAY_BEGIN, + EVENT_DELAY_END, + EVENT_HOOK_BEGIN, + EVENT_HOOK_END, + EVENT_RUN_END, +} = Runner.constants; +const debug = debugFactory("mocha:reporters:buffered"); + +/** + * List of events to listen to; these will be buffered and sent + * when `Mocha#run` is complete (via {@link ParallelBuffered#done}). + */ +const EVENT_NAMES = [ + EVENT_SUITE_BEGIN, + EVENT_SUITE_END, + EVENT_TEST_BEGIN, + EVENT_TEST_PENDING, + EVENT_TEST_FAIL, + EVENT_TEST_PASS, + EVENT_TEST_RETRY, + EVENT_TEST_END, + EVENT_HOOK_BEGIN, + EVENT_HOOK_END, +]; + +/** + * Like {@link EVENT_NAMES}, except we expect these events to only be emitted + * by the `Runner` once. + */ +const ONCE_EVENT_NAMES = [EVENT_DELAY_BEGIN, EVENT_DELAY_END]; + +/** + * The `ParallelBuffered` reporter is used by each worker process in "parallel" + * mode, by default. Instead of reporting to `STDOUT`, etc., it retains a + * list of events it receives and hands these off to the callback passed into + * {@link Mocha#run}. That callback will then return the data to the main + * process. + * @public + */ +export const createParallelBufferedClass = ({ + Base: BaseImpl = Base, + SerializableEvent: SerializableEventImpl = SerializableEvent, + SerializableWorkerResult: + SerializableWorkerResultImpl = SerializableWorkerResult, +} = {}) => + class ParallelBuffered extends BaseImpl { + /** + * Calls {@link ParallelBuffered#createListeners} + * @param {Runner} runner + */ + constructor(runner, opts) { + super(runner, opts); + + /** + * Retained list of events emitted from the {@link Runner} instance. + * @type {BufferedEvent[]} + * @public + */ + this.events = []; + + /** + * Map of `Runner` event names to listeners (for later teardown) + * @public + * @type {Map} + */ + this.listeners = new Map(); + + this.createListeners(runner); + } + + /** + * Returns a new listener which saves event data in memory to + * {@link ParallelBuffered#events}. Listeners are indexed by `eventName` and stored + * in {@link ParallelBuffered#listeners}. This is a defensive measure, so that we + * don't a) leak memory or b) remove _other_ listeners that may not be + * associated with this reporter. + * + * Subclasses could override this behavior. + * + * @public + * @param {string} eventName - Name of event to create listener for + * @returns {EventListener} + */ + createListener(eventName) { + const listener = (runnable, err) => { + this.events.push( + SerializableEventImpl.create(eventName, runnable, err), + ); + }; + return this.listeners.set(eventName, listener).get(eventName); + } + + /** + * Creates event listeners (using {@link ParallelBuffered#createListener}) for each + * reporter-relevant event emitted by a {@link Runner}. This array is drained when + * {@link ParallelBuffered#done} is called by {@link Runner#run}. + * + * Subclasses could override this behavior. + * @public + * @param {Runner} runner - Runner instance + * @returns {ParallelBuffered} + * @chainable + */ + createListeners(runner) { + EVENT_NAMES.forEach((evt) => { + runner.on(evt, this.createListener(evt)); + }); + ONCE_EVENT_NAMES.forEach((evt) => { + runner.once(evt, this.createListener(evt)); + }); + + runner.once(EVENT_RUN_END, () => { + debug("received EVENT_RUN_END"); + this.listeners.forEach((listener, evt) => { + runner.removeListener(evt, listener); + this.listeners.delete(evt); + }); + }); + + return this; + } + + /** + * Calls the {@link Mocha#run} callback (`callback`) with the test failure + * count and the array of {@link BufferedEvent} objects. Resets the array. + * + * This is called directly by `Runner#run` and should not be called by any other consumer. + * + * Subclasses could override this. + * + * @param {number} failures - Number of failed tests + * @param {Function} callback - The callback passed to {@link Mocha#run}. + * @public + */ + done(failures, callback) { + callback(SerializableWorkerResultImpl.create(this.events, failures)); + this.events = []; // defensive + } + }; + +export const ParallelBuffered = createParallelBufferedClass(); +export default ParallelBuffered; diff --git a/lib/nodejs/worker-core.mjs b/lib/nodejs/worker-core.mjs new file mode 100644 index 0000000000..3772ca8743 --- /dev/null +++ b/lib/nodejs/worker-core.mjs @@ -0,0 +1,174 @@ +/** + * Worker runtime helpers for parallel mode. + * @module worker-core + * @private + */ + +import debugModule from "debug"; +import Mocha from "../mocha.cjs"; +import runHelpers from "../cli/run-helpers.cjs"; +import { + createInvalidArgumentTypeError, + createInvalidArgumentValueError, +} from "../errors.js"; +import { serialize } from "./serializer.js"; +import workerpool from "workerpool"; + +/** + * @typedef {import('../types.d.ts').BufferedEvent} BufferedEvent + * @typedef {import('../types.d.ts').MochaOptions} MochaOptions + */ + +const { handleRequires, validateLegacyPlugin } = runHelpers; +const { setInterval, clearInterval } = global; + +const WORKER_ONLY_MESSAGE = + "This script is intended to be run as a worker (by the `workerpool` package)."; + +const createDebug = () => + debugModule.debug(`mocha:parallel:worker:${process.pid}`); + +const isDebugEnabled = () => + debugModule.enabled(`mocha:parallel:worker:${process.pid}`); + +export const createWorker = ({ + Mocha: MochaImpl = Mocha, + debug = createDebug(), + handleRequires: handleRequiresImpl = handleRequires, + isDebugEnabled: isDebugEnabledImpl = isDebugEnabled(), + serialize: serializeImpl = serialize, + validateLegacyPlugin: validateLegacyPluginImpl = validateLegacyPlugin, +} = {}) => { + let rootHooks; + + /** + * Initializes some stuff on the first call to {@link run}. + * + * Handles `--require` and `--ui`. Does _not_ handle `--reporter`, + * as only the `Buffered` reporter is used. + * + * **This function only runs once per worker**; it overwrites itself with a no-op + * before returning. + * + * @param {MochaOptions} argv - Command-line options + */ + let bootstrap = async (argv) => { + // globalSetup and globalTeardown do not run in workers + const plugins = await handleRequiresImpl(argv.require, { + ignoredPlugins: ["mochaGlobalSetup", "mochaGlobalTeardown"], + }); + validateLegacyPluginImpl(argv, "ui", MochaImpl.interfaces); + + rootHooks = plugins.rootHooks; + bootstrap = () => {}; + debug("bootstrap(): finished with args: %O", argv); + }; + + /** + * Runs a single test file in a worker thread. + * @param {string} filepath - Filepath of test file + * @param {string} [serializedOptions] - **Serialized** options. This string will be eval'd! + * @see https://npm.im/serialize-javascript + * @returns {Promise<{failures: number, events: BufferedEvent[]}>} - Test + * failure count and list of events. + */ + async function run(filepath, serializedOptions = "{}") { + if (!filepath) { + throw createInvalidArgumentTypeError( + 'Expected a non-empty "filepath" argument', + "file", + "string", + ); + } + + debug("run(): running test file %s", filepath); + + if (typeof serializedOptions !== "string") { + throw createInvalidArgumentTypeError( + "run() expects second parameter to be a string which was serialized by the `serialize-javascript` module", + "serializedOptions", + "string", + ); + } + let argv; + try { + argv = eval("(" + serializedOptions + ")"); + } catch { + throw createInvalidArgumentValueError( + "run() was unable to deserialize the options", + "serializedOptions", + serializedOptions, + ); + } + + const opts = Object.assign({ ui: "bdd" }, argv, { + // if this was true, it would cause infinite recursion. + parallel: false, + // this doesn't work in parallel mode + forbidOnly: true, + // it's useful for a Mocha instance to know if it's running in a worker process. + isWorker: true, + }); + + await bootstrap(opts); + + opts.rootHooks = rootHooks; + + const mocha = new MochaImpl(opts).addFile(filepath); + + try { + await mocha.loadFilesAsync(); + } catch (err) { + debug("run(): could not load file %s: %s", filepath, err); + throw err; + } + + return new Promise((resolve, reject) => { + let debugInterval; + /* istanbul ignore next */ + if (isDebugEnabledImpl) { + debugInterval = setInterval(() => { + debug("run(): still running %s...", filepath); + }, 5000).unref(); + } + mocha.run((result) => { + // Runner adds these; if we don't remove them, we'll get a leak. + process.removeAllListeners("uncaughtException"); + process.removeAllListeners("unhandledRejection"); + + try { + const serialized = serializeImpl(result); + debug( + "run(): completed run with %d test failures; returning to main process", + typeof result.failures === "number" ? result.failures : 0, + ); + resolve(serialized); + } catch (err) { + // TODO: figure out exactly what the sad path looks like here. + // rejection should only happen if an error is "unrecoverable" + debug("run(): serialization failed; rejecting: %O", err); + reject(err); + } finally { + clearInterval(debugInterval); + } + }); + }); + } + + return { run }; +}; + +export const startWorker = ({ + workerpool: workerpoolImpl = workerpool, + ...workerOptions +} = {}) => { + if (workerpoolImpl.isMainThread) { + throw new Error(WORKER_ONLY_MESSAGE); + } + + const debug = workerOptions.debug ?? createDebug(); + const worker = createWorker({ ...workerOptions, debug }); + workerpoolImpl.worker(worker); + debug("started worker process"); + return worker; +}; diff --git a/lib/nodejs/worker.cjs b/lib/nodejs/worker.cjs deleted file mode 100644 index 3aec72bdce..0000000000 --- a/lib/nodejs/worker.cjs +++ /dev/null @@ -1,158 +0,0 @@ -/** - * A worker process. Consumes {@link module:reporters/parallel-buffered} reporter. - * @module worker - * @private - */ - -"use strict"; - -/** - * @typedef {import('../types.d.ts').BufferedEvent} BufferedEvent - * @typedef {import('../types.d.ts').MochaOptions} MochaOptions - */ - -const { - createInvalidArgumentTypeError, - createInvalidArgumentValueError, -} = require("../errors.js"); -const workerpool = require("workerpool"); -const Mocha = require("../mocha.cjs"); -const { - handleRequires, - validateLegacyPlugin, -} = require("../cli/run-helpers.cjs"); -const d = require("debug"); -const debug = d.debug(`mocha:parallel:worker:${process.pid}`); -const isDebugEnabled = d.enabled(`mocha:parallel:worker:${process.pid}`); -const { serialize } = require("./serializer.js"); -const { setInterval, clearInterval } = global; - -let rootHooks; - -if (workerpool.isMainThread) { - throw new Error( - "This script is intended to be run as a worker (by the `workerpool` package).", - ); -} - -/** - * Initializes some stuff on the first call to {@link run}. - * - * Handles `--require` and `--ui`. Does _not_ handle `--reporter`, - * as only the `Buffered` reporter is used. - * - * **This function only runs once per worker**; it overwrites itself with a no-op - * before returning. - * - * @param {MochaOptions} argv - Command-line options - */ -let bootstrap = async (argv) => { - // globalSetup and globalTeardown do not run in workers - const plugins = await handleRequires(argv.require, { - ignoredPlugins: ["mochaGlobalSetup", "mochaGlobalTeardown"], - }); - validateLegacyPlugin(argv, "ui", Mocha.interfaces); - - rootHooks = plugins.rootHooks; - bootstrap = () => {}; - debug("bootstrap(): finished with args: %O", argv); -}; - -/** - * Runs a single test file in a worker thread. - * @param {string} filepath - Filepath of test file - * @param {string} [serializedOptions] - **Serialized** options. This string will be eval'd! - * @see https://npm.im/serialize-javascript - * @returns {Promise<{failures: number, events: BufferedEvent[]}>} - Test - * failure count and list of events. - */ -async function run(filepath, serializedOptions = "{}") { - if (!filepath) { - throw createInvalidArgumentTypeError( - 'Expected a non-empty "filepath" argument', - "file", - "string", - ); - } - - debug("run(): running test file %s", filepath); - - if (typeof serializedOptions !== "string") { - throw createInvalidArgumentTypeError( - "run() expects second parameter to be a string which was serialized by the `serialize-javascript` module", - "serializedOptions", - "string", - ); - } - let argv; - try { - argv = eval("(" + serializedOptions + ")"); - } catch { - throw createInvalidArgumentValueError( - "run() was unable to deserialize the options", - "serializedOptions", - serializedOptions, - ); - } - - const opts = Object.assign({ ui: "bdd" }, argv, { - // if this was true, it would cause infinite recursion. - parallel: false, - // this doesn't work in parallel mode - forbidOnly: true, - // it's useful for a Mocha instance to know if it's running in a worker process. - isWorker: true, - }); - - await bootstrap(opts); - - opts.rootHooks = rootHooks; - - const mocha = new Mocha(opts).addFile(filepath); - - try { - await mocha.loadFilesAsync(); - } catch (err) { - debug("run(): could not load file %s: %s", filepath, err); - throw err; - } - - return new Promise((resolve, reject) => { - let debugInterval; - /* istanbul ignore next */ - if (isDebugEnabled) { - debugInterval = setInterval(() => { - debug("run(): still running %s...", filepath); - }, 5000).unref(); - } - mocha.run((result) => { - // Runner adds these; if we don't remove them, we'll get a leak. - process.removeAllListeners("uncaughtException"); - process.removeAllListeners("unhandledRejection"); - - try { - const serialized = serialize(result); - debug( - "run(): completed run with %d test failures; returning to main process", - typeof result.failures === "number" ? result.failures : 0, - ); - resolve(serialized); - } catch (err) { - // TODO: figure out exactly what the sad path looks like here. - // rejection should only happen if an error is "unrecoverable" - debug("run(): serialization failed; rejecting: %O", err); - reject(err); - } finally { - clearInterval(debugInterval); - } - }); - }); -} - -// this registers the `run` function. -workerpool.worker({ run }); - -debug("started worker process"); - -// for testing -exports.run = run; diff --git a/lib/nodejs/worker.mjs b/lib/nodejs/worker.mjs new file mode 100644 index 0000000000..320d357a0b --- /dev/null +++ b/lib/nodejs/worker.mjs @@ -0,0 +1,13 @@ +/* istanbul ignore file */ +// worker process entrypoint; worker logic is tested through worker-core.mjs +/** + * A worker process. Consumes {@link module:reporters/parallel-buffered} reporter. + * @module worker + * @private + */ + +import { startWorker } from "./worker-core.mjs"; + +const { run } = startWorker(); + +export { run }; diff --git a/package.json b/package.json index 6a4bc014ff..278089207b 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "bin/*mocha*", "lib/**/*.html", "lib/**/*.js", + "lib/**/*.mjs", "lib/**/*.json", "lib/**/*.cjs", "index.js", @@ -168,13 +169,14 @@ "fs": false, "path": false, "supports-color": false, - "./lib/nodejs/buffered-worker-pool.cjs": false, + "./lib/nodejs/buffered-worker-pool.mjs": false, + "./lib/nodejs/parallel-buffered-runner.mjs": false, + "./lib/nodejs/worker-core.mjs": false, + "./lib/nodejs/worker.mjs": false, + "./lib/nodejs/reporters/parallel-buffered.mjs": false, "./lib/nodejs/esm-utils.cjs": false, "./lib/nodejs/file-unloader.cjs": false, - "./lib/nodejs/parallel-buffered-runner.cjs": false, "./lib/nodejs/serializer.js": false, - "./lib/nodejs/worker.cjs": false, - "./lib/nodejs/reporters/parallel-buffered.cjs": false, "./lib/cli/index.cjs": false }, "overrides": { diff --git a/test/node-unit/buffered-worker-pool.spec.cjs b/test/node-unit/buffered-worker-pool.spec.cjs index cb2d8f3e26..1c6f7ae572 100644 --- a/test/node-unit/buffered-worker-pool.spec.cjs +++ b/test/node-unit/buffered-worker-pool.spec.cjs @@ -1,7 +1,9 @@ "use strict"; -const rewiremock = require("rewiremock/node"); const sinon = require("sinon"); +const { + createBufferedWorkerPoolClass, +} = require("../../lib/nodejs/buffered-worker-pool.mjs"); describe("class BufferedWorkerPool", function () { let BufferedWorkerPool; @@ -29,17 +31,15 @@ describe("class BufferedWorkerPool", function () { }; serializeJavascript = sinon.spy(require("serialize-javascript")); - BufferedWorkerPool = rewiremock.proxy( - require.resolve("../../lib/nodejs/buffered-worker-pool.cjs"), - { - workerpool: { - pool: sinon.stub().returns(pool), - cpus: 8, - }, - "../../lib/nodejs/serializer.js": serializer, - "serialize-javascript": serializeJavascript, + BufferedWorkerPool = createBufferedWorkerPoolClass({ + workerPath: "worker.mjs", + workerpool: { + pool: sinon.stub().returns(pool), + cpus: 8, }, - ).BufferedWorkerPool; + deserialize: serializer.deserialize, + serializeJavascript, + }); // reset cache BufferedWorkerPool.resetOptionsCache(); @@ -111,6 +111,46 @@ describe("class BufferedWorkerPool", function () { }, }); }); + + it("should apply an explicit max worker count", function () { + expect(new BufferedWorkerPool({ maxWorkers: 4 }), "to satisfy", { + options: { + maxWorkers: 4, + }, + }); + }); + + it("should assign incremental worker ids", function () { + const workerPool = new BufferedWorkerPool(); + + expect( + workerPool.options.onCreateWorker({ + forkOpts: { + detached: true, + }, + }), + "to satisfy", + { + forkOpts: { + detached: true, + env: { + MOCHA_WORKER_ID: 0, + }, + }, + }, + ); + expect( + workerPool.options.onCreateWorker({ forkOpts: {} }), + "to satisfy", + { + forkOpts: { + env: { + MOCHA_WORKER_ID: 1, + }, + }, + }, + ); + }); }); describe("instance method", function () { diff --git a/test/node-unit/esm-utils.spec.cjs b/test/node-unit/esm-utils.spec.cjs index 1b855f7168..79055d324d 100644 --- a/test/node-unit/esm-utils.spec.cjs +++ b/test/node-unit/esm-utils.spec.cjs @@ -1,5 +1,6 @@ "use strict"; +const path = require("node:path"); const esmUtils = require("../../lib/nodejs/esm-utils.cjs"); const sinon = require("sinon"); const url = require("node:url"); @@ -62,6 +63,62 @@ describe("esm-utils", function () { }, ); }); + + describe("when require falls back to import", function () { + const missingFile = path.resolve( + "test/node-unit/fixtures/missing-file.js", + ); + + beforeEach(function () { + if (!process.features.require_module) { + this.skip(); + } + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return a namespace object without a default export", async function () { + sinon.stub(esmUtils, "doImport").resolves({ named: true }); + + const result = await esmUtils.requireOrImport(missingFile); + + expect(result, "to satisfy", { + named: true, + default: undefined, + }); + }); + + it("should throw the import error when fallback import fails", async function () { + const importError = Object.assign(new Error("import failed"), { + code: "ERR_IMPORT_FAILED", + }); + sinon.stub(esmUtils, "doImport").rejects(importError); + + return expect( + () => esmUtils.requireOrImport(missingFile), + "to be rejected with", + importError, + ); + }); + + it("should throw the require error for internal assertion import failures", async function () { + sinon.stub(esmUtils, "doImport").rejects( + Object.assign(new Error("internal assertion"), { + code: "ERR_INTERNAL_ASSERTION", + }), + ); + + return expect( + () => esmUtils.requireOrImport(missingFile), + "to be rejected with error satisfying", + { + code: "MODULE_NOT_FOUND", + }, + ); + }); + }); }); describe("loadFilesAsync()", function () { diff --git a/test/node-unit/mocha.spec.cjs b/test/node-unit/mocha.spec.cjs index 057f60b811..64cfde156f 100644 --- a/test/node-unit/mocha.spec.cjs +++ b/test/node-unit/mocha.spec.cjs @@ -23,6 +23,12 @@ describe("Mocha", function () { createMochaInstanceAlreadyDisposedError: sinon .stub() .throws({ code: "ERR_MOCHA_INSTANCE_ALREADY_DISPOSED" }), + createMochaInstanceAlreadyRunningError: sinon + .stub() + .throws({ code: "ERR_MOCHA_INSTANCE_ALREADY_RUNNING" }), + createInvalidInterfaceError: sinon + .stub() + .throws({ code: "ERR_MOCHA_INVALID_INTERFACE" }), createInvalidReporterError: sinon .stub() .throws({ code: "ERR_MOCHA_INVALID_REPORTER" }), @@ -66,8 +72,9 @@ describe("Mocha", function () { (r) => ({ "../../lib/utils.cjs": r.with(stubs.utils).callThrough(), "../../lib/suite.js": { Suite: stubs.Suite }, - "../../lib/nodejs/parallel-buffered-runner.cjs": - stubs.ParallelBufferedRunner, + "../../lib/nodejs/parallel-buffered-runner.mjs": { + ParallelBufferedRunner: stubs.ParallelBufferedRunner, + }, "../../lib/nodejs/esm-utils.cjs": stubs.esmUtils, "../../lib/runner.cjs": stubs.Runner, "../../lib/errors.js": stubs.errors, @@ -204,6 +211,21 @@ describe("Mocha", function () { }); }); + describe("ui()", function () { + it("should use a custom interface function", function () { + const bindInterface = sinon.stub(); + + expect(mocha.ui(bindInterface), "to be", mocha); + expect(bindInterface, "to have a call satisfying", [stubs.suite]); + }); + + it("should throw for an invalid interface", function () { + expect(() => mocha.ui("unknown-interface"), "to throw", { + code: "ERR_MOCHA_INVALID_INTERFACE", + }); + }); + }); + describe("loadFiles()", function () { it("should load all files from the files array", function () { this.timeout(1000); @@ -330,6 +352,47 @@ describe("Mocha", function () { }); }); + describe("dispose()", function () { + it("should not be allowed while the current instance is running", function () { + mocha._state = "running"; + + expect(() => mocha.dispose(), "to throw", { + code: "ERR_MOCHA_INSTANCE_ALREADY_RUNNING", + }); + }); + }); + + describe("failHookAffectedTests()", function () { + it("should be disabled only when passed false", function () { + expect(mocha.failHookAffectedTests(false), "to be", mocha); + expect(mocha.options.failHookAffectedTests, "to be false"); + + mocha.failHookAffectedTests(0); + expect(mocha.options.failHookAffectedTests, "to be true"); + }); + }); + + describe("_runGlobalFixtures()", function () { + it("should use default fixtures and context", async function () { + await expect(mocha._runGlobalFixtures(), "to be fulfilled with", {}); + }); + + it("should call fixtures with the provided context", async function () { + const context = {}; + const fixture = sinon.stub().callsFake(function () { + this.loaded = true; + }); + + await expect( + mocha._runGlobalFixtures([fixture], context), + "to be fulfilled with", + context, + ); + expect(fixture, "was called once"); + expect(context, "to satisfy", { loaded: true }); + }); + }); + describe("lazyLoadFiles()", function () { it("should return the `Mocha` instance", function () { expect(mocha.lazyLoadFiles(), "to be", mocha); diff --git a/test/node-unit/parallel-buffered-runner.spec.cjs b/test/node-unit/parallel-buffered-runner.spec.cjs index 3f20cdd4d8..84dccfdf02 100644 --- a/test/node-unit/parallel-buffered-runner.spec.cjs +++ b/test/node-unit/parallel-buffered-runner.spec.cjs @@ -7,56 +7,39 @@ const { EVENT_SUITE_END, EVENT_SUITE_BEGIN, } = require("../../lib/runner.cjs").constants; -const rewiremock = require("rewiremock/node"); const { Suite } = require("../../lib/suite.js"); const Runner = require("../../lib/runner.cjs"); const sinon = require("sinon"); const { constants } = require("../../lib/utils.cjs"); +const { + BufferedWorkerPool, +} = require("../../lib/nodejs/buffered-worker-pool.mjs"); +const { + ParallelBufferedRunner, +} = require("../../lib/nodejs/parallel-buffered-runner.mjs"); const { MOCHA_ID_PROP_NAME } = constants; describe("parallel-buffered-runner", function () { describe("ParallelBufferedRunner", function () { let run; - let BufferedWorkerPool; let terminate; - let ParallelBufferedRunner; let suite; - let warn; - let fatalError; beforeEach(function () { suite = new Suite("a root suite", {}, true); - warn = sinon.stub(); - - fatalError = new Error(); // tests will want to further define the behavior of these. run = sinon.stub(); terminate = sinon.stub(); - BufferedWorkerPool = { - create: sinon.stub().returns({ - run, - terminate, - stats: sinon.stub().returns({}), - }), - }; - /** - * @type {ParallelBufferedRunner} - */ - ParallelBufferedRunner = rewiremock.proxy( - () => require("../../lib/nodejs/parallel-buffered-runner.cjs"), - (r) => ({ - "../../lib/nodejs/buffered-worker-pool.cjs": { - BufferedWorkerPool, - }, - "../../lib/utils.cjs": r.with({ warn }).callThrough(), - "../../lib/errors.js": r - .with({ - createFatalError: sinon.stub().returns(fatalError), - }) - .callThrough(), - }), - ); + sinon.stub(BufferedWorkerPool, "create").returns({ + run, + terminate, + stats: sinon.stub().returns({}), + }); + }); + + afterEach(function () { + sinon.restore(); }); describe("constructor", function () { @@ -132,6 +115,22 @@ describe("parallel-buffered-runner", function () { ); }); + it("should return without completing when aborting", function (done) { + const callback = sinon.stub(); + run.callsFake(async () => { + runner._state = "ABORTING"; + return { failureCount: 0, events: [] }; + }); + + runner.run(callback, { files: ["some-file.js"], options: {} }); + + setImmediate(() => { + expect(callback, "was not called"); + expect(runner._state, "to be", "ABORTING"); + done(); + }); + }); + describe("when instructed to link objects", function () { beforeEach(function () { runner._linkPartialObjects = true; @@ -228,7 +227,7 @@ describe("parallel-buffered-runner", function () { runner.run( () => { expect(runner.uncaught, "to have a call satisfying", [ - fatalError, + expect.it("to have property", "code", "ERR_MOCHA_FATAL"), ]); done(); }, @@ -239,6 +238,52 @@ describe("parallel-buffered-runner", function () { }); describe("when a worker fails", function () { + it("should force-terminate and reject when uncaught errors are allowed", async function () { + const options = { reporter: runner._workerReporter }; + const err = new Error("whoops"); + runner.allowUncaught = true; + runner._state = "RUNNING"; + run.withArgs("some-file.js", options).rejects(err); + + await expect( + () => + runner._createFileRunner( + { run, terminate, stats: sinon.stub().returns({}) }, + options, + )("some-file.js"), + "to be rejected with", + err, + ); + expect(terminate, "to have a call satisfying", [true]).and( + "was called once", + ); + expect(runner._state, "to be", "ABORTING"); + }); + + it("should schedule uncaught worker failures to be rethrown", function (done) { + const err = new Error("whoops"); + const originalNextTick = process.nextTick; + runner.allowUncaught = true; + run.rejects(err); + sinon.stub(process, "nextTick").callsFake((callback, ...args) => { + if (String(callback).includes("re-throwing uncaught exception")) { + try { + callback(...args); + } catch (thrownError) { + expect(thrownError, "to be", err); + done(); + } + return; + } + return originalNextTick(callback, ...args); + }); + + runner.run(sinon.stub(), { + files: ["some-file.js"], + options: {}, + }); + }); + it("should recover", function (done) { const options = { reporter: runner._workerReporter }; run.withArgs("some-file.js", options).rejects(new Error("whoops")); @@ -620,50 +665,6 @@ describe("parallel-buffered-runner", function () { ); }); }); - - describe("when subsequent files have not yet been run", function () { - it("should cleanly terminate the thread pool", function (done) { - const options = { reporter: runner._workerReporter }; - const err = { - __type: "Error", - message: "oh no", - }; - run.withArgs("some-file.js", options).resolves({ - failureCount: 1, - events: [ - { - eventName: EVENT_TEST_FAIL, - data: { - title: "some test", - }, - error: err, - }, - { - eventName: EVENT_SUITE_END, - data: { - title: "some suite", - _bail: true, - }, - }, - ], - }); - run.withArgs("some-other-file.js", options).rejects(); - - runner.run( - () => { - expect(terminate, "to have calls satisfying", [ - { args: [] }, // this is the pool force-terminating - { args: [] }, // this will always be called, and will do nothing due to the previous call - ]).and("was called twice"); - done(); - }, - { - files: ["some-file.js", "some-other-file.js"], - options, - }, - ); - }); - }); }); }); }); @@ -705,6 +706,59 @@ describe("parallel-buffered-runner", function () { expect(runner.workerReporter(), "to be", runner); }); }); + + describe("_bindSigIntListener()", function () { + let runner; + let pool; + + beforeEach(function () { + runner = new ParallelBufferedRunner(suite); + pool = { + terminate: sinon.stub().resolves(), + }; + sinon.stub(process, "kill"); + sinon.stub(process, "once"); + }); + + it("should force-terminate the worker pool before re-sending SIGINT", async function () { + const sigIntListener = runner._bindSigIntListener(pool); + + await sigIntListener(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(pool.terminate, "to have a call satisfying", [true]).and( + "was called once", + ); + expect(runner._state, "to be", "ABORTED"); + expect(process.kill, "to have a call satisfying", [ + process.pid, + "SIGINT", + ]); + }); + + it("should set the exit code when force-termination fails", async function () { + const originalExitCode = process.exitCode; + pool.terminate.rejects(new Error("nope")); + sinon.stub(console, "error"); + + try { + process.exitCode = undefined; + const sigIntListener = runner._bindSigIntListener(pool); + + await sigIntListener(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(console.error, "was called once"); + expect(process.exitCode, "to be", 1); + expect(process.kill, "to have a call satisfying", [ + process.pid, + "SIGINT", + ]); + } finally { + process.exitCode = originalExitCode; + } + }); + }); }); }); }); diff --git a/test/node-unit/reporters/parallel-buffered.spec.cjs b/test/node-unit/reporters/parallel-buffered.spec.cjs index 3835905393..38698470c1 100644 --- a/test/node-unit/reporters/parallel-buffered.spec.cjs +++ b/test/node-unit/reporters/parallel-buffered.spec.cjs @@ -20,8 +20,14 @@ const { } = require("../../../lib/runner.cjs").constants; const { EventEmitter } = require("node:events"); const sinon = require("sinon"); -const rewiremock = require("rewiremock/node"); const semver = require("semver"); +const { + createParallelBufferedClass, +} = require("../../../lib/nodejs/reporters/parallel-buffered.mjs"); +const { + SerializableEvent, + SerializableWorkerResult, +} = require("../../../lib/nodejs/serializer.js"); describe("ParallelBuffered", function () { /** @type {EventEmitter} */ @@ -30,29 +36,9 @@ describe("ParallelBuffered", function () { beforeEach(function () { runner = new EventEmitter(); - ParallelBuffered = rewiremock.proxy( - () => require("../../../lib/nodejs/reporters/parallel-buffered.cjs"), - { - "../../../lib/nodejs/serializer.js": { - SerializableEvent: { - create: (eventName, runnable, err) => ({ - eventName, - data: runnable, - error: err, - __type: "MockSerializableEvent", - }), - }, - SerializableWorkerResult: { - create: (events, failures) => ({ - events, - failures, - __type: "MockSerializableWorkerResult", - }), - }, - }, - "../../../lib/reporters/base.js": { Base: class MockBase {} }, - }, - ); + ParallelBuffered = createParallelBufferedClass({ + Base: class MockBase {}, + }); }); afterEach(function () { @@ -133,33 +119,45 @@ describe("ParallelBuffered", function () { runner.emit(EVENT_TEST_PASS, test); runner.emit(EVENT_TEST_END, test); runner.emit(EVENT_SUITE_END, suite); - expect(reporter.events, "to equal", [ - { - eventName: EVENT_SUITE_BEGIN, - data: suite, - __type: "MockSerializableEvent", - }, - { - eventName: EVENT_TEST_BEGIN, - data: test, - __type: "MockSerializableEvent", - }, - { - eventName: EVENT_TEST_PASS, - data: test, - __type: "MockSerializableEvent", - }, - { - eventName: EVENT_TEST_END, - data: test, - __type: "MockSerializableEvent", - }, - { - eventName: EVENT_SUITE_END, - data: suite, - __type: "MockSerializableEvent", - }, - ]); + expect(reporter.events, "to have length", 5); + reporter.events.forEach((event) => { + expect(event, "to be a", SerializableEvent); + }); + expect( + reporter.events[0], + "to have property", + "eventName", + EVENT_SUITE_BEGIN, + ); + expect(reporter.events[0].originalValue, "to be", suite); + expect( + reporter.events[1], + "to have property", + "eventName", + EVENT_TEST_BEGIN, + ); + expect(reporter.events[1].originalValue, "to be", test); + expect( + reporter.events[2], + "to have property", + "eventName", + EVENT_TEST_PASS, + ); + expect(reporter.events[2].originalValue, "to be", test); + expect( + reporter.events[3], + "to have property", + "eventName", + EVENT_TEST_END, + ); + expect(reporter.events[3].originalValue, "to be", test); + expect( + reporter.events[4], + "to have property", + "eventName", + EVENT_SUITE_END, + ); + expect(reporter.events[4].originalValue, "to be", suite); }); }); }); @@ -187,37 +185,10 @@ describe("ParallelBuffered", function () { const cb = sinon.stub(); reporter.done(0, cb); expect(cb, "to have a call satisfying", [ - { - events: [ - { - eventName: EVENT_SUITE_BEGIN, - data: suite, - __type: "MockSerializableEvent", - }, - { - eventName: EVENT_TEST_BEGIN, - data: test, - __type: "MockSerializableEvent", - }, - { - eventName: EVENT_TEST_PASS, - data: test, - __type: "MockSerializableEvent", - }, - { - eventName: EVENT_TEST_END, - data: test, - __type: "MockSerializableEvent", - }, - { - eventName: EVENT_SUITE_END, - data: suite, - __type: "MockSerializableEvent", - }, - ], - failures: 0, - __type: "MockSerializableWorkerResult", - }, + expect.it("to be a", SerializableWorkerResult).and("to satisfy", { + events: expect.it("to have length", 5), + failureCount: 0, + }), ]); }); diff --git a/test/node-unit/worker.spec.cjs b/test/node-unit/worker.spec.cjs index c393e68e04..505300e4ec 100644 --- a/test/node-unit/worker.spec.cjs +++ b/test/node-unit/worker.spec.cjs @@ -1,11 +1,12 @@ "use strict"; const serializeJavascript = require("serialize-javascript"); -const rewiremock = require("rewiremock/node"); const { SerializableWorkerResult } = require("../../lib/nodejs/serializer.js"); const sinon = require("sinon"); - -const WORKER_PATH = require.resolve("../../lib/nodejs/worker.cjs"); +const { + createWorker, + startWorker, +} = require("../../lib/nodejs/worker-core.mjs"); describe("worker", function () { let worker; @@ -22,9 +23,13 @@ describe("worker", function () { }); describe("when run as main process", function () { + it("should throw with the default workerpool", function () { + expect(startWorker, "to throw"); + }); + it("should throw", function () { expect(() => { - rewiremock.proxy(WORKER_PATH, { + startWorker({ workerpool: { isMainThread: true, worker: stubs.workerpool.worker, @@ -34,6 +39,14 @@ describe("worker", function () { }); }); + describe("when created with default dependencies", function () { + it("should expose a run function", function () { + expect(createWorker(), "to satisfy", { + run: expect.it("to be a function"), + }); + }); + }); + describe("when run as worker process", function () { let mocha; @@ -58,16 +71,12 @@ describe("worker", function () { validateLegacyPlugin: sinon.stub(), }; - stubs.plugin = { - aggregateRootHooks: sinon.stub().resolves(), - }; - - worker = rewiremock.proxy(WORKER_PATH, { + worker = startWorker({ workerpool: stubs.workerpool, - "../../lib/mocha.cjs": stubs.Mocha, - "../../lib/nodejs/serializer.js": stubs.serializer, - "../../lib/cli/run-helpers.cjs": stubs.runHelpers, - "../../lib/plugin-loader.js": stubs.plugin, + Mocha: stubs.Mocha, + handleRequires: stubs.runHelpers.handleRequires, + validateLegacyPlugin: stubs.runHelpers.validateLegacyPlugin, + serialize: stubs.serializer.serialize, }); });