Skip to content

Commit

Permalink
Added jasmine.spyOnGlobalErrorsAsync
Browse files Browse the repository at this point in the history
* Allows testing code that's expected to prodeuce global errors or
  unhandled promise rejections
* Fixes #1843
* Fixes #1453
  • Loading branch information
sgravrock committed Jul 1, 2022
1 parent d0a9931 commit 6c56ebc
Show file tree
Hide file tree
Showing 11 changed files with 884 additions and 109 deletions.
171 changes: 151 additions & 20 deletions lib/jasmine-core/jasmine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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$) {
Expand Down Expand Up @@ -764,13 +807,19 @@ getJasmineRequireObj().Spec = function(j$) {

Spec.prototype.addExpectationResult = function(passed, data, isError) {
const expectationResult = j$.buildExpectationResult(data);

if (passed) {
this.result.passedExpectations.push(expectationResult);
} else {
if (this.reportedDone) {
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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -3853,18 +3946,26 @@ 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) {
handler.apply(null, Array.prototype.slice.call(arguments, 0));
} else {
throw arguments[0];
}
};
}

this.originalHandlers = {};
this.jasmineHandlers = {};
Expand Down Expand Up @@ -3895,6 +3996,11 @@ getJasmineRequireObj().GlobalErrors = function(j$) {

const handler = handlers[handlers.length - 1];

if (overrideHandler) {
overrideHandler(error);
return;
}

if (handler) {
handler(error);
} else {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
() => {
Expand Down Expand Up @@ -8136,6 +8261,7 @@ getJasmineRequireObj().RunableResources = function(j$) {
}

clearForRunable(runableId) {
this.globalErrors_.removeOverrideListener();
this.spyRegistry.clearSpies();
delete this.byRunableId_[runableId];
}
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 19 additions & 2 deletions spec/core/EnvSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,8 @@ describe('Env', function() {
'install',
'uninstall',
'pushListener',
'popListener'
'popListener',
'removeOverrideListener'
]);
spyOn(jasmineUnderTest, 'GlobalErrors').and.returnValue(globalErrors);
env.cleanup_();
Expand All @@ -483,7 +484,8 @@ describe('Env', function() {
'install',
'uninstall',
'pushListener',
'popListener'
'popListener',
'removeOverrideListener'
]);
spyOn(jasmineUnderTest, 'GlobalErrors').and.returnValue(globalErrors);
env.cleanup_();
Expand Down Expand Up @@ -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);
});
});
});
Loading

0 comments on commit 6c56ebc

Please sign in to comment.