gecko-dev/toolkit/modules/tests/PromiseTestUtils.jsm
Paolo Amadini 8b8676204b Bug 989960 - Unhandled rejections in DOM Promises should cause xpcshell tests to fail. r=Yoric
--HG--
extra : commitid : yWjnDGhk9j
extra : rebase_source : 24228660defd6971a621d52eee0b79be823c6926
extra : amend_source : ef8de24265fa9026b0f764a63453b816442f7232
extra : source : 3958782fe187026cbc3755ad6eae6920e67fa7d2
2016-02-03 12:58:03 +00:00

242 lines
8.4 KiB
JavaScript

/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
/*
* Detects and reports unhandled rejections during test runs. Test harnesses
* will fail tests in this case, unless the test whitelists itself.
*/
"use strict";
this.EXPORTED_SYMBOLS = [
"PromiseTestUtils",
];
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/Services.jsm", this);
// Keep "JSMPromise" separate so "Promise" still refers to DOM Promises.
let JSMPromise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
// For now, we need test harnesses to provide a reference to Assert.jsm.
let Assert = null;
this.PromiseTestUtils = {
/**
* Array of objects containing the details of the Promise rejections that are
* currently left uncaught. This includes DOM Promise and Promise.jsm. When
* rejections in DOM Promises are consumed, they are removed from this list.
*
* The objects contain at least the following properties:
* {
* message: The error message associated with the rejection, if any.
* date: Date object indicating when the rejection was observed.
* id: For DOM Promise only, the Promise ID from PromiseDebugging. This is
* only used for tracking and should not be checked by the callers.
* stack: nsIStackFrame, SavedFrame, or string indicating the stack at the
* time the rejection was triggered. May also be null if the
* rejection was triggered while a script was on the stack.
* }
*/
_rejections: [],
/**
* When an uncaught rejection is detected, it is ignored if one of the
* functions in this array returns true when called with the rejection details
* as its only argument. When a function matches an expected rejection, it is
* then removed from the array.
*/
_rejectionIgnoreFns: [],
/**
* Called only by the test infrastructure, registers the rejection observers.
*
* This should be called only once, and a matching "uninit" call must be made
* or the tests will crash on shutdown.
*/
init() {
if (this._initialized) {
Cu.reportError("This object was already initialized.");
return;
}
PromiseDebugging.addUncaughtRejectionObserver(this);
// Promise.jsm rejections are only reported to this observer when requested,
// so we don't have to store a key to remove them when consumed.
JSMPromise.Debugging.addUncaughtErrorObserver(
rejection => this._rejections.push(rejection));
this._initialized = true;
},
_initialized: false,
/**
* Called only by the test infrastructure, unregisters the observers.
*/
uninit() {
if (!this._initialized) {
return;
}
PromiseDebugging.removeUncaughtRejectionObserver(this);
JSMPromise.Debugging.clearUncaughtErrorObservers();
this._initialized = false;
},
/**
* Called only by the test infrastructure, spins the event loop until the
* messages for pending DOM Promise rejections have been processed.
*/
ensureDOMPromiseRejectionsProcessed() {
let observed = false;
let observer = {
onLeftUncaught: promise => {
if (PromiseDebugging.getState(promise).reason ===
this._ensureDOMPromiseRejectionsProcessedReason) {
observed = true;
}
},
onConsumed() {},
};
PromiseDebugging.addUncaughtRejectionObserver(observer);
Promise.reject(this._ensureDOMPromiseRejectionsProcessedReason);
while (!observed) {
Services.tm.mainThread.processNextEvent(true);
}
PromiseDebugging.removeUncaughtRejectionObserver(observer);
},
_ensureDOMPromiseRejectionsProcessedReason: {},
/**
* Called only by the tests for PromiseDebugging.addUncaughtRejectionObserver
* and for JSMPromise.Debugging, disables the observers in this module.
*/
disableUncaughtRejectionObserverForSelfTest() {
this.uninit();
},
/**
* Called by tests that have been whitelisted, disables the observers in this
* module. For new tests where uncaught rejections are expected, you should
* use the more granular expectUncaughtRejection function instead.
*/
thisTestLeaksUncaughtRejectionsAndShouldBeFixed() {
this.uninit();
},
/**
* Sets or updates the Assert object instance to be used for error reporting.
*/
set Assert(assert) {
Assert = assert;
},
// UncaughtRejectionObserver
onLeftUncaught(promise) {
let message = "(Unable to convert rejection reason to string.)";
try {
let reason = PromiseDebugging.getState(promise).reason;
if (reason === this._ensureDOMPromiseRejectionsProcessedReason) {
// Ignore the special promise for ensureDOMPromiseRejectionsProcessed.
return;
}
message = reason.message || ("" + reason);
} catch (ex) {}
// It's important that we don't store any reference to the provided Promise
// object or its value after this function returns in order to avoid leaks.
this._rejections.push({
id: PromiseDebugging.getPromiseID(promise),
message,
date: new Date(),
stack: PromiseDebugging.getRejectionStack(promise),
});
},
// UncaughtRejectionObserver
onConsumed(promise) {
// We don't expect that many unhandled rejections will appear at the same
// time, so the algorithm doesn't need to be optimized for that case.
let id = PromiseDebugging.getPromiseID(promise);
let index = this._rejections.findIndex(rejection => rejection.id == id);
// If we get a consumption notification for a rejection that was left
// uncaught before this module was initialized, we can safely ignore it.
if (index != -1) {
this._rejections.splice(index, 1);
}
},
/**
* Informs the test suite that the test code will generate a Promise rejection
* that will still be unhandled when the test file terminates.
*
* This method must be called once for each instance of Promise that is
* expected to be uncaught, even if the rejection reason is the same for each
* instance.
*
* If the expected rejection does not occur, the test will fail.
*
* @param regExpOrCheckFn
* This can either be a regular expression that should match the error
* message of the rejection, or a check function that is invoked with
* the rejection details object as its first argument.
*/
expectUncaughtRejection(regExpOrCheckFn) {
let checkFn = !("test" in regExpOrCheckFn) ? regExpOrCheckFn :
rejection => regExpOrCheckFn.test(rejection.message);
this._rejectionIgnoreFns.push(checkFn);
},
/**
* Fails the test if there are any uncaught rejections at this time that have
* not been whitelisted using expectUncaughtRejection.
*
* Depending on the configuration of the test suite, this function might only
* report the details of the first uncaught rejection that was generated.
*
* This is called by the test suite at the end of each test function.
*/
assertNoUncaughtRejections() {
// Ask Promise.jsm to report all uncaught rejections to the observer now.
JSMPromise.Debugging.flushUncaughtErrors();
// If there is any uncaught rejection left at this point, the test fails.
while (this._rejections.length > 0) {
let rejection = this._rejections.shift();
// If one of the ignore functions matches, ignore the rejection, then
// remove the function so that each function only matches one rejection.
let index = this._rejectionIgnoreFns.findIndex(f => f(rejection));
if (index != -1) {
this._rejectionIgnoreFns.splice(index, 1);
continue;
}
// Report the error. This operation can throw an exception, depending on
// the configuration of the test suite that handles the assertion.
Assert.ok(false,
`A promise chain failed to handle a rejection:` +
` ${rejection.message} - rejection date: ${rejection.date}`+
` - stack: ${rejection.stack}`);
}
},
/**
* Fails the test if any rejection indicated by expectUncaughtRejection has
* not yet been reported at this time.
*
* This is called by the test suite at the end of each test file.
*/
assertNoMoreExpectedRejections() {
// Only log this condition is there is a failure.
if (this._rejectionIgnoreFns.length > 0) {
Assert.equal(this._rejectionIgnoreFns.length, 0,
"Unable to find a rejection expected by expectUncaughtRejection.");
}
},
};