/* 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, {
  BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
  ContentTask: "resource://testing-common/ContentTask.sys.mjs",
});

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}
   */
  isHidden(element) {
    if (
      element.nodeType == Node.DOCUMENT_FRAGMENT_NODE &&
      element.containingShadowRoot == element
    ) {
      return BrowserTestUtils.isHidden(element.getRootNode().host);
    }

    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.isHidden(element.parentNode);
    }

    return false;
  },

  /**
   * Checks if a DOM element is visible.
   *
   * @param {Element} element
   *        The element which is to be checked.
   *
   * @return {boolean}
   */
  isVisible(element) {
    if (
      element.nodeType == Node.DOCUMENT_FRAGMENT_NODE &&
      element.containingShadowRoot == element
    ) {
      return BrowserTestUtils.isVisible(element.getRootNode().host);
    }

    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.isVisible(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."
      );
    }

    // Consumers may pass gBrowser instead of a browser, so adjust for that.
    if ("selectedBrowser" in browser) {
      browser = browser.selectedBrowser;
    }

    // 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) {
      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.
   * @param {boolean} [maybeErrorPage = false]
   *        See ``browserLoaded`` function.
   *
   * @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. BrowserCommands.openTab
   * and the tab does not load a URL, because no onLocationChange will fire.
   */
  waitForNewTab(
    tabbrowser,
    wantLoad = null,
    waitForLoad = false,
    waitForAnyTab = false,
    maybeErrorPage = 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 => {
      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,
              maybeErrorPage
            ).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 => {
      let progressListener = {
        onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI) {
          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]
   *        The url to await being loaded. If unset this may or may not wait for
   *        any page to be loaded, according to the waitForAnyURLLoaded param.
   * @param {bool} [aParams.waitForAnyURLLoaded] When `url` is unset, this
   *        controls whether to wait for any initial URL to be loaded.
   *        Defaults to false, that means the initial browser may or may not
   *        have finished loading its first page when this resolves.
   *        When `url` is set, this is ignored, thus the load is always awaited for.
   * @param {bool} [aParams.anyWindow]
   * @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,
      waitForAnyURLLoaded = 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) => {
        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 || waitForAnyURLLoaded) {
            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 || waitForAnyURLLoaded) {
            let loadPromise = this.browserLoaded(
              win.gBrowser.selectedBrowser,
              false,
              waitForAnyURLLoaded ? null : 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);
    });
  },

  /**
   * Starts the load of a new URI in the given browser, triggered by the system
   * principal.
   * Note this won't want for the load to be complete. For that you may either
   * use BrowserTestUtils.browserLoaded(), BrowserTestUtils.waitForErrorPage(),
   * or make your own handler.
   *
   * @param {xul:browser} browser
   *        A xul:browser.
   * @param {string} uri
   *        The URI to load.
   */
  startLoadingURIString(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) {
        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) {
        if (topic == "domwindowclosed" && (!win || subject === win)) {
          Services.ww.unregisterNotification(observer);
          resolve(subject);
        }
      }
      Services.ww.registerNotification(observer);
    });
  },

  /**
   * 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 => {
          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 => {
        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) {
    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.sys.mjs 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.sys.mjs 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.sys.mjs 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.sys.mjs 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) => {
        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 => {
          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 = () => {
      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(() => {
        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(() => {
        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) {
      for (let attempts = 0; attempts < 3; attempts++) {
        [win] = await TestUtils.topicObserved("subdialog-loaded");
        if (uri === undefined || uri === null || uri === "") {
          break;
        }
        if (win.document.documentURI === uri) {
          break;
        }
      }
    } 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 the 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.
   * @returns {Promise<Element>}
   *   Resolves to the opened about:preferences tab with the migration wizard
   *   running and loaded in it.
   */
  async waitForMigrationWizard(window) {
    let wizardReady = this.waitForEvent(window, "MigrationWizard:Ready");
    let wizardTab = await this.waitForNewTab(window.gBrowser, url => {
      return url.startsWith("about:preferences");
    });
    await wizardReady;

    return wizardTab;
  },
};

XPCOMUtils.defineLazyPreferenceGetter(
  BrowserTestUtils,
  "_httpsFirstEnabled",
  "dom.security.https_first",
  false
);

Services.obs.addObserver(BrowserTestUtils, "test-complete");
