Source code

Revision control

Copy as Markdown

Other Tools

/* Any copyright is dedicated to the Public Domain.
/*
* Detects and reports unhandled rejections during test runs. Test harnesses
* will fail tests in this case, unless the test explicitly allows rejections.
*/
import { Assert } from "resource://testing-common/Assert.sys.mjs";
export var PromiseTestUtils = {
/**
* Array of objects containing the details of the Promise rejections that are
* currently left uncaught. 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: [],
/**
* If any of the functions in this array returns true when called with the
* rejection details as its only argument, the rejection is ignored. This
* happens after the "_rejectionIgnoreFns" array is processed.
*/
_globalRejectionIgnoreFns: [],
/**
* 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) {
console.error("This object was already initialized.");
return;
}
PromiseDebugging.addUncaughtRejectionObserver(this);
this._initialized = true;
},
_initialized: false,
/**
* Called only by the test infrastructure, unregisters the observers.
*/
uninit() {
if (!this._initialized) {
return;
}
PromiseDebugging.removeUncaughtRejectionObserver(this);
this._initialized = false;
},
/**
* Called only by the test infrastructure, collect all the
* JavaScript Developer Errors that have been thrown and
* treat them as uncaught promise rejections.
*/
collectJSDevErrors() {
let recentJSDevError = ChromeUtils.recentJSDevError;
if (!recentJSDevError) {
// Either `recentJSDevError` is not implemented in this version or there is no recent JS dev error.
return;
}
ChromeUtils.clearRecentJSDevError();
Promise.reject(
`${recentJSDevError.message}\n${recentJSDevError.stack}\ndetected at\n`
);
},
/**
* 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;
return true;
}
return false;
},
onConsumed() {},
};
PromiseDebugging.addUncaughtRejectionObserver(observer);
Promise.reject(this._ensureDOMPromiseRejectionsProcessedReason);
Services.tm.spinEventLoopUntil(
"Test(PromiseTestUtils.sys.mjs:ensureDOMPromiseRejectionsProcessed)",
() => observed
);
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 with uncaught rejections to disable 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();
},
// UncaughtRejectionObserver
onLeftUncaught(promise) {
let message = "(Unable to convert rejection reason to string.)";
let reason = null;
try {
reason = PromiseDebugging.getState(promise).reason;
if (reason === this._ensureDOMPromiseRejectionsProcessedReason) {
// Ignore the special promise for ensureDOMPromiseRejectionsProcessed.
return;
}
message = reason?.message || "" + reason;
} catch (ex) {}
// We should convert the rejection stack to a string immediately. This is
// because the object might not be available when we report the rejection
// later, if the error occurred in a context that has been unloaded.
let stack = "(Unable to convert rejection stack to string.)";
try {
// In some cases, the rejection stack from `PromiseDebugging` may be null.
// If the rejection reason was an Error object, use its `stack` to recover
// a meaningful value.
stack =
"" +
((reason && reason.stack) ||
PromiseDebugging.getRejectionStack(promise) ||
"(No stack available.)");
} catch (ex) {}
// Always add a newline at the end of the stack for consistent reporting.
// This is already present when the stack is provided by PromiseDebugging.
if (!stack.endsWith("\n")) {
stack += "\n";
}
// 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,
});
},
// 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);
},
/**
* Allows an entire class of Promise rejections. Usage of this function
* should be kept to a minimum because it has a broad scope and doesn't
* prevent new unhandled rejections of this class from being added.
*
* @param regExp
* This should match the error message of the rejection.
*/
allowMatchingRejectionsGlobally(regExp) {
this._globalRejectionIgnoreFns.push(rejection =>
regExp.test(rejection.message)
);
},
/**
* Fails the test if there are any uncaught rejections at this time that have
* not been explicitly allowed 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() {
// If there is any uncaught rejection left at this point, the test fails.
while (this._rejections.length) {
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;
}
// Check the global ignore functions.
if (this._globalRejectionIgnoreFns.some(fn => fn(rejection))) {
continue;
}
// Report the error. This operation can throw an exception, depending on
// the configuration of the test suite that handles the assertion. The
// first line of the message, including the latest call on the stack, is
// used to identify related test failures. To keep the first line similar
// between executions, we place the time-dependent rejection date on its
// own line, after all the other stack lines.
Assert.ok(
false,
`A promise chain failed to handle a rejection:` +
` ${rejection.message} - stack: ${rejection.stack}` +
`Rejection date: ${rejection.date}`
);
}
},
/**
* 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) {
Assert.equal(
this._rejectionIgnoreFns.length,
0,
"Unable to find a rejection expected by expectUncaughtRejection."
);
}
// Reset the list of expected rejections in case the test suite continues.
this._rejectionIgnoreFns = [];
},
};