From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../modules/tests/modules/PromiseTestUtils.sys.mjs | 293 +++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 toolkit/modules/tests/modules/PromiseTestUtils.sys.mjs (limited to 'toolkit/modules/tests/modules/PromiseTestUtils.sys.mjs') 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 = []; + }, +}; -- cgit v1.2.3