summaryrefslogtreecommitdiffstats
path: root/testing/mochitest/BrowserTestUtils
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--testing/mochitest/BrowserTestUtils/BrowserTestUtils.sys.mjs2868
-rw-r--r--testing/mochitest/BrowserTestUtils/BrowserTestUtilsChild.sys.mjs391
-rw-r--r--testing/mochitest/BrowserTestUtils/BrowserTestUtilsParent.sys.mjs39
-rw-r--r--testing/mochitest/BrowserTestUtils/ContentEventListenerChild.sys.mjs178
-rw-r--r--testing/mochitest/BrowserTestUtils/ContentEventListenerParent.sys.mjs20
-rw-r--r--testing/mochitest/BrowserTestUtils/ContentTask.sys.mjs141
-rw-r--r--testing/mochitest/BrowserTestUtils/ContentTaskUtils.sys.mjs249
-rw-r--r--testing/mochitest/BrowserTestUtils/content/content-about-page-utils.js81
-rw-r--r--testing/mochitest/BrowserTestUtils/content/content-task.js124
-rw-r--r--testing/mochitest/BrowserTestUtils/moz.build16
10 files changed, 4107 insertions, 0 deletions
diff --git a/testing/mochitest/BrowserTestUtils/BrowserTestUtils.sys.mjs b/testing/mochitest/BrowserTestUtils/BrowserTestUtils.sys.mjs
new file mode 100644
index 0000000000..fcf65012cf
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/BrowserTestUtils.sys.mjs
@@ -0,0 +1,2868 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This module implements a number of utilities useful for browser tests.
+ *
+ * All asynchronous helper methods should return promises, rather than being
+ * callback based.
+ */
+
+// This file uses ContentTask & frame scripts, where these are available.
+/* global ContentTaskUtils */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { ComponentUtils } from "resource://gre/modules/ComponentUtils.sys.mjs";
+import { TestUtils } from "resource://testing-common/TestUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContentTask: "resource://testing-common/ContentTask.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ ProtocolProxyService: [
+ "@mozilla.org/network/protocol-proxy-service;1",
+ "nsIProtocolProxyService",
+ ],
+});
+
+const PROCESSSELECTOR_CONTRACTID = "@mozilla.org/ipc/processselector;1";
+const OUR_PROCESSSELECTOR_CID = Components.ID(
+ "{f9746211-3d53-4465-9aeb-ca0d96de0253}"
+);
+const EXISTING_JSID = Cc[PROCESSSELECTOR_CONTRACTID];
+const DEFAULT_PROCESSSELECTOR_CID = EXISTING_JSID
+ ? Components.ID(EXISTING_JSID.number)
+ : null;
+
+let gListenerId = 0;
+
+// A process selector that always asks for a new process.
+function NewProcessSelector() {}
+
+NewProcessSelector.prototype = {
+ classID: OUR_PROCESSSELECTOR_CID,
+ QueryInterface: ChromeUtils.generateQI(["nsIContentProcessProvider"]),
+
+ provideProcess() {
+ return Ci.nsIContentProcessProvider.NEW_PROCESS;
+ },
+};
+
+let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+let selectorFactory =
+ ComponentUtils.generateSingletonFactory(NewProcessSelector);
+registrar.registerFactory(OUR_PROCESSSELECTOR_CID, "", null, selectorFactory);
+
+const kAboutPageRegistrationContentScript =
+ "chrome://mochikit/content/tests/BrowserTestUtils/content-about-page-utils.js";
+
+/**
+ * Create and register the BrowserTestUtils and ContentEventListener window
+ * actors.
+ */
+function registerActors() {
+ ChromeUtils.registerWindowActor("BrowserTestUtils", {
+ parent: {
+ esModuleURI: "resource://testing-common/BrowserTestUtilsParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource://testing-common/BrowserTestUtilsChild.sys.mjs",
+ events: {
+ DOMContentLoaded: { capture: true },
+ load: { capture: true },
+ },
+ },
+ allFrames: true,
+ includeChrome: true,
+ });
+
+ ChromeUtils.registerWindowActor("ContentEventListener", {
+ parent: {
+ esModuleURI:
+ "resource://testing-common/ContentEventListenerParent.sys.mjs",
+ },
+ child: {
+ esModuleURI:
+ "resource://testing-common/ContentEventListenerChild.sys.mjs",
+ events: {
+ // We need to see the creation of all new windows, in case they have
+ // a browsing context we are interested in.
+ DOMWindowCreated: { capture: true },
+ },
+ },
+ allFrames: true,
+ });
+}
+
+registerActors();
+
+/**
+ * BrowserTestUtils provides useful test utilities for working with the browser
+ * in browser mochitests. Most common operations (opening, closing and switching
+ * between tabs and windows, loading URLs, waiting for events in the parent or
+ * content process, clicking things in the content process, registering about
+ * pages, etc.) have dedicated helpers on this object.
+ *
+ * @class
+ */
+export var BrowserTestUtils = {
+ /**
+ * Loads a page in a new tab, executes a Task and closes the tab.
+ *
+ * @param {Object|String} options
+ * If this is a string it is the url to open and will be opened in the
+ * currently active browser window.
+ * @param {tabbrowser} [options.gBrowser
+ * A reference to the ``tabbrowser`` element where the new tab should
+ * be opened,
+ * @param {string} options.url
+ * The URL of the page to load.
+ * @param {Function} taskFn
+ * Async function representing that will be executed while
+ * the tab is loaded. The first argument passed to the function is a
+ * reference to the browser object for the new tab.
+ *
+ * @return {Any} Returns the value that is returned from taskFn.
+ * @resolves When the tab has been closed.
+ * @rejects Any exception from taskFn is propagated.
+ */
+ async withNewTab(options, taskFn) {
+ if (typeof options == "string") {
+ options = {
+ gBrowser: Services.wm.getMostRecentWindow("navigator:browser").gBrowser,
+ url: options,
+ };
+ }
+ let tab = await BrowserTestUtils.openNewForegroundTab(options);
+ let originalWindow = tab.ownerGlobal;
+ let result;
+ try {
+ result = await taskFn(tab.linkedBrowser);
+ } finally {
+ let finalWindow = tab.ownerGlobal;
+ if (originalWindow == finalWindow && !tab.closing && tab.linkedBrowser) {
+ // taskFn may resolve within a tick after opening a new tab.
+ // We shouldn't remove the newly opened tab in the same tick.
+ // Wait for the next tick here.
+ await TestUtils.waitForTick();
+ BrowserTestUtils.removeTab(tab);
+ } else {
+ Services.console.logStringMessage(
+ "BrowserTestUtils.withNewTab: Tab was already closed before " +
+ "removeTab would have been called"
+ );
+ }
+ }
+
+ return Promise.resolve(result);
+ },
+
+ /**
+ * Opens a new tab in the foreground.
+ *
+ * This function takes an options object (which is preferred) or actual
+ * parameters. The names of the options must correspond to the names below.
+ * gBrowser is required and all other options are optional.
+ *
+ * @param {tabbrowser} gBrowser
+ * The tabbrowser to open the tab new in.
+ * @param {string} opening (or url)
+ * May be either a string URL to load in the tab, or a function that
+ * will be called to open a foreground tab. Defaults to "about:blank".
+ * @param {boolean} waitForLoad
+ * True to wait for the page in the new tab to load. Defaults to true.
+ * @param {boolean} waitForStateStop
+ * True to wait for the web progress listener to send STATE_STOP for the
+ * document in the tab. Defaults to false.
+ * @param {boolean} forceNewProcess
+ * True to force the new tab to load in a new process. Defaults to
+ * false.
+ *
+ * @return {Promise}
+ * Resolves when the tab is ready and loaded as necessary.
+ * @resolves The new tab.
+ */
+ openNewForegroundTab(tabbrowser, ...args) {
+ let startTime = Cu.now();
+ let options;
+ if (
+ tabbrowser.ownerGlobal &&
+ tabbrowser === tabbrowser.ownerGlobal.gBrowser
+ ) {
+ // tabbrowser is a tabbrowser, read the rest of the arguments from args.
+ let [
+ opening = "about:blank",
+ waitForLoad = true,
+ waitForStateStop = false,
+ forceNewProcess = false,
+ ] = args;
+
+ options = { opening, waitForLoad, waitForStateStop, forceNewProcess };
+ } else {
+ if ("url" in tabbrowser && !("opening" in tabbrowser)) {
+ tabbrowser.opening = tabbrowser.url;
+ }
+
+ let {
+ opening = "about:blank",
+ waitForLoad = true,
+ waitForStateStop = false,
+ forceNewProcess = false,
+ } = tabbrowser;
+
+ tabbrowser = tabbrowser.gBrowser;
+ options = { opening, waitForLoad, waitForStateStop, forceNewProcess };
+ }
+
+ let {
+ opening: opening,
+ waitForLoad: aWaitForLoad,
+ waitForStateStop: aWaitForStateStop,
+ } = options;
+
+ let promises, tab;
+ try {
+ // If we're asked to force a new process, replace the normal process
+ // selector with one that always asks for a new process.
+ // If DEFAULT_PROCESSSELECTOR_CID is null, we're in non-e10s mode and we
+ // should skip this.
+ if (options.forceNewProcess && DEFAULT_PROCESSSELECTOR_CID) {
+ Services.ppmm.releaseCachedProcesses();
+ registrar.registerFactory(
+ OUR_PROCESSSELECTOR_CID,
+ "",
+ PROCESSSELECTOR_CONTRACTID,
+ null
+ );
+ }
+
+ promises = [
+ BrowserTestUtils.switchTab(tabbrowser, function () {
+ if (typeof opening == "function") {
+ opening();
+ tab = tabbrowser.selectedTab;
+ } else {
+ tabbrowser.selectedTab = tab = BrowserTestUtils.addTab(
+ tabbrowser,
+ opening
+ );
+ }
+ }),
+ ];
+
+ if (aWaitForLoad) {
+ promises.push(BrowserTestUtils.browserLoaded(tab.linkedBrowser));
+ }
+ if (aWaitForStateStop) {
+ promises.push(BrowserTestUtils.browserStopped(tab.linkedBrowser));
+ }
+ } finally {
+ // Restore the original process selector, if needed.
+ if (options.forceNewProcess && DEFAULT_PROCESSSELECTOR_CID) {
+ registrar.registerFactory(
+ DEFAULT_PROCESSSELECTOR_CID,
+ "",
+ PROCESSSELECTOR_CONTRACTID,
+ null
+ );
+ }
+ }
+ return Promise.all(promises).then(() => {
+ let { innerWindowId } = tabbrowser.ownerGlobal.windowGlobalChild;
+ ChromeUtils.addProfilerMarker(
+ "BrowserTestUtils",
+ { startTime, category: "Test", innerWindowId },
+ "openNewForegroundTab"
+ );
+ return tab;
+ });
+ },
+
+ /**
+ * Checks if a DOM element is hidden.
+ *
+ * @param {Element} element
+ * The element which is to be checked.
+ *
+ * @return {boolean}
+ */
+ is_hidden(element) {
+ let win = element.ownerGlobal;
+ let style = win.getComputedStyle(element);
+ if (style.display == "none") {
+ return true;
+ }
+ if (style.visibility != "visible") {
+ return true;
+ }
+ if (win.XULPopupElement.isInstance(element)) {
+ return ["hiding", "closed"].includes(element.state);
+ }
+
+ // Hiding a parent element will hide all its children
+ if (element.parentNode != element.ownerDocument) {
+ return BrowserTestUtils.is_hidden(element.parentNode);
+ }
+
+ return false;
+ },
+
+ /**
+ * Checks if a DOM element is visible.
+ *
+ * @param {Element} element
+ * The element which is to be checked.
+ *
+ * @return {boolean}
+ */
+ is_visible(element) {
+ let win = element.ownerGlobal;
+ let style = win.getComputedStyle(element);
+ if (style.display == "none") {
+ return false;
+ }
+ if (style.visibility != "visible") {
+ return false;
+ }
+ if (win.XULPopupElement.isInstance(element) && element.state != "open") {
+ return false;
+ }
+
+ // Hiding a parent element will hide all its children
+ if (element.parentNode != element.ownerDocument) {
+ return BrowserTestUtils.is_visible(element.parentNode);
+ }
+
+ return true;
+ },
+
+ /**
+ * If the argument is a browsingContext, return it. If the
+ * argument is a browser/frame, returns the browsing context for it.
+ */
+ getBrowsingContextFrom(browser) {
+ if (Element.isInstance(browser)) {
+ return browser.browsingContext;
+ }
+
+ return browser;
+ },
+
+ /**
+ * Switches to a tab and resolves when it is ready.
+ *
+ * @param {tabbrowser} tabbrowser
+ * The tabbrowser.
+ * @param {tab} tab
+ * Either a tab element to switch to or a function to perform the switch.
+ *
+ * @return {Promise}
+ * Resolves when the tab has been switched to.
+ * @resolves The tab switched to.
+ */
+ switchTab(tabbrowser, tab) {
+ let startTime = Cu.now();
+ let { innerWindowId } = tabbrowser.ownerGlobal.windowGlobalChild;
+
+ let promise = new Promise(resolve => {
+ tabbrowser.addEventListener(
+ "TabSwitchDone",
+ function () {
+ TestUtils.executeSoon(() => {
+ ChromeUtils.addProfilerMarker(
+ "BrowserTestUtils",
+ { category: "Test", startTime, innerWindowId },
+ "switchTab"
+ );
+ resolve(tabbrowser.selectedTab);
+ });
+ },
+ { once: true }
+ );
+ });
+
+ if (typeof tab == "function") {
+ tab();
+ } else {
+ tabbrowser.selectedTab = tab;
+ }
+ return promise;
+ },
+
+ /**
+ * Waits for an ongoing page load in a browser window to complete.
+ *
+ * This can be used in conjunction with any synchronous method for starting a
+ * load, like the "addTab" method on "tabbrowser", and must be called before
+ * yielding control to the event loop. Note that calling this after multiple
+ * successive load operations can be racy, so ``wantLoad`` should be specified
+ * in these cases.
+ *
+ * This function works by listening for custom load events on ``browser``. These
+ * are sent by a BrowserTestUtils window actor in response to "load" and
+ * "DOMContentLoaded" content events.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ * @param {Boolean} [includeSubFrames = false]
+ * A boolean indicating if loads from subframes should be included.
+ * @param {string|function} [wantLoad = null]
+ * If a function, takes a URL and returns true if that's the load we're
+ * interested in. If a string, gives the URL of the load we're interested
+ * in. If not present, the first load resolves the promise.
+ * @param {boolean} [maybeErrorPage = false]
+ * If true, this uses DOMContentLoaded event instead of load event.
+ * Also wantLoad will be called with visible URL, instead of
+ * 'about:neterror?...' for error page.
+ *
+ * @return {Promise}
+ * @resolves When a load event is triggered for the browser.
+ */
+ browserLoaded(
+ browser,
+ includeSubFrames = false,
+ wantLoad = null,
+ maybeErrorPage = false
+ ) {
+ let startTime = Cu.now();
+ let { innerWindowId } = browser.ownerGlobal.windowGlobalChild;
+
+ // Passing a url as second argument is a common mistake we should prevent.
+ if (includeSubFrames && typeof includeSubFrames != "boolean") {
+ throw new Error(
+ "The second argument to browserLoaded should be a boolean."
+ );
+ }
+
+ // If browser belongs to tabbrowser-tab, ensure it has been
+ // inserted into the document.
+ let tabbrowser = browser.ownerGlobal.gBrowser;
+ if (tabbrowser && tabbrowser.getTabForBrowser) {
+ let tab = tabbrowser.getTabForBrowser(browser);
+ if (tab) {
+ tabbrowser._insertBrowser(tab);
+ }
+ }
+
+ function isWanted(url) {
+ if (!wantLoad) {
+ return true;
+ } else if (typeof wantLoad == "function") {
+ return wantLoad(url);
+ }
+
+ // HTTPS-First (Bug 1704453) TODO: In case we are waiting
+ // for an http:// URL to be loaded and https-first is enabled,
+ // then we also return true in case the backend upgraded
+ // the load to https://.
+ if (
+ BrowserTestUtils._httpsFirstEnabled &&
+ typeof wantLoad == "string" &&
+ wantLoad.startsWith("http://")
+ ) {
+ let wantLoadHttps = wantLoad.replace("http://", "https://");
+ if (wantLoadHttps == url) {
+ return true;
+ }
+ }
+
+ // It's a string.
+ return wantLoad == url;
+ }
+
+ // Error pages are loaded slightly differently, so listen for the
+ // DOMContentLoaded event for those instead.
+ let loadEvent = maybeErrorPage ? "DOMContentLoaded" : "load";
+ let eventName = `BrowserTestUtils:ContentEvent:${loadEvent}`;
+
+ return new Promise((resolve, reject) => {
+ function listener(event) {
+ switch (event.type) {
+ case eventName: {
+ let { browsingContext, internalURL, visibleURL } = event.detail;
+
+ // Sometimes we arrive here without an internalURL. If that's the
+ // case, just keep waiting until we get one.
+ if (!internalURL) {
+ return;
+ }
+
+ // Ignore subframes if we only care about the top-level load.
+ let subframe = browsingContext !== browsingContext.top;
+ if (subframe && !includeSubFrames) {
+ return;
+ }
+
+ // See testing/mochitest/BrowserTestUtils/content/BrowserTestUtilsChild.sys.mjs
+ // for the difference between visibleURL and internalURL.
+ if (!isWanted(maybeErrorPage ? visibleURL : internalURL)) {
+ return;
+ }
+
+ ChromeUtils.addProfilerMarker(
+ "BrowserTestUtils",
+ { startTime, category: "Test", innerWindowId },
+ "browserLoaded: " + internalURL
+ );
+ resolve(internalURL);
+ break;
+ }
+
+ case "unload":
+ reject(
+ new Error(
+ "The window unloaded while we were waiting for the browser to load - this should never happen."
+ )
+ );
+ break;
+
+ default:
+ return;
+ }
+
+ browser.removeEventListener(eventName, listener, true);
+ browser.ownerGlobal.removeEventListener("unload", listener);
+ }
+
+ browser.addEventListener(eventName, listener, true);
+ browser.ownerGlobal.addEventListener("unload", listener);
+ });
+ },
+
+ /**
+ * Waits for the selected browser to load in a new window. This
+ * is most useful when you've got a window that might not have
+ * loaded its DOM yet, and where you can't easily use browserLoaded
+ * on gBrowser.selectedBrowser since gBrowser doesn't yet exist.
+ *
+ * @param {xul:window} window
+ * A newly opened window for which we're waiting for the
+ * first browser load.
+ * @param {Boolean} aboutBlank [optional]
+ * If false, about:blank loads are ignored and we continue
+ * to wait.
+ * @param {function|null} checkFn [optional]
+ * If checkFn(browser) returns false, the load is ignored
+ * and we continue to wait.
+ *
+ * @return {Promise}
+ * @resolves Once the selected browser fires its load event.
+ */
+ firstBrowserLoaded(win, aboutBlank = true, checkFn = null) {
+ return this.waitForEvent(
+ win,
+ "BrowserTestUtils:ContentEvent:load",
+ true,
+ event => {
+ if (checkFn) {
+ return checkFn(event.target);
+ }
+ return (
+ win.gBrowser.selectedBrowser.currentURI.spec !== "about:blank" ||
+ aboutBlank
+ );
+ }
+ );
+ },
+
+ _webProgressListeners: new Set(),
+
+ _contentEventListenerSharedState: new Map(),
+
+ _contentEventListeners: new Map(),
+
+ /**
+ * Waits for the web progress listener associated with this tab to fire a
+ * state change that matches checkFn for the toplevel document.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ * @param {String} expectedURI (optional)
+ * A specific URL to check the channel load against
+ * @param {Function} checkFn
+ * If checkFn(aStateFlags, aStatus) returns false, the state change
+ * is ignored and we continue to wait.
+ *
+ * @return {Promise}
+ * @resolves When the desired state change reaches the tab's progress listener
+ */
+ waitForBrowserStateChange(browser, expectedURI, checkFn) {
+ return new Promise(resolve => {
+ let wpl = {
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ dump(
+ "Saw state " +
+ aStateFlags.toString(16) +
+ " and status " +
+ aStatus.toString(16) +
+ "\n"
+ );
+ if (checkFn(aStateFlags, aStatus) && aWebProgress.isTopLevel) {
+ let chan = aRequest.QueryInterface(Ci.nsIChannel);
+ dump(
+ "Browser got expected state change " +
+ chan.originalURI.spec +
+ "\n"
+ );
+ if (!expectedURI || chan.originalURI.spec == expectedURI) {
+ browser.removeProgressListener(wpl);
+ BrowserTestUtils._webProgressListeners.delete(wpl);
+ resolve();
+ }
+ }
+ },
+ onSecurityChange() {},
+ onStatusChange() {},
+ onLocationChange() {},
+ onContentBlockingEvent() {},
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ "nsISupportsWeakReference",
+ ]),
+ };
+ browser.addProgressListener(wpl);
+ this._webProgressListeners.add(wpl);
+ dump(
+ "Waiting for browser state change" +
+ (expectedURI ? " of " + expectedURI : "") +
+ "\n"
+ );
+ });
+ },
+
+ /**
+ * Waits for the web progress listener associated with this tab to fire a
+ * STATE_STOP for the toplevel document.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ * @param {String} expectedURI (optional)
+ * A specific URL to check the channel load against
+ * @param {Boolean} checkAborts (optional, defaults to false)
+ * Whether NS_BINDING_ABORTED stops 'count' as 'real' stops
+ * (e.g. caused by the stop button or equivalent APIs)
+ *
+ * @return {Promise}
+ * @resolves When STATE_STOP reaches the tab's progress listener
+ */
+ browserStopped(browser, expectedURI, checkAborts = false) {
+ let testFn = function (aStateFlags, aStatus) {
+ return (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ (checkAborts || aStatus != Cr.NS_BINDING_ABORTED)
+ );
+ };
+ dump(
+ "Waiting for browser load" +
+ (expectedURI ? " of " + expectedURI : "") +
+ "\n"
+ );
+ return BrowserTestUtils.waitForBrowserStateChange(
+ browser,
+ expectedURI,
+ testFn
+ );
+ },
+
+ /**
+ * Waits for the web progress listener associated with this tab to fire a
+ * STATE_START for the toplevel document.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ * @param {String} expectedURI (optional)
+ * A specific URL to check the channel load against
+ *
+ * @return {Promise}
+ * @resolves When STATE_START reaches the tab's progress listener
+ */
+ browserStarted(browser, expectedURI) {
+ let testFn = function (aStateFlags, aStatus) {
+ return (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_START
+ );
+ };
+ dump(
+ "Waiting for browser to start load" +
+ (expectedURI ? " of " + expectedURI : "") +
+ "\n"
+ );
+ return BrowserTestUtils.waitForBrowserStateChange(
+ browser,
+ expectedURI,
+ testFn
+ );
+ },
+
+ /**
+ * Waits for a tab to open and load a given URL.
+ *
+ * By default, the method doesn't wait for the tab contents to load.
+ *
+ * @param {tabbrowser} tabbrowser
+ * The tabbrowser to look for the next new tab in.
+ * @param {string|function} [wantLoad = null]
+ * If a function, takes a URL and returns true if that's the load we're
+ * interested in. If a string, gives the URL of the load we're interested
+ * in. If not present, the first non-about:blank load is used.
+ * @param {boolean} [waitForLoad = false]
+ * True to wait for the page in the new tab to load. Defaults to false.
+ * @param {boolean} [waitForAnyTab = false]
+ * True to wait for the url to be loaded in any new tab, not just the next
+ * one opened.
+ *
+ * @return {Promise}
+ * @resolves With the {xul:tab} when a tab is opened and its location changes
+ * to the given URL and optionally that browser has loaded.
+ *
+ * NB: this method will not work if you open a new tab with e.g. BrowserOpenTab
+ * and the tab does not load a URL, because no onLocationChange will fire.
+ */
+ waitForNewTab(
+ tabbrowser,
+ wantLoad = null,
+ waitForLoad = false,
+ waitForAnyTab = false
+ ) {
+ let urlMatches;
+ if (wantLoad && typeof wantLoad == "function") {
+ urlMatches = wantLoad;
+ } else if (wantLoad) {
+ urlMatches = urlToMatch => urlToMatch == wantLoad;
+ } else {
+ urlMatches = urlToMatch => urlToMatch != "about:blank";
+ }
+ return new Promise((resolve, reject) => {
+ tabbrowser.tabContainer.addEventListener(
+ "TabOpen",
+ function tabOpenListener(openEvent) {
+ if (!waitForAnyTab) {
+ tabbrowser.tabContainer.removeEventListener(
+ "TabOpen",
+ tabOpenListener
+ );
+ }
+ let newTab = openEvent.target;
+ let newBrowser = newTab.linkedBrowser;
+ let result;
+ if (waitForLoad) {
+ // If waiting for load, resolve with promise for that, which when load
+ // completes resolves to the new tab.
+ result = BrowserTestUtils.browserLoaded(
+ newBrowser,
+ false,
+ urlMatches
+ ).then(() => newTab);
+ } else {
+ // If not waiting for load, just resolve with the new tab.
+ result = newTab;
+ }
+
+ let progressListener = {
+ onLocationChange(aBrowser) {
+ // Only interested in location changes on our browser.
+ if (aBrowser != newBrowser) {
+ return;
+ }
+
+ // Check that new location is the URL we want.
+ if (!urlMatches(aBrowser.currentURI.spec)) {
+ return;
+ }
+ if (waitForAnyTab) {
+ tabbrowser.tabContainer.removeEventListener(
+ "TabOpen",
+ tabOpenListener
+ );
+ }
+ tabbrowser.removeTabsProgressListener(progressListener);
+ TestUtils.executeSoon(() => resolve(result));
+ },
+ };
+ tabbrowser.addTabsProgressListener(progressListener);
+ }
+ );
+ });
+ },
+
+ /**
+ * Waits for onLocationChange.
+ *
+ * @param {tabbrowser} tabbrowser
+ * The tabbrowser to wait for the location change on.
+ * @param {string} url
+ * The string URL to look for. The URL must match the URL in the
+ * location bar exactly.
+ * @return {Promise}
+ * @resolves When onLocationChange fires.
+ */
+ waitForLocationChange(tabbrowser, url) {
+ return new Promise((resolve, reject) => {
+ let progressListener = {
+ onLocationChange(
+ aBrowser,
+ aWebProgress,
+ aRequest,
+ aLocationURI,
+ aFlags
+ ) {
+ if (
+ (url && aLocationURI.spec != url) ||
+ (!url && aLocationURI.spec == "about:blank")
+ ) {
+ return;
+ }
+
+ tabbrowser.removeTabsProgressListener(progressListener);
+ resolve();
+ },
+ };
+ tabbrowser.addTabsProgressListener(progressListener);
+ });
+ },
+
+ /**
+ * Waits for the next browser window to open and be fully loaded.
+ *
+ * @param {Object} aParams
+ * @param {string} [aParams.url]
+ * If set, we will wait until the initial browser in the new window
+ * has loaded a particular page.
+ * If unset, the initial browser may or may not have finished
+ * loading its first page when the resulting Promise resolves.
+ * @param {bool} [aParams.anyWindow]
+ * True to wait for the url to be loaded in any new
+ * window, not just the next one opened.
+ * @param {bool} [aParams.maybeErrorPage]
+ * See ``browserLoaded`` function.
+ * @return {Promise}
+ * A Promise which resolves the next time that a DOM window
+ * opens and the delayed startup observer notification fires.
+ */
+ waitForNewWindow(aParams = {}) {
+ let { url = null, anyWindow = false, maybeErrorPage = false } = aParams;
+
+ if (anyWindow && !url) {
+ throw new Error("url should be specified if anyWindow is true");
+ }
+
+ return new Promise((resolve, reject) => {
+ let observe = async (win, topic, data) => {
+ if (topic != "domwindowopened") {
+ return;
+ }
+
+ try {
+ if (!anyWindow) {
+ Services.ww.unregisterNotification(observe);
+ }
+
+ // Add these event listeners now since they may fire before the
+ // DOMContentLoaded event down below.
+ let promises = [
+ this.waitForEvent(win, "focus", true),
+ this.waitForEvent(win, "activate"),
+ ];
+
+ if (url) {
+ await this.waitForEvent(win, "DOMContentLoaded");
+
+ if (win.document.documentURI != AppConstants.BROWSER_CHROME_URL) {
+ return;
+ }
+ }
+
+ promises.push(
+ TestUtils.topicObserved(
+ "browser-delayed-startup-finished",
+ subject => subject == win
+ )
+ );
+
+ if (url) {
+ let loadPromise = this.browserLoaded(
+ win.gBrowser.selectedBrowser,
+ false,
+ url,
+ maybeErrorPage
+ );
+ promises.push(loadPromise);
+ }
+
+ await Promise.all(promises);
+
+ if (anyWindow) {
+ Services.ww.unregisterNotification(observe);
+ }
+ resolve(win);
+ } catch (err) {
+ // We failed to wait for the load in this URI. This is only an error
+ // if `anyWindow` is not set, as if it is we can just wait for another
+ // window.
+ if (!anyWindow) {
+ reject(err);
+ }
+ }
+ };
+ Services.ww.registerNotification(observe);
+ });
+ },
+
+ /**
+ * Loads a new URI in the given browser, triggered by the system principal.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ * @param {string} uri
+ * The URI to load.
+ */
+ loadURIString(browser, uri) {
+ browser.fixupAndLoadURIString(uri, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ },
+
+ /**
+ * Maybe create a preloaded browser and ensure it's finished loading.
+ *
+ * @param gBrowser (<xul:tabbrowser>)
+ * The tabbrowser in which to preload a browser.
+ */
+ async maybeCreatePreloadedBrowser(gBrowser) {
+ let win = gBrowser.ownerGlobal;
+ win.NewTabPagePreloading.maybeCreatePreloadedBrowser(win);
+
+ // We cannot use the regular BrowserTestUtils helper for waiting here, since that
+ // would try to insert the preloaded browser, which would only break things.
+ await lazy.ContentTask.spawn(gBrowser.preloadedBrowser, [], async () => {
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ this.content.document &&
+ this.content.document.readyState == "complete"
+ );
+ });
+ });
+ },
+
+ /**
+ * @param win (optional)
+ * The window we should wait to have "domwindowopened" sent through
+ * the observer service for. If this is not supplied, we'll just
+ * resolve when the first "domwindowopened" notification is seen.
+ * @param {function} checkFn [optional]
+ * Called with the nsIDOMWindow object as argument, should return true
+ * if the event is the expected one, or false if it should be ignored
+ * and observing should continue. If not specified, the first window
+ * resolves the returned promise.
+ * @return {Promise}
+ * A Promise which resolves when a "domwindowopened" notification
+ * has been fired by the window watcher.
+ */
+ domWindowOpened(win, checkFn) {
+ return new Promise(resolve => {
+ async function observer(subject, topic, data) {
+ if (topic == "domwindowopened" && (!win || subject === win)) {
+ let observedWindow = subject;
+ if (checkFn && !(await checkFn(observedWindow))) {
+ return;
+ }
+ Services.ww.unregisterNotification(observer);
+ resolve(observedWindow);
+ }
+ }
+ Services.ww.registerNotification(observer);
+ });
+ },
+
+ /**
+ * @param win (optional)
+ * The window we should wait to have "domwindowopened" sent through
+ * the observer service for. If this is not supplied, we'll just
+ * resolve when the first "domwindowopened" notification is seen.
+ * The promise will be resolved once the new window's document has been
+ * loaded.
+ *
+ * @param {function} checkFn (optional)
+ * Called with the nsIDOMWindow object as argument, should return true
+ * if the event is the expected one, or false if it should be ignored
+ * and observing should continue. If not specified, the first window
+ * resolves the returned promise.
+ *
+ * @return {Promise}
+ * A Promise which resolves when a "domwindowopened" notification
+ * has been fired by the window watcher.
+ */
+ domWindowOpenedAndLoaded(win, checkFn) {
+ return this.domWindowOpened(win, async observedWin => {
+ await this.waitForEvent(observedWin, "load");
+ if (checkFn && !(await checkFn(observedWin))) {
+ return false;
+ }
+ return true;
+ });
+ },
+
+ /**
+ * @param win (optional)
+ * The window we should wait to have "domwindowclosed" sent through
+ * the observer service for. If this is not supplied, we'll just
+ * resolve when the first "domwindowclosed" notification is seen.
+ * @return {Promise}
+ * A Promise which resolves when a "domwindowclosed" notification
+ * has been fired by the window watcher.
+ */
+ domWindowClosed(win) {
+ return new Promise(resolve => {
+ function observer(subject, topic, data) {
+ if (topic == "domwindowclosed" && (!win || subject === win)) {
+ Services.ww.unregisterNotification(observer);
+ resolve(subject);
+ }
+ }
+ Services.ww.registerNotification(observer);
+ });
+ },
+
+ /**
+ * Clear the stylesheet cache and open a new window to ensure
+ * CSS @supports -moz-bool-pref(...) {} rules are correctly
+ * applied to the browser chrome.
+ *
+ * @param {Object} options See BrowserTestUtils.openNewBrowserWindow
+ * @returns {Promise} Resolves with the new window once it is loaded.
+ */
+ async openNewWindowWithFlushedCacheForMozSupports(options) {
+ ChromeUtils.clearStyleSheetCache();
+ return BrowserTestUtils.openNewBrowserWindow(options);
+ },
+
+ /**
+ * Open a new browser window from an existing one.
+ * This relies on OpenBrowserWindow in browser.js, and waits for the window
+ * to be completely loaded before resolving.
+ *
+ * @param {Object} options
+ * Options to pass to OpenBrowserWindow. Additionally, supports:
+ * @param {bool} options.waitForTabURL
+ * Forces the initial browserLoaded check to wait for the tab to
+ * load the given URL (instead of about:blank)
+ *
+ * @return {Promise}
+ * Resolves with the new window once it is loaded.
+ */
+ async openNewBrowserWindow(options = {}) {
+ let startTime = Cu.now();
+
+ let currentWin = lazy.BrowserWindowTracker.getTopWindow({ private: false });
+ if (!currentWin) {
+ throw new Error(
+ "Can't open a new browser window from this helper if no non-private window is open."
+ );
+ }
+ let win = currentWin.OpenBrowserWindow(options);
+
+ let promises = [
+ this.waitForEvent(win, "focus", true),
+ this.waitForEvent(win, "activate"),
+ ];
+
+ // Wait for browser-delayed-startup-finished notification, it indicates
+ // that the window has loaded completely and is ready to be used for
+ // testing.
+ promises.push(
+ TestUtils.topicObserved(
+ "browser-delayed-startup-finished",
+ subject => subject == win
+ ).then(() => win)
+ );
+
+ promises.push(
+ this.firstBrowserLoaded(win, !options.waitForTabURL, browser => {
+ return (
+ !options.waitForTabURL ||
+ options.waitForTabURL == browser.currentURI.spec
+ );
+ })
+ );
+
+ await Promise.all(promises);
+ ChromeUtils.addProfilerMarker(
+ "BrowserTestUtils",
+ { startTime, category: "Test" },
+ "openNewBrowserWindow"
+ );
+
+ return win;
+ },
+
+ /**
+ * Closes a window.
+ *
+ * @param {Window} win
+ * A window to close.
+ *
+ * @return {Promise}
+ * Resolves when the provided window has been closed. For browser
+ * windows, the Promise will also wait until all final SessionStore
+ * messages have been sent up from all browser tabs.
+ */
+ closeWindow(win) {
+ let closedPromise = BrowserTestUtils.windowClosed(win);
+ win.close();
+ return closedPromise;
+ },
+
+ /**
+ * Returns a Promise that resolves when a window has finished closing.
+ *
+ * @param {Window} win
+ * The closing window.
+ *
+ * @return {Promise}
+ * Resolves when the provided window has been fully closed. For
+ * browser windows, the Promise will also wait until all final
+ * SessionStore messages have been sent up from all browser tabs.
+ */
+ windowClosed(win) {
+ let domWinClosedPromise = BrowserTestUtils.domWindowClosed(win);
+ let promises = [domWinClosedPromise];
+ let winType = win.document.documentElement.getAttribute("windowtype");
+ let flushTopic = "sessionstore-browser-shutdown-flush";
+
+ if (winType == "navigator:browser") {
+ let finalMsgsPromise = new Promise(resolve => {
+ let browserSet = new Set(win.gBrowser.browsers);
+ // Ensure all browsers have been inserted or we won't get
+ // messages back from them.
+ browserSet.forEach(browser => {
+ win.gBrowser._insertBrowser(win.gBrowser.getTabForBrowser(browser));
+ });
+
+ let observer = (subject, topic, data) => {
+ if (browserSet.has(subject)) {
+ browserSet.delete(subject);
+ }
+ if (!browserSet.size) {
+ Services.obs.removeObserver(observer, flushTopic);
+ // Give the TabStateFlusher a chance to react to this final
+ // update and for the TabStateFlusher.flushWindow promise
+ // to resolve before we resolve.
+ TestUtils.executeSoon(resolve);
+ }
+ };
+
+ Services.obs.addObserver(observer, flushTopic);
+ });
+
+ promises.push(finalMsgsPromise);
+ }
+
+ return Promise.all(promises);
+ },
+
+ /**
+ * Returns a Promise that resolves once the SessionStore information for the
+ * given tab is updated and all listeners are called.
+ *
+ * @param {xul:tab} tab
+ * The tab that will be removed.
+ * @returns {Promise}
+ * @resolves When the SessionStore information is updated.
+ */
+ waitForSessionStoreUpdate(tab) {
+ return new Promise(resolve => {
+ let browser = tab.linkedBrowser;
+ let flushTopic = "sessionstore-browser-shutdown-flush";
+ let observer = (subject, topic, data) => {
+ if (subject === browser) {
+ Services.obs.removeObserver(observer, flushTopic);
+ // Wait for the next event tick to make sure other listeners are
+ // called.
+ TestUtils.executeSoon(() => resolve());
+ }
+ };
+ Services.obs.addObserver(observer, flushTopic);
+ });
+ },
+
+ /**
+ * Waits for an event to be fired on a specified element.
+ *
+ * @example
+ *
+ * let promiseEvent = BrowserTestUtils.waitForEvent(element, "eventName");
+ * // Do some processing here that will cause the event to be fired
+ * // ...
+ * // Now wait until the Promise is fulfilled
+ * let receivedEvent = await promiseEvent;
+ *
+ * @example
+ * // The promise resolution/rejection handler for the returned promise is
+ * // guaranteed not to be called until the next event tick after the event
+ * // listener gets called, so that all other event listeners for the element
+ * // are executed before the handler is executed.
+ *
+ * let promiseEvent = BrowserTestUtils.waitForEvent(element, "eventName");
+ * // Same event tick here.
+ * await promiseEvent;
+ * // Next event tick here.
+ *
+ * @example
+ * // If some code, such like adding yet another event listener, needs to be
+ * // executed in the same event tick, use raw addEventListener instead and
+ * // place the code inside the event listener.
+ *
+ * element.addEventListener("load", () => {
+ * // Add yet another event listener in the same event tick as the load
+ * // event listener.
+ * p = BrowserTestUtils.waitForEvent(element, "ready");
+ * }, { once: true });
+ *
+ * @param {Element} subject
+ * The element that should receive the event.
+ * @param {string} eventName
+ * Name of the event to listen to.
+ * @param {bool} [capture]
+ * True to use a capturing listener.
+ * @param {function} [checkFn]
+ * Called with the Event object as argument, should return true if the
+ * event is the expected one, or false if it should be ignored and
+ * listening should continue. If not specified, the first event with
+ * the specified name resolves the returned promise.
+ * @param {bool} [wantsUntrusted=false]
+ * True to receive synthetic events dispatched by web content.
+ *
+ * @note Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next event, since this is probably a bug in the test.
+ *
+ * @returns {Promise}
+ * @resolves The Event object.
+ */
+ waitForEvent(subject, eventName, capture, checkFn, wantsUntrusted) {
+ let startTime = Cu.now();
+ let innerWindowId = subject.ownerGlobal?.windowGlobalChild.innerWindowId;
+
+ return new Promise((resolve, reject) => {
+ let removed = false;
+ function listener(event) {
+ function cleanup() {
+ removed = true;
+ // Avoid keeping references to objects after the promise resolves.
+ subject = null;
+ checkFn = null;
+ }
+ try {
+ if (checkFn && !checkFn(event)) {
+ return;
+ }
+ subject.removeEventListener(eventName, listener, capture);
+ cleanup();
+ TestUtils.executeSoon(() => {
+ ChromeUtils.addProfilerMarker(
+ "BrowserTestUtils",
+ { startTime, category: "Test", innerWindowId },
+ "waitForEvent: " + eventName
+ );
+ resolve(event);
+ });
+ } catch (ex) {
+ try {
+ subject.removeEventListener(eventName, listener, capture);
+ } catch (ex2) {
+ // Maybe the provided object does not support removeEventListener.
+ }
+ cleanup();
+ TestUtils.executeSoon(() => reject(ex));
+ }
+ }
+
+ subject.addEventListener(eventName, listener, capture, wantsUntrusted);
+
+ TestUtils.promiseTestFinished?.then(() => {
+ if (removed) {
+ return;
+ }
+
+ subject.removeEventListener(eventName, listener, capture);
+ let text = eventName + " listener";
+ if (subject.id) {
+ text += ` on #${subject.id}`;
+ }
+ text += " not removed before the end of test";
+ reject(text);
+ ChromeUtils.addProfilerMarker(
+ "BrowserTestUtils",
+ { startTime, category: "Test", innerWindowId },
+ "waitForEvent: " + text
+ );
+ });
+ });
+ },
+
+ /**
+ * Like waitForEvent, but adds the event listener to the message manager
+ * global for browser.
+ *
+ * @param {string} eventName
+ * Name of the event to listen to.
+ * @param {bool} capture [optional]
+ * Whether to use a capturing listener.
+ * @param {function} checkFn [optional]
+ * Called with the Event object as argument, should return true if the
+ * event is the expected one, or false if it should be ignored and
+ * listening should continue. If not specified, the first event with
+ * the specified name resolves the returned promise.
+ * @param {bool} wantUntrusted [optional]
+ * Whether to accept untrusted events
+ *
+ * @note As of bug 1588193, this function no longer rejects the returned
+ * promise in the case of a checkFn error. Instead, since checkFn is now
+ * called through eval in the content process, the error is thrown in
+ * the listener created by ContentEventListenerChild. Work to improve
+ * error handling (eg. to reject the promise as before and to preserve
+ * the filename/stack) is being tracked in bug 1593811.
+ *
+ * @returns {Promise}
+ */
+ waitForContentEvent(
+ browser,
+ eventName,
+ capture = false,
+ checkFn,
+ wantUntrusted = false
+ ) {
+ return new Promise(resolve => {
+ let removeEventListener = this.addContentEventListener(
+ browser,
+ eventName,
+ () => {
+ removeEventListener();
+ resolve(eventName);
+ },
+ { capture, wantUntrusted },
+ checkFn
+ );
+ });
+ },
+
+ /**
+ * Like waitForEvent, but acts on a popup. It ensures the popup is not already
+ * in the expected state.
+ *
+ * @param {Element} popup
+ * The popup element that should receive the event.
+ * @param {string} eventSuffix
+ * The event suffix expected to be received, one of "shown" or "hidden".
+ * @returns {Promise}
+ */
+ waitForPopupEvent(popup, eventSuffix) {
+ let endState = { shown: "open", hidden: "closed" }[eventSuffix];
+
+ if (popup.state == endState) {
+ return Promise.resolve();
+ }
+ return this.waitForEvent(popup, "popup" + eventSuffix);
+ },
+
+ /**
+ * Waits for the select popup to be shown. This is needed because the select
+ * dropdown is created lazily.
+ *
+ * @param {Window} win
+ * A window to expect the popup in.
+ *
+ * @return {Promise}
+ * Resolves when the popup has been fully opened. The resolution value
+ * is the select popup.
+ */
+ async waitForSelectPopupShown(win) {
+ let getMenulist = () =>
+ win.document.getElementById("ContentSelectDropdown");
+ let menulist = getMenulist();
+ if (!menulist) {
+ await this.waitForMutationCondition(
+ win.document,
+ { childList: true, subtree: true },
+ getMenulist
+ );
+ menulist = getMenulist();
+ if (menulist.menupopup.state == "open") {
+ return menulist.menupopup;
+ }
+ }
+ await this.waitForEvent(menulist.menupopup, "popupshown");
+ return menulist.menupopup;
+ },
+
+ /**
+ * Waits for the datetime picker popup to be shown.
+ *
+ * @param {Window} win
+ * A window to expect the popup in.
+ *
+ * @return {Promise}
+ * Resolves when the popup has been fully opened. The resolution value
+ * is the select popup.
+ */
+ async waitForDateTimePickerPanelShown(win) {
+ let getPanel = () => win.document.getElementById("DateTimePickerPanel");
+ let panel = getPanel();
+ let ensureReady = async () => {
+ let frame = panel.querySelector("#dateTimePopupFrame");
+ let isValidUrl = () => {
+ return (
+ frame.browsingContext?.currentURI?.spec ==
+ "chrome://global/content/datepicker.xhtml" ||
+ frame.browsingContext?.currentURI?.spec ==
+ "chrome://global/content/timepicker.xhtml"
+ );
+ };
+
+ // Ensure it's loaded.
+ if (!isValidUrl() || frame.contentDocument.readyState != "complete") {
+ await new Promise(resolve => {
+ frame.addEventListener(
+ "load",
+ function listener() {
+ if (isValidUrl()) {
+ frame.removeEventListener("load", listener, { capture: true });
+ resolve();
+ }
+ },
+ { capture: true }
+ );
+ });
+ }
+
+ // Ensure it's ready.
+ if (!frame.contentWindow.PICKER_READY) {
+ await new Promise(resolve => {
+ frame.contentDocument.addEventListener("PickerReady", resolve, {
+ once: true,
+ });
+ });
+ }
+ // And that l10n mutations are flushed.
+ // FIXME(bug 1828721): We should ideally localize everything before
+ // showing the panel.
+ if (frame.contentDocument.hasPendingL10nMutations) {
+ await new Promise(resolve => {
+ frame.contentDocument.addEventListener(
+ "L10nMutationsFinished",
+ resolve,
+ {
+ once: true,
+ }
+ );
+ });
+ }
+ };
+
+ if (!panel) {
+ await this.waitForMutationCondition(
+ win.document,
+ { childList: true, subtree: true },
+ getPanel
+ );
+ panel = getPanel();
+ if (panel.state == "open") {
+ await ensureReady();
+ return panel;
+ }
+ }
+ await this.waitForEvent(panel, "popupshown");
+ await ensureReady();
+ return panel;
+ },
+
+ /**
+ * Adds a content event listener on the given browser
+ * element. Similar to waitForContentEvent, but the listener will
+ * fire until it is removed. A callable object is returned that,
+ * when called, removes the event listener. Note that this function
+ * works even if the browser's frameloader is swapped.
+ *
+ * @param {xul:browser} browser
+ * The browser element to listen for events in.
+ * @param {string} eventName
+ * Name of the event to listen to.
+ * @param {function} listener
+ * Function to call in parent process when event fires.
+ * Not passed any arguments.
+ * @param {object} listenerOptions [optional]
+ * Options to pass to the event listener.
+ * @param {function} checkFn [optional]
+ * Called with the Event object as argument, should return true if the
+ * event is the expected one, or false if it should be ignored and
+ * listening should continue. If not specified, the first event with
+ * the specified name resolves the returned promise. This is called
+ * within the content process and can have no closure environment.
+ *
+ * @returns function
+ * If called, the return value will remove the event listener.
+ */
+ addContentEventListener(
+ browser,
+ eventName,
+ listener,
+ listenerOptions = {},
+ checkFn
+ ) {
+ let id = gListenerId++;
+ let contentEventListeners = this._contentEventListeners;
+ contentEventListeners.set(id, {
+ listener,
+ browserId: browser.browserId,
+ });
+
+ let eventListenerState = this._contentEventListenerSharedState;
+ eventListenerState.set(id, {
+ eventName,
+ listenerOptions,
+ checkFnSource: checkFn ? checkFn.toSource() : "",
+ });
+
+ Services.ppmm.sharedData.set(
+ "BrowserTestUtils:ContentEventListener",
+ eventListenerState
+ );
+ Services.ppmm.sharedData.flush();
+
+ let unregisterFunction = function () {
+ if (!eventListenerState.has(id)) {
+ return;
+ }
+ eventListenerState.delete(id);
+ contentEventListeners.delete(id);
+ Services.ppmm.sharedData.set(
+ "BrowserTestUtils:ContentEventListener",
+ eventListenerState
+ );
+ Services.ppmm.sharedData.flush();
+ };
+ return unregisterFunction;
+ },
+
+ /**
+ * This is an internal method to be invoked by
+ * BrowserTestUtilsParent.sys.mjs when a content event we were listening for
+ * happens.
+ *
+ * @private
+ */
+ _receivedContentEventListener(listenerId, browserId) {
+ let listenerData = this._contentEventListeners.get(listenerId);
+ if (!listenerData) {
+ return;
+ }
+ if (listenerData.browserId != browserId) {
+ return;
+ }
+ listenerData.listener();
+ },
+
+ /**
+ * This is an internal method that cleans up any state from content event
+ * listeners.
+ *
+ * @private
+ */
+ _cleanupContentEventListeners() {
+ this._contentEventListeners.clear();
+
+ if (this._contentEventListenerSharedState.size != 0) {
+ this._contentEventListenerSharedState.clear();
+ Services.ppmm.sharedData.set(
+ "BrowserTestUtils:ContentEventListener",
+ this._contentEventListenerSharedState
+ );
+ Services.ppmm.sharedData.flush();
+ }
+
+ if (this._contentEventListenerActorRegistered) {
+ this._contentEventListenerActorRegistered = false;
+ ChromeUtils.unregisterWindowActor("ContentEventListener");
+ }
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "test-complete":
+ this._cleanupContentEventListeners();
+ break;
+ }
+ },
+
+ /**
+ * Wait until DOM mutations cause the condition expressed in checkFn
+ * to pass.
+ *
+ * Intended as an easy-to-use alternative to waitForCondition.
+ *
+ * @param {Element} target The target in which to observe mutations.
+ * @param {Object} options The options to pass to MutationObserver.observe();
+ * @param {function} checkFn Function that returns true when it wants the promise to be
+ * resolved.
+ */
+ waitForMutationCondition(target, options, checkFn) {
+ if (checkFn()) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve => {
+ let obs = new target.ownerGlobal.MutationObserver(function () {
+ if (checkFn()) {
+ obs.disconnect();
+ resolve();
+ }
+ });
+ obs.observe(target, options);
+ });
+ },
+
+ /**
+ * Like browserLoaded, but waits for an error page to appear.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ *
+ * @return {Promise}
+ * @resolves When an error page has been loaded in the browser.
+ */
+ waitForErrorPage(browser) {
+ return this.waitForContentEvent(
+ browser,
+ "AboutNetErrorLoad",
+ false,
+ null,
+ true
+ );
+ },
+
+ /**
+ * Waits for the next top-level document load in the current browser. The URI
+ * of the document is compared against expectedURL. The load is then stopped
+ * before it actually starts.
+ *
+ * @param {string} expectedURL
+ * The URL of the document that is expected to load.
+ * @param {object} browser
+ * The browser to wait for.
+ * @param {function} checkFn (optional)
+ * Function to run on the channel before stopping it.
+ * @returns {Promise}
+ */
+ waitForDocLoadAndStopIt(expectedURL, browser, checkFn) {
+ let isHttp = url => /^https?:/.test(url);
+
+ return new Promise(resolve => {
+ // Redirect non-http URIs to http://mochi.test:8888/, so we can still
+ // use http-on-before-connect to listen for loads. Since we're
+ // aborting the load as early as possible, it doesn't matter whether the
+ // server handles it sensibly or not. However, this also means that this
+ // helper shouldn't be used to load local URIs (about pages, chrome://
+ // URIs, etc).
+ let proxyFilter;
+ if (!isHttp(expectedURL)) {
+ proxyFilter = {
+ proxyInfo: lazy.ProtocolProxyService.newProxyInfo(
+ "http",
+ "mochi.test",
+ 8888,
+ "",
+ "",
+ 0,
+ 4096,
+ null
+ ),
+
+ applyFilter(channel, defaultProxyInfo, callback) {
+ callback.onProxyFilterResult(
+ isHttp(channel.URI.spec) ? defaultProxyInfo : this.proxyInfo
+ );
+ },
+ };
+
+ lazy.ProtocolProxyService.registerChannelFilter(proxyFilter, 0);
+ }
+
+ function observer(chan) {
+ chan.QueryInterface(Ci.nsIHttpChannel);
+ if (!chan.originalURI || chan.originalURI.spec !== expectedURL) {
+ return;
+ }
+ if (checkFn && !checkFn(chan)) {
+ return;
+ }
+
+ // TODO: We should check that the channel's BrowsingContext matches
+ // the browser's. See bug 1587114.
+
+ try {
+ chan.cancel(Cr.NS_BINDING_ABORTED);
+ } finally {
+ if (proxyFilter) {
+ lazy.ProtocolProxyService.unregisterChannelFilter(proxyFilter);
+ }
+ Services.obs.removeObserver(observer, "http-on-before-connect");
+ resolve();
+ }
+ }
+
+ Services.obs.addObserver(observer, "http-on-before-connect");
+ });
+ },
+
+ /**
+ * Versions of EventUtils.jsm synthesizeMouse functions that synthesize a
+ * mouse event in a child process and return promises that resolve when the
+ * event has fired and completed. Instead of a window, a browser or
+ * browsing context is required to be passed to this function.
+ *
+ * @param target
+ * One of the following:
+ * - a selector string that identifies the element to target. The syntax is as
+ * for querySelector.
+ * - a function to be run in the content process that returns the element to
+ * target
+ * - null, in which case the offset is from the content document's edge.
+ * @param {integer} offsetX
+ * x offset from target's left bounding edge
+ * @param {integer} offsetY
+ * y offset from target's top bounding edge
+ * @param {Object} event object
+ * Additional arguments, similar to the EventUtils.jsm version
+ * @param {BrowserContext|MozFrameLoaderOwner} browsingContext
+ * Browsing context or browser element, must not be null
+ * @param {boolean} handlingUserInput
+ * Whether the synthesize should be perfomed while simulating
+ * user interaction (making windowUtils.isHandlingUserInput be true).
+ *
+ * @returns {Promise}
+ * @resolves True if the mouse event was cancelled.
+ */
+ synthesizeMouse(
+ target,
+ offsetX,
+ offsetY,
+ event,
+ browsingContext,
+ handlingUserInput
+ ) {
+ let targetFn = null;
+ if (typeof target == "function") {
+ targetFn = target.toString();
+ target = null;
+ } else if (typeof target != "string" && !Array.isArray(target)) {
+ target = null;
+ }
+
+ browsingContext = this.getBrowsingContextFrom(browsingContext);
+ return this.sendQuery(browsingContext, "Test:SynthesizeMouse", {
+ target,
+ targetFn,
+ x: offsetX,
+ y: offsetY,
+ event,
+ handlingUserInput,
+ });
+ },
+
+ /**
+ * Versions of EventUtils.jsm synthesizeTouch functions that synthesize a
+ * touch event in a child process and return promises that resolve when the
+ * event has fired and completed. Instead of a window, a browser or
+ * browsing context is required to be passed to this function.
+ *
+ * @param target
+ * One of the following:
+ * - a selector string that identifies the element to target. The syntax is as
+ * for querySelector.
+ * - a function to be run in the content process that returns the element to
+ * target
+ * - null, in which case the offset is from the content document's edge.
+ * @param {integer} offsetX
+ * x offset from target's left bounding edge
+ * @param {integer} offsetY
+ * y offset from target's top bounding edge
+ * @param {Object} event object
+ * Additional arguments, similar to the EventUtils.jsm version
+ * @param {BrowserContext|MozFrameLoaderOwner} browsingContext
+ * Browsing context or browser element, must not be null
+ *
+ * @returns {Promise}
+ * @resolves True if the touch event was cancelled.
+ */
+ synthesizeTouch(target, offsetX, offsetY, event, browsingContext) {
+ let targetFn = null;
+ if (typeof target == "function") {
+ targetFn = target.toString();
+ target = null;
+ } else if (typeof target != "string" && !Array.isArray(target)) {
+ target = null;
+ }
+
+ browsingContext = this.getBrowsingContextFrom(browsingContext);
+ return this.sendQuery(browsingContext, "Test:SynthesizeTouch", {
+ target,
+ targetFn,
+ x: offsetX,
+ y: offsetY,
+ event,
+ });
+ },
+
+ /**
+ * Wait for a message to be fired from a particular message manager
+ *
+ * @param {nsIMessageManager} messageManager
+ * The message manager that should be used.
+ * @param {String} message
+ * The message we're waiting for.
+ * @param {Function} checkFn (optional)
+ * Optional function to invoke to check the message.
+ */
+ waitForMessage(messageManager, message, checkFn) {
+ return new Promise(resolve => {
+ messageManager.addMessageListener(message, function onMessage(msg) {
+ if (!checkFn || checkFn(msg)) {
+ messageManager.removeMessageListener(message, onMessage);
+ resolve(msg.data);
+ }
+ });
+ });
+ },
+
+ /**
+ * Version of synthesizeMouse that uses the center of the target as the mouse
+ * location. Arguments and the return value are the same.
+ */
+ synthesizeMouseAtCenter(target, event, browsingContext) {
+ // Use a flag to indicate to center rather than having a separate message.
+ event.centered = true;
+ return BrowserTestUtils.synthesizeMouse(
+ target,
+ 0,
+ 0,
+ event,
+ browsingContext
+ );
+ },
+
+ /**
+ * Version of synthesizeMouse that uses a client point within the child
+ * window instead of a target as the offset. Otherwise, the arguments and
+ * return value are the same as synthesizeMouse.
+ */
+ synthesizeMouseAtPoint(offsetX, offsetY, event, browsingContext) {
+ return BrowserTestUtils.synthesizeMouse(
+ null,
+ offsetX,
+ offsetY,
+ event,
+ browsingContext
+ );
+ },
+
+ /**
+ * Removes the given tab from its parent tabbrowser.
+ * This method doesn't SessionStore etc.
+ *
+ * @param (tab) tab
+ * The tab to remove.
+ * @param (Object) options
+ * Extra options to pass to tabbrowser's removeTab method.
+ */
+ removeTab(tab, options = {}) {
+ tab.ownerGlobal.gBrowser.removeTab(tab, options);
+ },
+
+ /**
+ * Returns a Promise that resolves once the tab starts closing.
+ *
+ * @param (tab) tab
+ * The tab that will be removed.
+ * @returns (Promise)
+ * @resolves When the tab starts closing. Does not get passed a value.
+ */
+ waitForTabClosing(tab) {
+ return this.waitForEvent(tab, "TabClose");
+ },
+
+ /**
+ *
+ * @param {tab} tab
+ * The tab that will be reloaded.
+ * @param {Boolean} [includeSubFrames = false]
+ * A boolean indicating if loads from subframes should be included
+ * when waiting for the frame to reload.
+ * @returns {Promise}
+ * @resolves When the tab finishes reloading.
+ */
+ reloadTab(tab, includeSubFrames = false) {
+ const finished = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ includeSubFrames
+ );
+ tab.ownerGlobal.gBrowser.reloadTab(tab);
+ return finished;
+ },
+
+ /**
+ * Create enough tabs to cause a tab overflow in the given window.
+ * @param {Function} registerCleanupFunction
+ * The test framework doesn't keep its cleanup stuff anywhere accessible,
+ * so the first argument is a reference to your cleanup registration
+ * function, allowing us to clean up after you if necessary.
+ * @param {Window} win
+ * The window where the tabs need to be overflowed.
+ * @param {object} params [optional]
+ * Parameters object for BrowserTestUtils.overflowTabs.
+ * overflowAtStart: bool
+ * Determines whether the new tabs are added at the beginning of the
+ * URL bar or at the end of it.
+ * overflowTabFactor: 3 | 1.1
+ * Factor that helps in determining the tab count for overflow.
+ */
+ async overflowTabs(registerCleanupFunction, win, params = {}) {
+ if (!params.hasOwnProperty("overflowAtStart")) {
+ params.overflowAtStart = true;
+ }
+ if (!params.hasOwnProperty("overflowTabFactor")) {
+ params.overflowTabFactor = 1.1;
+ }
+ let index = params.overflowAtStart ? 0 : undefined;
+ let { gBrowser } = win;
+ let arrowScrollbox = gBrowser.tabContainer.arrowScrollbox;
+ const originalSmoothScroll = arrowScrollbox.smoothScroll;
+ arrowScrollbox.smoothScroll = false;
+ registerCleanupFunction(() => {
+ arrowScrollbox.smoothScroll = originalSmoothScroll;
+ });
+
+ let width = ele => ele.getBoundingClientRect().width;
+ let tabMinWidth = parseInt(
+ win.getComputedStyle(gBrowser.selectedTab).minWidth
+ );
+ let tabCountForOverflow = Math.ceil(
+ (width(arrowScrollbox) / tabMinWidth) * params.overflowTabFactor
+ );
+ while (gBrowser.tabs.length < tabCountForOverflow) {
+ BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ skipAnimation: true,
+ index,
+ });
+ }
+ },
+
+ /**
+ * Crashes a remote frame tab and cleans up the generated minidumps.
+ * Resolves with the data from the .extra file (the crash annotations).
+ *
+ * @param (Browser) browser
+ * A remote <xul:browser> element. Must not be null.
+ * @param (bool) shouldShowTabCrashPage
+ * True if it is expected that the tab crashed page will be shown
+ * for this browser. If so, the Promise will only resolve once the
+ * tab crash page has loaded.
+ * @param (bool) shouldClearMinidumps
+ * True if the minidumps left behind by the crash should be removed.
+ * @param (BrowsingContext) browsingContext
+ * The context where the frame leaves. Default to
+ * top level context if not supplied.
+ * @param (object?) options
+ * An object with any of the following fields:
+ * crashType: "CRASH_INVALID_POINTER_DEREF" | "CRASH_OOM"
+ * The type of crash. If unspecified, default to "CRASH_INVALID_POINTER_DEREF"
+ * asyncCrash: bool
+ * If specified and `true`, cause the crash asynchronously.
+ *
+ * @returns (Promise)
+ * @resolves An Object with key-value pairs representing the data from the
+ * crash report's extra file (if applicable).
+ */
+ async crashFrame(
+ browser,
+ shouldShowTabCrashPage = true,
+ shouldClearMinidumps = true,
+ browsingContext,
+ options = {}
+ ) {
+ let extra = {};
+
+ if (!browser.isRemoteBrowser) {
+ throw new Error("<xul:browser> needs to be remote in order to crash");
+ }
+
+ /**
+ * Returns the directory where crash dumps are stored.
+ *
+ * @return nsIFile
+ */
+ function getMinidumpDirectory() {
+ let dir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ dir.append("minidumps");
+ return dir;
+ }
+
+ /**
+ * Removes a file from a directory. This is a no-op if the file does not
+ * exist.
+ *
+ * @param directory
+ * The nsIFile representing the directory to remove from.
+ * @param filename
+ * A string for the file to remove from the directory.
+ */
+ function removeFile(directory, filename) {
+ let file = directory.clone();
+ file.append(filename);
+ if (file.exists()) {
+ file.remove(false);
+ }
+ }
+
+ let expectedPromises = [];
+
+ let crashCleanupPromise = new Promise((resolve, reject) => {
+ let observer = (subject, topic, data) => {
+ if (topic != "ipc:content-shutdown") {
+ reject("Received incorrect observer topic: " + topic);
+ return;
+ }
+ if (!(subject instanceof Ci.nsIPropertyBag2)) {
+ reject("Subject did not implement nsIPropertyBag2");
+ return;
+ }
+ // we might see this called as the process terminates due to previous tests.
+ // We are only looking for "abnormal" exits...
+ if (!subject.hasKey("abnormal")) {
+ dump(
+ "\nThis is a normal termination and isn't the one we are looking for...\n"
+ );
+ return;
+ }
+
+ Services.obs.removeObserver(observer, "ipc:content-shutdown");
+
+ let dumpID;
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ dumpID = subject.getPropertyAsAString("dumpID");
+ if (!dumpID) {
+ reject(
+ "dumpID was not present despite crash reporting being enabled"
+ );
+ return;
+ }
+ }
+
+ let removalPromise = Promise.resolve();
+
+ if (dumpID) {
+ removalPromise = Services.crashmanager
+ .ensureCrashIsPresent(dumpID)
+ .then(async () => {
+ let minidumpDirectory = getMinidumpDirectory();
+ let extrafile = minidumpDirectory.clone();
+ extrafile.append(dumpID + ".extra");
+ if (extrafile.exists()) {
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ extra = await IOUtils.readJSON(extrafile.path);
+ } else {
+ dump(
+ "\nCrashReporter not enabled - will not return any extra data\n"
+ );
+ }
+ } else {
+ dump(`\nNo .extra file for dumpID: ${dumpID}\n`);
+ }
+
+ if (shouldClearMinidumps) {
+ removeFile(minidumpDirectory, dumpID + ".dmp");
+ removeFile(minidumpDirectory, dumpID + ".extra");
+ }
+ });
+ }
+
+ removalPromise.then(() => {
+ dump("\nCrash cleaned up\n");
+ // There might be other ipc:content-shutdown handlers that need to
+ // run before we want to continue, so we'll resolve on the next tick
+ // of the event loop.
+ TestUtils.executeSoon(() => resolve());
+ });
+ };
+
+ Services.obs.addObserver(observer, "ipc:content-shutdown");
+ });
+
+ expectedPromises.push(crashCleanupPromise);
+
+ if (shouldShowTabCrashPage) {
+ expectedPromises.push(
+ new Promise((resolve, reject) => {
+ browser.addEventListener(
+ "AboutTabCrashedReady",
+ function onCrash() {
+ browser.removeEventListener("AboutTabCrashedReady", onCrash);
+ dump("\nabout:tabcrashed loaded and ready\n");
+ resolve();
+ },
+ false,
+ true
+ );
+ })
+ );
+ }
+
+ // Trigger crash by sending a message to BrowserTestUtils actor.
+ this.sendAsyncMessage(
+ browsingContext || browser.browsingContext,
+ "BrowserTestUtils:CrashFrame",
+ {
+ crashType: options.crashType || "",
+ asyncCrash: options.asyncCrash || false,
+ }
+ );
+
+ await Promise.all(expectedPromises);
+
+ if (shouldShowTabCrashPage) {
+ let gBrowser = browser.ownerGlobal.gBrowser;
+ let tab = gBrowser.getTabForBrowser(browser);
+ if (tab.getAttribute("crashed") != "true") {
+ throw new Error("Tab should be marked as crashed");
+ }
+ }
+
+ return extra;
+ },
+
+ /**
+ * Attempts to simulate a launch fail by crashing a browser, but
+ * stripping the browser of its childID so that the TabCrashHandler
+ * thinks it was a launch fail.
+ *
+ * @param browser (<xul:browser>)
+ * The browser to simulate a content process launch failure on.
+ * @return Promise
+ * @resolves undefined
+ * Resolves when the TabCrashHandler should be done handling the
+ * simulated crash.
+ */
+ simulateProcessLaunchFail(browser, dueToBuildIDMismatch = false) {
+ const NORMAL_CRASH_TOPIC = "ipc:content-shutdown";
+
+ Object.defineProperty(browser.frameLoader, "childID", {
+ get: () => 0,
+ });
+
+ let sawNormalCrash = false;
+ let observer = (subject, topic, data) => {
+ sawNormalCrash = true;
+ };
+
+ Services.obs.addObserver(observer, NORMAL_CRASH_TOPIC);
+
+ Services.obs.notifyObservers(
+ browser.frameLoader,
+ "oop-frameloader-crashed"
+ );
+
+ let eventType = dueToBuildIDMismatch
+ ? "oop-browser-buildid-mismatch"
+ : "oop-browser-crashed";
+
+ let event = new browser.ownerGlobal.CustomEvent(eventType, {
+ bubbles: true,
+ });
+ event.isTopFrame = true;
+ browser.dispatchEvent(event);
+
+ Services.obs.removeObserver(observer, NORMAL_CRASH_TOPIC);
+
+ if (sawNormalCrash) {
+ throw new Error(`Unexpectedly saw ${NORMAL_CRASH_TOPIC}`);
+ }
+
+ return new Promise(resolve => TestUtils.executeSoon(resolve));
+ },
+
+ /**
+ * Returns a promise that is resolved when element gains attribute (or,
+ * optionally, when it is set to value).
+ * @param {String} attr
+ * The attribute to wait for
+ * @param {Element} element
+ * The element which should gain the attribute
+ * @param {String} value (optional)
+ * Optional, the value the attribute should have.
+ *
+ * @returns {Promise}
+ */
+ waitForAttribute(attr, element, value) {
+ let MutationObserver = element.ownerGlobal.MutationObserver;
+ return new Promise(resolve => {
+ let mut = new MutationObserver(mutations => {
+ if (
+ (!value && element.hasAttribute(attr)) ||
+ (value && element.getAttribute(attr) === value)
+ ) {
+ resolve();
+ mut.disconnect();
+ }
+ });
+
+ mut.observe(element, { attributeFilter: [attr] });
+ });
+ },
+
+ /**
+ * Returns a promise that is resolved when element loses an attribute.
+ * @param {String} attr
+ * The attribute to wait for
+ * @param {Element} element
+ * The element which should lose the attribute
+ *
+ * @returns {Promise}
+ */
+ waitForAttributeRemoval(attr, element) {
+ if (!element.hasAttribute(attr)) {
+ return Promise.resolve();
+ }
+
+ let MutationObserver = element.ownerGlobal.MutationObserver;
+ return new Promise(resolve => {
+ dump("Waiting for removal\n");
+ let mut = new MutationObserver(mutations => {
+ if (!element.hasAttribute(attr)) {
+ resolve();
+ mut.disconnect();
+ }
+ });
+
+ mut.observe(element, { attributeFilter: [attr] });
+ });
+ },
+
+ /**
+ * Version of EventUtils' `sendChar` function; it will synthesize a keypress
+ * event in a child process and returns a Promise that will resolve when the
+ * event was fired. Instead of a Window, a Browser or Browsing Context
+ * is required to be passed to this function.
+ *
+ * @param {String} char
+ * A character for the keypress event that is sent to the browser.
+ * @param {BrowserContext|MozFrameLoaderOwner} browsingContext
+ * Browsing context or browser element, must not be null
+ *
+ * @returns {Promise}
+ * @resolves True if the keypress event was synthesized.
+ */
+ sendChar(char, browsingContext) {
+ browsingContext = this.getBrowsingContextFrom(browsingContext);
+ return this.sendQuery(browsingContext, "Test:SendChar", { char });
+ },
+
+ /**
+ * Version of EventUtils' `synthesizeKey` function; it will synthesize a key
+ * event in a child process and returns a Promise that will resolve when the
+ * event was fired. Instead of a Window, a Browser or Browsing Context
+ * is required to be passed to this function.
+ *
+ * @param {String} key
+ * See the documentation available for EventUtils#synthesizeKey.
+ * @param {Object} event
+ * See the documentation available for EventUtils#synthesizeKey.
+ * @param {BrowserContext|MozFrameLoaderOwner} browsingContext
+ * Browsing context or browser element, must not be null
+ *
+ * @returns {Promise}
+ */
+ synthesizeKey(key, event, browsingContext) {
+ browsingContext = this.getBrowsingContextFrom(browsingContext);
+ return this.sendQuery(browsingContext, "Test:SynthesizeKey", {
+ key,
+ event,
+ });
+ },
+
+ /**
+ * Version of EventUtils' `synthesizeComposition` function; it will synthesize
+ * a composition event in a child process and returns a Promise that will
+ * resolve when the event was fired. Instead of a Window, a Browser or
+ * Browsing Context is required to be passed to this function.
+ *
+ * @param {Object} event
+ * See the documentation available for EventUtils#synthesizeComposition.
+ * @param {BrowserContext|MozFrameLoaderOwner} browsingContext
+ * Browsing context or browser element, must not be null
+ *
+ * @returns {Promise}
+ * @resolves False if the composition event could not be synthesized.
+ */
+ synthesizeComposition(event, browsingContext) {
+ browsingContext = this.getBrowsingContextFrom(browsingContext);
+ return this.sendQuery(browsingContext, "Test:SynthesizeComposition", {
+ event,
+ });
+ },
+
+ /**
+ * Version of EventUtils' `synthesizeCompositionChange` function; it will
+ * synthesize a compositionchange event in a child process and returns a
+ * Promise that will resolve when the event was fired. Instead of a Window, a
+ * Browser or Browsing Context object is required to be passed to this function.
+ *
+ * @param {Object} event
+ * See the documentation available for EventUtils#synthesizeCompositionChange.
+ * @param {BrowserContext|MozFrameLoaderOwner} browsingContext
+ * Browsing context or browser element, must not be null
+ *
+ * @returns {Promise}
+ */
+ synthesizeCompositionChange(event, browsingContext) {
+ browsingContext = this.getBrowsingContextFrom(browsingContext);
+ return this.sendQuery(browsingContext, "Test:SynthesizeCompositionChange", {
+ event,
+ });
+ },
+
+ // TODO: Fix consumers and remove me.
+ waitForCondition: TestUtils.waitForCondition,
+
+ /**
+ * Waits for a <xul:notification> with a particular value to appear
+ * for the <xul:notificationbox> of the passed in browser.
+ *
+ * @param {xul:tabbrowser} tabbrowser
+ * The gBrowser that hosts the browser that should show
+ * the notification. For most tests, this will probably be
+ * gBrowser.
+ * @param {xul:browser} browser
+ * The browser that should be showing the notification.
+ * @param {String} notificationValue
+ * The "value" of the notification, which is often used as
+ * a unique identifier. Example: "plugin-crashed".
+ *
+ * @return {Promise}
+ * Resolves to the <xul:notification> that is being shown.
+ */
+ waitForNotificationBar(tabbrowser, browser, notificationValue) {
+ let notificationBox = tabbrowser.getNotificationBox(browser);
+ return this.waitForNotificationInNotificationBox(
+ notificationBox,
+ notificationValue
+ );
+ },
+
+ /**
+ * Waits for a <xul:notification> with a particular value to appear
+ * in the global <xul:notificationbox> of the given browser window.
+ *
+ * @param {Window} win
+ * The browser window in whose global notificationbox the
+ * notification is expected to appear.
+ * @param {String} notificationValue
+ * The "value" of the notification, which is often used as
+ * a unique identifier. Example: "captive-portal-detected".
+ *
+ * @return {Promise}
+ * Resolves to the <xul:notification> that is being shown.
+ */
+ waitForGlobalNotificationBar(win, notificationValue) {
+ return this.waitForNotificationInNotificationBox(
+ win.gNotificationBox,
+ notificationValue
+ );
+ },
+
+ waitForNotificationInNotificationBox(notificationBox, notificationValue) {
+ return new Promise(resolve => {
+ let check = event => {
+ return event.target.getAttribute("value") == notificationValue;
+ };
+
+ BrowserTestUtils.waitForEvent(
+ notificationBox.stack,
+ "AlertActive",
+ false,
+ check
+ ).then(event => {
+ // The originalTarget of the AlertActive on a notificationbox
+ // will be the notification itself.
+ resolve(event.originalTarget);
+ });
+ });
+ },
+
+ /**
+ * Waits for CSS transitions to complete for an element. Tracks any
+ * transitions that start after this function is called and resolves once all
+ * started transitions complete.
+ *
+ * @param {Element} element
+ * The element that will transition.
+ * @param {Number} timeout
+ * The maximum time to wait in milliseconds. Defaults to 5 seconds.
+ * @return {Promise}
+ * Resolves when transitions complete or rejects if the timeout is hit.
+ */
+ waitForTransition(element, timeout = 5000) {
+ return new Promise((resolve, reject) => {
+ let cleanup = () => {
+ element.removeEventListener("transitionrun", listener);
+ element.removeEventListener("transitionend", listener);
+ };
+
+ let timer = element.ownerGlobal.setTimeout(() => {
+ cleanup();
+ reject();
+ }, timeout);
+
+ let transitionCount = 0;
+
+ let listener = event => {
+ if (event.type == "transitionrun") {
+ transitionCount++;
+ } else {
+ transitionCount--;
+ if (transitionCount == 0) {
+ cleanup();
+ element.ownerGlobal.clearTimeout(timer);
+ resolve();
+ }
+ }
+ };
+
+ element.addEventListener("transitionrun", listener);
+ element.addEventListener("transitionend", listener);
+ element.addEventListener("transitioncancel", listener);
+ });
+ },
+
+ _knownAboutPages: new Set(),
+ _loadedAboutContentScript: false,
+
+ /**
+ * Registers an about: page with particular flags in both the parent
+ * and any content processes. Returns a promise that resolves when
+ * registration is complete.
+ *
+ * @param {Function} registerCleanupFunction
+ * The test framework doesn't keep its cleanup stuff anywhere accessible,
+ * so the first argument is a reference to your cleanup registration
+ * function, allowing us to clean up after you if necessary.
+ * @param {String} aboutModule
+ * The name of the about page.
+ * @param {String} pageURI
+ * The URI the about: page should point to.
+ * @param {Number} flags
+ * The nsIAboutModule flags to use for registration.
+ *
+ * @returns {Promise}
+ * Promise that resolves when registration has finished.
+ */
+ registerAboutPage(registerCleanupFunction, aboutModule, pageURI, flags) {
+ // Return a promise that resolves when registration finished.
+ const kRegistrationMsgId =
+ "browser-test-utils:about-registration:registered";
+ let rv = this.waitForMessage(Services.ppmm, kRegistrationMsgId, msg => {
+ return msg.data == aboutModule;
+ });
+ // Load a script that registers our page, then send it a message to execute the registration.
+ if (!this._loadedAboutContentScript) {
+ Services.ppmm.loadProcessScript(
+ kAboutPageRegistrationContentScript,
+ true
+ );
+ this._loadedAboutContentScript = true;
+ registerCleanupFunction(this._removeAboutPageRegistrations.bind(this));
+ }
+ Services.ppmm.broadcastAsyncMessage(
+ "browser-test-utils:about-registration:register",
+ { aboutModule, pageURI, flags }
+ );
+ return rv.then(() => {
+ this._knownAboutPages.add(aboutModule);
+ });
+ },
+
+ unregisterAboutPage(aboutModule) {
+ if (!this._knownAboutPages.has(aboutModule)) {
+ return Promise.reject(
+ new Error("We don't think this about page exists!")
+ );
+ }
+ const kUnregistrationMsgId =
+ "browser-test-utils:about-registration:unregistered";
+ let rv = this.waitForMessage(Services.ppmm, kUnregistrationMsgId, msg => {
+ return msg.data == aboutModule;
+ });
+ Services.ppmm.broadcastAsyncMessage(
+ "browser-test-utils:about-registration:unregister",
+ aboutModule
+ );
+ return rv.then(() => this._knownAboutPages.delete(aboutModule));
+ },
+
+ async _removeAboutPageRegistrations() {
+ for (let aboutModule of this._knownAboutPages) {
+ await this.unregisterAboutPage(aboutModule);
+ }
+ Services.ppmm.removeDelayedProcessScript(
+ kAboutPageRegistrationContentScript
+ );
+ },
+
+ /**
+ * Waits for the dialog to open, and clicks the specified button.
+ *
+ * @param {string} buttonNameOrElementID
+ * The name of the button ("accept", "cancel", etc) or element ID to
+ * click.
+ * @param {string} uri
+ * The URI of the dialog to wait for. Defaults to the common dialog.
+ * @return {Promise}
+ * A Promise which resolves when a "domwindowopened" notification
+ * for a dialog has been fired by the window watcher and the
+ * specified button is clicked.
+ */
+ async promiseAlertDialogOpen(
+ buttonNameOrElementID,
+ uri = "chrome://global/content/commonDialog.xhtml",
+ options = { callback: null, isSubDialog: false }
+ ) {
+ let win;
+ if (uri == "chrome://global/content/commonDialog.xhtml") {
+ [win] = await TestUtils.topicObserved("common-dialog-loaded");
+ } else if (options.isSubDialog) {
+ [win] = await TestUtils.topicObserved("subdialog-loaded");
+ } else {
+ // The test listens for the "load" event which guarantees that the alert
+ // class has already been added (it is added when "DOMContentLoaded" is
+ // fired).
+ win = await this.domWindowOpenedAndLoaded(null, win => {
+ return win.document.documentURI === uri;
+ });
+ }
+
+ if (options.callback) {
+ await options.callback(win);
+ return win;
+ }
+
+ if (buttonNameOrElementID) {
+ let dialog = win.document.querySelector("dialog");
+ let element =
+ dialog.getButton(buttonNameOrElementID) ||
+ win.document.getElementById(buttonNameOrElementID);
+ element.click();
+ }
+
+ return win;
+ },
+
+ /**
+ * Wait for the containing dialog with the id `window-modal-dialog` to become
+ * empty and close.
+ *
+ * @param {HTMLDialogElement} dialog
+ * The dialog to wait on.
+ * @return {Promise}
+ * Resolves once the the dialog has closed
+ */
+ async waitForDialogClose(dialog) {
+ return this.waitForEvent(dialog, "close").then(() => {
+ return this.waitForMutationCondition(
+ dialog,
+ { childList: true, attributes: true },
+ () => !dialog.hasChildNodes() && !dialog.open
+ );
+ });
+ },
+
+ /**
+ * Waits for the dialog to open, and clicks the specified button, and waits
+ * for the dialog to close.
+ *
+ * @param {string} buttonNameOrElementID
+ * The name of the button ("accept", "cancel", etc) or element ID to
+ * click.
+ * @param {string} uri
+ * The URI of the dialog to wait for. Defaults to the common dialog.
+ *
+ * @return {Promise}
+ * A Promise which resolves when a "domwindowopened" notification
+ * for a dialog has been fired by the window watcher and the
+ * specified button is clicked, and the dialog has been fully closed.
+ */
+ async promiseAlertDialog(
+ buttonNameOrElementID,
+ uri = "chrome://global/content/commonDialog.xhtml",
+ options = { callback: null, isSubDialog: false }
+ ) {
+ let win = await this.promiseAlertDialogOpen(
+ buttonNameOrElementID,
+ uri,
+ options
+ );
+ if (!win.docShell.browsingContext.embedderElement) {
+ return this.windowClosed(win);
+ }
+ const dialog = win.top.document.getElementById("window-modal-dialog");
+ return this.waitForDialogClose(dialog);
+ },
+
+ /**
+ * Opens a tab with a given uri and params object. If the params object is not set
+ * or the params parameter does not include a triggeringPrincipal then this function
+ * provides a params object using the systemPrincipal as the default triggeringPrincipal.
+ *
+ * @param {xul:tabbrowser} tabbrowser
+ * The gBrowser object to open the tab with.
+ * @param {string} uri
+ * The URI to open in the new tab.
+ * @param {object} params [optional]
+ * Parameters object for gBrowser.addTab.
+ * @param {function} beforeLoadFunc [optional]
+ * A function to run after that xul:browser has been created but before the URL is
+ * loaded. Can spawn a content task in the tab, for example.
+ */
+ addTab(tabbrowser, uri, params = {}, beforeLoadFunc = null) {
+ if (!params.triggeringPrincipal) {
+ params.triggeringPrincipal =
+ Services.scriptSecurityManager.getSystemPrincipal();
+ }
+ if (!params.allowInheritPrincipal) {
+ params.allowInheritPrincipal = true;
+ }
+ if (beforeLoadFunc) {
+ let window = tabbrowser.ownerGlobal;
+ window.addEventListener(
+ "TabOpen",
+ function (e) {
+ beforeLoadFunc(e.target);
+ },
+ { once: true }
+ );
+ }
+ return tabbrowser.addTab(uri, params);
+ },
+
+ /**
+ * There are two ways to listen for observers in a content process:
+ * 1. Call contentTopicObserved which will watch for an observer notification
+ * in a content process to occur, and will return a promise which resolves
+ * when that notification occurs.
+ * 2. Enclose calls to contentTopicObserved inside a pair of calls to
+ * startObservingTopics and stopObservingTopics. Usually this pair will be
+ * placed at the start and end of a test or set of tests. Any observer
+ * notification that happens between the start and stop that doesn't match
+ * any explicitly expected by using contentTopicObserved will cause
+ * stopObservingTopics to reject with an error.
+ * For example:
+ *
+ * await BrowserTestUtils.startObservingTopics(bc, ["a", "b", "c"]);
+ * await BrowserTestUtils contentTopicObserved(bc, "a", 2);
+ * await BrowserTestUtils.stopObservingTopics(bc, ["a", "b", "c"]);
+ *
+ * This will expect two "a" notifications to occur, but will fail if more
+ * than two occur, or if any "b" or "c" notifications occur.
+ *
+ * Note that this function doesn't handle adding a listener for the same topic
+ * more than once. To do that, use the aCount argument.
+ *
+ * @param aBrowsingContext
+ * The browsing context associated with the content process to listen to.
+ * @param {string} aTopic
+ * Observer topic to listen to. May be null to listen to any topic.
+ * @param {number} aCount
+ * Number of such matching topics to listen to, defaults to 1. A match
+ * occurs when the topic and filter function match.
+ * @param {function} aFilterFn
+ * Function to be evaluated in the content process which should
+ * return true if the notification matches. This function is passed
+ * the same arguments as nsIObserver.observe(). May be null to
+ * always match.
+ * @returns {Promise} resolves when the notification occurs.
+ */
+ contentTopicObserved(aBrowsingContext, aTopic, aCount = 1, aFilterFn = null) {
+ return this.sendQuery(aBrowsingContext, "BrowserTestUtils:ObserveTopic", {
+ topic: aTopic,
+ count: aCount,
+ filterFunctionSource: aFilterFn ? aFilterFn.toSource() : null,
+ });
+ },
+
+ /**
+ * Starts observing a list of topics in a content process. Use contentTopicObserved
+ * to allow an observer notification. Any other observer notification that occurs that
+ * matches one of the specified topics will cause the promise to reject.
+ *
+ * Calling this function more than once adds additional topics to be observed without
+ * replacing the existing ones.
+ *
+ * @param {BrowsingContext} aBrowsingContext
+ * The browsing context associated with the content process to listen to.
+ * @param {String[]} aTopics array of observer topics
+ * @returns {Promise} resolves when the listeners have been added.
+ */
+ startObservingTopics(aBrowsingContext, aTopics) {
+ return this.sendQuery(
+ aBrowsingContext,
+ "BrowserTestUtils:StartObservingTopics",
+ {
+ topics: aTopics,
+ }
+ );
+ },
+
+ /**
+ * Stop listening to a set of observer topics.
+ *
+ * @param {BrowsingContext} aBrowsingContext
+ * The browsing context associated with the content process to listen to.
+ * @param {String[]} aTopics array of observer topics. If empty, then all
+ * current topics being listened to are removed.
+ * @returns {Promise} promise that fails if an unexpected observer occurs.
+ */
+ stopObservingTopics(aBrowsingContext, aTopics) {
+ return this.sendQuery(
+ aBrowsingContext,
+ "BrowserTestUtils:StopObservingTopics",
+ {
+ topics: aTopics,
+ }
+ );
+ },
+
+ /**
+ * Sends a message to a specific BrowserTestUtils window actor.
+ * @param {BrowsingContext} aBrowsingContext
+ * The browsing context where the actor lives.
+ * @param {string} aMessageName
+ * Name of the message to be sent to the actor.
+ * @param {object} aMessageData
+ * Extra information to pass to the actor.
+ */
+ async sendAsyncMessage(aBrowsingContext, aMessageName, aMessageData) {
+ if (!aBrowsingContext.currentWindowGlobal) {
+ await this.waitForCondition(() => aBrowsingContext.currentWindowGlobal);
+ }
+
+ let actor =
+ aBrowsingContext.currentWindowGlobal.getActor("BrowserTestUtils");
+ actor.sendAsyncMessage(aMessageName, aMessageData);
+ },
+
+ /**
+ * Sends a query to a specific BrowserTestUtils window actor.
+ * @param {BrowsingContext} aBrowsingContext
+ * The browsing context where the actor lives.
+ * @param {string} aMessageName
+ * Name of the message to be sent to the actor.
+ * @param {object} aMessageData
+ * Extra information to pass to the actor.
+ */
+ async sendQuery(aBrowsingContext, aMessageName, aMessageData) {
+ let startTime = Cu.now();
+ if (!aBrowsingContext.currentWindowGlobal) {
+ await this.waitForCondition(() => aBrowsingContext.currentWindowGlobal);
+ }
+
+ let actor =
+ aBrowsingContext.currentWindowGlobal.getActor("BrowserTestUtils");
+ return actor.sendQuery(aMessageName, aMessageData).then(val => {
+ ChromeUtils.addProfilerMarker(
+ "BrowserTestUtils",
+ { startTime, category: "Test" },
+ aMessageName
+ );
+ return val;
+ });
+ },
+
+ /**
+ * A helper function for this test that returns a Promise that resolves
+ * once either the legacy or new migration wizard appears.
+ *
+ * @param {DOMWindow} window
+ * The top-level window that the about:preferences tab is likely to open
+ * in if the new migration wizard is enabled.
+ * @param {boolean} forceLegacy
+ * True if, despite the browser.migrate.content-modal.enabled pref value,
+ * the legacy XUL migration wizard is expected.
+ * @returns {Promise<Element>}
+ * Resolves to the dialog window in the legacy case, and the
+ * about:preferences tab otherwise.
+ */
+ async waitForMigrationWizard(window, forceLegacy = false) {
+ if (!this._usingNewMigrationWizard || forceLegacy) {
+ return this.waitForCondition(() => {
+ let win = Services.wm.getMostRecentWindow("Browser:MigrationWizard");
+ if (win?.document?.readyState == "complete") {
+ return win;
+ }
+ return false;
+ }, "Wait for migration wizard to open");
+ }
+
+ let wizardReady = this.waitForEvent(window, "MigrationWizard:Ready");
+ let wizardTab = await this.waitForNewTab(window.gBrowser, url => {
+ return url.startsWith("about:preferences");
+ });
+ await wizardReady;
+
+ return wizardTab;
+ },
+
+ /**
+ * Closes the migration wizard.
+ *
+ * @param {Element} wizardWindowOrTab
+ * The XUL dialog window for the migration wizard in the legacy case, and
+ * the about:preferences tab otherwise. In general, it's probably best to
+ * just pass whatever BrowserTestUtils.waitForMigrationWizard resolved to
+ * into this in order to handle both the old and new migration wizard.
+ * @param {boolean} forceLegacy
+ * True if, despite the browser.migrate.content-modal.enabled pref value,
+ * the legacy XUL migration wizard is expected.
+ * @returns {Promise<undefined>}
+ */
+ closeMigrationWizard(wizardWindowOrTab, forceLegacy = false) {
+ if (!this._usingNewMigrationWizard || forceLegacy) {
+ return BrowserTestUtils.closeWindow(wizardWindowOrTab);
+ }
+
+ return BrowserTestUtils.removeTab(wizardWindowOrTab);
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ BrowserTestUtils,
+ "_httpsFirstEnabled",
+ "dom.security.https_first",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ BrowserTestUtils,
+ "_usingNewMigrationWizard",
+ "browser.migrate.content-modal.enabled",
+ false
+);
+
+Services.obs.addObserver(BrowserTestUtils, "test-complete");
diff --git a/testing/mochitest/BrowserTestUtils/BrowserTestUtilsChild.sys.mjs b/testing/mochitest/BrowserTestUtils/BrowserTestUtilsChild.sys.mjs
new file mode 100644
index 0000000000..b7769ea8b3
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/BrowserTestUtilsChild.sys.mjs
@@ -0,0 +1,391 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+});
+
+class BrowserTestUtilsChildObserver {
+ constructor() {
+ this.currentObserverStatus = "";
+ this.observerItems = [];
+ }
+
+ startObservingTopics(aTopics) {
+ for (let topic of aTopics) {
+ Services.obs.addObserver(this, topic);
+ this.observerItems.push({ topic });
+ }
+ }
+
+ stopObservingTopics(aTopics) {
+ if (aTopics) {
+ for (let topic of aTopics) {
+ let index = this.observerItems.findIndex(item => item.topic == topic);
+ if (index >= 0) {
+ Services.obs.removeObserver(this, topic);
+ this.observerItems.splice(index, 1);
+ }
+ }
+ } else {
+ for (let topic of this.observerItems) {
+ Services.obs.removeObserver(this, topic);
+ }
+ this.observerItems = [];
+ }
+
+ if (this.currentObserverStatus) {
+ let error = new Error(this.currentObserverStatus);
+ this.currentObserverStatus = "";
+ throw error;
+ }
+ }
+
+ observeTopic(topic, count, filterFn, callbackResolver) {
+ // If the topic is in the list already, assume that it came from a
+ // startObservingTopics call. If it isn't in the list already, assume
+ // that it isn't within a start/stop set and the observer has to be
+ // removed afterwards.
+ let removeObserver = false;
+ let index = this.observerItems.findIndex(item => item.topic == topic);
+ if (index == -1) {
+ removeObserver = true;
+ this.startObservingTopics([topic]);
+ }
+
+ for (let item of this.observerItems) {
+ if (item.topic == topic) {
+ item.count = count || 1;
+ item.filterFn = filterFn;
+ item.promiseResolver = () => {
+ if (removeObserver) {
+ this.stopObservingTopics([topic]);
+ }
+ callbackResolver();
+ };
+ break;
+ }
+ }
+ }
+
+ observe(aSubject, aTopic, aData) {
+ for (let item of this.observerItems) {
+ if (item.topic != aTopic) {
+ continue;
+ }
+ if (item.filterFn && !item.filterFn(aSubject, aTopic, aData)) {
+ break;
+ }
+
+ if (--item.count >= 0) {
+ if (item.count == 0 && item.promiseResolver) {
+ item.promiseResolver();
+ }
+ return;
+ }
+ }
+
+ // Otherwise, if the observer doesn't match, fail.
+ console.log(
+ "Failed: Observer topic " + aTopic + " not expected in content process"
+ );
+ this.currentObserverStatus +=
+ "Topic " + aTopic + " not expected in content process\n";
+ }
+}
+
+BrowserTestUtilsChildObserver.prototype.QueryInterface = ChromeUtils.generateQI(
+ ["nsIObserver", "nsISupportsWeakReference"]
+);
+
+export class BrowserTestUtilsChild extends JSWindowActorChild {
+ actorCreated() {
+ this._EventUtils = null;
+ }
+
+ get EventUtils() {
+ if (!this._EventUtils) {
+ // Set up a dummy environment so that EventUtils works. We need to be careful to
+ // pass a window object into each EventUtils method we call rather than having
+ // it rely on the |window| global.
+ let win = this.contentWindow;
+ let EventUtils = {
+ get KeyboardEvent() {
+ return win.KeyboardEvent;
+ },
+ // EventUtils' `sendChar` function relies on the navigator to synthetize events.
+ get navigator() {
+ return win.navigator;
+ },
+ };
+
+ EventUtils.window = {};
+ EventUtils.parent = EventUtils.window;
+ EventUtils._EU_Ci = Ci;
+ EventUtils._EU_Cc = Cc;
+
+ Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ EventUtils
+ );
+
+ this._EventUtils = EventUtils;
+ }
+
+ return this._EventUtils;
+ }
+
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "Test:SynthesizeMouse": {
+ return this.synthesizeMouse(aMessage.data, this.contentWindow);
+ }
+
+ case "Test:SynthesizeTouch": {
+ return this.synthesizeTouch(aMessage.data, this.contentWindow);
+ }
+
+ case "Test:SendChar": {
+ return this.EventUtils.sendChar(aMessage.data.char, this.contentWindow);
+ }
+
+ case "Test:SynthesizeKey":
+ this.EventUtils.synthesizeKey(
+ aMessage.data.key,
+ aMessage.data.event || {},
+ this.contentWindow
+ );
+ break;
+
+ case "Test:SynthesizeComposition": {
+ return this.EventUtils.synthesizeComposition(
+ aMessage.data.event,
+ this.contentWindow
+ );
+ }
+
+ case "Test:SynthesizeCompositionChange":
+ this.EventUtils.synthesizeCompositionChange(
+ aMessage.data.event,
+ this.contentWindow
+ );
+ break;
+
+ case "BrowserTestUtils:StartObservingTopics": {
+ this.observer = new BrowserTestUtilsChildObserver();
+ this.observer.startObservingTopics(aMessage.data.topics);
+ break;
+ }
+
+ case "BrowserTestUtils:StopObservingTopics": {
+ if (this.observer) {
+ this.observer.stopObservingTopics(aMessage.data.topics);
+ this.observer = null;
+ }
+ break;
+ }
+
+ case "BrowserTestUtils:ObserveTopic": {
+ return new Promise(resolve => {
+ let filterFn;
+ if (aMessage.data.filterFunctionSource) {
+ /* eslint-disable-next-line no-eval */
+ filterFn = eval(
+ `(() => (${aMessage.data.filterFunctionSource}))()`
+ );
+ }
+
+ let observer = this.observer || new BrowserTestUtilsChildObserver();
+ observer.observeTopic(
+ aMessage.data.topic,
+ aMessage.data.count,
+ filterFn,
+ resolve
+ );
+ });
+ }
+
+ case "BrowserTestUtils:CrashFrame": {
+ // This is to intentionally crash the frame.
+ // We crash by using js-ctypes. The crash
+ // should happen immediately
+ // upon loading this frame script.
+
+ const { ctypes } = ChromeUtils.importESModule(
+ "resource://gre/modules/ctypes.sys.mjs"
+ );
+
+ let dies = function () {
+ dump("\nEt tu, Brute?\n");
+ ChromeUtils.privateNoteIntentionalCrash();
+
+ switch (aMessage.data.crashType) {
+ case "CRASH_OOM": {
+ let debug = Cc["@mozilla.org/xpcom/debug;1"].getService(
+ Ci.nsIDebug2
+ );
+ debug.crashWithOOM();
+ break;
+ }
+ case "CRASH_SYSCALL": {
+ if (Services.appinfo.OS == "Linux") {
+ let libc = ctypes.open("libc.so.6");
+ let chroot = libc.declare(
+ "chroot",
+ ctypes.default_abi,
+ ctypes.int,
+ ctypes.char.ptr
+ );
+ chroot("/");
+ }
+ break;
+ }
+ case "CRASH_INVALID_POINTER_DEREF": // Fallthrough
+ default: {
+ // Dereference a bad pointer.
+ let zero = new ctypes.intptr_t(8);
+ let badptr = ctypes.cast(
+ zero,
+ ctypes.PointerType(ctypes.int32_t)
+ );
+ badptr.contents;
+ }
+ }
+ };
+
+ if (aMessage.data.asyncCrash) {
+ let { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+ );
+ // Get out of the stack.
+ setTimeout(dies, 0);
+ } else {
+ dies();
+ }
+ }
+ }
+
+ return undefined;
+ }
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "DOMContentLoaded":
+ case "load": {
+ this.sendAsyncMessage(aEvent.type, {
+ internalURL: aEvent.target.documentURI,
+ visibleURL: aEvent.target.location
+ ? aEvent.target.location.href
+ : null,
+ });
+ break;
+ }
+ }
+ }
+
+ synthesizeMouse(data, window) {
+ let target = data.target;
+ if (typeof target == "string") {
+ target = this.document.querySelector(target);
+ } else if (typeof data.targetFn == "string") {
+ let runnablestr = `
+ (() => {
+ return (${data.targetFn});
+ })();`;
+ /* eslint-disable no-eval */
+ target = eval(runnablestr)();
+ /* eslint-enable no-eval */
+ }
+
+ let left = data.x;
+ let top = data.y;
+ if (target) {
+ if (target.ownerDocument !== this.document) {
+ // Account for nodes found in iframes.
+ let cur = target;
+ do {
+ // eslint-disable-next-line mozilla/use-ownerGlobal
+ let frame = cur.ownerDocument.defaultView.frameElement;
+ let rect = frame.getBoundingClientRect();
+
+ left += rect.left;
+ top += rect.top;
+
+ cur = frame;
+ } while (cur && cur.ownerDocument !== this.document);
+
+ // node must be in this document tree.
+ if (!cur) {
+ throw new Error("target must be in the main document tree");
+ }
+ }
+
+ let rect = target.getBoundingClientRect();
+ left += rect.left;
+ top += rect.top;
+
+ if (data.event.centered) {
+ left += rect.width / 2;
+ top += rect.height / 2;
+ }
+ }
+
+ let result;
+
+ lazy.E10SUtils.wrapHandlingUserInput(window, data.handlingUserInput, () => {
+ if (data.event && data.event.wheel) {
+ this.EventUtils.synthesizeWheelAtPoint(left, top, data.event, window);
+ } else {
+ result = this.EventUtils.synthesizeMouseAtPoint(
+ left,
+ top,
+ data.event,
+ window
+ );
+ }
+ });
+
+ return result;
+ }
+
+ synthesizeTouch(data, window) {
+ let target = data.target;
+ if (typeof target == "string") {
+ target = this.document.querySelector(target);
+ } else if (typeof data.targetFn == "string") {
+ let runnablestr = `
+ (() => {
+ return (${data.targetFn});
+ })();`;
+ /* eslint-disable no-eval */
+ target = eval(runnablestr)();
+ /* eslint-enable no-eval */
+ }
+
+ if (target) {
+ if (target.ownerDocument !== this.document) {
+ // Account for nodes found in iframes.
+ let cur = target;
+ do {
+ cur = cur.ownerGlobal.frameElement;
+ } while (cur && cur.ownerDocument !== this.document);
+
+ // node must be in this document tree.
+ if (!cur) {
+ throw new Error("target must be in the main document tree");
+ }
+ }
+ }
+
+ return this.EventUtils.synthesizeTouch(
+ target,
+ data.x,
+ data.y,
+ data.event,
+ window
+ );
+ }
+}
diff --git a/testing/mochitest/BrowserTestUtils/BrowserTestUtilsParent.sys.mjs b/testing/mochitest/BrowserTestUtils/BrowserTestUtilsParent.sys.mjs
new file mode 100644
index 0000000000..ae8321233a
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/BrowserTestUtilsParent.sys.mjs
@@ -0,0 +1,39 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class BrowserTestUtilsParent extends JSWindowActorParent {
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "DOMContentLoaded":
+ case "load": {
+ // Don't dispatch events that came from stale actors.
+ let bc = this.browsingContext;
+ if (
+ (bc.embedderElement && bc.embedderElement.browsingContext != bc) ||
+ !(this.manager && this.manager.isCurrentGlobal)
+ ) {
+ return;
+ }
+
+ let event = new CustomEvent(
+ `BrowserTestUtils:ContentEvent:${aMessage.name}`,
+ {
+ detail: {
+ browsingContext: this.browsingContext,
+ ...aMessage.data,
+ },
+ }
+ );
+
+ let browser = this.browsingContext.top.embedderElement;
+ if (browser) {
+ browser.dispatchEvent(event);
+ }
+
+ break;
+ }
+ }
+ }
+}
diff --git a/testing/mochitest/BrowserTestUtils/ContentEventListenerChild.sys.mjs b/testing/mochitest/BrowserTestUtils/ContentEventListenerChild.sys.mjs
new file mode 100644
index 0000000000..937a9d35cc
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/ContentEventListenerChild.sys.mjs
@@ -0,0 +1,178 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class ContentEventListenerChild extends JSWindowActorChild {
+ actorCreated() {
+ this._contentEvents = new Map();
+ this._shutdown = false;
+ this._chromeEventHandler = null;
+ Services.cpmm.sharedData.addEventListener("change", this);
+ }
+
+ didDestroy() {
+ this._shutdown = true;
+ Services.cpmm.sharedData.removeEventListener("change", this);
+ this._updateContentEventListeners(/* clearListeners = */ true);
+ if (this._contentEvents.size != 0) {
+ throw new Error(`Didn't expect content events after willDestroy`);
+ }
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "DOMWindowCreated": {
+ this._updateContentEventListeners();
+ break;
+ }
+
+ case "change": {
+ if (
+ !event.changedKeys.includes("BrowserTestUtils:ContentEventListener")
+ ) {
+ return;
+ }
+ this._updateContentEventListeners();
+ break;
+ }
+ }
+ }
+
+ /**
+ * This method first determines the desired set of content event listeners
+ * for the window. This is either the empty set, if clearListeners is true,
+ * or is retrieved from the message manager's shared data. It then compares
+ * this event listener data to the existing set of listeners that we have
+ * registered, as recorded in this._contentEvents. Each content event listener
+ * has been assigned a unique id by the parent process. If a listener was
+ * added, but is not in the new event data, it is removed. If a listener was
+ * not present, but is in the new event data, it is added. If it is in both,
+ * then a basic check is done to see if they are the same.
+ *
+ * @param {bool} clearListeners [optional]
+ * If this is true, then instead of checking shared data to decide
+ * what the desired set of listeners is, just use the empty set. This
+ * will result in any existing listeners being cleared, and is used
+ * when the window is going away.
+ */
+ _updateContentEventListeners(clearListeners = false) {
+ // If we've already begun the destruction process, any new event
+ // listeners for our bc id can't possibly really be for us, so ignore them.
+ if (this._shutdown && !clearListeners) {
+ throw new Error(
+ "Tried to update after we shut down content event listening"
+ );
+ }
+
+ let newEventData;
+ if (!clearListeners) {
+ newEventData = Services.cpmm.sharedData.get(
+ "BrowserTestUtils:ContentEventListener"
+ );
+ }
+ if (!newEventData) {
+ newEventData = new Map();
+ }
+
+ // Check that entries that continue to exist are the same and remove entries
+ // that no longer exist.
+ for (let [
+ listenerId,
+ { eventName, listener, listenerOptions },
+ ] of this._contentEvents.entries()) {
+ let newData = newEventData.get(listenerId);
+ if (newData) {
+ if (newData.eventName !== eventName) {
+ // Could potentially check if listenerOptions are the same, but
+ // checkFnSource can't be checked unless we store it, and this is
+ // just a smoke test anyways, so don't bother.
+ throw new Error(
+ "Got new content event listener that disagreed with existing data"
+ );
+ }
+ continue;
+ }
+ if (!this._chromeEventHandler) {
+ throw new Error(
+ "Trying to remove an event listener for waitForContentEvent without a cached event handler"
+ );
+ }
+ this._chromeEventHandler.removeEventListener(
+ eventName,
+ listener,
+ listenerOptions
+ );
+ this._contentEvents.delete(listenerId);
+ }
+
+ let actorChild = this;
+
+ // Add in new entries.
+ for (let [
+ listenerId,
+ { eventName, listenerOptions, checkFnSource },
+ ] of newEventData.entries()) {
+ let oldData = this._contentEvents.get(listenerId);
+ if (oldData) {
+ // We checked that the data is the same in the previous loop.
+ continue;
+ }
+
+ /* eslint-disable no-eval */
+ let checkFn;
+ if (checkFnSource) {
+ checkFn = eval(`(() => (${unescape(checkFnSource)}))()`);
+ }
+ /* eslint-enable no-eval */
+
+ function listener(event) {
+ if (checkFn && !checkFn(event)) {
+ return;
+ }
+ actorChild.sendAsyncMessage("ContentEventListener:Run", {
+ listenerId,
+ });
+ }
+
+ // Cache the chrome event handler because this.docShell won't be
+ // available during shut down.
+ if (!this._chromeEventHandler) {
+ try {
+ this._chromeEventHandler = this.docShell.chromeEventHandler;
+ } catch (error) {
+ if (error.name === "InvalidStateError") {
+ // We'll arrive here if we no longer have our manager, so we can
+ // just swallow this error.
+ continue;
+ }
+ throw error;
+ }
+ }
+
+ // Some windows, like top-level browser windows, maybe not have a chrome
+ // event handler set up as this point, but we don't actually care about
+ // events on those windows, so ignore them.
+ if (!this._chromeEventHandler) {
+ continue;
+ }
+
+ this._chromeEventHandler.addEventListener(
+ eventName,
+ listener,
+ listenerOptions
+ );
+ this._contentEvents.set(listenerId, {
+ eventName,
+ listener,
+ listenerOptions,
+ });
+ }
+
+ // If there are no active content events, clear our reference to the chrome
+ // event handler to prevent leaks.
+ if (this._contentEvents.size == 0) {
+ this._chromeEventHandler = null;
+ }
+ }
+}
diff --git a/testing/mochitest/BrowserTestUtils/ContentEventListenerParent.sys.mjs b/testing/mochitest/BrowserTestUtils/ContentEventListenerParent.sys.mjs
new file mode 100644
index 0000000000..1568287070
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/ContentEventListenerParent.sys.mjs
@@ -0,0 +1,20 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { BrowserTestUtils } from "resource://testing-common/BrowserTestUtils.sys.mjs";
+
+export class ContentEventListenerParent extends JSWindowActorParent {
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "ContentEventListener:Run": {
+ BrowserTestUtils._receivedContentEventListener(
+ aMessage.data.listenerId,
+ this.browsingContext.browserId
+ );
+ break;
+ }
+ }
+ }
+}
diff --git a/testing/mochitest/BrowserTestUtils/ContentTask.sys.mjs b/testing/mochitest/BrowserTestUtils/ContentTask.sys.mjs
new file mode 100644
index 0000000000..6c3df200c7
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/ContentTask.sys.mjs
@@ -0,0 +1,141 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const FRAME_SCRIPT = "resource://testing-common/content-task.js";
+
+/**
+ * Keeps track of whether the frame script was already loaded.
+ */
+var gFrameScriptLoaded = false;
+
+/**
+ * Mapping from message id to associated promise.
+ */
+var gPromises = new Map();
+
+/**
+ * Incrementing integer to generate unique message id.
+ */
+var gMessageID = 1;
+
+/**
+ * This object provides the public module functions.
+ */
+export var ContentTask = {
+ /**
+ * _testScope saves the current testScope from
+ * browser-test.js. This is used to implement SimpleTest functions
+ * like ok() and is() in the content process. The scope is only
+ * valid for tasks spawned in the current test, so we keep track of
+ * the ID of the first task spawned in this test (_scopeValidId).
+ */
+ _testScope: null,
+ _scopeValidId: 0,
+
+ /**
+ * Creates and starts a new task in a browser's content.
+ *
+ * @param browser A xul:browser
+ * @param arg A single serializable argument that will be passed to the
+ * task when executed on the content process.
+ * @param task
+ * - A generator or function which will be serialized and sent to
+ * the remote browser to be executed. Unlike Task.spawn, this
+ * argument may not be an iterator as it will be serialized and
+ * sent to the remote browser.
+ * @return A promise object where you can register completion callbacks to be
+ * called when the task terminates.
+ * @resolves With the final returned value of the task if it executes
+ * successfully.
+ * @rejects An error message if execution fails.
+ */
+ spawn: function ContentTask_spawn(browser, arg, task) {
+ // Load the frame script if needed.
+ if (!gFrameScriptLoaded) {
+ Services.mm.loadFrameScript(FRAME_SCRIPT, true);
+ gFrameScriptLoaded = true;
+ }
+
+ let deferred = {};
+ deferred.promise = new Promise((resolve, reject) => {
+ deferred.resolve = resolve;
+ deferred.reject = reject;
+ });
+
+ let id = gMessageID++;
+ gPromises.set(id, deferred);
+
+ browser.messageManager.sendAsyncMessage("content-task:spawn", {
+ id,
+ runnable: task.toString(),
+ arg,
+ });
+
+ return deferred.promise;
+ },
+
+ setTestScope(scope) {
+ this._testScope = scope;
+ this._scopeValidId = gMessageID;
+ },
+};
+
+var ContentMessageListener = {
+ receiveMessage(aMessage) {
+ let id = aMessage.data.id;
+
+ if (id < ContentTask._scopeValidId) {
+ throw new Error("test result returned after test finished");
+ }
+
+ if (aMessage.name == "content-task:complete") {
+ let deferred = gPromises.get(id);
+ gPromises.delete(id);
+
+ if (aMessage.data.error) {
+ deferred.reject(aMessage.data.error);
+ } else {
+ deferred.resolve(aMessage.data.result);
+ }
+ } else if (aMessage.name == "content-task:test-result") {
+ let data = aMessage.data;
+ ContentTask._testScope.record(
+ data.condition,
+ data.name,
+ null,
+ data.stack
+ );
+ } else if (aMessage.name == "content-task:test-info") {
+ ContentTask._testScope.info(aMessage.data.name);
+ } else if (aMessage.name == "content-task:test-todo") {
+ ContentTask._testScope.todo(aMessage.data.expr, aMessage.data.name);
+ } else if (aMessage.name == "content-task:test-todo_is") {
+ ContentTask._testScope.todo_is(
+ aMessage.data.a,
+ aMessage.data.b,
+ aMessage.data.name
+ );
+ }
+ },
+};
+
+Services.mm.addMessageListener("content-task:complete", ContentMessageListener);
+Services.mm.addMessageListener(
+ "content-task:test-result",
+ ContentMessageListener
+);
+Services.mm.addMessageListener(
+ "content-task:test-info",
+ ContentMessageListener
+);
+Services.mm.addMessageListener(
+ "content-task:test-todo",
+ ContentMessageListener
+);
+Services.mm.addMessageListener(
+ "content-task:test-todo_is",
+ ContentMessageListener
+);
diff --git a/testing/mochitest/BrowserTestUtils/ContentTaskUtils.sys.mjs b/testing/mochitest/BrowserTestUtils/ContentTaskUtils.sys.mjs
new file mode 100644
index 0000000000..f1ff280db1
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/ContentTaskUtils.sys.mjs
@@ -0,0 +1,249 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This module implements a number of utility functions that can be loaded
+ * into content scope.
+ *
+ * All asynchronous helper methods should return promises, rather than being
+ * callback based.
+ */
+
+// Disable ownerGlobal use since that's not available on content-privileged elements.
+
+/* eslint-disable mozilla/use-ownerGlobal */
+
+import { setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+export var ContentTaskUtils = {
+ /**
+ * Checks if a DOM element is hidden.
+ *
+ * @param {Element} element
+ * The element which is to be checked.
+ *
+ * @return {boolean}
+ */
+ is_hidden(element) {
+ let style = element.ownerDocument.defaultView.getComputedStyle(element);
+ if (style.display == "none") {
+ return true;
+ }
+ if (style.visibility != "visible") {
+ return true;
+ }
+
+ // Hiding a parent element will hide all its children
+ if (
+ element.parentNode != element.ownerDocument &&
+ element.parentNode.nodeType != Node.DOCUMENT_FRAGMENT_NODE
+ ) {
+ return ContentTaskUtils.is_hidden(element.parentNode);
+ }
+
+ // Walk up the shadow DOM if we've reached the top of the shadow root
+ if (element.parentNode.host) {
+ return ContentTaskUtils.is_hidden(element.parentNode.host);
+ }
+
+ return false;
+ },
+
+ /**
+ * Checks if a DOM element is visible.
+ *
+ * @param {Element} element
+ * The element which is to be checked.
+ *
+ * @return {boolean}
+ */
+ is_visible(element) {
+ return !this.is_hidden(element);
+ },
+
+ /**
+ * Will poll a condition function until it returns true.
+ *
+ * @param condition
+ * A condition function that must return true or false. If the
+ * condition ever throws, this is also treated as a false.
+ * @param msg
+ * The message to use when the returned promise is rejected.
+ * This message will be extended with additional information
+ * about the number of tries or the thrown exception.
+ * @param interval
+ * The time interval to poll the condition function. Defaults
+ * to 100ms.
+ * @param maxTries
+ * The number of times to poll before giving up and rejecting
+ * if the condition has not yet returned true. Defaults to 50
+ * (~5 seconds for 100ms intervals)
+ * @return Promise
+ * Resolves when condition is true.
+ * Rejects if timeout is exceeded or condition ever throws.
+ */
+ async waitForCondition(condition, msg, interval = 100, maxTries = 50) {
+ let startTime = Cu.now();
+ for (let tries = 0; tries < maxTries; ++tries) {
+ await new Promise(resolve => setTimeout(resolve, interval));
+
+ let conditionPassed = false;
+ try {
+ conditionPassed = await condition();
+ } catch (e) {
+ msg += ` - threw exception: ${e}`;
+ ChromeUtils.addProfilerMarker(
+ "ContentTaskUtils",
+ { startTime, category: "Test" },
+ `waitForCondition - ${msg}`
+ );
+ throw msg;
+ }
+ if (conditionPassed) {
+ ChromeUtils.addProfilerMarker(
+ "ContentTaskUtils",
+ { startTime, category: "Test" },
+ `waitForCondition succeeded after ${tries} retries - ${msg}`
+ );
+ return conditionPassed;
+ }
+ }
+
+ msg += ` - timed out after ${maxTries} tries.`;
+ ChromeUtils.addProfilerMarker(
+ "ContentTaskUtils",
+ { startTime, category: "Test" },
+ `waitForCondition - ${msg}`
+ );
+ throw msg;
+ },
+
+ /**
+ * Waits for an event to be fired on a specified element.
+ *
+ * Usage:
+ * let promiseEvent = ContentTasKUtils.waitForEvent(element, "eventName");
+ * // Do some processing here that will cause the event to be fired
+ * // ...
+ * // Now yield until the Promise is fulfilled
+ * let receivedEvent = yield promiseEvent;
+ *
+ * @param {Element} subject
+ * The element that should receive the event.
+ * @param {string} eventName
+ * Name of the event to listen to.
+ * @param {bool} capture [optional]
+ * True to use a capturing listener.
+ * @param {function} checkFn [optional]
+ * Called with the Event object as argument, should return true if the
+ * event is the expected one, or false if it should be ignored and
+ * listening should continue. If not specified, the first event with
+ * the specified name resolves the returned promise.
+ *
+ * @note Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next event, since this is probably a bug in the test.
+ *
+ * @returns {Promise}
+ * @resolves The Event object.
+ */
+ waitForEvent(subject, eventName, capture, checkFn, wantsUntrusted = false) {
+ return new Promise((resolve, reject) => {
+ let startTime = Cu.now();
+ subject.addEventListener(
+ eventName,
+ function listener(event) {
+ try {
+ if (checkFn && !checkFn(event)) {
+ return;
+ }
+ subject.removeEventListener(eventName, listener, capture);
+ setTimeout(() => {
+ ChromeUtils.addProfilerMarker(
+ "ContentTaskUtils",
+ { category: "Test", startTime },
+ "waitForEvent - " + eventName
+ );
+ resolve(event);
+ }, 0);
+ } catch (ex) {
+ try {
+ subject.removeEventListener(eventName, listener, capture);
+ } catch (ex2) {
+ // Maybe the provided object does not support removeEventListener.
+ }
+ setTimeout(() => reject(ex), 0);
+ }
+ },
+ capture,
+ wantsUntrusted
+ );
+ });
+ },
+
+ /**
+ * Wait until DOM mutations cause the condition expressed in checkFn to pass.
+ * Intended as an easy-to-use alternative to waitForCondition.
+ *
+ * @param {Element} subject
+ * The element on which to observe mutations.
+ * @param {Object} options
+ * The options to pass to MutationObserver.observe();
+ * @param {function} checkFn [optional]
+ * Function that returns true when it wants the promise to be resolved.
+ * If not specified, the first mutation will resolve the promise.
+ *
+ * @returns {Promise<void>}
+ */
+ waitForMutationCondition(subject, options, checkFn) {
+ if (checkFn?.()) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve => {
+ let obs = new subject.ownerGlobal.MutationObserver(function () {
+ if (checkFn && !checkFn()) {
+ return;
+ }
+ obs.disconnect();
+ resolve();
+ });
+ obs.observe(subject, options);
+ });
+ },
+
+ /**
+ * Gets an instance of the `EventUtils` helper module for usage in
+ * content tasks. See https://searchfox.org/mozilla-central/source/testing/mochitest/tests/SimpleTest/EventUtils.js
+ *
+ * @param content
+ * The `content` global object from your content task.
+ *
+ * @returns an EventUtils instance.
+ */
+ getEventUtils(content) {
+ if (content._EventUtils) {
+ return content._EventUtils;
+ }
+
+ let EventUtils = (content._EventUtils = {});
+
+ EventUtils.window = {};
+ EventUtils.setTimeout = setTimeout;
+ EventUtils.parent = EventUtils.window;
+ /* eslint-disable camelcase */
+ EventUtils._EU_Ci = Ci;
+ EventUtils._EU_Cc = Cc;
+ /* eslint-enable camelcase */
+ // EventUtils' `sendChar` function relies on the navigator to synthetize events.
+ EventUtils.navigator = content.navigator;
+ EventUtils.KeyboardEvent = content.KeyboardEvent;
+
+ Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ EventUtils
+ );
+
+ return EventUtils;
+ },
+};
diff --git a/testing/mochitest/BrowserTestUtils/content/content-about-page-utils.js b/testing/mochitest/BrowserTestUtils/content/content-about-page-utils.js
new file mode 100644
index 0000000000..e20100db4b
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/content/content-about-page-utils.js
@@ -0,0 +1,81 @@
+/* eslint-env mozilla/process-script */
+
+"use strict";
+
+var Cm = Components.manager;
+
+function AboutPage(aboutHost, chromeURL, uriFlags) {
+ this.chromeURL = chromeURL;
+ this.aboutHost = aboutHost;
+ this.classID = Components.ID(Services.uuid.generateUUID().number);
+ this.description = "BrowserTestUtils: " + aboutHost;
+ this.uriFlags = uriFlags;
+}
+
+AboutPage.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIAboutModule"]),
+ getURIFlags(aURI) {
+ // eslint-disable-line no-unused-vars
+ return this.uriFlags;
+ },
+
+ newChannel(aURI, aLoadInfo) {
+ let newURI = Services.io.newURI(this.chromeURL);
+ let channel = Services.io.newChannelFromURIWithLoadInfo(newURI, aLoadInfo);
+ channel.originalURI = aURI;
+
+ if (this.uriFlags & Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT) {
+ channel.owner = null;
+ }
+ return channel;
+ },
+
+ createInstance(iid) {
+ return this.QueryInterface(iid);
+ },
+
+ register() {
+ Cm.QueryInterface(Ci.nsIComponentRegistrar).registerFactory(
+ this.classID,
+ this.description,
+ "@mozilla.org/network/protocol/about;1?what=" + this.aboutHost,
+ this
+ );
+ },
+
+ unregister() {
+ Cm.QueryInterface(Ci.nsIComponentRegistrar).unregisterFactory(
+ this.classID,
+ this
+ );
+ },
+};
+
+const gRegisteredPages = new Map();
+
+addMessageListener("browser-test-utils:about-registration:register", msg => {
+ let { aboutModule, pageURI, flags } = msg.data;
+ if (gRegisteredPages.has(aboutModule)) {
+ gRegisteredPages.get(aboutModule).unregister();
+ }
+ let moduleObj = new AboutPage(aboutModule, pageURI, flags);
+ moduleObj.register();
+ gRegisteredPages.set(aboutModule, moduleObj);
+ sendAsyncMessage(
+ "browser-test-utils:about-registration:registered",
+ aboutModule
+ );
+});
+
+addMessageListener("browser-test-utils:about-registration:unregister", msg => {
+ let aboutModule = msg.data;
+ let moduleObj = gRegisteredPages.get(aboutModule);
+ if (moduleObj) {
+ moduleObj.unregister();
+ gRegisteredPages.delete(aboutModule);
+ }
+ sendAsyncMessage(
+ "browser-test-utils:about-registration:unregistered",
+ aboutModule
+ );
+});
diff --git a/testing/mochitest/BrowserTestUtils/content/content-task.js b/testing/mochitest/BrowserTestUtils/content/content-task.js
new file mode 100644
index 0000000000..22847d64af
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/content/content-task.js
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-env mozilla/frame-script */
+
+"use strict";
+
+let { ContentTaskUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ContentTaskUtils.sys.mjs"
+);
+const { Assert: AssertCls } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+// Injects EventUtils into ContentTask scope. To avoid leaks, this does not hold on
+// to the window global. This means you **need** to pass the window as an argument to
+// the individual EventUtils functions.
+// See SimpleTest/EventUtils.js for documentation.
+var EventUtils = {
+ _EU_Ci: Ci,
+ _EU_Cc: Cc,
+ KeyboardEvent: content.KeyboardEvent,
+ navigator: content.navigator,
+ setTimeout,
+ window: {},
+};
+
+EventUtils.synthesizeClick = element =>
+ new Promise(resolve => {
+ element.addEventListener(
+ "click",
+ function () {
+ resolve();
+ },
+ { once: true }
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ element,
+ { type: "mousedown", isSynthesized: false },
+ content
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ element,
+ { type: "mouseup", isSynthesized: false },
+ content
+ );
+ });
+
+try {
+ Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ EventUtils
+ );
+} catch (e) {
+ // There are some xpcshell tests which may use ContentTask.
+ // Just ignore if loading EventUtils fails. Tests that need it
+ // will fail anyway.
+ EventUtils = null;
+}
+
+addMessageListener("content-task:spawn", async function (msg) {
+ let id = msg.data.id;
+ let source = msg.data.runnable || "()=>{}";
+
+ function getStack(aStack) {
+ let frames = [];
+ for (let frame = aStack; frame; frame = frame.caller) {
+ frames.push(frame.filename + ":" + frame.name + ":" + frame.lineNumber);
+ }
+ return frames.join("\n");
+ }
+
+ var Assert = new AssertCls((err, message, stack) => {
+ sendAsyncMessage("content-task:test-result", {
+ id,
+ condition: !err,
+ name: err ? err.message : message,
+ stack: getStack(err ? err.stack : stack),
+ });
+ });
+
+ /* eslint-disable no-unused-vars */
+ var ok = Assert.ok.bind(Assert);
+ var is = Assert.equal.bind(Assert);
+ var isnot = Assert.notEqual.bind(Assert);
+
+ function todo(expr, name) {
+ sendAsyncMessage("content-task:test-todo", { id, expr, name });
+ }
+
+ function todo_is(a, b, name) {
+ sendAsyncMessage("content-task:test-todo_is", { id, a, b, name });
+ }
+
+ function info(name) {
+ sendAsyncMessage("content-task:test-info", { id, name });
+ }
+ /* eslint-enable no-unused-vars */
+
+ try {
+ let runnablestr = `
+ (() => {
+ return (${source});
+ })();`;
+
+ // eslint-disable-next-line no-eval
+ let runnable = eval(runnablestr);
+ let result = await runnable.call(this, msg.data.arg);
+ sendAsyncMessage("content-task:complete", {
+ id,
+ result,
+ });
+ } catch (ex) {
+ sendAsyncMessage("content-task:complete", {
+ id,
+ error: ex.toString(),
+ });
+ }
+});
diff --git a/testing/mochitest/BrowserTestUtils/moz.build b/testing/mochitest/BrowserTestUtils/moz.build
new file mode 100644
index 0000000000..91675cdfbf
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/moz.build
@@ -0,0 +1,16 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+TESTING_JS_MODULES += [
+ "BrowserTestUtils.sys.mjs",
+ "BrowserTestUtilsChild.sys.mjs",
+ "BrowserTestUtilsParent.sys.mjs",
+ "content/content-task.js",
+ "ContentEventListenerChild.sys.mjs",
+ "ContentEventListenerParent.sys.mjs",
+ "ContentTask.sys.mjs",
+ "ContentTaskUtils.sys.mjs",
+]