Skip to content

Commit 6c56ebc

Browse files
committed
Added jasmine.spyOnGlobalErrorsAsync
* Allows testing code that's expected to prodeuce global errors or unhandled promise rejections * Fixes #1843 * Fixes #1453
1 parent d0a9931 commit 6c56ebc

File tree

11 files changed

+884
-109
lines changed

11 files changed

+884
-109
lines changed

lib/jasmine-core/jasmine.js

Lines changed: 151 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,49 @@ getJasmineRequireObj().base = function(j$, jasmineGlobal) {
594594
j$.debugLog = function(msg) {
595595
j$.getEnv().debugLog(msg);
596596
};
597+
598+
/**
599+
* Replaces Jasmine's global error handling with a spy. This prevents Jasmine
600+
* from treating uncaught exceptions and unhandled promise rejections
601+
* as spec failures and allows them to be inspected using the spy's
602+
* {@link Spy#calls|calls property} and related matchers such as
603+
* {@link matchers#toHaveBeenCalledWith|toHaveBeenCalledWith}.
604+
*
605+
* After installing the spy, spyOnGlobalErrorsAsync immediately calls its
606+
* argument, which must be an async or promise-returning function. The spy
607+
* will be passed as the first argument to that callback. Normal error
608+
* handling will be restored when the promise returned from the callback is
609+
* settled.
610+
*
611+
* Note: The JavaScript runtime may deliver uncaught error events and unhandled
612+
* rejection events asynchronously, especially in browsers. If the event
613+
* occurs after the promise returned from the callback is settled, it won't
614+
* be routed to the spy even if the underlying error occurred previously.
615+
* It's up to you to ensure that the returned promise isn't resolved until
616+
* all of the error/rejection events that you want to handle have occurred.
617+
*
618+
* You must await the return value of spyOnGlobalErrorsAsync.
619+
* @name jasmine.spyOnGlobalErrorsAsync
620+
* @function
621+
* @async
622+
* @param {AsyncFunction} fn - A function to run, during which the global error spy will be effective
623+
* @example
624+
* it('demonstrates global error spies', async function() {
625+
* await jasmine.spyOnGlobalErrorsAsync(async function(globalErrorSpy) {
626+
* setTimeout(function() {
627+
* throw new Error('the expected error');
628+
* });
629+
* await new Promise(function(resolve) {
630+
* setTimeout(resolve);
631+
* });
632+
* const expected = new Error('the expected error');
633+
* expect(globalErrorSpy).toHaveBeenCalledWith(expected);
634+
* });
635+
* });
636+
*/
637+
j$.spyOnGlobalErrorsAsync = async function(fn) {
638+
await jasmine.getEnv().spyOnGlobalErrorsAsync(fn);
639+
};
597640
};
598641

599642
getJasmineRequireObj().util = function(j$) {
@@ -764,13 +807,19 @@ getJasmineRequireObj().Spec = function(j$) {
764807

765808
Spec.prototype.addExpectationResult = function(passed, data, isError) {
766809
const expectationResult = j$.buildExpectationResult(data);
810+
767811
if (passed) {
768812
this.result.passedExpectations.push(expectationResult);
769813
} else {
770814
if (this.reportedDone) {
771815
this.onLateError(expectationResult);
772816
} else {
773817
this.result.failedExpectations.push(expectationResult);
818+
819+
// TODO: refactor so that we don't need to override cached status
820+
if (this.result.status) {
821+
this.result.status = 'failed';
822+
}
774823
}
775824

776825
if (this.throwOnExpectationFailure && !isError) {
@@ -1117,9 +1166,23 @@ getJasmineRequireObj().Env = function(j$) {
11171166
new j$.MockDate(global)
11181167
);
11191168

1120-
const runableResources = new j$.RunableResources(function() {
1121-
const r = runner.currentRunable();
1122-
return r ? r.id : null;
1169+
const globalErrors = new j$.GlobalErrors();
1170+
const installGlobalErrors = (function() {
1171+
let installed = false;
1172+
return function() {
1173+
if (!installed) {
1174+
globalErrors.install();
1175+
installed = true;
1176+
}
1177+
};
1178+
})();
1179+
1180+
const runableResources = new j$.RunableResources({
1181+
getCurrentRunableId: function() {
1182+
const r = runner.currentRunable();
1183+
return r ? r.id : null;
1184+
},
1185+
globalErrors
11231186
});
11241187

11251188
let reporter;
@@ -1226,20 +1289,9 @@ getJasmineRequireObj().Env = function(j$) {
12261289
verboseDeprecations: false
12271290
};
12281291

1229-
let globalErrors = null;
1230-
1231-
function installGlobalErrors() {
1232-
if (globalErrors) {
1233-
return;
1234-
}
1235-
1236-
globalErrors = new j$.GlobalErrors();
1237-
globalErrors.install();
1238-
}
1239-
12401292
if (!options.suppressLoadErrors) {
12411293
installGlobalErrors();
1242-
globalErrors.pushListener(function(
1294+
globalErrors.pushListener(function loadtimeErrorHandler(
12431295
message,
12441296
filename,
12451297
lineno,
@@ -1712,6 +1764,47 @@ getJasmineRequireObj().Env = function(j$) {
17121764
);
17131765
};
17141766

1767+
this.spyOnGlobalErrorsAsync = async function(fn) {
1768+
const spy = this.createSpy('global error handler');
1769+
const associatedRunable = runner.currentRunable();
1770+
let cleanedUp = false;
1771+
1772+
globalErrors.setOverrideListener(spy, () => {
1773+
if (!cleanedUp) {
1774+
const message =
1775+
'Global error spy was not uninstalled. (Did you ' +
1776+
'forget to await the return value of spyOnGlobalErrorsAsync?)';
1777+
associatedRunable.addExpectationResult(false, {
1778+
matcherName: '',
1779+
passed: false,
1780+
expected: '',
1781+
actual: '',
1782+
message,
1783+
error: null
1784+
});
1785+
}
1786+
1787+
cleanedUp = true;
1788+
});
1789+
1790+
try {
1791+
const maybePromise = fn(spy);
1792+
1793+
if (!j$.isPromiseLike(maybePromise)) {
1794+
throw new Error(
1795+
'The callback to spyOnGlobalErrorsAsync must be an async or promise-returning function'
1796+
);
1797+
}
1798+
1799+
await maybePromise;
1800+
} finally {
1801+
if (!cleanedUp) {
1802+
cleanedUp = true;
1803+
globalErrors.removeOverrideListener();
1804+
}
1805+
}
1806+
};
1807+
17151808
function ensureIsNotNested(method) {
17161809
const runable = runner.currentRunable();
17171810
if (runable !== null && runable !== undefined) {
@@ -3853,18 +3946,26 @@ getJasmineRequireObj().formatErrorMsg = function() {
38533946

38543947
getJasmineRequireObj().GlobalErrors = function(j$) {
38553948
function GlobalErrors(global) {
3856-
const handlers = [];
38573949
global = global || j$.getGlobal();
38583950

3859-
const onerror = function onerror() {
3951+
const handlers = [];
3952+
let overrideHandler = null,
3953+
onRemoveOverrideHandler = null;
3954+
3955+
function onerror(message, source, lineno, colno, error) {
3956+
if (overrideHandler) {
3957+
overrideHandler(error || message);
3958+
return;
3959+
}
3960+
38603961
const handler = handlers[handlers.length - 1];
38613962

38623963
if (handler) {
38633964
handler.apply(null, Array.prototype.slice.call(arguments, 0));
38643965
} else {
38653966
throw arguments[0];
38663967
}
3867-
};
3968+
}
38683969

38693970
this.originalHandlers = {};
38703971
this.jasmineHandlers = {};
@@ -3895,6 +3996,11 @@ getJasmineRequireObj().GlobalErrors = function(j$) {
38953996

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

3999+
if (overrideHandler) {
4000+
overrideHandler(error);
4001+
return;
4002+
}
4003+
38984004
if (handler) {
38994005
handler(error);
39004006
} else {
@@ -3979,6 +4085,24 @@ getJasmineRequireObj().GlobalErrors = function(j$) {
39794085

39804086
handlers.pop();
39814087
};
4088+
4089+
this.setOverrideListener = function(listener, onRemove) {
4090+
if (overrideHandler) {
4091+
throw new Error("Can't set more than one override listener at a time");
4092+
}
4093+
4094+
overrideHandler = listener;
4095+
onRemoveOverrideHandler = onRemove;
4096+
};
4097+
4098+
this.removeOverrideListener = function() {
4099+
if (onRemoveOverrideHandler) {
4100+
onRemoveOverrideHandler();
4101+
}
4102+
4103+
overrideHandler = null;
4104+
onRemoveOverrideHandler = null;
4105+
};
39824106
}
39834107

39844108
return GlobalErrors;
@@ -8083,9 +8207,10 @@ getJasmineRequireObj().interface = function(jasmine, env) {
80838207

80848208
getJasmineRequireObj().RunableResources = function(j$) {
80858209
class RunableResources {
8086-
constructor(getCurrentRunableId) {
8210+
constructor(options) {
80878211
this.byRunableId_ = {};
8088-
this.getCurrentRunableId_ = getCurrentRunableId;
8212+
this.getCurrentRunableId_ = options.getCurrentRunableId;
8213+
this.globalErrors_ = options.globalErrors;
80898214

80908215
this.spyFactory = new j$.SpyFactory(
80918216
() => {
@@ -8136,6 +8261,7 @@ getJasmineRequireObj().RunableResources = function(j$) {
81368261
}
81378262

81388263
clearForRunable(runableId) {
8264+
this.globalErrors_.removeOverrideListener();
81398265
this.spyRegistry.clearSpies();
81408266
delete this.byRunableId_[runableId];
81418267
}
@@ -9597,6 +9723,11 @@ getJasmineRequireObj().Suite = function(j$) {
95979723
this.onLateError(expectationResult);
95989724
} else {
95999725
this.result.failedExpectations.push(expectationResult);
9726+
9727+
// TODO: refactor so that we don't need to override cached status
9728+
if (this.result.status) {
9729+
this.result.status = 'failed';
9730+
}
96009731
}
96019732

96029733
if (this.throwOnExpectationFailure) {

spec/core/EnvSpec.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,8 @@ describe('Env', function() {
468468
'install',
469469
'uninstall',
470470
'pushListener',
471-
'popListener'
471+
'popListener',
472+
'removeOverrideListener'
472473
]);
473474
spyOn(jasmineUnderTest, 'GlobalErrors').and.returnValue(globalErrors);
474475
env.cleanup_();
@@ -483,7 +484,8 @@ describe('Env', function() {
483484
'install',
484485
'uninstall',
485486
'pushListener',
486-
'popListener'
487+
'popListener',
488+
'removeOverrideListener'
487489
]);
488490
spyOn(jasmineUnderTest, 'GlobalErrors').and.returnValue(globalErrors);
489491
env.cleanup_();
@@ -591,4 +593,19 @@ describe('Env', function() {
591593
});
592594
});
593595
});
596+
597+
describe('#spyOnGlobalErrorsAsync', function() {
598+
it('throws if the callback does not return a promise', async function() {
599+
const msg =
600+
'The callback to spyOnGlobalErrorsAsync must be an async or ' +
601+
'promise-returning function';
602+
603+
await expectAsync(
604+
env.spyOnGlobalErrorsAsync(() => undefined)
605+
).toBeRejectedWithError(msg);
606+
await expectAsync(
607+
env.spyOnGlobalErrorsAsync(() => 'not a promise')
608+
).toBeRejectedWithError(msg);
609+
});
610+
});
594611
});

0 commit comments

Comments
 (0)