diff --git a/lib/worker.js b/lib/worker.js index 60072a68fc..f32aef648e 100644 --- a/lib/worker.js +++ b/lib/worker.js @@ -1,63 +1,99 @@ 'use strict'; +const {createInvalidArgumentTypeError} = require('./errors'); const workerpool = require('workerpool'); const Mocha = require('./mocha'); const {handleRequires, validatePlugin} = require('./cli/run-helpers'); -const debug = require('debug')('mocha:worker'); +const debug = require('debug')(`mocha:worker:${process.pid}`); const {serialize} = require('./serializer'); -let bootstrapped = false; + +const BUFFERED_REPORTER_PATH = require.resolve('./reporters/buffered'); + +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**; it overwrites itself with a no-op + * before returning. + * + * @param {Options} argv - Command-line options + */ +let bootstrap = argv => { + handleRequires(argv.require); + validatePlugin(argv, 'ui', Mocha.interfaces); + process.on('beforeExit', () => { + /* istanbul ignore next */ + debug('exiting'); + }); + debug('bootstrapped'); + bootstrap = () => {}; +}; /** * Runs a single test file in a worker thread. - * @param {string} file - Filepath of test file - * @param {Options} argv - Parsed command-line options object - * @returns {Promise<[number, BufferedEvent[]]>} A tuple of failures and - * serializable event data + * @param {string} filepath - Filepath of test file + * @param {Options} [argv] - Parsed command-line options object + * @returns {Promise<{failures: number, events: BufferedEvent[]}>} - Test + * failure count and list of events. */ -async function run(file, argv) { - debug('running test file %s on process [%d]', file, process.pid); - // the buffered reporter retains its events; these events are returned - // from this function back to the main process. - argv.reporter = require.resolve('./reporters/buffered'); - // if these were set, it would cause infinite recursion by spawning another worker - delete argv.parallel; - delete argv.jobs; - if (!bootstrapped) { - // setup requires and ui, but only do this once--we will reuse this worker! - handleRequires(argv.require); - validatePlugin(argv, 'ui', Mocha.interfaces); - bootstrapped = true; - debug('bootstrapped process [%d]', process.pid); +async function run(filepath, argv = {ui: 'bdd'}) { + if (!filepath) { + throw createInvalidArgumentTypeError( + 'Expected a non-empty "filepath" argument', + 'file', + 'string' + ); } - const mocha = new Mocha(argv); - mocha.files = [file]; + + debug('running test file %s', filepath); + + const opts = Object.assign(argv, { + // workers only use the `Buffered` reporter. + reporter: BUFFERED_REPORTER_PATH, + // if this was true, it would cause infinite recursion. + parallel: false + }); + + bootstrap(opts); + + const mocha = new Mocha(opts).addFile(filepath); + try { await mocha.loadFilesAsync(); } catch (err) { - debug( - 'process [%d] rejecting; could not load file %s: %s', - process.pid, - file, - err - ); + debug('could not load file %s: %s', filepath, err); throw err; } - return new Promise(resolve => { - // TODO: figure out exactly what the sad path looks like here. - // will depend on allowUncaught - // rejection should only happen if an error is "unrecoverable" + + return new Promise((resolve, reject) => { mocha.run(result => { + // Runner adds these; if we don't remove them, we'll get a leak. process.removeAllListeners('uncaughtException'); - debug('process [%d] resolving', process.pid); - resolve(serialize(result)); + + debug('completed run with %d test failures', result.failures); + try { + resolve(serialize(result)); + } catch (err) { + // TODO: figure out exactly what the sad path looks like here. + // rejection should only happen if an error is "unrecoverable" + reject(err); + } }); }); } -workerpool.worker({ - run -}); +// this registers the `run` function. +workerpool.worker({run}); + +debug('running'); -process.on('beforeExit', () => { - debug('process [%d] exiting', process.pid); -}); +// for testing +exports.run = run; diff --git a/test/node-unit/worker.spec.js b/test/node-unit/worker.spec.js new file mode 100644 index 0000000000..4b62a1f9b6 --- /dev/null +++ b/test/node-unit/worker.spec.js @@ -0,0 +1,186 @@ +'use strict'; + +const {SerializableWorkerResult} = require('../../lib/serializer'); +const rewiremock = require('rewiremock/node'); +const {createSandbox} = require('sinon'); + +const WORKER_PATH = require.resolve('../../lib/worker.js'); + +describe('worker', function() { + let worker; + let workerpoolWorker; + let sandbox; + + beforeEach(function() { + sandbox = createSandbox(); + workerpoolWorker = sandbox.stub(); + sandbox.spy(process, 'removeAllListeners'); + }); + + describe('when run as main "thread"', function() { + it('should throw', function() { + expect(() => { + rewiremock.proxy(WORKER_PATH, { + workerpool: { + isMainThread: true, + worker: workerpoolWorker + } + }); + }, 'to throw'); + }); + }); + + describe('when run as "worker thread"', function() { + class MockMocha {} + let serializer; + let runHelpers; + + beforeEach(function() { + MockMocha.prototype.addFile = sandbox.stub().returnsThis(); + MockMocha.prototype.loadFilesAsync = sandbox.stub(); + MockMocha.prototype.run = sandbox.stub(); + MockMocha.interfaces = { + bdd: sandbox.stub() + }; + + serializer = { + serialize: sandbox.stub() + }; + + runHelpers = { + handleRequires: sandbox.stub(), + validatePlugin: sandbox.stub() + }; + + worker = rewiremock.proxy(WORKER_PATH, { + workerpool: { + isMainThread: false, + worker: workerpoolWorker + }, + '../../lib/mocha': MockMocha, + '../../lib/serializer': serializer, + '../../lib/cli/run-helpers': runHelpers + }); + }); + + it('should register itself with workerpool', function() { + expect(workerpoolWorker, 'to have a call satisfying', [ + {run: worker.run} + ]); + }); + + describe('function', function() { + describe('run', function() { + describe('when called without arguments', function() { + it('should reject', async function() { + return expect(worker.run, 'to be rejected with error satisfying', { + code: 'ERR_MOCHA_INVALID_ARG_TYPE' + }); + }); + }); + + describe('when called with empty "filepath" argument', function() { + it('should reject', async function() { + return expect( + () => worker.run(''), + 'to be rejected with error satisfying', + { + code: 'ERR_MOCHA_INVALID_ARG_TYPE' + } + ); + }); + }); + + describe('when the file at "filepath" argument is unloadable', function() { + it('should reject', async function() { + MockMocha.prototype.loadFilesAsync.rejects(); + return expect( + () => worker.run('some-non-existent-file.js'), + 'to be rejected' + ); + }); + }); + + describe('when the file at "filepath" is loadable', function() { + let result; + beforeEach(function() { + result = SerializableWorkerResult.create(); + + MockMocha.prototype.loadFilesAsync.resolves(); + MockMocha.prototype.run.yields(result); + }); + + it('should handle "--require"', async function() { + await worker.run('some-file.js', {require: 'foo'}); + expect(runHelpers.handleRequires, 'to have a call satisfying', [ + 'foo' + ]).and('was called once'); + }); + + it('should handle "--ui"', async function() { + const argv = {}; + await worker.run('some-file.js', argv); + + expect(runHelpers.validatePlugin, 'to have a call satisfying', [ + argv, + 'ui', + MockMocha.interfaces + ]).and('was called once'); + }); + + it('should call Mocha#run', async function() { + await worker.run('some-file.js'); + expect(MockMocha.prototype.run, 'was called once'); + }); + + it('should remove all uncaughtException listeners', async function() { + await worker.run('some-file.js'); + expect(process.removeAllListeners, 'to have a call satisfying', [ + 'uncaughtException' + ]); + }); + + describe('when serialization succeeds', function() { + beforeEach(function() { + serializer.serialize.returnsArg(0); + }); + + it('should resolve with a SerializedWorkerResult', async function() { + return expect( + worker.run('some-file.js'), + 'to be fulfilled with', + result + ); + }); + }); + + describe('when serialization fails', function() { + beforeEach(function() { + serializer.serialize.throws(); + }); + + it('should reject', async function() { + return expect(worker.run('some-file.js'), 'to be rejected'); + }); + }); + + describe('when run twice', function() { + it('should initialize only once', async function() { + await worker.run('some-file.js'); + await worker.run('some-other-file.js'); + + expect(runHelpers, 'to satisfy', { + handleRequires: expect.it('was called once'), + validatePlugin: expect.it('was called once') + }); + }); + }); + }); + }); + }); + }); + + afterEach(function() { + sandbox.restore(); + }); +});