diff --git a/karma.conf.js b/karma.conf.js index 14f0be2b88..82e934bbc7 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -156,6 +156,7 @@ module.exports = config => { require.resolve('unexpected/unexpected'), {pattern: require.resolve('unexpected/unexpected.js.map'), included: false}, require.resolve('unexpected-sinon'), + require.resolve('unexpected-eventemitter/dist/unexpected-eventemitter.js'), require.resolve('./test/browser-specific/setup') ); diff --git a/lib/runner.js b/lib/runner.js index b288d55bca..f571f3a820 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -772,17 +772,9 @@ Runner.prototype.runSuite = function(suite, fn) { */ Runner.prototype.uncaught = function(err) { if (err) { - debug( - 'uncaught exception %s', - err === - function() { - return this; - }.call(err) - ? err.message || err - : err - ); + debug('uncaught exception %O', err); } else { - debug('uncaught undefined exception'); + debug('uncaught undefined/falsy exception'); err = createInvalidExceptionError( 'Caught falsy/undefined exception which would otherwise be uncaught. No stack trace found; try a debugger', err @@ -831,6 +823,7 @@ Runner.prototype.uncaught = function(err) { this.hookUp(HOOK_TYPE_AFTER_EACH, this.next); return; } + debug(runnable); // recover from hooks var errSuite = this.suite; diff --git a/package-lock.json b/package-lock.json index ecebbdc3f3..c62af45979 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20963,6 +20963,12 @@ "integrity": "sha1-SaysdTsFVt7WAlIQ7pYYIwfSssk=", "dev": true }, + "unexpected-eventemitter": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/unexpected-eventemitter/-/unexpected-eventemitter-1.1.3.tgz", + "integrity": "sha512-30MfVuCOCSEvUzNUErYZ3ZzLiPOgADcJsyxi+0Z5bhwgI/Yv4xHR/2v/YEe2alaEXDdkteCQ4gLBfa5/J2iTPA==", + "dev": true + }, "unexpected-sinon": { "version": "10.10.1", "resolved": "https://registry.npmjs.org/unexpected-sinon/-/unexpected-sinon-10.10.1.tgz", diff --git a/package.json b/package.json index 154438b0bc..313842129f 100644 --- a/package.json +++ b/package.json @@ -560,6 +560,7 @@ "through2": "^3.0.0", "to-vfile": "^5.0.2", "unexpected": "^10.39.2", + "unexpected-eventemitter": "^1.1.3", "unexpected-sinon": "^10.10.1", "uslug": "^1.0.4", "watchify": "^3.7.0" diff --git a/test/browser-specific/setup.js b/test/browser-specific/setup.js index d14c9e784d..31f5c82396 100644 --- a/test/browser-specific/setup.js +++ b/test/browser-specific/setup.js @@ -4,4 +4,5 @@ process.stdout = require('browser-stdout')(); global.expect = global.weknowhow.expect .clone() - .use(global.weknowhow.unexpectedSinon); + .use(global.weknowhow.unexpectedSinon) + .use(global.unexpectedEventEmitter); diff --git a/test/setup.js b/test/setup.js index a29e2ebb34..1732ae9d84 100644 --- a/test/setup.js +++ b/test/setup.js @@ -2,5 +2,8 @@ var unexpected = require('unexpected'); global.expect = require('./assertions').mixinMochaAssertions( - unexpected.clone().use(require('unexpected-sinon')) + unexpected + .clone() + .use(require('unexpected-sinon')) + .use(require('unexpected-eventemitter')) ); diff --git a/test/unit/runner.spec.js b/test/unit/runner.spec.js index 6c04f93f31..f48144ee47 100644 --- a/test/unit/runner.spec.js +++ b/test/unit/runner.spec.js @@ -1,12 +1,13 @@ 'use strict'; +var path = require('path'); +var sinon = require('sinon'); var Mocha = require('../../lib/mocha'); var Suite = Mocha.Suite; var Runner = Mocha.Runner; var Test = Mocha.Test; var Runnable = Mocha.Runnable; var Hook = Mocha.Hook; -var path = require('path'); var noop = Mocha.utils.noop; var EVENT_TEST_FAIL = Runner.constants.EVENT_TEST_FAIL; var EVENT_TEST_RETRY = Runner.constants.EVENT_TEST_RETRY; @@ -14,12 +15,18 @@ var EVENT_RUN_END = Runner.constants.EVENT_RUN_END; var STATE_FAILED = Runnable.constants.STATE_FAILED; describe('Runner', function() { + var sandbox; var suite; var runner; beforeEach(function() { suite = new Suite('Suite', 'root'); runner = new Runner(suite); + sandbox = sinon.createSandbox(); + }); + + afterEach(function() { + sandbox.restore(); }); describe('.grep()', function() { @@ -606,4 +613,314 @@ describe('Runner', function() { expect(runner.abort(), 'to be', runner); }); }); + + describe('uncaught()', function() { + beforeEach(function() { + sandbox.stub(runner, 'fail'); + }); + + describe('when provided an object argument', function() { + describe('when argument is not an Error', function() { + var err; + beforeEach(function() { + err = {whatever: 'yolo'}; + }); + + it('should fail with a transient Runnable and a new Error coerced from the object', function() { + runner.uncaught(err); + + expect(runner.fail, 'to have all calls satisfying', [ + expect.it('to be a', Runnable).and('to satisfy', { + parent: runner.suite, + title: /uncaught error outside test suite/i + }), + expect.it('to be an', Error).and('to satisfy', { + message: /throw an error/i, + uncaught: true + }) + ]).and('was called once'); + }); + }); + + describe('when argument is an Error', function() { + var err; + beforeEach(function() { + err = new Error('sorry dave'); + }); + + it('should add the "uncaught" property to the Error', function() { + runner.uncaught(err); + expect(err, 'to have property', 'uncaught', true); + }); + + describe('when no Runnables are running', function() { + beforeEach(function() { + delete runner.currentRunnable; + }); + + it('should fail with a transient Runnable and the error', function() { + runner.uncaught(err); + + expect(runner.fail, 'to have all calls satisfying', [ + expect.it('to be a', Runnable).and('to satisfy', { + parent: runner.suite, + title: /uncaught error outside test suite/i + }), + err + ]).and('was called once'); + }); + + describe('when Runner has already started', function() { + beforeEach(function() { + runner.started = true; + }); + + it('should not emit start/end events', function() { + expect( + function() { + runner.uncaught(err); + }, + 'not to emit from', + runner, + 'start' + ).and('not to emit from', runner, 'end'); + }); + }); + + describe('when Runner has not already started', function() { + beforeEach(function() { + runner.started = false; + }); + + it('should emit start/end events for the benefit of reporters', function() { + expect( + function() { + runner.uncaught(err); + }, + 'to emit from', + runner, + 'start' + ).and('to emit from', runner, 'end'); + }); + }); + }); + + describe('when a Runnable is running or has run', function() { + var runnable; + beforeEach(function() { + runnable = new Runnable(); + runnable.parent = runner.suite; + sandbox.stub(runnable, 'clearTimeout'); + runner.currentRunnable = runnable; + runner.nextSuite = sandbox.spy(); + }); + + afterEach(function() { + delete runner.nextSuite; + }); + + it('should clear any pending timeouts', function() { + runner.uncaught(err); + expect(runnable.clearTimeout, 'was called times', 1); + }); + + describe('when current Runnable has already failed', function() { + beforeEach(function() { + sandbox.stub(runnable, 'isFailed').returns(true); + }); + + it('should not attempt to fail again', function() { + runner.uncaught(err); + expect(runner.fail, 'was not called'); + }); + }); + + describe('when current Runnable has been marked pending', function() { + beforeEach(function() { + sandbox.stub(runnable, 'isPending').returns(true); + }); + + it('should not attempt to fail', function() { + runner.uncaught(err); + expect(runner.fail, 'was not called'); + }); + }); + + describe('when the current Runnable has already passed', function() { + beforeEach(function() { + sandbox.stub(runnable, 'isPassed').returns(true); + }); + + it('should fail with the current Runnable and the error', function() { + runner.uncaught(err); + + expect(runner.fail, 'to have all calls satisfying', [ + expect.it('to be', runnable), + err + ]).and('was called once'); + }); + + it('should notify run has ended', function() { + expect( + function() { + runner.uncaught(err); + }, + 'to emit from', + runner, + 'end' + ); + }); + }); + + describe('when the current Runnable is currently running', function() { + describe('when the current Runnable is a Test', function() { + beforeEach(function() { + runnable = new Test('goomba', noop); + runnable.parent = runner.suite; + runner.currentRunnable = runnable; + sandbox.stub(runner, 'hookUp'); + runner.next = sandbox.spy(); + }); + + afterEach(function() { + delete runner.next; + }); + + it('should fail with the current Runnable and the error', function() { + runner.uncaught(err); + + expect(runner.fail, 'to have all calls satisfying', [ + expect.it('to be', runnable), + err + ]).and('was called once'); + }); + + it('should notify test has ended', function() { + expect( + function() { + runner.uncaught(err); + }, + 'to emit from', + runner, + 'test end', + runnable + ); + }); + + it('should not notify run has ended', function() { + expect( + function() { + runner.uncaught(err); + }, + 'not to emit from', + runner, + 'end' + ); + }); + + it('should call any remaining "after each" hooks', function() { + runner.uncaught(err); + expect(runner.hookUp, 'to have all calls satisfying', [ + 'afterEach', + expect.it('to be', runner.next) + ]).and('was called once'); + }); + }); + + describe('when the current Runnable is a "before all" or "after all" hook', function() { + beforeEach(function() { + runnable = new Hook('', noop); + runnable.parent = runner.suite; + runner.currentRunnable = runnable; + }); + + it('should continue to the next suite', function() { + runner.uncaught(err); + expect(runner.nextSuite, 'to have all calls satisfying', [ + runner.suite + ]).and('was called once'); + }); + + it('should not notify run has ended', function() { + expect( + function() { + runner.uncaught(err); + }, + 'not to emit from', + runner, + 'end' + ); + }); + }); + + describe('when the current Runnable is a "before each" hook', function() { + beforeEach(function() { + runnable = new Hook('before each', noop); + runnable.parent = runner.suite; + runner.currentRunnable = runnable; + runner.hookErr = sandbox.spy(); + }); + + afterEach(function() { + delete runner.hookErr; + }); + + it('should associate its failure with the current test', function() { + runner.uncaught(err); + expect(runner.hookErr, 'to have all calls satisfying', [ + err, + runner.suite, + false + ]).and('was called once'); + }); + + it('should not notify run has ended', function() { + expect( + function() { + runner.uncaught(err); + }, + 'not to emit from', + runner, + 'end' + ); + }); + }); + + describe('when the current Runnable is an "after each" hook', function() { + beforeEach(function() { + runnable = new Hook('after each', noop); + runnable.parent = runner.suite; + runner.currentRunnable = runnable; + runner.hookErr = sandbox.spy(); + }); + + afterEach(function() { + delete runner.hookErr; + }); + + it('should associate its failure with the current test', function() { + runner.uncaught(err); + expect(runner.hookErr, 'to have all calls satisfying', [ + err, + runner.suite, + true + ]).and('was called once'); + }); + + it('should not notify run has ended', function() { + expect( + function() { + runner.uncaught(err); + }, + 'not to emit from', + runner, + 'end' + ); + }); + }); + }); + }); + }); + }); + }); });