summaryrefslogtreecommitdiffstats
path: root/toolkit/modules/tests/modules/PromiseTestUtils.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/modules/tests/modules/PromiseTestUtils.sys.mjs
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/modules/tests/modules/PromiseTestUtils.sys.mjs')
-rw-r--r--toolkit/modules/tests/modules/PromiseTestUtils.sys.mjs293
1 files changed, 293 insertions, 0 deletions
diff --git a/toolkit/modules/tests/modules/PromiseTestUtils.sys.mjs b/toolkit/modules/tests/modules/PromiseTestUtils.sys.mjs
new file mode 100644
index 0000000000..2286ac03da
--- /dev/null
+++ b/toolkit/modules/tests/modules/PromiseTestUtils.sys.mjs
@@ -0,0 +1,293 @@
+/* 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 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. 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: [],
+
+ /**
+ * 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 = [];
+ },
+};