diff --git a/lib/jasmine-core/jasmine.js b/lib/jasmine-core/jasmine.js index 1fbf7841..1201ec00 100644 --- a/lib/jasmine-core/jasmine.js +++ b/lib/jasmine-core/jasmine.js @@ -594,6 +594,49 @@ getJasmineRequireObj().base = function(j$, jasmineGlobal) { j$.debugLog = function(msg) { j$.getEnv().debugLog(msg); }; + + /** + * Replaces Jasmine's global error handling with a spy. This prevents Jasmine + * from treating uncaught exceptions and unhandled promise rejections + * as spec failures and allows them to be inspected using the spy's + * {@link Spy#calls|calls property} and related matchers such as + * {@link matchers#toHaveBeenCalledWith|toHaveBeenCalledWith}. + * + * After installing the spy, spyOnGlobalErrorsAsync immediately calls its + * argument, which must be an async or promise-returning function. The spy + * will be passed as the first argument to that callback. Normal error + * handling will be restored when the promise returned from the callback is + * settled. + * + * Note: The JavaScript runtime may deliver uncaught error events and unhandled + * rejection events asynchronously, especially in browsers. If the event + * occurs after the promise returned from the callback is settled, it won't + * be routed to the spy even if the underlying error occurred previously. + * It's up to you to ensure that the returned promise isn't resolved until + * all of the error/rejection events that you want to handle have occurred. + * + * You must await the return value of spyOnGlobalErrorsAsync. + * @name jasmine.spyOnGlobalErrorsAsync + * @function + * @async + * @param {AsyncFunction} fn - A function to run, during which the global error spy will be effective + * @example + * it('demonstrates global error spies', async function() { + * await jasmine.spyOnGlobalErrorsAsync(async function(globalErrorSpy) { + * setTimeout(function() { + * throw new Error('the expected error'); + * }); + * await new Promise(function(resolve) { + * setTimeout(resolve); + * }); + * const expected = new Error('the expected error'); + * expect(globalErrorSpy).toHaveBeenCalledWith(expected); + * }); + * }); + */ + j$.spyOnGlobalErrorsAsync = async function(fn) { + await jasmine.getEnv().spyOnGlobalErrorsAsync(fn); + }; }; getJasmineRequireObj().util = function(j$) { @@ -764,6 +807,7 @@ getJasmineRequireObj().Spec = function(j$) { Spec.prototype.addExpectationResult = function(passed, data, isError) { const expectationResult = j$.buildExpectationResult(data); + if (passed) { this.result.passedExpectations.push(expectationResult); } else { @@ -771,6 +815,11 @@ getJasmineRequireObj().Spec = function(j$) { this.onLateError(expectationResult); } else { this.result.failedExpectations.push(expectationResult); + + // TODO: refactor so that we don't need to override cached status + if (this.result.status) { + this.result.status = 'failed'; + } } if (this.throwOnExpectationFailure && !isError) { @@ -1117,9 +1166,23 @@ getJasmineRequireObj().Env = function(j$) { new j$.MockDate(global) ); - const runableResources = new j$.RunableResources(function() { - const r = runner.currentRunable(); - return r ? r.id : null; + const globalErrors = new j$.GlobalErrors(); + const installGlobalErrors = (function() { + let installed = false; + return function() { + if (!installed) { + globalErrors.install(); + installed = true; + } + }; + })(); + + const runableResources = new j$.RunableResources({ + getCurrentRunableId: function() { + const r = runner.currentRunable(); + return r ? r.id : null; + }, + globalErrors }); let reporter; @@ -1226,20 +1289,9 @@ getJasmineRequireObj().Env = function(j$) { verboseDeprecations: false }; - let globalErrors = null; - - function installGlobalErrors() { - if (globalErrors) { - return; - } - - globalErrors = new j$.GlobalErrors(); - globalErrors.install(); - } - if (!options.suppressLoadErrors) { installGlobalErrors(); - globalErrors.pushListener(function( + globalErrors.pushListener(function loadtimeErrorHandler( message, filename, lineno, @@ -1712,6 +1764,47 @@ getJasmineRequireObj().Env = function(j$) { ); }; + this.spyOnGlobalErrorsAsync = async function(fn) { + const spy = this.createSpy('global error handler'); + const associatedRunable = runner.currentRunable(); + let cleanedUp = false; + + globalErrors.setOverrideListener(spy, () => { + if (!cleanedUp) { + const message = + 'Global error spy was not uninstalled. (Did you ' + + 'forget to await the return value of spyOnGlobalErrorsAsync?)'; + associatedRunable.addExpectationResult(false, { + matcherName: '', + passed: false, + expected: '', + actual: '', + message, + error: null + }); + } + + cleanedUp = true; + }); + + try { + const maybePromise = fn(spy); + + if (!j$.isPromiseLike(maybePromise)) { + throw new Error( + 'The callback to spyOnGlobalErrorsAsync must be an async or promise-returning function' + ); + } + + await maybePromise; + } finally { + if (!cleanedUp) { + cleanedUp = true; + globalErrors.removeOverrideListener(); + } + } + }; + function ensureIsNotNested(method) { const runable = runner.currentRunable(); if (runable !== null && runable !== undefined) { @@ -3853,10 +3946,18 @@ getJasmineRequireObj().formatErrorMsg = function() { getJasmineRequireObj().GlobalErrors = function(j$) { function GlobalErrors(global) { - const handlers = []; global = global || j$.getGlobal(); - const onerror = function onerror() { + const handlers = []; + let overrideHandler = null, + onRemoveOverrideHandler = null; + + function onerror(message, source, lineno, colno, error) { + if (overrideHandler) { + overrideHandler(error || message); + return; + } + const handler = handlers[handlers.length - 1]; if (handler) { @@ -3864,7 +3965,7 @@ getJasmineRequireObj().GlobalErrors = function(j$) { } else { throw arguments[0]; } - }; + } this.originalHandlers = {}; this.jasmineHandlers = {}; @@ -3895,6 +3996,11 @@ getJasmineRequireObj().GlobalErrors = function(j$) { const handler = handlers[handlers.length - 1]; + if (overrideHandler) { + overrideHandler(error); + return; + } + if (handler) { handler(error); } else { @@ -3979,6 +4085,24 @@ getJasmineRequireObj().GlobalErrors = function(j$) { handlers.pop(); }; + + this.setOverrideListener = function(listener, onRemove) { + if (overrideHandler) { + throw new Error("Can't set more than one override listener at a time"); + } + + overrideHandler = listener; + onRemoveOverrideHandler = onRemove; + }; + + this.removeOverrideListener = function() { + if (onRemoveOverrideHandler) { + onRemoveOverrideHandler(); + } + + overrideHandler = null; + onRemoveOverrideHandler = null; + }; } return GlobalErrors; @@ -8083,9 +8207,10 @@ getJasmineRequireObj().interface = function(jasmine, env) { getJasmineRequireObj().RunableResources = function(j$) { class RunableResources { - constructor(getCurrentRunableId) { + constructor(options) { this.byRunableId_ = {}; - this.getCurrentRunableId_ = getCurrentRunableId; + this.getCurrentRunableId_ = options.getCurrentRunableId; + this.globalErrors_ = options.globalErrors; this.spyFactory = new j$.SpyFactory( () => { @@ -8136,6 +8261,7 @@ getJasmineRequireObj().RunableResources = function(j$) { } clearForRunable(runableId) { + this.globalErrors_.removeOverrideListener(); this.spyRegistry.clearSpies(); delete this.byRunableId_[runableId]; } @@ -9597,6 +9723,11 @@ getJasmineRequireObj().Suite = function(j$) { this.onLateError(expectationResult); } else { this.result.failedExpectations.push(expectationResult); + + // TODO: refactor so that we don't need to override cached status + if (this.result.status) { + this.result.status = 'failed'; + } } if (this.throwOnExpectationFailure) { diff --git a/spec/core/EnvSpec.js b/spec/core/EnvSpec.js index 3d68f24c..eccc972f 100644 --- a/spec/core/EnvSpec.js +++ b/spec/core/EnvSpec.js @@ -468,7 +468,8 @@ describe('Env', function() { 'install', 'uninstall', 'pushListener', - 'popListener' + 'popListener', + 'removeOverrideListener' ]); spyOn(jasmineUnderTest, 'GlobalErrors').and.returnValue(globalErrors); env.cleanup_(); @@ -483,7 +484,8 @@ describe('Env', function() { 'install', 'uninstall', 'pushListener', - 'popListener' + 'popListener', + 'removeOverrideListener' ]); spyOn(jasmineUnderTest, 'GlobalErrors').and.returnValue(globalErrors); env.cleanup_(); @@ -591,4 +593,19 @@ describe('Env', function() { }); }); }); + + describe('#spyOnGlobalErrorsAsync', function() { + it('throws if the callback does not return a promise', async function() { + const msg = + 'The callback to spyOnGlobalErrorsAsync must be an async or ' + + 'promise-returning function'; + + await expectAsync( + env.spyOnGlobalErrorsAsync(() => undefined) + ).toBeRejectedWithError(msg); + await expectAsync( + env.spyOnGlobalErrorsAsync(() => 'not a promise') + ).toBeRejectedWithError(msg); + }); + }); }); diff --git a/spec/core/GlobalErrorsSpec.js b/spec/core/GlobalErrorsSpec.js index 6a179bfa..8c8c9e12 100644 --- a/spec/core/GlobalErrorsSpec.js +++ b/spec/core/GlobalErrorsSpec.js @@ -404,4 +404,158 @@ describe('GlobalErrors', function() { }); }); }); + + describe('#setOverrideListener', function() { + it('overrides the existing handlers in browsers until removed', function() { + const fakeGlobal = { onerror: null }; + const handler0 = jasmine.createSpy('handler0'); + const handler1 = jasmine.createSpy('handler1'); + const overrideHandler = jasmine.createSpy('overrideHandler'); + const errors = new jasmineUnderTest.GlobalErrors(fakeGlobal); + + errors.install(); + errors.pushListener(handler0); + errors.setOverrideListener(overrideHandler, () => {}); + errors.pushListener(handler1); + fakeGlobal.onerror('foo'); + fakeGlobal.onerror(null, null, null, null, new Error('bar')); + + expect(overrideHandler).toHaveBeenCalledWith('foo'); + expect(overrideHandler).toHaveBeenCalledWith(new Error('bar')); + expect(handler0).not.toHaveBeenCalled(); + expect(handler1).not.toHaveBeenCalled(); + + errors.removeOverrideListener(); + + fakeGlobal.onerror('baz'); + expect(overrideHandler).not.toHaveBeenCalledWith('baz'); + expect(handler1).toHaveBeenCalledWith('baz'); + }); + + it('overrides the existing handlers in Node until removed', function() { + const globalEventListeners = {}; + const fakeGlobal = { + process: { + on: (name, listener) => (globalEventListeners[name] = listener), + removeListener: () => {}, + listeners: name => globalEventListeners[name], + removeAllListeners: name => (globalEventListeners[name] = []) + } + }; + const handler0 = jasmine.createSpy('handler0'); + const handler1 = jasmine.createSpy('handler1'); + const overrideHandler = jasmine.createSpy('overrideHandler'); + const errors = new jasmineUnderTest.GlobalErrors(fakeGlobal); + + errors.install(); + errors.pushListener(handler0); + errors.setOverrideListener(overrideHandler); + errors.pushListener(handler1); + + globalEventListeners['uncaughtException'](new Error('foo')); + + expect(overrideHandler).toHaveBeenCalledWith(new Error('foo')); + expect(handler0).not.toHaveBeenCalled(); + expect(handler1).not.toHaveBeenCalled(); + + errors.removeOverrideListener(); + + globalEventListeners['uncaughtException'](new Error('bar')); + expect(overrideHandler).not.toHaveBeenCalledWith(new Error('bar')); + expect(handler1).toHaveBeenCalledWith(new Error('bar')); + }); + + it('handles unhandled promise rejections in browsers', function() { + const globalEventListeners = {}; + const fakeGlobal = { + addEventListener(name, listener) { + globalEventListeners[name] = listener; + }, + removeEventListener() {} + }; + const handler = jasmine.createSpy('handler'); + const overrideHandler = jasmine.createSpy('overrideHandler'); + const errors = new jasmineUnderTest.GlobalErrors(fakeGlobal); + + errors.install(); + errors.pushListener(handler); + errors.setOverrideListener(overrideHandler, () => {}); + + const reason = new Error('bar'); + + globalEventListeners['unhandledrejection']({ reason: reason }); + + expect(overrideHandler).toHaveBeenCalledWith( + jasmine.objectContaining({ + jasmineMessage: 'Unhandled promise rejection: Error: bar', + message: reason.message, + stack: reason.stack + }) + ); + expect(handler).not.toHaveBeenCalled(); + }); + + it('handles unhandled promise rejections in Node', function() { + const globalEventListeners = {}; + const fakeGlobal = { + process: { + on(name, listener) { + globalEventListeners[name] = listener; + }, + removeListener() {}, + listeners(name) { + return globalEventListeners[name]; + }, + removeAllListeners(name) { + globalEventListeners[name] = null; + } + } + }; + const handler0 = jasmine.createSpy('handler0'); + const handler1 = jasmine.createSpy('handler1'); + const overrideHandler = jasmine.createSpy('overrideHandler'); + const errors = new jasmineUnderTest.GlobalErrors(fakeGlobal); + + errors.install(); + errors.pushListener(handler0); + errors.setOverrideListener(overrideHandler, () => {}); + errors.pushListener(handler1); + + globalEventListeners['unhandledRejection'](new Error('nope')); + + expect(overrideHandler).toHaveBeenCalledWith(new Error('nope')); + expect(handler0).not.toHaveBeenCalled(); + expect(handler1).not.toHaveBeenCalled(); + }); + + it('throws if there is already an override handler', function() { + const fakeGlobal = { onerror: null }; + const errors = new jasmineUnderTest.GlobalErrors(fakeGlobal); + + errors.setOverrideListener(() => {}, () => {}); + expect(function() { + errors.setOverrideListener(() => {}, () => {}); + }).toThrowError("Can't set more than one override listener at a time"); + }); + }); + + describe('#removeOverrideListener', function() { + it("calls the handler's onRemove callback", function() { + const fakeGlobal = { onerror: null }; + const onRemove = jasmine.createSpy('onRemove'); + const errors = new jasmineUnderTest.GlobalErrors(fakeGlobal); + + errors.setOverrideListener(() => {}, onRemove); + errors.removeOverrideListener(); + + expect(onRemove).toHaveBeenCalledWith(); + }); + + it('does not throw if there is no handler', function() { + const fakeGlobal = { onerror: null }; + const errors = new jasmineUnderTest.GlobalErrors(fakeGlobal); + + expect(() => errors.removeOverrideListener()).not.toThrow(); + }); + }); }); diff --git a/spec/core/RunableResourcesSpec.js b/spec/core/RunableResourcesSpec.js index 225aaab2..a337c291 100644 --- a/spec/core/RunableResourcesSpec.js +++ b/spec/core/RunableResourcesSpec.js @@ -38,9 +38,10 @@ describe('RunableResources', function() { describe('#addCustomMatchers', function() { it("adds all properties to the current runable's matchers", function() { const currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); function toBeFoo() {} @@ -69,9 +70,10 @@ describe('RunableResources', function() { describe('#addCustomAsyncMatchers', function() { it("adds all properties to the current runable's matchers", function() { const currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); function toBeFoo() {} @@ -93,9 +95,10 @@ describe('RunableResources', function() { describe('#defaultSpyStrategy', function() { it('returns undefined for a newly initialized resource', function() { let currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); expect(runableResources.defaultSpyStrategy()).toBeUndefined(); @@ -103,9 +106,10 @@ describe('RunableResources', function() { it('returns the value previously set by #setDefaultSpyStrategy', function() { let currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); const fn = () => {}; runableResources.setDefaultSpyStrategy(fn); @@ -115,9 +119,10 @@ describe('RunableResources', function() { it('is per-runable', function() { let currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); runableResources.setDefaultSpyStrategy(() => {}); currentRunableId = 2; @@ -127,17 +132,19 @@ describe('RunableResources', function() { }); it('does not require a current runable', function() { - const runableResources = new jasmineUnderTest.RunableResources( - () => null - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => null + }); expect(runableResources.defaultSpyStrategy()).toBeUndefined(); }); it("inherits the parent runable's value", function() { let currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); const fn = () => {}; runableResources.setDefaultSpyStrategy(fn); @@ -150,9 +157,10 @@ describe('RunableResources', function() { describe('#setDefaultSpyStrategy', function() { it('throws a user-facing error when there is no current runable', function() { - const runableResources = new jasmineUnderTest.RunableResources( - () => null - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => null + }); expect(function() { runableResources.setDefaultSpyStrategy(); }).toThrowError( @@ -163,7 +171,10 @@ describe('RunableResources', function() { describe('#makePrettyPrinter', function() { it('returns a pretty printer configured with the current customObjectFormatters', function() { - const runableResources = new jasmineUnderTest.RunableResources(() => 1); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => 1 + }); runableResources.initForRunable(1); function cof() {} runableResources.customObjectFormatters().push(cof); @@ -182,7 +193,10 @@ describe('RunableResources', function() { describe('#makeMatchersUtil', function() { describe('When there is a current runable', function() { it('returns a MatchersUtil configured with the current resources', function() { - const runableResources = new jasmineUnderTest.RunableResources(() => 1); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => 1 + }); runableResources.initForRunable(1); function cof() {} runableResources.customObjectFormatters().push(cof); @@ -217,9 +231,10 @@ describe('RunableResources', function() { describe('When there is no current runable', function() { it('returns a MatchersUtil configured with defaults', function() { - const runableResources = new jasmineUnderTest.RunableResources( - () => null - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => null + }); const expectedMatchersUtil = {}; spyOn(jasmineUnderTest, 'MatchersUtil').and.returnValue( expectedMatchersUtil @@ -243,9 +258,10 @@ describe('RunableResources', function() { describe('.spyFactory', function() { describe('When there is no current runable', function() { it('is configured with default strategies and matchersUtil', function() { - const runableResources = new jasmineUnderTest.RunableResources( - () => null - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => null + }); spyOn(jasmineUnderTest, 'Spy'); const matchersUtil = {}; spyOn(runableResources, 'makeMatchersUtil').and.returnValue( @@ -267,7 +283,10 @@ describe('RunableResources', function() { describe('When there is a current runable', function() { it("is configured with the current runable's strategies and matchersUtil", function() { - const runableResources = new jasmineUnderTest.RunableResources(() => 1); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => 1 + }); runableResources.initForRunable(1); function customStrategy() {} function defaultStrategy() {} @@ -306,7 +325,10 @@ describe('RunableResources', function() { describe('.spyRegistry', function() { it("writes to the current runable's spies", function() { - const runableResources = new jasmineUnderTest.RunableResources(() => 1); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => 1 + }); runableResources.initForRunable(1); function foo() {} const spyObj = { foo }; @@ -326,7 +348,10 @@ describe('RunableResources', function() { describe('#clearForRunable', function() { it('removes resources for the specified runable', function() { - const runableResources = new jasmineUnderTest.RunableResources(() => 1); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => 1 + }); runableResources.initForRunable(1); expect(function() { runableResources.spies(); @@ -338,7 +363,10 @@ describe('RunableResources', function() { }); it('clears spies', function() { - const runableResources = new jasmineUnderTest.RunableResources(() => 1); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => 1 + }); runableResources.initForRunable(1); function foo() {} const spyObj = { foo }; @@ -349,8 +377,25 @@ describe('RunableResources', function() { expect(spyObj.foo).toBe(foo); }); + it('clears the global error spy', function() { + const globalErrors = jasmine.createSpyObj('globalErrors', [ + 'removeOverrideListener' + ]); + const runableResources = new jasmineUnderTest.RunableResources({ + getCurrentRunableId: () => 1, + globalErrors + }); + runableResources.initForRunable(1); + + runableResources.clearForRunable(1); + expect(globalErrors.removeOverrideListener).toHaveBeenCalled(); + }); + it('does not remove resources for other runables', function() { - const runableResources = new jasmineUnderTest.RunableResources(() => 1); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => 1 + }); runableResources.initForRunable(1); function cof() {} runableResources.customObjectFormatters().push(cof); @@ -366,9 +411,10 @@ describe('RunableResources', function() { ) { it('is initially empty', function() { const currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); expect(runableResources[methodName]()).toEqual([]); @@ -376,9 +422,10 @@ describe('RunableResources', function() { it('is mutable', function() { const currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); function newItem() {} runableResources[methodName]().push(newItem); @@ -387,9 +434,10 @@ describe('RunableResources', function() { it('is per-runable', function() { let currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); runableResources[methodName]().push(() => {}); runableResources.initForRunable(2); @@ -398,9 +446,10 @@ describe('RunableResources', function() { }); it('throws a user-facing error when there is no current runable', function() { - const runableResources = new jasmineUnderTest.RunableResources( - () => null - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => null + }); expect(function() { runableResources[methodName](); }).toThrowError(errorMsg); @@ -409,9 +458,10 @@ describe('RunableResources', function() { if (inherits) { it('inherits from the parent runable', function() { let currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); function parentItem() {} runableResources[methodName]().push(parentItem); @@ -430,9 +480,10 @@ describe('RunableResources', function() { function behavesLikeAPerRunableMutableObject(methodName, errorMsg) { it('is initially empty', function() { const currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); expect(runableResources[methodName]()).toEqual({}); @@ -440,9 +491,10 @@ describe('RunableResources', function() { it('is mutable', function() { const currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); function newItem() {} runableResources[methodName]().foo = newItem; @@ -451,9 +503,10 @@ describe('RunableResources', function() { it('is per-runable', function() { let currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); runableResources[methodName]().foo = function() {}; runableResources.initForRunable(2); @@ -462,9 +515,10 @@ describe('RunableResources', function() { }); it('throws a user-facing error when there is no current runable', function() { - const runableResources = new jasmineUnderTest.RunableResources( - () => null - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => null + }); expect(function() { runableResources[methodName](); }).toThrowError(errorMsg); @@ -472,9 +526,10 @@ describe('RunableResources', function() { it('inherits from the parent runable', function() { let currentRunableId = 1; - const runableResources = new jasmineUnderTest.RunableResources( - () => currentRunableId - ); + const runableResources = new jasmineUnderTest.RunableResources({ + globalErrors: stubGlobalErrors(), + getCurrentRunableId: () => currentRunableId + }); runableResources.initForRunable(1); function parentItem() {} runableResources[methodName]().parentName = parentItem; @@ -493,4 +548,10 @@ describe('RunableResources', function() { }); }); } + + function stubGlobalErrors() { + return { + removeOverrideListener() {} + }; + } }); diff --git a/spec/core/integration/EnvSpec.js b/spec/core/integration/EnvSpec.js index 1aa43fb6..01533038 100644 --- a/spec/core/integration/EnvSpec.js +++ b/spec/core/integration/EnvSpec.js @@ -3625,4 +3625,285 @@ describe('Env integration', function() { done(); }); }); + + describe('#spyOnGlobalErrorsAsync', function() { + const leftInstalledMessage = + 'Global error spy was not uninstalled. ' + + '(Did you forget to await the return value of spyOnGlobalErrorsAsync?)'; + + function resultForRunable(reporterSpy, fullName) { + const match = reporterSpy.calls.all().find(function(call) { + return call.args[0].fullName === fullName; + }); + + if (!match) { + throw new Error(`No result for runable "${fullName}"`); + } + + return match.args[0]; + } + + it('allows global errors to be suppressed and spied on', async function() { + env.it('a passing spec', async function() { + await env.spyOnGlobalErrorsAsync(async spy => { + setTimeout(() => { + throw new Error('nope'); + }); + await new Promise(resolve => setTimeout(resolve)); + env.expect(spy).toHaveBeenCalledWith(new Error('nope')); + }); + }); + + env.it('a failing spec', async function() { + await env.spyOnGlobalErrorsAsync(async spy => { + setTimeout(() => { + throw new Error('yep'); + }); + await new Promise(resolve => setTimeout(resolve)); + env.expect(spy).toHaveBeenCalledWith(new Error('nope')); + }); + }); + + const reporter = jasmine.createSpyObj('reporter', ['specDone']); + env.addReporter(reporter); + await env.execute(); + + const passingResult = resultForRunable( + reporter.specDone, + 'a passing spec' + ); + expect(passingResult.status).toEqual('passed'); + expect(passingResult.failedExpectations).toEqual([]); + + const failingResult = resultForRunable( + reporter.specDone, + 'a failing spec' + ); + expect(failingResult.status).toEqual('failed'); + expect(failingResult.failedExpectations[0].message).toMatch( + /Expected \$\[0] = Error: yep to equal Error: nope\./ + ); + }); + + it('cleans up if the global error spy is left installed in a beforeAll', async function() { + env.configure({ random: false }); + + env.describe('Suite 1', function() { + env.beforeAll(async function() { + env.spyOnGlobalErrorsAsync(function() { + // Never resolves + return new Promise(() => {}); + }); + }); + + env.it('a spec', function() {}); + }); + + env.describe('Suite 2', function() { + env.it('a spec', async function() { + setTimeout(function() { + throw new Error('should fail the spec'); + }); + await new Promise(resolve => setTimeout(resolve)); + }); + }); + + const reporter = jasmine.createSpyObj('reporter', [ + 'specDone', + 'suiteDone' + ]); + env.addReporter(reporter); + await env.execute(); + + const suiteResult = resultForRunable(reporter.suiteDone, 'Suite 1'); + expect(suiteResult.status).toEqual('failed'); + expect(suiteResult.failedExpectations.length).toEqual(1); + expect(suiteResult.failedExpectations[0].message).toEqual( + leftInstalledMessage + ); + + const specResult = resultForRunable(reporter.specDone, 'Suite 2 a spec'); + expect(specResult.status).toEqual('failed'); + expect(specResult.failedExpectations.length).toEqual(1); + expect(specResult.failedExpectations[0].message).toMatch( + /Error: should fail the spec/ + ); + }); + + it('cleans up if the global error spy is left installed in an afterAll', async function() { + env.configure({ random: false }); + + env.describe('Suite 1', function() { + env.afterAll(async function() { + env.spyOnGlobalErrorsAsync(function() { + // Never resolves + return new Promise(() => {}); + }); + }); + + env.it('a spec', function() {}); + }); + + env.describe('Suite 2', function() { + env.it('a spec', async function() { + setTimeout(function() { + throw new Error('should fail the spec'); + }); + await new Promise(resolve => setTimeout(resolve)); + }); + }); + + const reporter = jasmine.createSpyObj('reporter', [ + 'specDone', + 'suiteDone' + ]); + env.addReporter(reporter); + await env.execute(); + + expect(reporter.suiteDone).toHaveFailedExpectationsForRunnable( + 'Suite 1', + [leftInstalledMessage] + ); + + const suiteResult = resultForRunable(reporter.suiteDone, 'Suite 1'); + expect(suiteResult.status).toEqual('failed'); + expect(suiteResult.failedExpectations.length).toEqual(1); + expect(suiteResult.failedExpectations[0].message).toEqual( + leftInstalledMessage + ); + + const specResult = resultForRunable(reporter.specDone, 'Suite 2 a spec'); + expect(specResult.status).toEqual('failed'); + expect(specResult.failedExpectations.length).toEqual(1); + expect(specResult.failedExpectations[0].message).toMatch( + /Error: should fail the spec/ + ); + }); + + it('cleans up if the global error spy is left installed in a beforeEach', async function() { + env.configure({ random: false }); + + env.describe('Suite 1', function() { + env.beforeEach(async function() { + env.spyOnGlobalErrorsAsync(function() { + // Never resolves + return new Promise(() => {}); + }); + }); + + env.it('a spec', function() {}); + }); + + env.describe('Suite 2', function() { + env.it('a spec', async function() { + setTimeout(function() { + throw new Error('should fail the spec'); + }); + await new Promise(resolve => setTimeout(resolve)); + }); + }); + + const reporter = jasmine.createSpyObj('reporter', [ + 'specDone', + 'suiteDone' + ]); + env.addReporter(reporter); + await env.execute(); + + const spec1Result = resultForRunable(reporter.specDone, 'Suite 1 a spec'); + expect(spec1Result.status).toEqual('failed'); + expect(spec1Result.failedExpectations.length).toEqual(1); + expect(spec1Result.failedExpectations[0].message).toEqual( + leftInstalledMessage + ); + + const spec2Result = resultForRunable(reporter.specDone, 'Suite 2 a spec'); + expect(spec2Result.status).toEqual('failed'); + expect(spec2Result.failedExpectations.length).toEqual(1); + expect(spec2Result.failedExpectations[0].message).toMatch( + /Error: should fail the spec/ + ); + }); + + it('cleans up if the global error spy is left installed in an it', async function() { + env.configure({ random: false }); + + env.it('spec 1', async function() { + env.spyOnGlobalErrorsAsync(function() { + // Never resolves + return new Promise(() => {}); + }); + }); + + env.it('spec 2', async function() { + setTimeout(function() { + throw new Error('should fail the spec'); + }); + await new Promise(resolve => setTimeout(resolve)); + }); + + const reporter = jasmine.createSpyObj('reporter', ['specDone']); + env.addReporter(reporter); + await env.execute(); + + const spec1Result = resultForRunable(reporter.specDone, 'spec 1'); + expect(spec1Result.status).toEqual('failed'); + expect(spec1Result.failedExpectations.length).toEqual(1); + expect(spec1Result.failedExpectations[0].message).toEqual( + leftInstalledMessage + ); + + const spec2Result = resultForRunable(reporter.specDone, 'spec 2'); + expect(spec2Result.status).toEqual('failed'); + expect(spec2Result.failedExpectations.length).toEqual(1); + expect(spec2Result.failedExpectations[0].message).toMatch( + /Error: should fail the spec/ + ); + }); + + it('cleans up if the global error spy is left installed in an afterEach', async function() { + env.configure({ random: false }); + + env.describe('Suite 1', function() { + env.afterEach(async function() { + env.spyOnGlobalErrorsAsync(function() { + // Never resolves + return new Promise(() => {}); + }); + }); + + env.it('a spec', function() {}); + }); + + env.describe('Suite 2', function() { + env.it('a spec', async function() { + setTimeout(function() { + throw new Error('should fail the spec'); + }); + await new Promise(resolve => setTimeout(resolve)); + }); + }); + + const reporter = jasmine.createSpyObj('reporter', [ + 'specDone', + 'suiteDone' + ]); + env.addReporter(reporter); + await env.execute(); + + const spec1Result = resultForRunable(reporter.specDone, 'Suite 1 a spec'); + expect(spec1Result.status).toEqual('failed'); + expect(spec1Result.failedExpectations.length).toEqual(1); + expect(spec1Result.failedExpectations[0].message).toEqual( + leftInstalledMessage + ); + + const spec2Result = resultForRunable(reporter.specDone, 'Suite 2 a spec'); + expect(spec2Result.status).toEqual('failed'); + expect(spec2Result.failedExpectations.length).toEqual(1); + expect(spec2Result.failedExpectations[0].message).toMatch( + /Error: should fail the spec/ + ); + }); + }); }); diff --git a/src/core/Env.js b/src/core/Env.js index 49076754..a9905328 100644 --- a/src/core/Env.js +++ b/src/core/Env.js @@ -24,9 +24,23 @@ getJasmineRequireObj().Env = function(j$) { new j$.MockDate(global) ); - const runableResources = new j$.RunableResources(function() { - const r = runner.currentRunable(); - return r ? r.id : null; + const globalErrors = new j$.GlobalErrors(); + const installGlobalErrors = (function() { + let installed = false; + return function() { + if (!installed) { + globalErrors.install(); + installed = true; + } + }; + })(); + + const runableResources = new j$.RunableResources({ + getCurrentRunableId: function() { + const r = runner.currentRunable(); + return r ? r.id : null; + }, + globalErrors }); let reporter; @@ -133,20 +147,9 @@ getJasmineRequireObj().Env = function(j$) { verboseDeprecations: false }; - let globalErrors = null; - - function installGlobalErrors() { - if (globalErrors) { - return; - } - - globalErrors = new j$.GlobalErrors(); - globalErrors.install(); - } - if (!options.suppressLoadErrors) { installGlobalErrors(); - globalErrors.pushListener(function( + globalErrors.pushListener(function loadtimeErrorHandler( message, filename, lineno, @@ -619,6 +622,47 @@ getJasmineRequireObj().Env = function(j$) { ); }; + this.spyOnGlobalErrorsAsync = async function(fn) { + const spy = this.createSpy('global error handler'); + const associatedRunable = runner.currentRunable(); + let cleanedUp = false; + + globalErrors.setOverrideListener(spy, () => { + if (!cleanedUp) { + const message = + 'Global error spy was not uninstalled. (Did you ' + + 'forget to await the return value of spyOnGlobalErrorsAsync?)'; + associatedRunable.addExpectationResult(false, { + matcherName: '', + passed: false, + expected: '', + actual: '', + message, + error: null + }); + } + + cleanedUp = true; + }); + + try { + const maybePromise = fn(spy); + + if (!j$.isPromiseLike(maybePromise)) { + throw new Error( + 'The callback to spyOnGlobalErrorsAsync must be an async or promise-returning function' + ); + } + + await maybePromise; + } finally { + if (!cleanedUp) { + cleanedUp = true; + globalErrors.removeOverrideListener(); + } + } + }; + function ensureIsNotNested(method) { const runable = runner.currentRunable(); if (runable !== null && runable !== undefined) { diff --git a/src/core/GlobalErrors.js b/src/core/GlobalErrors.js index af2cf9ac..bd121681 100644 --- a/src/core/GlobalErrors.js +++ b/src/core/GlobalErrors.js @@ -1,9 +1,17 @@ getJasmineRequireObj().GlobalErrors = function(j$) { function GlobalErrors(global) { - const handlers = []; global = global || j$.getGlobal(); - const onerror = function onerror() { + const handlers = []; + let overrideHandler = null, + onRemoveOverrideHandler = null; + + function onerror(message, source, lineno, colno, error) { + if (overrideHandler) { + overrideHandler(error || message); + return; + } + const handler = handlers[handlers.length - 1]; if (handler) { @@ -11,7 +19,7 @@ getJasmineRequireObj().GlobalErrors = function(j$) { } else { throw arguments[0]; } - }; + } this.originalHandlers = {}; this.jasmineHandlers = {}; @@ -42,6 +50,11 @@ getJasmineRequireObj().GlobalErrors = function(j$) { const handler = handlers[handlers.length - 1]; + if (overrideHandler) { + overrideHandler(error); + return; + } + if (handler) { handler(error); } else { @@ -126,6 +139,24 @@ getJasmineRequireObj().GlobalErrors = function(j$) { handlers.pop(); }; + + this.setOverrideListener = function(listener, onRemove) { + if (overrideHandler) { + throw new Error("Can't set more than one override listener at a time"); + } + + overrideHandler = listener; + onRemoveOverrideHandler = onRemove; + }; + + this.removeOverrideListener = function() { + if (onRemoveOverrideHandler) { + onRemoveOverrideHandler(); + } + + overrideHandler = null; + onRemoveOverrideHandler = null; + }; } return GlobalErrors; diff --git a/src/core/RunableResources.js b/src/core/RunableResources.js index a71b93b1..33e8b025 100644 --- a/src/core/RunableResources.js +++ b/src/core/RunableResources.js @@ -1,8 +1,9 @@ getJasmineRequireObj().RunableResources = function(j$) { class RunableResources { - constructor(getCurrentRunableId) { + constructor(options) { this.byRunableId_ = {}; - this.getCurrentRunableId_ = getCurrentRunableId; + this.getCurrentRunableId_ = options.getCurrentRunableId; + this.globalErrors_ = options.globalErrors; this.spyFactory = new j$.SpyFactory( () => { @@ -53,6 +54,7 @@ getJasmineRequireObj().RunableResources = function(j$) { } clearForRunable(runableId) { + this.globalErrors_.removeOverrideListener(); this.spyRegistry.clearSpies(); delete this.byRunableId_[runableId]; } diff --git a/src/core/Spec.js b/src/core/Spec.js index 987242b3..531e0046 100644 --- a/src/core/Spec.js +++ b/src/core/Spec.js @@ -70,6 +70,7 @@ getJasmineRequireObj().Spec = function(j$) { Spec.prototype.addExpectationResult = function(passed, data, isError) { const expectationResult = j$.buildExpectationResult(data); + if (passed) { this.result.passedExpectations.push(expectationResult); } else { @@ -77,6 +78,11 @@ getJasmineRequireObj().Spec = function(j$) { this.onLateError(expectationResult); } else { this.result.failedExpectations.push(expectationResult); + + // TODO: refactor so that we don't need to override cached status + if (this.result.status) { + this.result.status = 'failed'; + } } if (this.throwOnExpectationFailure && !isError) { diff --git a/src/core/Suite.js b/src/core/Suite.js index 9ff02d03..2a0c928c 100644 --- a/src/core/Suite.js +++ b/src/core/Suite.js @@ -227,6 +227,11 @@ getJasmineRequireObj().Suite = function(j$) { this.onLateError(expectationResult); } else { this.result.failedExpectations.push(expectationResult); + + // TODO: refactor so that we don't need to override cached status + if (this.result.status) { + this.result.status = 'failed'; + } } if (this.throwOnExpectationFailure) { diff --git a/src/core/base.js b/src/core/base.js index b9f29e38..ba851a03 100644 --- a/src/core/base.js +++ b/src/core/base.js @@ -421,4 +421,47 @@ getJasmineRequireObj().base = function(j$, jasmineGlobal) { j$.debugLog = function(msg) { j$.getEnv().debugLog(msg); }; + + /** + * Replaces Jasmine's global error handling with a spy. This prevents Jasmine + * from treating uncaught exceptions and unhandled promise rejections + * as spec failures and allows them to be inspected using the spy's + * {@link Spy#calls|calls property} and related matchers such as + * {@link matchers#toHaveBeenCalledWith|toHaveBeenCalledWith}. + * + * After installing the spy, spyOnGlobalErrorsAsync immediately calls its + * argument, which must be an async or promise-returning function. The spy + * will be passed as the first argument to that callback. Normal error + * handling will be restored when the promise returned from the callback is + * settled. + * + * Note: The JavaScript runtime may deliver uncaught error events and unhandled + * rejection events asynchronously, especially in browsers. If the event + * occurs after the promise returned from the callback is settled, it won't + * be routed to the spy even if the underlying error occurred previously. + * It's up to you to ensure that the returned promise isn't resolved until + * all of the error/rejection events that you want to handle have occurred. + * + * You must await the return value of spyOnGlobalErrorsAsync. + * @name jasmine.spyOnGlobalErrorsAsync + * @function + * @async + * @param {AsyncFunction} fn - A function to run, during which the global error spy will be effective + * @example + * it('demonstrates global error spies', async function() { + * await jasmine.spyOnGlobalErrorsAsync(async function(globalErrorSpy) { + * setTimeout(function() { + * throw new Error('the expected error'); + * }); + * await new Promise(function(resolve) { + * setTimeout(resolve); + * }); + * const expected = new Error('the expected error'); + * expect(globalErrorSpy).toHaveBeenCalledWith(expected); + * }); + * }); + */ + j$.spyOnGlobalErrorsAsync = async function(fn) { + await jasmine.getEnv().spyOnGlobalErrorsAsync(fn); + }; };