diff --git a/lib/jasmine-core/jasmine.js b/lib/jasmine-core/jasmine.js index 605e7a55..6cd6bf52 100644 --- a/lib/jasmine-core/jasmine.js +++ b/lib/jasmine-core/jasmine.js @@ -760,6 +760,7 @@ getJasmineRequireObj().Spec = function(j$) { function Spec(attrs) { this.expectationFactory = attrs.expectationFactory; this.asyncExpectationFactory = attrs.asyncExpectationFactory; + this.setTimeout = attrs.setTimeout; this.resultCallback = attrs.resultCallback || function() {}; this.id = attrs.id; this.filename = attrs.filename; @@ -830,9 +831,11 @@ getJasmineRequireObj().Spec = function(j$) { Spec.prototype.execute = function( queueRunnerFactory, + globalErrors, onComplete, excluded, - failSpecWithNoExp + failSpecWithNoExp, + detectLateRejectionHandling ) { const onStart = { fn: done => { @@ -893,6 +896,21 @@ getJasmineRequireObj().Spec = function(j$) { } runnerConfig.queueableFns.unshift(onStart); + + if (detectLateRejectionHandling) { + // Conditional because the setTimeout imposes a significant performance + // penalty in suites with lots of fast specs. + runnerConfig.queueableFns.push({ + fn: done => { + // setTimeout is necessary to trigger rejectionhandled events + // TODO: let clearStack know about this so it doesn't do redundant setTimeouts + this.setTimeout(function() { + globalErrors.reportUnhandledRejections(); + done(); + }); + } + }); + } runnerConfig.queueableFns.push(complete); queueRunnerFactory(runnerConfig); @@ -1190,7 +1208,12 @@ getJasmineRequireObj().Env = function(j$) { new j$.MockDate(global) ); - const globalErrors = new j$.GlobalErrors(); + const globalErrors = new j$.GlobalErrors( + undefined, + // Configuration is late-bound because GlobalErrors needs to be constructed + // before it's set to detect load-time errors in browsers + () => this.configuration() + ); const installGlobalErrors = (function() { let installed = false; return function() { @@ -1324,7 +1347,21 @@ getJasmineRequireObj().Env = function(j$) { * @type Boolean * @default false */ - verboseDeprecations: false + verboseDeprecations: false, + + /** + * Whether to detect late promise rejection handling during spec + * execution. If this option is enabled, a promise rejection that triggers + * the JavaScript runtime's unhandled rejection event will not be treated + * as an error as long as it's handled before the spec finishes. + * + * This option is off by default because it imposes a performance penalty. + * @name Configuration#detectLateRejectionHandling + * @since 5.10.0 + * @type Boolean + * @default false + */ + detectLateRejectionHandling: false }; if (!options.suppressLoadErrors) { @@ -1362,7 +1399,8 @@ getJasmineRequireObj().Env = function(j$) { 'stopOnSpecFailure', 'stopSpecOnExpectationFailure', 'autoCleanClosures', - 'forbidDuplicateNames' + 'forbidDuplicateNames', + 'detectLateRejectionHandling' ]; booleanProps.forEach(function(prop) { @@ -1664,6 +1702,7 @@ getJasmineRequireObj().Env = function(j$) { reporter, queueRunnerFactory, TreeProcessor: j$.TreeProcessor, + globalErrors, getConfig: () => config, reportSpecDone }); @@ -4306,28 +4345,36 @@ getJasmineRequireObj().formatErrorMsg = function() { getJasmineRequireObj().GlobalErrors = function(j$) { class GlobalErrors { + #getConfig; #adapter; #handlers; #overrideHandler; #onRemoveOverrideHandler; + #pendingUnhandledRejections; - constructor(global) { + constructor(global, getConfig) { global = global || j$.getGlobal(); - const dispatchError = this.#dispatchError.bind(this); + this.#getConfig = getConfig; + this.#pendingUnhandledRejections = new Map(); + this.#handlers = []; + this.#overrideHandler = null; + this.#onRemoveOverrideHandler = null; + + const dispatch = { + onUncaughtException: this.#onUncaughtException.bind(this), + onUnhandledRejection: this.#onUnhandledRejection.bind(this), + onRejectionHandled: this.#onRejectionHandled.bind(this) + }; if ( global.process && global.process.listeners && j$.isFunction_(global.process.on) ) { - this.#adapter = new NodeAdapter(global, dispatchError); + this.#adapter = new NodeAdapter(global, dispatch); } else { - this.#adapter = new BrowserAdapter(global, dispatchError); + this.#adapter = new BrowserAdapter(global, dispatch); } - - this.#handlers = []; - this.#overrideHandler = null; - this.#onRemoveOverrideHandler = null; } install() { @@ -4375,6 +4422,41 @@ getJasmineRequireObj().GlobalErrors = function(j$) { this.#onRemoveOverrideHandler = null; } + reportUnhandledRejections() { + for (const { + reason, + event + } of this.#pendingUnhandledRejections.values()) { + this.#dispatchError(reason, event); + } + + this.#pendingUnhandledRejections.clear(); + } + + // Either error or event may be undefined + #onUncaughtException(error, event) { + this.#dispatchError(error, event); + } + + // event or promise may be undefined + // event is passed through for backwards compatibility reasons. It's probably + // unnecessary, but user code could depend on it. + #onUnhandledRejection(reason, promise, event) { + if (this.#detectLateRejectionHandling() && promise) { + this.#pendingUnhandledRejections.set(promise, { reason, event }); + } else { + this.#dispatchError(reason, event); + } + } + + #detectLateRejectionHandling() { + return this.#getConfig().detectLateRejectionHandling; + } + + #onRejectionHandled(promise) { + this.#pendingUnhandledRejections.delete(promise); + } + // Either error or event may be undefined #dispatchError(error, event) { if (this.#overrideHandler) { @@ -4395,24 +4477,29 @@ getJasmineRequireObj().GlobalErrors = function(j$) { class BrowserAdapter { #global; - #dispatchError; + #dispatch; #onError; #onUnhandledRejection; + #onRejectionHandled; - constructor(global, dispatchError) { + constructor(global, dispatch) { this.#global = global; - this.#dispatchError = dispatchError; - this.#onError = event => this.#dispatchError(event.error, event); + this.#dispatch = dispatch; + this.#onError = event => dispatch.onUncaughtException(event.error, event); this.#onUnhandledRejection = this.#unhandledRejectionHandler.bind(this); + this.#onRejectionHandled = this.#rejectionHandledHandler.bind(this); } install() { this.#global.addEventListener('error', this.#onError); - this.#global.addEventListener( 'unhandledrejection', this.#onUnhandledRejection ); + this.#global.addEventListener( + 'rejectionhandled', + this.#onRejectionHandled + ); } uninstall() { @@ -4421,50 +4508,55 @@ getJasmineRequireObj().GlobalErrors = function(j$) { 'unhandledrejection', this.#onUnhandledRejection ); + this.#global.removeEventListener( + 'rejectionhandled', + this.#onRejectionHandled + ); } #unhandledRejectionHandler(event) { + const jasmineMessage = 'Unhandled promise rejection: ' + event.reason; + let reason; + if (j$.isError_(event.reason)) { - event.reason.jasmineMessage = - 'Unhandled promise rejection: ' + event.reason; - this.#dispatchError(event.reason, event); + reason = event.reason; + reason.jasmineMessage = jasmineMessage; } else { - this.#dispatchError( - 'Unhandled promise rejection: ' + event.reason, - event - ); + reason = jasmineMessage; } + + this.#dispatch.onUnhandledRejection(reason, event.promise, event); + } + + #rejectionHandledHandler(event) { + this.#dispatch.onRejectionHandled(event.promise); } } class NodeAdapter { #global; - #dispatchError; + #dispatch; #originalHandlers; #jasmineHandlers; - #onError; - #onUnhandledRejection; - constructor(global, dispatchError) { + constructor(global, dispatch) { this.#global = global; - this.#dispatchError = dispatchError; + this.#dispatch = dispatch; this.#jasmineHandlers = {}; this.#originalHandlers = {}; - this.#onError = error => - this.#eventHandler(error, 'uncaughtException', 'Uncaught exception'); - this.#onUnhandledRejection = error => - this.#eventHandler( - error, - 'unhandledRejection', - 'Unhandled promise rejection' - ); + this.onError = this.onError.bind(this); + this.onUnhandledRejection = this.onUnhandledRejection.bind(this); } install() { - this.#installHandler('uncaughtException', this.#onError); - this.#installHandler('unhandledRejection', this.#onUnhandledRejection); + this.#installHandler('uncaughtException', this.onError); + this.#installHandler('unhandledRejection', this.onUnhandledRejection); + this.#installHandler( + 'rejectionHandled', + this.#dispatch.onRejectionHandled + ); } uninstall() { @@ -4496,31 +4588,49 @@ getJasmineRequireObj().GlobalErrors = function(j$) { this.#global.process.on(errorType, handler); } - #eventHandler(error, errorType, jasmineMessage) { + #augmentError(error, isUnhandledRejection) { + let jasmineMessagePrefix; + + if (isUnhandledRejection) { + jasmineMessagePrefix = 'Unhandled promise rejection'; + } else { + jasmineMessagePrefix = 'Uncaught exception'; + } + if (j$.isError_(error)) { - error.jasmineMessage = jasmineMessage + ': ' + error; + error.jasmineMessage = jasmineMessagePrefix + ': ' + error; + return error; } else { let substituteMsg; if (error) { - substituteMsg = jasmineMessage + ': ' + error; + substituteMsg = jasmineMessagePrefix + ': ' + error; } else { - substituteMsg = jasmineMessage + ' with no error or message'; + substituteMsg = jasmineMessagePrefix + ' with no error or message'; } - if (errorType === 'unhandledRejection') { + if (isUnhandledRejection) { substituteMsg += '\n' + '(Tip: to get a useful stack trace, use ' + - 'Promise.reject(new Error(...)) instead of Promise.reject(' + + 'Promise.reject(n' + + 'ew Error(...)) instead of Promise.reject(' + (error ? '...' : '') + ').)'; } - error = new Error(substituteMsg); + return new Error(substituteMsg); } + } - this.#dispatchError(error); + onError(error) { + error = this.#augmentError(error, false); + this.#dispatch.onUncaughtException(error); + } + + onUnhandledRejection(reason, promise) { + reason = this.#augmentError(reason, true); + this.#dispatch.onUnhandledRejection(reason, promise); } } @@ -9286,6 +9396,7 @@ getJasmineRequireObj().Runner = function(j$) { this.runableResources_ = options.runableResources; this.queueRunnerFactory_ = options.queueRunnerFactory; this.TreeProcessor_ = options.TreeProcessor; + this.globalErrors_ = options.globalErrors; this.reporter_ = options.reporter; this.getConfig_ = options.getConfig; this.reportSpecDone_ = options.reportSpecDone; @@ -9351,7 +9462,9 @@ getJasmineRequireObj().Runner = function(j$) { return this.queueRunnerFactory_(options); }, + globalErrors: this.globalErrors_, failSpecWithNoExpectations: config.failSpecWithNoExpectations, + detectLateRejectionHandling: config.detectLateRejectionHandling, nodeStart: (suite, next) => { this.currentlyExecutingSuites_.push(suite); this.runableResources_.initForRunable(suite.id, suite.parentSuite.id); @@ -11016,6 +11129,7 @@ getJasmineRequireObj().SuiteBuilder = function(j$) { const config = this.env_.configuration(); const suite = this.currentDeclarationSuite_; const parentSuiteId = suite === this.topSuite ? null : suite.id; + const global = j$.getGlobal(); const spec = new j$.Spec({ id: 'spec' + this.nextSpecId_++, filename, @@ -11023,6 +11137,7 @@ getJasmineRequireObj().SuiteBuilder = function(j$) { beforeAndAfterFns: beforeAndAfterFns(suite), expectationFactory: this.expectationFactory_, asyncExpectationFactory: this.specAsyncExpectationFactory_, + setTimeout: global.setTimeout.bind(global), onLateError: this.onLateError_, resultCallback: (result, next) => { this.specResultCallback_(spec, result, next); @@ -11173,6 +11288,9 @@ getJasmineRequireObj().TreeProcessor = function() { const nodeStart = attrs.nodeStart || function() {}; const nodeComplete = attrs.nodeComplete || function() {}; const failSpecWithNoExpectations = !!attrs.failSpecWithNoExpectations; + const detectLateRejectionHandling = !!attrs.detectLateRejectionHandling; + const globalErrors = attrs.globalErrors; + const orderChildren = attrs.orderChildren || function(node) { @@ -11400,9 +11518,11 @@ getJasmineRequireObj().TreeProcessor = function() { fn: function(done) { node.execute( queueRunnerFactory, + globalErrors, done, stats[node.id].excluded, - failSpecWithNoExpectations + failSpecWithNoExpectations, + detectLateRejectionHandling ); } }; diff --git a/spec/core/GlobalErrorsSpec.js b/spec/core/GlobalErrorsSpec.js index eae2bef9..959bb340 100644 --- a/spec/core/GlobalErrorsSpec.js +++ b/spec/core/GlobalErrorsSpec.js @@ -2,7 +2,10 @@ describe('GlobalErrors', function() { it('calls the added handler on error', function() { const globals = browserGlobals(); const handler = jasmine.createSpy('errorHandler'); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); errors.install(); errors.pushListener(handler); @@ -19,7 +22,10 @@ describe('GlobalErrors', function() { it('is not affected by overriding global.onerror', function() { const globals = browserGlobals(); const handler = jasmine.createSpy('errorHandler'); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); errors.install(); errors.pushListener(handler); @@ -39,7 +45,10 @@ describe('GlobalErrors', function() { const globals = browserGlobals(); const handler1 = jasmine.createSpy('errorHandler1'); const handler2 = jasmine.createSpy('errorHandler2'); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); errors.install(); errors.pushListener(handler1); @@ -59,7 +68,10 @@ describe('GlobalErrors', function() { const globals = browserGlobals(); const handler1 = jasmine.createSpy('errorHandler1'); const handler2 = jasmine.createSpy('errorHandler2'); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); errors.install(); errors.pushListener(handler1); @@ -86,7 +98,10 @@ describe('GlobalErrors', function() { it('uninstalls itself', function() { const globals = browserGlobals(); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); function unrelatedListener() {} errors.install(); @@ -98,7 +113,10 @@ describe('GlobalErrors', function() { it('rethrows the original error when there is no handler', function() { const globals = browserGlobals(); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); const originalError = new Error('nope'); errors.install(); @@ -114,7 +132,10 @@ describe('GlobalErrors', function() { it('reports uncaught exceptions in node.js', function() { const globals = nodeGlobals(); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); const handler = jasmine.createSpy('errorHandler'); function originalHandler() {} globals.listeners.uncaughtException = [originalHandler]; @@ -144,7 +165,10 @@ describe('GlobalErrors', function() { describe('Reporting unhandled promise rejections in node.js', function() { it('reports rejections with `Error` reasons', function() { const globals = nodeGlobals(); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); const handler = jasmine.createSpy('errorHandler'); function originalHandler() {} globals.listeners.unhandledRejection = [originalHandler]; @@ -173,7 +197,10 @@ describe('GlobalErrors', function() { it('reports rejections with non-`Error` reasons', function() { const globals = nodeGlobals(); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); const handler = jasmine.createSpy('errorHandler'); errors.install(); @@ -193,7 +220,10 @@ describe('GlobalErrors', function() { it('reports rejections with no reason provided', function() { const globals = nodeGlobals(); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); const handler = jasmine.createSpy('errorHandler'); errors.install(); @@ -210,12 +240,149 @@ describe('GlobalErrors', function() { undefined ); }); + + describe('When detectLateRejectionHandling is true', function() { + let globals, errors; + + beforeEach(function() { + globals = nodeGlobals(); + errors = new jasmineUnderTest.GlobalErrors(globals.global, () => ({ + detectLateRejectionHandling: true + })); + }); + + it('subscribes and unsubscribes from the rejectionHandled event', function() { + function originalHandler() {} + globals.global.process.on('rejectionHandled', originalHandler); + errors.install(); + + expect(globals.listeners.rejectionHandled).toEqual([ + jasmine.any(Function) + ]); + expect(globals.listeners.rejectionHandled).not.toEqual([ + originalHandler + ]); + + errors.uninstall(); + expect(globals.listeners.rejectionHandled).toEqual([originalHandler]); + }); + + describe("When the unhandledRejection event doesn't have a promise", function() { + it('immediately reports the rejection', function() { + const handler = jasmine.createSpy('errorHandler'); + + errors.install(); + errors.pushListener(handler); + + dispatchEvent( + globals.listeners, + 'unhandledRejection', + new Error('nope'), + undefined + ); + + expect(handler).toHaveBeenCalledWith(new Error('nope'), undefined); + expect(handler.calls.argsFor(0)[0].jasmineMessage).toBe( + 'Unhandled promise rejection: Error: nope' + ); + }); + }); + + describe('When the unhandledRejection event has a promise property', function() { + it('does not immediately report the rejection', function() { + const handler = jasmine.createSpy('errorHandler'); + + errors.install(); + errors.pushListener(handler); + + const promise = Promise.reject('nope'); + promise.catch(() => {}); + dispatchEvent( + globals.listeners, + 'unhandledRejection', + 'nope', + promise + ); + + expect(handler).not.toHaveBeenCalled(); + }); + + describe('When reportUnhandledRejections is called', function() { + it('reports rejections that have not been handled', function() { + const handler = jasmine.createSpy('errorHandler'); + errors.install(); + errors.pushListener(handler); + + const reason = new Error('nope'); + const promise = Promise.reject(reason); + promise.catch(() => {}); + dispatchEvent( + globals.listeners, + 'unhandledRejection', + reason, + promise + ); + errors.reportUnhandledRejections(); + + expect(handler).toHaveBeenCalledWith(new Error('nope'), undefined); + expect(handler.calls.argsFor(0)[0].jasmineMessage).toBe( + 'Unhandled promise rejection: Error: nope' + ); + }); + + it('does not report rejections that have been handled', function() { + const handler = jasmine.createSpy('errorHandler'); + errors.install(); + errors.pushListener(handler); + + const reason = new Error('nope'); + const promise = Promise.reject(reason); + promise.catch(() => {}); + dispatchEvent( + globals.listeners, + 'unhandledRejection', + reason, + promise + ); + dispatchEvent(globals.listeners, 'rejectionHandled', promise); + errors.reportUnhandledRejections(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not report the same rejection on subsequent calls', function() { + const handler = jasmine.createSpy('errorHandler'); + + errors.install(); + errors.pushListener(handler); + + const promise = Promise.reject('nope'); + promise.catch(() => {}); + dispatchEvent( + globals.listeners, + 'unhandledRejection', + 'nope', + promise + ); + errors.reportUnhandledRejections(); + expect(handler).toHaveBeenCalled(); + handler.calls.reset(); + + errors.reportUnhandledRejections(); + expect(handler).not.toHaveBeenCalled(); + }); + }); + }); + }); }); describe('Reporting unhandled promise rejections in the browser', function() { it('subscribes and unsubscribes from the unhandledrejection event', function() { const globals = browserGlobals(); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); errors.install(); expect(globals.listeners.unhandledrejection).toEqual([ @@ -229,7 +396,10 @@ describe('GlobalErrors', function() { it('reports rejections whose reason is a string', function() { const globals = browserGlobals(); const handler = jasmine.createSpy('errorHandler'); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); errors.install(); errors.pushListener(handler); @@ -246,7 +416,10 @@ describe('GlobalErrors', function() { it('reports rejections whose reason is an Error', function() { const globals = browserGlobals(); const handler = jasmine.createSpy('errorHandler'); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); errors.install(); errors.pushListener(handler); @@ -264,12 +437,129 @@ describe('GlobalErrors', function() { event ); }); + + describe('When detectLateRejectionHandling is true', function() { + let globals, errors; + + beforeEach(function() { + globals = browserGlobals(); + errors = new jasmineUnderTest.GlobalErrors(globals.global, () => ({ + detectLateRejectionHandling: true + })); + }); + + it('subscribes and unsubscribes from the rejectionhandled event', function() { + errors.install(); + expect(globals.listeners.rejectionhandled).toEqual([ + jasmine.any(Function) + ]); + + errors.uninstall(); + expect(globals.listeners.rejectionhandled).toEqual([]); + }); + + describe("When the unhandledrejection event doesn't have a promise property", function() { + it('immediately reports the rejection', function() { + const handler = jasmine.createSpy('errorHandler'); + + errors.install(); + errors.pushListener(handler); + + const event = { reason: 'nope' }; + dispatchEvent(globals.listeners, 'unhandledrejection', event); + + expect(handler).toHaveBeenCalledWith( + 'Unhandled promise rejection: nope', + event + ); + }); + }); + + describe('When the unhandledrejection event has a promise property', function() { + it('does not immediately report the rejection', function() { + const handler = jasmine.createSpy('errorHandler'); + + errors.install(); + errors.pushListener(handler); + + const promise = Promise.reject('nope'); + promise.catch(() => {}); + dispatchEvent(globals.listeners, 'unhandledrejection', { + reason: 'nope', + promise + }); + + expect(handler).not.toHaveBeenCalled(); + }); + + describe('When reportUnhandledRejections is called', function() { + it('reports rejections that have not been handled', function() { + const handler = jasmine.createSpy('errorHandler'); + errors.install(); + errors.pushListener(handler); + + const promise = Promise.reject('nope'); + promise.catch(() => {}); + dispatchEvent(globals.listeners, 'unhandledrejection', { + reason: 'nope', + promise + }); + errors.reportUnhandledRejections(); + + expect(handler).toHaveBeenCalledWith( + 'Unhandled promise rejection: nope', + { reason: 'nope', promise } + ); + }); + + it('does not report rejections that have been handled', function() { + const handler = jasmine.createSpy('errorHandler'); + errors.install(); + errors.pushListener(handler); + + const promise = Promise.reject('nope'); + promise.catch(() => {}); + dispatchEvent(globals.listeners, 'unhandledrejection', { + reason: 'nope', + promise + }); + dispatchEvent(globals.listeners, 'rejectionhandled', { promise }); + errors.reportUnhandledRejections(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not report the same rejection on subsequent calls', function() { + const handler = jasmine.createSpy('errorHandler'); + + errors.install(); + errors.pushListener(handler); + + const promise = Promise.reject('nope'); + promise.catch(() => {}); + dispatchEvent(globals.listeners, 'unhandledrejection', { + reason: 'nope', + promise + }); + errors.reportUnhandledRejections(); + expect(handler).toHaveBeenCalled(); + handler.calls.reset(); + + errors.reportUnhandledRejections(); + expect(handler).not.toHaveBeenCalled(); + }); + }); + }); + }); }); describe('Reporting uncaught exceptions in node.js', function() { it('prepends a descriptive message when the error is not an `Error`', function() { const globals = nodeGlobals(); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); const handler = jasmine.createSpy('errorHandler'); errors.install(); @@ -285,7 +575,10 @@ describe('GlobalErrors', function() { it('substitutes a descriptive message when the error is falsy', function() { const globals = nodeGlobals(); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); const handler = jasmine.createSpy('errorHandler'); errors.install(); @@ -306,7 +599,10 @@ describe('GlobalErrors', function() { const handler0 = jasmine.createSpy('handler0'); const handler1 = jasmine.createSpy('handler1'); const overrideHandler = jasmine.createSpy('overrideHandler'); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); errors.install(); errors.pushListener(handler0); @@ -331,7 +627,10 @@ describe('GlobalErrors', function() { const handler0 = jasmine.createSpy('handler0'); const handler1 = jasmine.createSpy('handler1'); const overrideHandler = jasmine.createSpy('overrideHandler'); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); errors.install(); errors.pushListener(handler0); @@ -356,7 +655,10 @@ describe('GlobalErrors', function() { const globals = browserGlobals(); const handler = jasmine.createSpy('handler'); const overrideHandler = jasmine.createSpy('overrideHandler'); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); errors.install(); errors.pushListener(handler); @@ -381,7 +683,10 @@ describe('GlobalErrors', function() { const handler0 = jasmine.createSpy('handler0'); const handler1 = jasmine.createSpy('handler1'); const overrideHandler = jasmine.createSpy('overrideHandler'); - const errors = new jasmineUnderTest.GlobalErrors(globals.global); + const errors = new jasmineUnderTest.GlobalErrors( + globals.global, + () => ({}) + ); errors.install(); errors.pushListener(handler0); @@ -424,7 +729,11 @@ describe('GlobalErrors', function() { }); function browserGlobals() { - const listeners = { error: [], unhandledrejection: [] }; + const listeners = { + error: [], + unhandledrejection: [], + rejectionhandled: [] + }; return { listeners, global: { @@ -441,7 +750,11 @@ describe('GlobalErrors', function() { } function nodeGlobals() { - const listeners = { uncaughtException: [], unhandledRejection: [] }; + const listeners = { + uncaughtException: [], + unhandledRejection: [], + rejectionHandled: [] + }; return { listeners, global: { @@ -465,13 +778,13 @@ describe('GlobalErrors', function() { }; } - function dispatchEvent(listeners, eventName, event) { + function dispatchEvent(listeners, eventName, ...args) { expect(listeners[eventName].length) .withContext(`number of ${eventName} listeners`) .toBeGreaterThan(0); for (const l of listeners[eventName]) { - l(event); + l.apply(null, args); } } }); diff --git a/spec/core/SpecSpec.js b/spec/core/SpecSpec.js index bcde9478..33277300 100644 --- a/spec/core/SpecSpec.js +++ b/spec/core/SpecSpec.js @@ -121,6 +121,58 @@ describe('Spec', function() { ]); }); + describe('Late promise rejection handling', function() { + it('is enabled when the detectLateRejectionHandling param is true', function() { + const fakeQueueRunner = jasmine.createSpy('fakeQueueRunner'); + const globalErrors = jasmine.createSpyObj('globalErrors', [ + 'reportUnhandledRejections' + ]); + const setTimeout = jasmine.createSpy('setTimeout'); + const before = jasmine.createSpy('before'); + const after = jasmine.createSpy('after'); + const queueableFn = { + fn: jasmine.createSpy('test body').and.callFake(function() { + expect(before).toHaveBeenCalled(); + expect(after).not.toHaveBeenCalled(); + }) + }; + const spec = new jasmineUnderTest.Spec({ + queueableFn, + setTimeout, + beforeAndAfterFns: function() { + return { befores: [before], afters: [after] }; + } + }); + + spec.execute(fakeQueueRunner, globalErrors, null, false, false, true); + + const options = fakeQueueRunner.calls.mostRecent().args[0]; + expect(options.queueableFns).toEqual([ + { fn: jasmine.any(Function) }, + before, + queueableFn, + after, + { fn: jasmine.any(Function) }, + { + fn: jasmine.any(Function), + type: 'specCleanup' + } + ]); + + const done = jasmine.createSpy('done'); + options.queueableFns[4].fn(done); + expect(globalErrors.reportUnhandledRejections).not.toHaveBeenCalled(); + expect(done).not.toHaveBeenCalled(); + + expect(setTimeout).toHaveBeenCalledOnceWith(jasmine.any(Function)); + setTimeout.calls.argsFor(0)[0](); + expect(globalErrors.reportUnhandledRejections).toHaveBeenCalled(); + expect(globalErrors.reportUnhandledRejections).toHaveBeenCalledBefore( + done + ); + }); + }); + it("tells the queue runner that it's a leaf node", function() { const fakeQueueRunner = jasmine.createSpy('fakeQueueRunner'), spec = new jasmineUnderTest.Spec({ @@ -162,7 +214,7 @@ describe('Spec', function() { resultCallback: resultCallback }); - spec.execute(fakeQueueRunner, 'cally-back', true); + spec.execute(fakeQueueRunner, null, 'cally-back', true); expect(fakeQueueRunner).toHaveBeenCalledWith( jasmine.objectContaining({ @@ -245,7 +297,7 @@ describe('Spec', function() { resultCallback: function() {} }); - spec.execute(attrs => attrs.onComplete(), done); + spec.execute(attrs => attrs.onComplete(), null, done); expect(done).toHaveBeenCalled(); }); @@ -264,7 +316,7 @@ describe('Spec', function() { spec.result.status = 'failed'; attrs.onComplete(); } - spec.execute(queueRunnerFactory, done); + spec.execute(queueRunnerFactory, null, done); expect(done).toHaveBeenCalledWith( jasmine.any(jasmineUnderTest.StopExecutionError) @@ -295,7 +347,7 @@ describe('Spec', function() { config.onComplete(); } - spec.execute(queueRunnerFactory, function() {}); + spec.execute(queueRunnerFactory, null, function() {}); expect(duration).toBe(77000); }); @@ -309,7 +361,7 @@ describe('Spec', function() { resultCallback: function() {} }); spec.setSpecProperty('a', 4); - spec.execute(attrs => attrs.onComplete(), done); + spec.execute(attrs => attrs.onComplete(), null, done); expect(spec.result.properties).toEqual({ a: 4 }); }); @@ -665,7 +717,7 @@ describe('Spec', function() { config.onComplete(false); } - spec.execute(queueRunnerFactory, function() {}); + spec.execute(queueRunnerFactory, null, function() {}); expect(resultCallback).toHaveBeenCalledWith( jasmine.objectContaining({ debugLogs: null }), undefined @@ -689,7 +741,7 @@ describe('Spec', function() { config.onComplete(false); } - spec.execute(queueRunnerFactory, function() {}); + spec.execute(queueRunnerFactory, null, function() {}); expect(resultCallback).toHaveBeenCalled(); expect(spec.result.debugLogs).toBeNull(); }); @@ -719,7 +771,7 @@ describe('Spec', function() { config.onComplete(true); } - spec.execute(queueRunnerFactory, function() {}); + spec.execute(queueRunnerFactory, null, function() {}); expect(resultCallback).toHaveBeenCalledWith( jasmine.objectContaining({ debugLogs: [{ message: 'msg', timestamp: timestamp }] diff --git a/spec/core/TreeProcessorSpec.js b/spec/core/TreeProcessorSpec.js index dc03dc18..9c07d785 100644 --- a/spec/core/TreeProcessorSpec.js +++ b/spec/core/TreeProcessorSpec.js @@ -275,14 +275,21 @@ describe('TreeProcessor', function() { }); it('runs a single leaf', async function() { - const leaf = new Leaf(), - node = new Node({ children: [leaf], userContext: { root: 'context' } }), - queueRunner = jasmine.createSpy('queueRunner'), - processor = new jasmineUnderTest.TreeProcessor({ - tree: node, - runnableIds: [leaf.id], - queueRunnerFactory: queueRunner - }); + const leaf = new Leaf(); + const node = new Node({ + children: [leaf], + userContext: { root: 'context' } + }); + const queueRunner = jasmine.createSpy('queueRunner'); + const globalErrors = 'the globalErrors instance'; + const detectLateRejectionHandling = true; + const processor = new jasmineUnderTest.TreeProcessor({ + tree: node, + runnableIds: [leaf.id], + queueRunnerFactory: queueRunner, + globalErrors, + detectLateRejectionHandling + }); const promise = processor.execute(); @@ -296,7 +303,14 @@ describe('TreeProcessor', function() { const queueRunnerArgs = queueRunner.calls.mostRecent().args[0]; queueRunnerArgs.queueableFns[0].fn('foo'); - expect(leaf.execute).toHaveBeenCalledWith(queueRunner, 'foo', false, false); + expect(leaf.execute).toHaveBeenCalledWith( + queueRunner, + globalErrors, + 'foo', + false, + false, + detectLateRejectionHandling + ); queueRunnerArgs.onComplete(); await expectAsync(promise).toBeResolvedTo(undefined); @@ -355,18 +369,22 @@ describe('TreeProcessor', function() { }); it('runs a node with children', function() { - const leaf1 = new Leaf(), - leaf2 = new Leaf(), - node = new Node({ children: [leaf1, leaf2] }), - root = new Node({ children: [node] }), - queueRunner = jasmine.createSpy('queueRunner'), - processor = new jasmineUnderTest.TreeProcessor({ - tree: root, - runnableIds: [node.id], - queueRunnerFactory: queueRunner - }), - treeComplete = jasmine.createSpy('treeComplete'), - nodeDone = jasmine.createSpy('nodeDone'); + const leaf1 = new Leaf(); + const leaf2 = new Leaf(); + const node = new Node({ children: [leaf1, leaf2] }); + const root = new Node({ children: [node] }); + const queueRunner = jasmine.createSpy('queueRunner'); + const globalErrors = 'the globalErrors instance'; + const detectLateRejectionHandling = false; + const processor = new jasmineUnderTest.TreeProcessor({ + tree: root, + runnableIds: [node.id], + queueRunnerFactory: queueRunner, + globalErrors, + detectLateRejectionHandling + }); + const treeComplete = jasmine.createSpy('treeComplete'); + const nodeDone = jasmine.createSpy('nodeDone'); processor.execute(treeComplete); let queueableFns = queueRunner.calls.mostRecent().args[0].queueableFns; @@ -378,34 +396,42 @@ describe('TreeProcessor', function() { queueableFns[1].fn('foo'); expect(leaf1.execute).toHaveBeenCalledWith( queueRunner, + globalErrors, 'foo', false, - false + false, + detectLateRejectionHandling ); queueableFns[2].fn('bar'); expect(leaf2.execute).toHaveBeenCalledWith( queueRunner, + globalErrors, 'bar', false, - false + false, + detectLateRejectionHandling ); }); it('cascades errors up the tree', function() { - const leaf = new Leaf(), - node = new Node({ children: [leaf] }), - root = new Node({ children: [node] }), - queueRunner = jasmine.createSpy('queueRunner'), - nodeComplete = jasmine.createSpy('nodeComplete'), - processor = new jasmineUnderTest.TreeProcessor({ - tree: root, - runnableIds: [node.id], - nodeComplete: nodeComplete, - queueRunnerFactory: queueRunner - }), - treeComplete = jasmine.createSpy('treeComplete'), - nodeDone = jasmine.createSpy('nodeDone'); + const leaf = new Leaf(); + const node = new Node({ children: [leaf] }); + const root = new Node({ children: [node] }); + const queueRunner = jasmine.createSpy('queueRunner'); + const globalErrors = 'the globalErrors instance'; + const detectLateRejectionHandling = false; + const nodeComplete = jasmine.createSpy('nodeComplete'); + const processor = new jasmineUnderTest.TreeProcessor({ + tree: root, + runnableIds: [node.id], + nodeComplete: nodeComplete, + queueRunnerFactory: queueRunner, + globalErrors, + detectLateRejectionHandling + }); + const treeComplete = jasmine.createSpy('treeComplete'); + const nodeDone = jasmine.createSpy('nodeDone'); processor.execute(treeComplete); let queueableFns = queueRunner.calls.mostRecent().args[0].queueableFns; @@ -415,7 +441,14 @@ describe('TreeProcessor', function() { expect(queueableFns.length).toBe(2); queueableFns[1].fn('foo'); - expect(leaf.execute).toHaveBeenCalledWith(queueRunner, 'foo', false, false); + expect(leaf.execute).toHaveBeenCalledWith( + queueRunner, + globalErrors, + 'foo', + false, + false, + detectLateRejectionHandling + ); queueRunner.calls.mostRecent().args[0].onComplete('things'); expect(nodeComplete).toHaveBeenCalled(); @@ -424,21 +457,25 @@ describe('TreeProcessor', function() { }); it('runs an excluded node with leaf', function() { - const leaf1 = new Leaf(), - node = new Node({ children: [leaf1] }), - root = new Node({ children: [node] }), - queueRunner = jasmine.createSpy('queueRunner'), - nodeStart = jasmine.createSpy('nodeStart'), - nodeComplete = jasmine.createSpy('nodeComplete'), - processor = new jasmineUnderTest.TreeProcessor({ - tree: root, - runnableIds: [], - queueRunnerFactory: queueRunner, - nodeStart: nodeStart, - nodeComplete: nodeComplete - }), - treeComplete = jasmine.createSpy('treeComplete'), - nodeDone = jasmine.createSpy('nodeDone'); + const leaf1 = new Leaf(); + const node = new Node({ children: [leaf1] }); + const root = new Node({ children: [node] }); + const queueRunner = jasmine.createSpy('queueRunner'); + const globalErrors = 'the globalErrors instance'; + const detectLateRejectionHandling = false; + const nodeStart = jasmine.createSpy('nodeStart'); + const nodeComplete = jasmine.createSpy('nodeComplete'); + const processor = new jasmineUnderTest.TreeProcessor({ + tree: root, + runnableIds: [], + queueRunnerFactory: queueRunner, + nodeStart: nodeStart, + nodeComplete: nodeComplete, + globalErrors, + detectLateRejectionHandling + }); + const treeComplete = jasmine.createSpy('treeComplete'); + const nodeDone = jasmine.createSpy('nodeDone'); processor.execute(treeComplete); let queueableFns = queueRunner.calls.mostRecent().args[0].queueableFns; @@ -451,7 +488,14 @@ describe('TreeProcessor', function() { expect(nodeStart).toHaveBeenCalledWith(node, 'bar'); queueableFns[1].fn('foo'); - expect(leaf1.execute).toHaveBeenCalledWith(queueRunner, 'foo', true, false); + expect(leaf1.execute).toHaveBeenCalledWith( + queueRunner, + globalErrors, + 'foo', + true, + false, + detectLateRejectionHandling + ); node.getResult.and.returnValue({ im: 'disabled' }); @@ -464,22 +508,26 @@ describe('TreeProcessor', function() { }); it('should execute node with correct arguments when failSpecWithNoExpectations option is set', function() { - const leaf = new Leaf(), - node = new Node({ children: [leaf] }), - root = new Node({ children: [node] }), - queueRunner = jasmine.createSpy('queueRunner'), - nodeStart = jasmine.createSpy('nodeStart'), - nodeComplete = jasmine.createSpy('nodeComplete'), - processor = new jasmineUnderTest.TreeProcessor({ - tree: root, - runnableIds: [], - queueRunnerFactory: queueRunner, - nodeStart: nodeStart, - nodeComplete: nodeComplete, - failSpecWithNoExpectations: true - }), - treeComplete = jasmine.createSpy('treeComplete'), - nodeDone = jasmine.createSpy('nodeDone'); + const leaf = new Leaf(); + const node = new Node({ children: [leaf] }); + const root = new Node({ children: [node] }); + const queueRunner = jasmine.createSpy('queueRunner'); + const globalErrors = 'the globalErrors instance'; + const detectLateRejectionHandling = false; + const nodeStart = jasmine.createSpy('nodeStart'); + const nodeComplete = jasmine.createSpy('nodeComplete'); + const processor = new jasmineUnderTest.TreeProcessor({ + tree: root, + runnableIds: [], + queueRunnerFactory: queueRunner, + nodeStart: nodeStart, + nodeComplete: nodeComplete, + globalErrors, + detectLateRejectionHandling, + failSpecWithNoExpectations: true + }); + const treeComplete = jasmine.createSpy('treeComplete'); + const nodeDone = jasmine.createSpy('nodeDone'); processor.execute(treeComplete); let queueableFns = queueRunner.calls.mostRecent().args[0].queueableFns; @@ -489,7 +537,14 @@ describe('TreeProcessor', function() { expect(queueableFns.length).toBe(2); queueableFns[1].fn('foo'); - expect(leaf.execute).toHaveBeenCalledWith(queueRunner, 'foo', true, true); + expect(leaf.execute).toHaveBeenCalledWith( + queueRunner, + globalErrors, + 'foo', + true, + true, + detectLateRejectionHandling + ); }); it('runs beforeAlls for a node with children', function() { @@ -635,16 +690,20 @@ describe('TreeProcessor', function() { }); it('runs specified leaves before non-specified leaves within a parent node', function() { - const specified = new Leaf(), - nonSpecified = new Leaf(), - root = new Node({ children: [nonSpecified, specified] }), - queueRunner = jasmine.createSpy('queueRunner'), - processor = new jasmineUnderTest.TreeProcessor({ - tree: root, - runnableIds: [specified.id], - queueRunnerFactory: queueRunner - }), - treeComplete = jasmine.createSpy('treeComplete'); + const specified = new Leaf(); + const nonSpecified = new Leaf(); + const root = new Node({ children: [nonSpecified, specified] }); + const queueRunner = jasmine.createSpy('queueRunner'); + const globalErrors = 'the globalErrors instance'; + const detectLateRejectionHandling = false; + const processor = new jasmineUnderTest.TreeProcessor({ + tree: root, + runnableIds: [specified.id], + queueRunnerFactory: queueRunner, + globalErrors, + detectLateRejectionHandling + }); + const treeComplete = jasmine.createSpy('treeComplete'); processor.execute(treeComplete); const queueableFns = queueRunner.calls.mostRecent().args[0].queueableFns; @@ -653,18 +712,22 @@ describe('TreeProcessor', function() { expect(nonSpecified.execute).not.toHaveBeenCalled(); expect(specified.execute).toHaveBeenCalledWith( queueRunner, + globalErrors, undefined, false, - false + false, + detectLateRejectionHandling ); queueableFns[1].fn(); expect(nonSpecified.execute).toHaveBeenCalledWith( queueRunner, + globalErrors, undefined, true, - false + false, + detectLateRejectionHandling ); }); diff --git a/spec/core/integration/EnvSpec.js b/spec/core/integration/EnvSpec.js index 9b7f4f49..a9f130b2 100644 --- a/spec/core/integration/EnvSpec.js +++ b/spec/core/integration/EnvSpec.js @@ -3697,7 +3697,7 @@ describe('Env integration', function() { function browserEventMethods() { return { - listeners_: { error: [], unhandledrejection: [] }, + listeners_: { error: [], unhandledrejection: [], rejectionhandled: [] }, addEventListener(eventName, listener) { this.listeners_[eventName].push(listener); }, diff --git a/spec/core/integration/GlobalErrorHandlingSpec.js b/spec/core/integration/GlobalErrorHandlingSpec.js index 0380e1ec..3cfe09b0 100644 --- a/spec/core/integration/GlobalErrorHandlingSpec.js +++ b/spec/core/integration/GlobalErrorHandlingSpec.js @@ -802,6 +802,118 @@ describe('Global error handling (integration)', function() { ); } }); + + describe('When the detectLateRejectionHandling config option is set', function() { + describe('When the unhandled rejection event has a promise', function() { + it('reports the rejection unless a corresponding rejection handled event occurs', async function() { + function makeEvent(suffix) { + const reason = `rejection ${suffix}`; + const promise = Promise.reject(reason); + promise.catch(() => {}); + return { reason, promise }; + } + + const global = { + ...browserEventMethods(), + setTimeout: function(fn, delay) { + return setTimeout(fn, delay); + }, + clearTimeout: function(fn, delay) { + clearTimeout(fn, delay); + }, + queueMicrotask: function(fn) { + queueMicrotask(fn); + } + }; + spyOn(jasmineUnderTest, 'getGlobal').and.returnValue(global); + env.cleanup_(); + env = new jasmineUnderTest.Env(); + env.configure({ detectLateRejectionHandling: true }); + const reporter = jasmine.createSpyObj('fakeReporter', [ + 'specDone', + 'suiteDone' + ]); + + env.addReporter(reporter); + + env.describe('A suite', function() { + env.it('fails', function(specDone) { + setTimeout(function() { + const events = ['spec 1', 'spec 2'].map(makeEvent); + + for (const e of events) { + dispatchErrorEvent(global, 'unhandledrejection', e); + } + + dispatchErrorEvent(global, 'rejectionhandled', events[0]); + specDone(); + }); + }); + }); + + await env.execute(); + + expect(reporter.specDone).toHaveBeenCalledWith( + jasmine.objectContaining({ + fullName: 'A suite fails', + failedExpectations: [ + // Only the second rejection should be reported, since the first + // one was eventually handled. + jasmine.objectContaining({ + message: + 'Unhandled promise rejection: rejection spec 2 thrown' + }) + ] + }) + ); + }); + }); + + describe("When the unhandled rejection event doesn't have a promise", function() { + it('reports the rejection', async function() { + const global = { + ...browserEventMethods(), + setTimeout: function(fn, delay) { + return setTimeout(fn, delay); + }, + clearTimeout: function(fn, delay) { + clearTimeout(fn, delay); + }, + queueMicrotask: function(fn) { + queueMicrotask(fn); + } + }; + spyOn(jasmineUnderTest, 'getGlobal').and.returnValue(global); + env.cleanup_(); + env = new jasmineUnderTest.Env(); + env.configure({ detectLateRejectionHandling: true }); + const reporter = jasmine.createSpyObj('fakeReporter', [ + 'specDone', + 'suiteDone' + ]); + + env.addReporter(reporter); + + env.describe('A suite', function() { + env.it('fails', function(specDone) { + setTimeout(function() { + dispatchErrorEvent(global, 'unhandledrejection', { + reason: 'fail' + }); + specDone(); + }); + }); + }); + + await env.execute(); + + expect(reporter.specDone).toHaveFailedExpectationsForRunnable( + 'A suite fails', + ['Unhandled promise rejection: fail thrown'] + ); + }); + }); + }); }); describe('#spyOnGlobalErrorsAsync', function() { @@ -1147,7 +1259,7 @@ describe('Global error handling (integration)', function() { function browserEventMethods() { return { - listeners_: { error: [], unhandledrejection: [] }, + listeners_: { error: [], unhandledrejection: [], rejectionhandled: [] }, addEventListener(eventName, listener) { this.listeners_[eventName].push(listener); }, diff --git a/src/core/Env.js b/src/core/Env.js index 46b5dba7..5897795f 100644 --- a/src/core/Env.js +++ b/src/core/Env.js @@ -24,7 +24,12 @@ getJasmineRequireObj().Env = function(j$) { new j$.MockDate(global) ); - const globalErrors = new j$.GlobalErrors(); + const globalErrors = new j$.GlobalErrors( + undefined, + // Configuration is late-bound because GlobalErrors needs to be constructed + // before it's set to detect load-time errors in browsers + () => this.configuration() + ); const installGlobalErrors = (function() { let installed = false; return function() { @@ -158,7 +163,21 @@ getJasmineRequireObj().Env = function(j$) { * @type Boolean * @default false */ - verboseDeprecations: false + verboseDeprecations: false, + + /** + * Whether to detect late promise rejection handling during spec + * execution. If this option is enabled, a promise rejection that triggers + * the JavaScript runtime's unhandled rejection event will not be treated + * as an error as long as it's handled before the spec finishes. + * + * This option is off by default because it imposes a performance penalty. + * @name Configuration#detectLateRejectionHandling + * @since 5.10.0 + * @type Boolean + * @default false + */ + detectLateRejectionHandling: false }; if (!options.suppressLoadErrors) { @@ -196,7 +215,8 @@ getJasmineRequireObj().Env = function(j$) { 'stopOnSpecFailure', 'stopSpecOnExpectationFailure', 'autoCleanClosures', - 'forbidDuplicateNames' + 'forbidDuplicateNames', + 'detectLateRejectionHandling' ]; booleanProps.forEach(function(prop) { @@ -498,6 +518,7 @@ getJasmineRequireObj().Env = function(j$) { reporter, queueRunnerFactory, TreeProcessor: j$.TreeProcessor, + globalErrors, getConfig: () => config, reportSpecDone }); diff --git a/src/core/GlobalErrors.js b/src/core/GlobalErrors.js index 03b5bccd..27af0128 100644 --- a/src/core/GlobalErrors.js +++ b/src/core/GlobalErrors.js @@ -1,27 +1,35 @@ getJasmineRequireObj().GlobalErrors = function(j$) { class GlobalErrors { + #getConfig; #adapter; #handlers; #overrideHandler; #onRemoveOverrideHandler; + #pendingUnhandledRejections; - constructor(global) { + constructor(global, getConfig) { global = global || j$.getGlobal(); - const dispatchError = this.#dispatchError.bind(this); + this.#getConfig = getConfig; + this.#pendingUnhandledRejections = new Map(); + this.#handlers = []; + this.#overrideHandler = null; + this.#onRemoveOverrideHandler = null; + + const dispatch = { + onUncaughtException: this.#onUncaughtException.bind(this), + onUnhandledRejection: this.#onUnhandledRejection.bind(this), + onRejectionHandled: this.#onRejectionHandled.bind(this) + }; if ( global.process && global.process.listeners && j$.isFunction_(global.process.on) ) { - this.#adapter = new NodeAdapter(global, dispatchError); + this.#adapter = new NodeAdapter(global, dispatch); } else { - this.#adapter = new BrowserAdapter(global, dispatchError); + this.#adapter = new BrowserAdapter(global, dispatch); } - - this.#handlers = []; - this.#overrideHandler = null; - this.#onRemoveOverrideHandler = null; } install() { @@ -69,6 +77,41 @@ getJasmineRequireObj().GlobalErrors = function(j$) { this.#onRemoveOverrideHandler = null; } + reportUnhandledRejections() { + for (const { + reason, + event + } of this.#pendingUnhandledRejections.values()) { + this.#dispatchError(reason, event); + } + + this.#pendingUnhandledRejections.clear(); + } + + // Either error or event may be undefined + #onUncaughtException(error, event) { + this.#dispatchError(error, event); + } + + // event or promise may be undefined + // event is passed through for backwards compatibility reasons. It's probably + // unnecessary, but user code could depend on it. + #onUnhandledRejection(reason, promise, event) { + if (this.#detectLateRejectionHandling() && promise) { + this.#pendingUnhandledRejections.set(promise, { reason, event }); + } else { + this.#dispatchError(reason, event); + } + } + + #detectLateRejectionHandling() { + return this.#getConfig().detectLateRejectionHandling; + } + + #onRejectionHandled(promise) { + this.#pendingUnhandledRejections.delete(promise); + } + // Either error or event may be undefined #dispatchError(error, event) { if (this.#overrideHandler) { @@ -89,24 +132,29 @@ getJasmineRequireObj().GlobalErrors = function(j$) { class BrowserAdapter { #global; - #dispatchError; + #dispatch; #onError; #onUnhandledRejection; + #onRejectionHandled; - constructor(global, dispatchError) { + constructor(global, dispatch) { this.#global = global; - this.#dispatchError = dispatchError; - this.#onError = event => this.#dispatchError(event.error, event); + this.#dispatch = dispatch; + this.#onError = event => dispatch.onUncaughtException(event.error, event); this.#onUnhandledRejection = this.#unhandledRejectionHandler.bind(this); + this.#onRejectionHandled = this.#rejectionHandledHandler.bind(this); } install() { this.#global.addEventListener('error', this.#onError); - this.#global.addEventListener( 'unhandledrejection', this.#onUnhandledRejection ); + this.#global.addEventListener( + 'rejectionhandled', + this.#onRejectionHandled + ); } uninstall() { @@ -115,50 +163,55 @@ getJasmineRequireObj().GlobalErrors = function(j$) { 'unhandledrejection', this.#onUnhandledRejection ); + this.#global.removeEventListener( + 'rejectionhandled', + this.#onRejectionHandled + ); } #unhandledRejectionHandler(event) { + const jasmineMessage = 'Unhandled promise rejection: ' + event.reason; + let reason; + if (j$.isError_(event.reason)) { - event.reason.jasmineMessage = - 'Unhandled promise rejection: ' + event.reason; - this.#dispatchError(event.reason, event); + reason = event.reason; + reason.jasmineMessage = jasmineMessage; } else { - this.#dispatchError( - 'Unhandled promise rejection: ' + event.reason, - event - ); + reason = jasmineMessage; } + + this.#dispatch.onUnhandledRejection(reason, event.promise, event); + } + + #rejectionHandledHandler(event) { + this.#dispatch.onRejectionHandled(event.promise); } } class NodeAdapter { #global; - #dispatchError; + #dispatch; #originalHandlers; #jasmineHandlers; - #onError; - #onUnhandledRejection; - constructor(global, dispatchError) { + constructor(global, dispatch) { this.#global = global; - this.#dispatchError = dispatchError; + this.#dispatch = dispatch; this.#jasmineHandlers = {}; this.#originalHandlers = {}; - this.#onError = error => - this.#eventHandler(error, 'uncaughtException', 'Uncaught exception'); - this.#onUnhandledRejection = error => - this.#eventHandler( - error, - 'unhandledRejection', - 'Unhandled promise rejection' - ); + this.onError = this.onError.bind(this); + this.onUnhandledRejection = this.onUnhandledRejection.bind(this); } install() { - this.#installHandler('uncaughtException', this.#onError); - this.#installHandler('unhandledRejection', this.#onUnhandledRejection); + this.#installHandler('uncaughtException', this.onError); + this.#installHandler('unhandledRejection', this.onUnhandledRejection); + this.#installHandler( + 'rejectionHandled', + this.#dispatch.onRejectionHandled + ); } uninstall() { @@ -190,31 +243,49 @@ getJasmineRequireObj().GlobalErrors = function(j$) { this.#global.process.on(errorType, handler); } - #eventHandler(error, errorType, jasmineMessage) { + #augmentError(error, isUnhandledRejection) { + let jasmineMessagePrefix; + + if (isUnhandledRejection) { + jasmineMessagePrefix = 'Unhandled promise rejection'; + } else { + jasmineMessagePrefix = 'Uncaught exception'; + } + if (j$.isError_(error)) { - error.jasmineMessage = jasmineMessage + ': ' + error; + error.jasmineMessage = jasmineMessagePrefix + ': ' + error; + return error; } else { let substituteMsg; if (error) { - substituteMsg = jasmineMessage + ': ' + error; + substituteMsg = jasmineMessagePrefix + ': ' + error; } else { - substituteMsg = jasmineMessage + ' with no error or message'; + substituteMsg = jasmineMessagePrefix + ' with no error or message'; } - if (errorType === 'unhandledRejection') { + if (isUnhandledRejection) { substituteMsg += '\n' + '(Tip: to get a useful stack trace, use ' + - 'Promise.reject(new Error(...)) instead of Promise.reject(' + + 'Promise.reject(n' + + 'ew Error(...)) instead of Promise.reject(' + (error ? '...' : '') + ').)'; } - error = new Error(substituteMsg); + return new Error(substituteMsg); } + } - this.#dispatchError(error); + onError(error) { + error = this.#augmentError(error, false); + this.#dispatch.onUncaughtException(error); + } + + onUnhandledRejection(reason, promise) { + reason = this.#augmentError(reason, true); + this.#dispatch.onUnhandledRejection(reason, promise); } } diff --git a/src/core/Runner.js b/src/core/Runner.js index 1ab486fa..9db08a05 100644 --- a/src/core/Runner.js +++ b/src/core/Runner.js @@ -8,6 +8,7 @@ getJasmineRequireObj().Runner = function(j$) { this.runableResources_ = options.runableResources; this.queueRunnerFactory_ = options.queueRunnerFactory; this.TreeProcessor_ = options.TreeProcessor; + this.globalErrors_ = options.globalErrors; this.reporter_ = options.reporter; this.getConfig_ = options.getConfig; this.reportSpecDone_ = options.reportSpecDone; @@ -73,7 +74,9 @@ getJasmineRequireObj().Runner = function(j$) { return this.queueRunnerFactory_(options); }, + globalErrors: this.globalErrors_, failSpecWithNoExpectations: config.failSpecWithNoExpectations, + detectLateRejectionHandling: config.detectLateRejectionHandling, nodeStart: (suite, next) => { this.currentlyExecutingSuites_.push(suite); this.runableResources_.initForRunable(suite.id, suite.parentSuite.id); diff --git a/src/core/Spec.js b/src/core/Spec.js index c6101734..01314397 100644 --- a/src/core/Spec.js +++ b/src/core/Spec.js @@ -2,6 +2,7 @@ getJasmineRequireObj().Spec = function(j$) { function Spec(attrs) { this.expectationFactory = attrs.expectationFactory; this.asyncExpectationFactory = attrs.asyncExpectationFactory; + this.setTimeout = attrs.setTimeout; this.resultCallback = attrs.resultCallback || function() {}; this.id = attrs.id; this.filename = attrs.filename; @@ -72,9 +73,11 @@ getJasmineRequireObj().Spec = function(j$) { Spec.prototype.execute = function( queueRunnerFactory, + globalErrors, onComplete, excluded, - failSpecWithNoExp + failSpecWithNoExp, + detectLateRejectionHandling ) { const onStart = { fn: done => { @@ -135,6 +138,21 @@ getJasmineRequireObj().Spec = function(j$) { } runnerConfig.queueableFns.unshift(onStart); + + if (detectLateRejectionHandling) { + // Conditional because the setTimeout imposes a significant performance + // penalty in suites with lots of fast specs. + runnerConfig.queueableFns.push({ + fn: done => { + // setTimeout is necessary to trigger rejectionhandled events + // TODO: let clearStack know about this so it doesn't do redundant setTimeouts + this.setTimeout(function() { + globalErrors.reportUnhandledRejections(); + done(); + }); + } + }); + } runnerConfig.queueableFns.push(complete); queueRunnerFactory(runnerConfig); diff --git a/src/core/SuiteBuilder.js b/src/core/SuiteBuilder.js index b1052d46..45c7ff7e 100644 --- a/src/core/SuiteBuilder.js +++ b/src/core/SuiteBuilder.js @@ -239,6 +239,7 @@ getJasmineRequireObj().SuiteBuilder = function(j$) { const config = this.env_.configuration(); const suite = this.currentDeclarationSuite_; const parentSuiteId = suite === this.topSuite ? null : suite.id; + const global = j$.getGlobal(); const spec = new j$.Spec({ id: 'spec' + this.nextSpecId_++, filename, @@ -246,6 +247,7 @@ getJasmineRequireObj().SuiteBuilder = function(j$) { beforeAndAfterFns: beforeAndAfterFns(suite), expectationFactory: this.expectationFactory_, asyncExpectationFactory: this.specAsyncExpectationFactory_, + setTimeout: global.setTimeout.bind(global), onLateError: this.onLateError_, resultCallback: (result, next) => { this.specResultCallback_(spec, result, next); diff --git a/src/core/TreeProcessor.js b/src/core/TreeProcessor.js index 2349dcd3..03fff44b 100644 --- a/src/core/TreeProcessor.js +++ b/src/core/TreeProcessor.js @@ -6,6 +6,9 @@ getJasmineRequireObj().TreeProcessor = function() { const nodeStart = attrs.nodeStart || function() {}; const nodeComplete = attrs.nodeComplete || function() {}; const failSpecWithNoExpectations = !!attrs.failSpecWithNoExpectations; + const detectLateRejectionHandling = !!attrs.detectLateRejectionHandling; + const globalErrors = attrs.globalErrors; + const orderChildren = attrs.orderChildren || function(node) { @@ -233,9 +236,11 @@ getJasmineRequireObj().TreeProcessor = function() { fn: function(done) { node.execute( queueRunnerFactory, + globalErrors, done, stats[node.id].excluded, - failSpecWithNoExpectations + failSpecWithNoExpectations, + detectLateRejectionHandling ); } };