diff --git a/lib/buffered-runner.js b/lib/buffered-runner.js index 8ed3008294..031fc5c50c 100644 --- a/lib/buffered-runner.js +++ b/lib/buffered-runner.js @@ -6,7 +6,7 @@ const Runner = require('./runner'); const {EVENT_RUN_BEGIN, EVENT_RUN_END} = Runner.constants; const debug = require('debug')('mocha:buffered-runner'); const workerpool = require('workerpool'); -const {deserializeMessage} = require('./serializer'); +const {deserialize} = require('./serializer'); /** * This `Runner` delegates tests runs to worker threads. Does not execute any @@ -48,12 +48,11 @@ class BufferedRunner extends Runner { this.emit(EVENT_RUN_BEGIN); const poolProxy = await pool.proxy(); - // const tasks = new Set( const results = await allSettled( files.map(async file => { debug('enqueueing test file %s', file); try { - const {failures, events} = deserializeMessage( + const {failures, events} = deserialize( await poolProxy.run(file, opts) ); debug( @@ -91,6 +90,7 @@ class BufferedRunner extends Runner { await pool.terminate(); + // XXX I'm not sure this is ever non-empty const uncaughtExceptions = results.filter( ({status}) => status === 'rejected' ); diff --git a/lib/hook.js b/lib/hook.js index 7936cda495..aa031e283f 100644 --- a/lib/hook.js +++ b/lib/hook.js @@ -46,7 +46,7 @@ Hook.prototype.error = function(err) { }; Hook.prototype.serialize = function serialize() { - return Object.freeze({ + return { $$titlePath: this.titlePath(), ctx: { currentTest: { @@ -59,5 +59,5 @@ Hook.prototype.serialize = function serialize() { }, title: this.title, type: this.type - }); + }; }; diff --git a/lib/serializer.js b/lib/serializer.js index f9058511a1..28a2bdfe59 100644 --- a/lib/serializer.js +++ b/lib/serializer.js @@ -1,12 +1,17 @@ 'use strict'; +const {type} = require('./utils'); +const {createInvalidArgumentTypeError} = require('./errors'); // const debug = require('debug')('mocha:serializer'); +const SERIALIZABLE_RESULT_NAME = 'SerializableWorkerResult'; +const SERIALIZABLE_TYPES = new Set(['object', 'array', 'function', 'error']); + class SerializableWorkerResult { constructor(failures, events) { this.failures = failures; this.events = events; - this.__type = 'SerializableWorkerResult'; + this.__type = SERIALIZABLE_RESULT_NAME; } static create(...args) { @@ -24,90 +29,198 @@ class SerializableWorkerResult { obj.events.forEach(SerializableEvent.deserialize); return obj; } + + /** + * Returns `true` if this is a {@link SerializableWorkerResult}, even if serialized + * (in other words, not an instance). + * + * @param {*} value - A value to check + */ + static isSerializableWorkerResult(value) { + return ( + type(value) === 'object' && value.__type === SERIALIZABLE_RESULT_NAME + ); + } } +/** + * Represents an event, emitted by a {@link Runner}, which is to be transmitted + * over IPC. + * + * Due to the contents of the event data, it's not possible to send them verbatim. + * When received by the main process--and handled by reporters--these objects are + * expected to contain {@link Runnable} instances. This class provides facilities + * to perform the translation via serialization and deserialization. + */ class SerializableEvent { - constructor(eventName, rawObject, error) { + /** + * Constructs a `SerializableEvent`, throwing if we receive unexpected data. + * + * Practically, events emitted from `Runner` have a minumum of zero (0) arguments-- + * (for example, {@link Runnable.constants.EVENT_RUN_BEGIN}) and a maximum of two (2) + * (for example, {@link Runnable.constants.EVENT_TEST_FAIL}, where the second argument + * is an `Error`). The first argument, if present, is a {@link Runnable}. + * This constructor's arguments adhere to this convention. + * @param {string} eventName - A non-empty event name. + * @param {any} [originalValue] - Some data. Corresponds to extra arguments passed to `EventEmitter#emit`. + * @param {Error} [originalError] - An error, if there's an error. + * @throws If `eventName` is empty, or `originalValue` is a non-object. + */ + constructor(eventName, originalValue, originalError) { + if (!eventName) { + throw new Error('expected a non-empty `eventName` argument'); + } + /** + * The event name. + * @memberof SerializableEvent + */ this.eventName = eventName; - if (rawObject && typeof rawObject !== 'object') { + const originalValueType = type(originalValue); + if (originalValueType !== 'object' && originalValueType !== 'undefined') { throw new Error( - `expected object, received [${typeof rawObject}]: ${rawObject}` + `expected object, received [${originalValueType}]: ${originalValue}` ); } - this.error = error; - // we don't want this value sent via IPC. - Object.defineProperty(this, 'rawObject', { - value: rawObject, + /** + * An error, if present. + * @memberof SerializableEvent + */ + Object.defineProperty(this, 'originalError', { + value: originalError, + enumerable: false + }); + + /** + * The raw value. + * + * We don't want this value sent via IPC; making it non-enumerable will do that. + * + * @memberof SerializableEvent + */ + Object.defineProperty(this, 'originalValue', { + value: originalValue, enumerable: false }); } + /** + * In case you hated using `new` (I do). + * + * @param {...any} args - Args for {@link SerializableEvent#constructor}. + * @returns {SerializableEvent} A new `SerializableEvent` + */ static create(...args) { return new SerializableEvent(...args); } + /** + * Modifies this object *in place* (for theoretical memory consumption & performance + * reasons); serializes `SerializableEvent#originalValue` (placing the result in + * `SerializableEvent#data`) and `SerializableEvent#error`. Freezes this object. + * The result is an object that can be transmitted over IPC. + */ serialize() { - const createError = err => { - const _serializeError = ([value, key]) => { - if (value) { - if (typeof value[key] === 'object') { - const obj = value[key]; - Object.keys(obj) - .map(key => [obj[key], key]) - .forEach(_serializeError); - } else if (typeof value[key] === 'function') { - delete value[key]; - } - } - }; - const error = { - message: err.message, - stack: err.stack, - __type: 'Error' - }; - - Object.keys(err) - .map(key => [err[key], key]) - .forEach(_serializeError); - return error; - }; - const obj = this.rawObject; - this.data = Object.create(null); - Object.assign( - this.data, - typeof obj.serialize === 'function' ? obj.serialize() : obj - ); - Object.keys(this.data).forEach(key => { - if (this.data[key] instanceof Error) { - this.data[key] = createError(this.data[key]); + // list of types within values that we will attempt to serialize + + // given a parent object and a key, inspect the value and decide whether + // to replace it, remove it, or add it to our `pairs` array to further process. + // this is recursion in loop form. + const _serialize = (parent, key) => { + let value = parent[key]; + switch (type(value)) { + case 'error': + // we need to reference the stack prop b/c it's lazily-loaded. + // `__type` is necessary for deserialization to create an `Error` later. + // fall through to the 'object' branch below to further process & remove + // any junk that an assertion lib may throw in there. + // `message` is apparently not enumerable, so we must handle it specifically. + value = Object.assign(Object.create(null), value, { + stack: value.stack, + message: value.message, + __type: 'Error' + }); + parent[key] = value; + // falls through + case 'object': + // by adding props to the `pairs` array, we will process it further + pairs.push( + ...Object.keys(value) + .filter(key => SERIALIZABLE_TYPES.has(type(value[key]))) + .map(key => [value, key]) + ); + break; + case 'function': + // we _may_ want to dig in to functions for some assertion libraries + // that might put a usable property on a function. + // for now, just zap it. + delete parent[key]; + break; + case 'array': + pairs.push( + ...value + .filter(value => SERIALIZABLE_TYPES.has(type(value))) + .map((value, index) => [value, index]) + ); + break; } + }; + + const result = Object.assign(Object.create(null), { + data: + type(this.originalValue) === 'object' && + type(this.originalValue.serialize) === 'function' + ? this.originalValue.serialize() + : this.originalValue, + error: this.originalError }); - if (this.error) { - this.error = createError(this.error); + + const pairs = Object.keys(result).map(key => [result, key]); + + let pair; + while ((pair = pairs.shift())) { + _serialize(...pair); } + + this.data = result.data; + this.error = result.error; + return Object.freeze(this); } + /** + * Deserialize value returned from a worker into something more useful. + * Does not return the same object. + * @todo - do this in a loop instead of with recursion (if necessary) + * @param {SerializedEvent} obj - Object returned from worker + * @returns {SerializedEvent} Deserialized result + */ static deserialize(obj) { const createError = value => { const error = new Error(value.message); error.stack = value.stack; Object.assign(error, value); + delete error.__type; return error; }; const _deserialize = ([object, key]) => { - const value = typeof key !== 'undefined' ? object[key] : object; - if (typeof key === 'string' && key.startsWith('$$')) { + if (key === '__proto__') { + delete object[key]; + return; + } + const value = type(key) !== 'undefined' ? object[key] : object; + // keys beginning with `$$` are converted into functions returning the value + // and renamed, stripping the `$$` prefix + if (type(key) === 'string' && key.startsWith('$$')) { const newKey = key.slice(2); object[newKey] = () => value; delete object[key]; key = newKey; } - if (Array.isArray(value)) { + if (type(value) === 'array') { value.forEach((_, idx) => { _deserialize([value, idx]); }); - } else if (value && typeof value === 'object') { + } else if (type(value) === 'object') { if (value.__type === 'Error') { object[key] = createError(value); } else { @@ -118,27 +231,60 @@ class SerializableEvent { } }; - Object.keys(obj.data) - .map(key => [obj.data, key]) - .forEach(_deserialize); + if (!obj) { + throw createInvalidArgumentTypeError('Expected value', obj); + } + + obj = Object.assign(Object.create(null), obj); + + if (obj.data) { + Object.keys(obj.data) + .map(key => [obj.data, key]) + .forEach(_deserialize); + } + if (obj.error) { obj.error = createError(obj.error); } + return obj; } } -exports.serializeObject = function serializeObject(obj) { - return obj instanceof SerializableWorkerResult ? obj.serialize() : obj; +/** + * "Serializes" a value for transmission over IPC as a message. + * + * If value is an object and has a `serialize()` method, call that method; otherwise return the object and hope for the best. + * + * @param {*} obj - A value to serialize + */ +exports.serialize = function serialize(value) { + return type(value) === 'object' && type(value.serialize) === 'function' + ? value.serialize() + : value; }; -exports.deserializeMessage = function deserializeMessage(message) { - return message && - typeof message === 'object' && - message.__type === 'SerializableWorkerResult' +/** + * "Deserializes" a "message" received over IPC. + * + * This could be expanded with other objects that need deserialization, + * but at present time we only care about {@link SerializableWorkerResult} objects. + * + * @param {*} message - A "message" to deserialize + */ +exports.deserialize = function deserialize(message) { + return SerializableWorkerResult.isSerializableWorkerResult(message) ? SerializableWorkerResult.deserialize(message) : message; }; exports.SerializableEvent = SerializableEvent; exports.SerializableWorkerResult = SerializableWorkerResult; + +/** + * The result of calling `SerializableEvent.serialize`, as received + * by the deserializer. + * @typedef {Object} SerializedEvent + * @property {object?} data - Optional serialized data + * @property {object?} error - Optional serialized `Error` + */ diff --git a/lib/suite.js b/lib/suite.js index e6e8c24413..62030fd4b6 100644 --- a/lib/suite.js +++ b/lib/suite.js @@ -555,12 +555,12 @@ Suite.prototype.cleanReferences = function cleanReferences() { * @returns {Object} */ Suite.prototype.serialize = function serialize() { - return Object.freeze({ + return { _bail: this._bail, $$fullTitle: this.fullTitle(), root: this.root, title: this.title - }); + }; }; var constants = utils.defineConstants( diff --git a/lib/test.js b/lib/test.js index e87a245ade..391f613e73 100644 --- a/lib/test.js +++ b/lib/test.js @@ -69,7 +69,7 @@ Test.prototype.clone = function() { * @returns {Object} */ Test.prototype.serialize = function serialize() { - return Object.freeze({ + return { $$currentRetry: this._currentRetry, $$fullTitle: this.fullTitle(), $$retriedTest: this._retriedTest || null, @@ -84,5 +84,5 @@ Test.prototype.serialize = function serialize() { speed: this.speed, title: this.title, type: this.type - }); + }; }; diff --git a/lib/worker.js b/lib/worker.js index f9696add4c..60072a68fc 100644 --- a/lib/worker.js +++ b/lib/worker.js @@ -4,7 +4,7 @@ const workerpool = require('workerpool'); const Mocha = require('./mocha'); const {handleRequires, validatePlugin} = require('./cli/run-helpers'); const debug = require('debug')('mocha:worker'); -const {serializeObject} = require('./serializer'); +const {serialize} = require('./serializer'); let bootstrapped = false; /** @@ -42,16 +42,14 @@ async function run(file, argv) { ); throw err; } - return new Promise((resolve, reject) => { - function workerRejection(err) { - debug('process [%d] rejecting due to uncaught exception', process.pid); - reject(err); - } - process.once('uncaughtException', workerRejection); + 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" mocha.run(result => { process.removeAllListeners('uncaughtException'); debug('process [%d] resolving', process.pid); - resolve(serializeObject(result)); + resolve(serialize(result)); }); }); } diff --git a/test/integration/fixtures/options/parallel/a.fixture.js b/test/integration/fixtures/options/parallel/a.fixture.js new file mode 100644 index 0000000000..43f53bbda8 --- /dev/null +++ b/test/integration/fixtures/options/parallel/a.fixture.js @@ -0,0 +1,3 @@ +describe('a', function() { + it('should pass', function() {}); +}); diff --git a/test/integration/fixtures/options/parallel/b.fixture.js b/test/integration/fixtures/options/parallel/b.fixture.js new file mode 100644 index 0000000000..8e6437a56a --- /dev/null +++ b/test/integration/fixtures/options/parallel/b.fixture.js @@ -0,0 +1,3 @@ +describe('b', function() { + it('should be pending'); +}); diff --git a/test/integration/fixtures/options/parallel/c.fixture.js b/test/integration/fixtures/options/parallel/c.fixture.js new file mode 100644 index 0000000000..d06b6a3ee6 --- /dev/null +++ b/test/integration/fixtures/options/parallel/c.fixture.js @@ -0,0 +1,5 @@ +describe('c', function() { + it('should fail', function() { + throw new Error('failure'); + }); +}); diff --git a/test/integration/fixtures/options/parallel/d.fixture.js b/test/integration/fixtures/options/parallel/d.fixture.js new file mode 100644 index 0000000000..ee19d54594 --- /dev/null +++ b/test/integration/fixtures/options/parallel/d.fixture.js @@ -0,0 +1,7 @@ +describe('d', function() { + it('should pass, then fail', function() { + process.nextTick(function() { + throw new Error('uncaught!!'); + }); + }); +}); diff --git a/test/integration/helpers.js b/test/integration/helpers.js index dc112206bb..b84048c5a3 100644 --- a/test/integration/helpers.js +++ b/test/integration/helpers.js @@ -4,7 +4,7 @@ var format = require('util').format; var spawn = require('cross-spawn').spawn; var path = require('path'); var Base = require('../../lib/reporters/base'); -var debug = require('debug')('mocha:tests:integratin:helpers'); +var debug = require('debug')('mocha:tests:integration:helpers'); var DEFAULT_FIXTURE = resolveFixturePath('__default__'); var MOCHA_EXECUTABLE = require.resolve('../../bin/mocha'); var _MOCHA_EXECUTABLE = require.resolve('../../bin/_mocha'); @@ -287,6 +287,7 @@ function _spawnMochaWithListeners(args, fn, opts) { }, opts || {} ); + debug('spawning: %s', [process.execPath].concat(args).join(' ')); var mocha = spawn(process.execPath, args, opts); var listener = function(data) { output += data; diff --git a/test/integration/options/parallel.spec.js b/test/integration/options/parallel.spec.js new file mode 100644 index 0000000000..5f407486b6 --- /dev/null +++ b/test/integration/options/parallel.spec.js @@ -0,0 +1,24 @@ +'use strict'; + +var path = require('path'); +var helpers = require('../helpers'); +var runMochaJSON = helpers.runMochaJSON; + +describe('--parallel', function() { + it('should not appear fundamentally different than without', function(done) { + runMochaJSON( + path.join('options', 'parallel', '*.fixture.js'), + ['--parallel'], + function(err, res) { + if (err) { + return done(err); + } + expect(res, 'to have failed') + .and('to have passed test count', 2) + .and('to have pending test count', 1) + .and('to have failed test count', 2); + done(); + } + ); + }); +}); diff --git a/test/node-unit/serializer.spec.js b/test/node-unit/serializer.spec.js new file mode 100644 index 0000000000..62567b31ec --- /dev/null +++ b/test/node-unit/serializer.spec.js @@ -0,0 +1,296 @@ +'use strict'; + +const {createSandbox} = require('sinon'); +const {SerializableEvent} = require('../../lib/serializer'); + +describe('SerializableEvent', function() { + let sandbox; + + beforeEach(function() { + sandbox = createSandbox(); + }); + + afterEach(function() { + sandbox.restore(); + }); + + describe('constructor', function() { + describe('when called without `eventName`', function() { + it('should throw', function() { + expect( + () => new SerializableEvent(), + 'to throw', + /expected a non-empty `eventName`/ + ); + }); + }); + + describe('when called with a non-object `rawObject`', function() { + it('should throw', function() { + expect( + () => new SerializableEvent('blub', 'glug'), + 'to throw', + /expected object, received \[string\]/ + ); + }); + }); + }); + + describe('instance method', function() { + describe('serialize', function() { + it('should mutate the instance in-place', function() { + const evt = SerializableEvent.create('foo'); + expect(evt.serialize(), 'to be', evt); + }); + + it('should freeze the instance', function() { + expect( + Object.isFrozen(SerializableEvent.create('foo').serialize()), + 'to be true' + ); + }); + + describe('when passed an object with a `serialize` method', function() { + it('should call the `serialize` method', function() { + const obj = { + serialize: sandbox.stub() + }; + SerializableEvent.create('some-event', obj).serialize(); + expect(obj.serialize, 'was called once'); + }); + }); + + describe('when passed an object containing a non-`serialize` method', function() { + it('should remove functions', function() { + const obj = { + func: () => {} + }; + + expect( + SerializableEvent.create('some-event', obj).serialize(), + 'to satisfy', + { + data: expect.it('not to have property', 'func') + } + ); + }); + }); + + describe('when passed an object containing an array', function() { + it('should serialize the array', function() { + const obj = { + list: [{herp: 'derp'}, {bing: 'bong'}] + }; + expect( + SerializableEvent.create('some-event', obj).serialize(), + 'to satisfy', + {data: {list: [{herp: 'derp'}, {bing: 'bong'}]}} + ); + }); + }); + + describe('when passed an error', function() { + it('should serialize the error', function() { + const obj = {}; + const err = new Error('monkeypants'); + expect( + SerializableEvent.create('some-event', obj, err).serialize(), + 'to satisfy', + { + eventName: 'some-event', + error: { + message: 'monkeypants', + stack: /^Error: monkeypants/, + __type: 'Error' + }, + data: obj + } + ); + }); + + it('should retain own props', function() { + const obj = {}; + const err = new Error('monkeypants'); + err.code = 'MONKEY'; + expect( + SerializableEvent.create('some-event', obj, err).serialize(), + 'to satisfy', + { + eventName: 'some-event', + error: { + code: 'MONKEY', + message: 'monkeypants', + stack: /^Error: monkeypants/, + __type: 'Error' + }, + data: obj + } + ); + }); + + it('should not retain not-own props', function() { + const obj = {}; + const err = new Error('monkeypants'); + // eslint-disable-next-line no-proto + err.__proto__.code = 'MONKEY'; + expect( + SerializableEvent.create('some-event', obj, err).serialize(), + 'to satisfy', + { + eventName: 'some-event', + error: { + message: 'monkeypants', + stack: /^Error: monkeypants/, + __type: 'Error' + }, + data: obj + } + ); + }); + }); + + describe('when passed an object containing a top-level prop with an Error value', function() { + it('should serialize the Error', function() { + const obj = { + monkeyError: new Error('pantsmonkey') + }; + const evt = SerializableEvent.create('some-event', obj); + expect(evt.serialize(), 'to satisfy', { + eventName: 'some-event', + data: { + monkeyError: { + message: 'pantsmonkey', + stack: /^Error: pantsmonkey/, + __type: 'Error' + } + } + }); + }); + }); + describe('when passed an object containing a nested prop with an Error value', function() { + it('should serialize the Error', function() { + const obj = { + nestedObj: { + monkeyError: new Error('pantsmonkey') + } + }; + const evt = SerializableEvent.create('some-event', obj); + expect(evt.serialize(), 'to satisfy', { + eventName: 'some-event', + data: { + nestedObj: { + monkeyError: { + message: 'pantsmonkey', + stack: /^Error: pantsmonkey/, + __type: 'Error' + } + } + } + }); + }); + }); + }); + }); + + describe('static method', function() { + describe('deserialize', function() { + describe('when passed a falsy parameter', function() { + it('should throw "invalid arg type" error', function() { + expect(SerializableEvent.deserialize, 'to throw', { + code: 'ERR_MOCHA_INVALID_ARG_TYPE' + }); + }); + }); + + it('should return a new object w/ null prototype', function() { + const obj = {bob: 'bob'}; + expect(SerializableEvent.deserialize(obj), 'to satisfy', obj) + .and('not to equal', obj) + .and('not to have property', 'constructor'); + }); + + describe('when passed value contains `data` prop', function() { + it('should ignore __proto__', function() { + const obj = { + data: Object.create(null) + }; + // eslint-disable-next-line no-proto + obj.data.__proto__ = {peaches: 'prunes'}; + + const expected = Object.assign(Object.create(null), { + data: Object.create(null) + }); + expect(SerializableEvent.deserialize(obj), 'to equal', expected); + }); + + describe('when `data` prop contains a nested serialized Error prop', function() { + it('should create an Error instance from the nested serialized Error prop', function() { + const message = 'problems!'; + const stack = 'problem instructions'; + const code = 'EIEIO'; + const expected = Object.assign(Object.create(null), { + data: { + whoops: Object.assign(new Error(message), { + stack, + code + }) + } + }); + + expect( + SerializableEvent.deserialize({ + data: { + whoops: { + message, + stack, + code, + __type: 'Error' + } + } + }), + 'to equal', + expected + ); + }); + }); + }); + + describe('when passed value contains an `error` prop', function() { + it('should create an Error instance from the prop', function() { + const message = 'problems!'; + const stack = 'problem instructions'; + const code = 'EIEIO'; + const expected = Object.assign(Object.create(null), { + error: Object.assign(new Error(message), { + stack, + code + }) + }); + + expect( + SerializableEvent.deserialize({ + error: { + message, + stack, + code, + __type: 'Error' + } + }), + 'to equal', + expected + ); + }); + }); + }); + + describe('create', function() { + it('should instantiate a SerializableEvent', function() { + expect( + SerializableEvent.create('some-event'), + 'to be a', + SerializableEvent + ); + }); + }); + }); +});