summaryrefslogtreecommitdiffstats
path: root/testing/mochitest/BrowserTestUtils
diff options
context:
space:
mode:
Diffstat (limited to 'testing/mochitest/BrowserTestUtils')
-rw-r--r--testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm1329
-rw-r--r--testing/mochitest/BrowserTestUtils/ContentTask.jsm128
-rw-r--r--testing/mochitest/BrowserTestUtils/ContentTaskUtils.jsm120
-rw-r--r--testing/mochitest/BrowserTestUtils/content/content-about-page-utils.js76
-rw-r--r--testing/mochitest/BrowserTestUtils/content/content-task.js71
-rw-r--r--testing/mochitest/BrowserTestUtils/content/content-utils.js16
-rw-r--r--testing/mochitest/BrowserTestUtils/moz.build11
7 files changed, 1751 insertions, 0 deletions
diff --git a/testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm b/testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm
new file mode 100644
index 000000000..eebcbb6bb
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm
@@ -0,0 +1,1329 @@
+/* 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.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+ "BrowserTestUtils",
+];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://testing-common/TestUtils.jsm");
+Cu.import("resource://testing-common/ContentTask.jsm");
+
+Cc["@mozilla.org/globalmessagemanager;1"]
+ .getService(Ci.nsIMessageListenerManager)
+ .loadFrameScript(
+ "chrome://mochikit/content/tests/BrowserTestUtils/content-utils.js", true);
+
+XPCOMUtils.defineLazyModuleGetter(this, "E10SUtils",
+ "resource:///modules/E10SUtils.jsm");
+
+// For now, we'll allow tests to use CPOWs in this module for
+// some cases.
+Cu.permitCPOWsInScope(this);
+
+var gSendCharCount = 0;
+var gSynthesizeKeyCount = 0;
+var gSynthesizeCompositionCount = 0;
+var gSynthesizeCompositionChangeCount = 0;
+
+const kAboutPageRegistrationContentScript =
+ "chrome://mochikit/content/tests/BrowserTestUtils/content-about-page-utils.js";
+
+this.BrowserTestUtils = {
+ /**
+ * Loads a page in a new tab, executes a Task and closes the tab.
+ *
+ * @param options
+ * An object or string.
+ * If this is a string it is the url to open and will be opened in the
+ * currently active browser window.
+ * If an object it should have the following properties:
+ * {
+ * gBrowser:
+ * Reference to the "tabbrowser" element where the new tab should
+ * be opened.
+ * url:
+ * String with the URL of the page to load.
+ * }
+ * @param taskFn
+ * Generator function representing a Task 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 {} Returns the value that is returned from taskFn.
+ * @resolves When the tab has been closed.
+ * @rejects Any exception from taskFn is propagated.
+ */
+ withNewTab: Task.async(function* (options, taskFn) {
+ if (typeof(options) == "string") {
+ options = {
+ gBrowser: Services.wm.getMostRecentWindow("navigator:browser").gBrowser,
+ url: options
+ }
+ }
+ let tab = yield BrowserTestUtils.openNewForegroundTab(options.gBrowser, options.url);
+ let originalWindow = tab.ownerDocument.defaultView;
+ let result = yield taskFn(tab.linkedBrowser);
+ let finalWindow = tab.ownerDocument.defaultView;
+ if (originalWindow == finalWindow && !tab.closing && tab.linkedBrowser) {
+ yield 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.
+ *
+ * @param {tabbrowser} tabbrowser
+ * The tabbrowser to open the tab new in.
+ * @param {string} opening
+ * 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.
+ *
+ * @return {Promise}
+ * Resolves when the tab is ready and loaded as necessary.
+ * @resolves The new tab.
+ */
+ openNewForegroundTab(tabbrowser, opening = "about:blank", aWaitForLoad = true, aWaitForStateStop = false) {
+ let tab;
+ let promises = [
+ BrowserTestUtils.switchTab(tabbrowser, function () {
+ if (typeof opening == "function") {
+ opening();
+ tab = tabbrowser.selectedTab;
+ }
+ else {
+ tabbrowser.selectedTab = tab = tabbrowser.addTab(opening);
+ }
+ })
+ ];
+
+ if (aWaitForLoad) {
+ promises.push(BrowserTestUtils.browserLoaded(tab.linkedBrowser));
+ }
+ if (aWaitForStateStop) {
+ promises.push(BrowserTestUtils.browserStopped(tab.linkedBrowser));
+ }
+
+ return Promise.all(promises).then(() => tab);
+ },
+
+ /**
+ * 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 promise = new Promise(resolve => {
+ tabbrowser.addEventListener("TabSwitchDone", function onSwitch() {
+ tabbrowser.removeEventListener("TabSwitchDone", onSwitch);
+ TestUtils.executeSoon(() => resolve(tabbrowser.selectedTab));
+ });
+ });
+
+ 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. This is guaranteed to work because the
+ * way we're listening for the load is in the content-utils.js frame script,
+ * and then sending an async message up, so we can't miss the message.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ * @param {Boolean} includeSubFrames
+ * A boolean indicating if loads from subframes should be included.
+ * @param {optional string or function} wantLoad
+ * 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.
+ *
+ * @return {Promise}
+ * @resolves When a load event is triggered for the browser.
+ */
+ browserLoaded(browser, includeSubFrames=false, wantLoad=null) {
+ function isWanted(url) {
+ if (!wantLoad) {
+ return true;
+ } else if (typeof(wantLoad) == "function") {
+ return wantLoad(url);
+ } else {
+ // It's a string.
+ return wantLoad == url;
+ }
+ }
+
+ return new Promise(resolve => {
+ let mm = browser.ownerDocument.defaultView.messageManager;
+ mm.addMessageListener("browser-test-utils:loadEvent", function onLoad(msg) {
+ if (msg.target == browser && (!msg.data.subframe || includeSubFrames) &&
+ isWanted(msg.data.url)) {
+ mm.removeMessageListener("browser-test-utils:loadEvent", onLoad);
+ resolve(msg.data.url);
+ }
+ });
+ });
+ },
+
+ /**
+ * 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 {win}
+ * A newly opened window for which we're waiting for the
+ * first browser load.
+ *
+ * @return {Promise}
+ * @resolves Once the selected browser fires its load event.
+ */
+ firstBrowserLoaded(win, aboutBlank = true) {
+ let mm = win.messageManager;
+ return this.waitForMessage(mm, "browser-test-utils:loadEvent", (msg) => {
+ let selectedBrowser = win.gBrowser.selectedBrowser;
+ return msg.target == selectedBrowser &&
+ (aboutBlank || selectedBrowser.currentURI.spec != "about:blank")
+ });
+ },
+
+ /**
+ * 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.
+ *
+ * @return {Promise}
+ * @resolves When STATE_STOP reaches the tab's progress listener
+ */
+ browserStopped(browser) {
+ return new Promise(resolve => {
+ let wpl = {
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ aWebProgress.isTopLevel) {
+ browser.webProgress.removeProgressListener(filter);
+ filter.removeProgressListener(wpl);
+ resolve();
+ };
+ },
+ onSecurityChange() {},
+ onStatusChange() {},
+ onLocationChange() {},
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIWebProgressListener,
+ Ci.nsIWebProgressListener2,
+ ]),
+ };
+ const filter = Cc["@mozilla.org/appshell/component/browser-status-filter;1"]
+ .createInstance(Ci.nsIWebProgress);
+ filter.addProgressListener(wpl, Ci.nsIWebProgress.NOTIFY_ALL);
+ browser.webProgress.addProgressListener(filter, Ci.nsIWebProgress.NOTIFY_ALL);
+ });
+ },
+
+ /**
+ * Waits for the next tab to open and load a given URL.
+ *
+ * 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} url
+ * A string URL to look for in the new tab. If null, allows any non-blank URL.
+ *
+ * @return {Promise}
+ * @resolves With the {xul:tab} when a tab is opened and its location changes to the given URL.
+ *
+ * NB: this method will not work if you open a new tab with e.g. BrowserOpenTab
+ * and the tab does not load a URL, because no onLocationChange will fire.
+ */
+ waitForNewTab(tabbrowser, url) {
+ return new Promise((resolve, reject) => {
+ tabbrowser.tabContainer.addEventListener("TabOpen", function onTabOpen(openEvent) {
+ tabbrowser.tabContainer.removeEventListener("TabOpen", onTabOpen);
+
+ let progressListener = {
+ onLocationChange(aBrowser) {
+ if (aBrowser != openEvent.target.linkedBrowser ||
+ (url && aBrowser.currentURI.spec != url) ||
+ (!url && aBrowser.currentURI.spec == "about:blank")) {
+ return;
+ }
+
+ tabbrowser.removeTabsProgressListener(progressListener);
+ resolve(openEvent.target);
+ },
+ };
+ tabbrowser.addTabsProgressListener(progressListener);
+
+ });
+ });
+ },
+
+ /**
+ * Waits for onLocationChange.
+ *
+ * @param {tabbrowser} tabbrowser
+ * The tabbrowser to wait for the location change on.
+ * @param {string} url
+ * The string URL to look for. The URL must match the URL in the
+ * location bar exactly.
+ * @return {Promise}
+ * @resolves When onLocationChange fires.
+ */
+ waitForLocationChange(tabbrowser, url) {
+ return new Promise((resolve, reject) => {
+ let progressListener = {
+ onLocationChange(aBrowser) {
+ if ((url && aBrowser.currentURI.spec != url) ||
+ (!url && aBrowser.currentURI.spec == "about:blank")) {
+ return;
+ }
+
+ tabbrowser.removeTabsProgressListener(progressListener);
+ resolve();
+ },
+ };
+ tabbrowser.addTabsProgressListener(progressListener);
+ });
+ },
+
+ /**
+ * Waits for the next browser window to open and be fully loaded.
+ *
+ * @param {bool} delayedStartup (optional)
+ * Whether or not to wait for the browser-delayed-startup-finished
+ * observer notification before resolving. Defaults to true.
+ * @param {string} initialBrowserLoaded (optional)
+ * If set, we will wait until the initial browser in the new
+ * window has loaded a particular page. If unset, the initial
+ * browser may or may not have finished loading its first page
+ * when the resulting Promise resolves.
+ * @return {Promise}
+ * A Promise which resolves the next time that a DOM window
+ * opens and the delayed startup observer notification fires.
+ */
+ waitForNewWindow: Task.async(function* (delayedStartup=true,
+ initialBrowserLoaded=null) {
+ let win = yield this.domWindowOpened();
+
+ let promises = [
+ TestUtils.topicObserved("browser-delayed-startup-finished",
+ subject => subject == win),
+ ];
+
+ if (initialBrowserLoaded) {
+ yield this.waitForEvent(win, "DOMContentLoaded");
+
+ let browser = win.gBrowser.selectedBrowser;
+
+ // Retrieve the given browser's current process type.
+ let process =
+ browser.isRemoteBrowser ? Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT
+ : Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
+ if (win.gMultiProcessBrowser &&
+ !E10SUtils.canLoadURIInProcess(initialBrowserLoaded, process)) {
+ yield this.waitForEvent(browser, "XULFrameLoaderCreated");
+ }
+
+ let loadPromise = this.browserLoaded(browser, false, initialBrowserLoaded);
+ promises.push(loadPromise);
+ }
+
+ yield Promise.all(promises);
+
+ return win;
+ }),
+
+ /**
+ * Loads a new URI in the given browser and waits until we really started
+ * loading. In e10s browser.loadURI() can be an asynchronous operation due
+ * to having to switch the browser's remoteness and keep its shistory data.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ * @param {string} uri
+ * The URI to load.
+ *
+ * @return {Promise}
+ * @resolves When we started loading the given URI.
+ */
+ loadURI: Task.async(function* (browser, uri) {
+ // Load the new URI.
+ browser.loadURI(uri);
+
+ // Nothing to do in non-e10s mode.
+ if (!browser.ownerDocument.defaultView.gMultiProcessBrowser) {
+ return;
+ }
+
+ // Retrieve the given browser's current process type.
+ let process = browser.isRemoteBrowser ? Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT
+ : Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
+
+ // If the new URI can't load in the browser's current process then we
+ // should wait for the new frameLoader to be created. This will happen
+ // asynchronously when the browser's remoteness changes.
+ if (!E10SUtils.canLoadURIInProcess(uri, process)) {
+ yield this.waitForEvent(browser, "XULFrameLoaderCreated");
+ }
+ }),
+
+ /**
+ * @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 => {
+ function observer(subject, topic, data) {
+ if (topic == "domwindowopened" && (!win || subject === win)) {
+ let observedWindow = subject.QueryInterface(Ci.nsIDOMWindow);
+ if (checkFn && !checkFn(observedWindow)) {
+ return;
+ }
+ Services.ww.unregisterNotification(observer);
+ resolve(observedWindow);
+ }
+ }
+ Services.ww.registerNotification(observer);
+ });
+ },
+
+ /**
+ * @param win (optional)
+ * The window we should wait to have "domwindowclosed" sent through
+ * the observer service for. If this is not supplied, we'll just
+ * resolve when the first "domwindowclosed" notification is seen.
+ * @return {Promise}
+ * A Promise which resolves when a "domwindowclosed" notification
+ * has been fired by the window watcher.
+ */
+ domWindowClosed(win) {
+ return new Promise((resolve) => {
+ function observer(subject, topic, data) {
+ if (topic == "domwindowclosed" && (!win || subject === win)) {
+ Services.ww.unregisterNotification(observer);
+ resolve(subject.QueryInterface(Ci.nsIDOMWindow));
+ }
+ }
+ Services.ww.registerNotification(observer);
+ });
+ },
+
+ /**
+ * @param {Object} options
+ * {
+ * private: A boolean indicating if the window should be
+ * private
+ * remote: A boolean indicating if the window should run
+ * remote browser tabs or not. If omitted, the window
+ * will choose the profile default state.
+ * width: Desired width of window
+ * height: Desired height of window
+ * }
+ * @return {Promise}
+ * Resolves with the new window once it is loaded.
+ */
+ openNewBrowserWindow: Task.async(function*(options={}) {
+ let argString = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ argString.data = "";
+ let features = "chrome,dialog=no,all";
+
+ if (options.private) {
+ features += ",private";
+ }
+
+ if (options.width) {
+ features += ",width=" + options.width;
+ }
+ if (options.height) {
+ features += ",height=" + options.height;
+ }
+
+ if (options.hasOwnProperty("remote")) {
+ let remoteState = options.remote ? "remote" : "non-remote";
+ features += `,${remoteState}`;
+ }
+
+ let win = Services.ww.openWindow(
+ null, Services.prefs.getCharPref("browser.chromeURL"), "_blank",
+ features, argString);
+
+ // Wait for browser-delayed-startup-finished notification, it indicates
+ // that the window has loaded completely and is ready to be used for
+ // testing.
+ let startupPromise =
+ TestUtils.topicObserved("browser-delayed-startup-finished",
+ subject => subject == win).then(() => win);
+
+ let loadPromise = this.firstBrowserLoaded(win);
+
+ yield startupPromise;
+ yield loadPromise;
+
+ return win;
+ }),
+
+ /**
+ * Closes a window.
+ *
+ * @param {Window}
+ * 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}
+ * 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");
+
+ if (winType == "navigator:browser") {
+ let finalMsgsPromise = new Promise((resolve) => {
+ let browserSet = new Set(win.gBrowser.browsers);
+ let mm = win.getGroupMessageManager("browsers");
+
+ mm.addMessageListener("SessionStore:update", function onMessage(msg) {
+ if (browserSet.has(msg.target) && msg.data.isFinal) {
+ browserSet.delete(msg.target);
+ if (!browserSet.size) {
+ mm.removeMessageListener("SessionStore:update", onMessage);
+ // 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);
+ }
+ }
+ }, true);
+ });
+
+ promises.push(finalMsgsPromise);
+ }
+
+ return Promise.all(promises);
+ },
+
+ /**
+ * Waits for an event to be fired on a specified element.
+ *
+ * Usage:
+ * let promiseEvent = BrowserTestUtils.waitForEvent(element, "eventName");
+ * // Do some processing here that will cause the event to be fired
+ * // ...
+ * // Now yield until the Promise is fulfilled
+ * let receivedEvent = yield promiseEvent;
+ *
+ * @param {Element} subject
+ * The element that should receive the event.
+ * @param {string} eventName
+ * Name of the event to listen to.
+ * @param {bool} capture [optional]
+ * True to use a capturing listener.
+ * @param {function} checkFn [optional]
+ * Called with the Event object as argument, should return true if the
+ * event is the expected one, or false if it should be ignored and
+ * listening should continue. If not specified, the first event with
+ * the specified name resolves the returned promise.
+ * @param {bool} wantsUntrusted [optional]
+ * 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) {
+ return new Promise((resolve, reject) => {
+ subject.addEventListener(eventName, function listener(event) {
+ try {
+ if (checkFn && !checkFn(event)) {
+ return;
+ }
+ subject.removeEventListener(eventName, listener, capture);
+ resolve(event);
+ } catch (ex) {
+ try {
+ subject.removeEventListener(eventName, listener, capture);
+ } catch (ex2) {
+ // Maybe the provided object does not support removeEventListener.
+ }
+ reject(ex);
+ }
+ }, capture, wantsUntrusted);
+ });
+ },
+
+ /**
+ * 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} wantsUntrusted [optional]
+ * Whether to accept untrusted events
+ *
+ * @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}
+ */
+ waitForContentEvent(browser, eventName, capture = false, checkFn, wantsUntrusted = false) {
+ let parameters = {
+ eventName,
+ capture,
+ checkFnSource: checkFn ? checkFn.toSource() : null,
+ wantsUntrusted,
+ };
+ return ContentTask.spawn(browser, parameters,
+ function({ eventName, capture, checkFnSource, wantsUntrusted }) {
+ let checkFn;
+ if (checkFnSource) {
+ checkFn = eval(`(() => (${checkFnSource}))()`);
+ }
+ return new Promise((resolve, reject) => {
+ addEventListener(eventName, function listener(event) {
+ let completion = resolve;
+ try {
+ if (checkFn && !checkFn(event)) {
+ return;
+ }
+ } catch (e) {
+ completion = () => reject(e);
+ }
+ removeEventListener(eventName, listener, capture);
+ completion();
+ }, capture, wantsUntrusted);
+ });
+ });
+ },
+
+ /**
+ * Like browserLoaded, but waits for an error page to appear.
+ * This explicitly deals with cases where the browser is not currently remote and a
+ * remoteness switch will occur before the error page is loaded, which is tricky
+ * because error pages don't fire 'regular' load events that we can rely on.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ *
+ * @return {Promise}
+ * @resolves When an error page has been loaded in the browser.
+ */
+ waitForErrorPage(browser) {
+ let waitForLoad = () =>
+ this.waitForContentEvent(browser, "AboutNetErrorLoad", false, null, true);
+
+ let win = browser.ownerDocument.defaultView;
+ let tab = win.gBrowser.getTabForBrowser(browser);
+ if (!tab || browser.isRemoteBrowser || !win.gMultiProcessBrowser) {
+ return waitForLoad();
+ }
+
+ // We're going to switch remoteness when loading an error page. We need to be
+ // quite careful in order to make sure we're adding the listener in time to
+ // get this event:
+ return new Promise((resolve, reject) => {
+ tab.addEventListener("TabRemotenessChange", function onTRC() {
+ tab.removeEventListener("TabRemotenessChange", onTRC);
+ waitForLoad().then(resolve, reject);
+ });
+ });
+ },
+
+ /**
+ * Versions of EventUtils.jsm synthesizeMouse functions that synthesize a
+ * mouse event in a child process and return promises that resolve when the
+ * event has fired and completed. Instead of a window, a browser 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 CPOW element (for easier test-conversion).
+ * - a function to be run in the content process that returns the element to
+ * target
+ * - null, in which case the offset is from the content document's edge.
+ * @param {integer} offsetX
+ * x offset from target's left bounding edge
+ * @param {integer} offsetY
+ * y offset from target's top bounding edge
+ * @param {Object} event object
+ * Additional arguments, similar to the EventUtils.jsm version
+ * @param {Browser} browser
+ * Browser element, must not be null
+ *
+ * @returns {Promise}
+ * @resolves True if the mouse event was cancelled.
+ */
+ synthesizeMouse(target, offsetX, offsetY, event, browser)
+ {
+ return new Promise((resolve, reject) => {
+ let mm = browser.messageManager;
+ mm.addMessageListener("Test:SynthesizeMouseDone", function mouseMsg(message) {
+ mm.removeMessageListener("Test:SynthesizeMouseDone", mouseMsg);
+ if (message.data.hasOwnProperty("defaultPrevented")) {
+ resolve(message.data.defaultPrevented);
+ } else {
+ reject(new Error(message.data.error));
+ }
+ });
+
+ let cpowObject = null;
+ let targetFn = null;
+ if (typeof target == "function") {
+ targetFn = target.toString();
+ target = null;
+ } else if (typeof target != "string") {
+ cpowObject = target;
+ target = null;
+ }
+
+ mm.sendAsyncMessage("Test:SynthesizeMouse",
+ {target, targetFn, x: offsetX, y: offsetY, event: event},
+ {object: cpowObject});
+ });
+ },
+
+ /**
+ * 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();
+ }
+ });
+ });
+ },
+
+ /**
+ * 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, browser)
+ {
+ // Use a flag to indicate to center rather than having a separate message.
+ event.centered = true;
+ return BrowserTestUtils.synthesizeMouse(target, 0, 0, event, browser);
+ },
+
+ /**
+ * 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, browser)
+ {
+ return BrowserTestUtils.synthesizeMouse(null, offsetX, offsetY, event, browser);
+ },
+
+ /**
+ * Removes the given tab from its parent tabbrowser and
+ * waits until its final message has reached the parent.
+ */
+ removeTab(tab, options = {}) {
+ let dontRemove = options && options.dontRemove;
+
+ return new Promise(resolve => {
+ let {messageManager: mm, frameLoader} = tab.linkedBrowser;
+ mm.addMessageListener("SessionStore:update", function onMessage(msg) {
+ if (msg.targetFrameLoader == frameLoader && msg.data.isFinal) {
+ mm.removeMessageListener("SessionStore:update", onMessage);
+ resolve();
+ }
+ }, true);
+
+ if (!dontRemove && !tab.closing) {
+ tab.ownerDocument.defaultView.gBrowser.removeTab(tab);
+ }
+ });
+ },
+
+ /**
+ * Crashes a remote browser 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.
+ *
+ * @returns (Promise)
+ * @resolves An Object with key-value pairs representing the data from the
+ * crash report's extra file (if applicable).
+ */
+ crashBrowser: Task.async(function*(browser, shouldShowTabCrashPage=true) {
+ let extra = {};
+ let KeyValueParser = {};
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ Cu.import("resource://gre/modules/KeyValueParser.jsm", KeyValueParser);
+ }
+
+ 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);
+ }
+ }
+
+ // This frame script is injected into the remote browser, and used to
+ // intentionally crash the tab. We crash by using js-ctypes and dereferencing
+ // a bad pointer. The crash should happen immediately upon loading this
+ // frame script.
+ let frame_script = () => {
+ const Cu = Components.utils;
+ Cu.import("resource://gre/modules/ctypes.jsm");
+
+ let dies = function() {
+ privateNoteIntentionalCrash();
+ let zero = new ctypes.intptr_t(8);
+ let badptr = ctypes.cast(zero, ctypes.PointerType(ctypes.int32_t));
+ badptr.contents
+ };
+
+ dump("\nEt tu, Brute?\n");
+ dies();
+ }
+
+ let expectedPromises = [];
+
+ let crashCleanupPromise = new Promise((resolve, reject) => {
+ let observer = (subject, topic, data) => {
+ if (topic != "ipc:content-shutdown") {
+ return reject("Received incorrect observer topic: " + topic);
+ }
+ if (!(subject instanceof Ci.nsIPropertyBag2)) {
+ return reject("Subject did not implement nsIPropertyBag2");
+ }
+ // 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;
+ }
+
+ let dumpID;
+ if ('nsICrashReporter' in Ci) {
+ dumpID = subject.getPropertyAsAString('dumpID');
+ if (!dumpID) {
+ return reject("dumpID was not present despite crash reporting " +
+ "being enabled");
+ }
+ }
+
+ if (dumpID) {
+ let minidumpDirectory = getMinidumpDirectory();
+ let extrafile = minidumpDirectory.clone();
+ extrafile.append(dumpID + '.extra');
+ if (extrafile.exists()) {
+ dump(`\nNo .extra file for dumpID: ${dumpID}\n`);
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ extra = KeyValueParser.parseKeyValuePairsFromFile(extrafile);
+ } else {
+ dump('\nCrashReporter not enabled - will not return any extra data\n');
+ }
+ }
+
+ removeFile(minidumpDirectory, dumpID + '.dmp');
+ removeFile(minidumpDirectory, dumpID + '.extra');
+ }
+
+ Services.obs.removeObserver(observer, 'ipc:content-shutdown');
+ 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', false);
+ });
+
+ expectedPromises.push(crashCleanupPromise);
+
+ if (shouldShowTabCrashPage) {
+ expectedPromises.push(new Promise((resolve, reject) => {
+ browser.addEventListener("AboutTabCrashedReady", function onCrash() {
+ browser.removeEventListener("AboutTabCrashedReady", onCrash, false);
+ dump("\nabout:tabcrashed loaded and ready\n");
+ resolve();
+ }, false, true);
+ }));
+ }
+
+ // This frame script will crash the remote browser as soon as it is
+ // evaluated.
+ let mm = browser.messageManager;
+ mm.loadFrameScript("data:,(" + frame_script.toString() + ")();", false);
+
+ yield Promise.all(expectedPromises);
+
+ if (shouldShowTabCrashPage) {
+ let gBrowser = browser.ownerDocument.defaultView.gBrowser;
+ let tab = gBrowser.getTabForBrowser(browser);
+ if (tab.getAttribute("crashed") != "true") {
+ throw new Error("Tab should be marked as crashed");
+ }
+ }
+
+ return extra;
+ }),
+
+ /**
+ * 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.ownerDocument.defaultView.MutationObserver;
+ return new Promise(resolve => {
+ let mut = new MutationObserver(mutations => {
+ if ((!value && element.getAttribute(attr)) ||
+ (value && element.getAttribute(attr) === value)) {
+ resolve();
+ mut.disconnect();
+ return;
+ }
+ });
+
+ 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 object is required to be
+ * passed to this function.
+ *
+ * @param {String} char
+ * A character for the keypress event that is sent to the browser.
+ * @param {Browser} browser
+ * Browser element, must not be null.
+ *
+ * @returns {Promise}
+ * @resolves True if the keypress event was synthesized.
+ */
+ sendChar(char, browser) {
+ return new Promise(resolve => {
+ let seq = ++gSendCharCount;
+ let mm = browser.messageManager;
+
+ mm.addMessageListener("Test:SendCharDone", function charMsg(message) {
+ if (message.data.seq != seq)
+ return;
+
+ mm.removeMessageListener("Test:SendCharDone", charMsg);
+ resolve(message.data.result);
+ });
+
+ mm.sendAsyncMessage("Test:SendChar", {
+ char: char,
+ seq: seq
+ });
+ });
+ },
+
+ /**
+ * 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 object 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 {Browser} browser
+ * Browser element, must not be null.
+ *
+ * @returns {Promise}
+ */
+ synthesizeKey(key, event, browser) {
+ return new Promise(resolve => {
+ let seq = ++gSynthesizeKeyCount;
+ let mm = browser.messageManager;
+
+ mm.addMessageListener("Test:SynthesizeKeyDone", function keyMsg(message) {
+ if (message.data.seq != seq)
+ return;
+
+ mm.removeMessageListener("Test:SynthesizeKeyDone", keyMsg);
+ resolve();
+ });
+
+ mm.sendAsyncMessage("Test:SynthesizeKey", { key, event, seq });
+ });
+ },
+
+ /**
+ * 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 object is
+ * required to be passed to this function.
+ *
+ * @param {Object} event
+ * See the documentation available for EventUtils#synthesizeComposition.
+ * @param {Browser} browser
+ * Browser element, must not be null.
+ *
+ * @returns {Promise}
+ * @resolves False if the composition event could not be synthesized.
+ */
+ synthesizeComposition(event, browser) {
+ return new Promise(resolve => {
+ let seq = ++gSynthesizeCompositionCount;
+ let mm = browser.messageManager;
+
+ mm.addMessageListener("Test:SynthesizeCompositionDone", function compMsg(message) {
+ if (message.data.seq != seq)
+ return;
+
+ mm.removeMessageListener("Test:SynthesizeCompositionDone", compMsg);
+ resolve(message.data.result);
+ });
+
+ mm.sendAsyncMessage("Test:SynthesizeComposition", { event, seq });
+ });
+ },
+
+ /**
+ * 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 object is required to be passed to this function.
+ *
+ * @param {Object} event
+ * See the documentation available for EventUtils#synthesizeCompositionChange.
+ * @param {Browser} browser
+ * Browser element, must not be null.
+ *
+ * @returns {Promise}
+ */
+ synthesizeCompositionChange(event, browser) {
+ return new Promise(resolve => {
+ let seq = ++gSynthesizeCompositionChangeCount;
+ let mm = browser.messageManager;
+
+ mm.addMessageListener("Test:SynthesizeCompositionChangeDone", function compMsg(message) {
+ if (message.data.seq != seq)
+ return;
+
+ mm.removeMessageListener("Test:SynthesizeCompositionChangeDone", compMsg);
+ resolve();
+ });
+
+ mm.sendAsyncMessage("Test:SynthesizeCompositionChange", { event, seq });
+ });
+ },
+
+ /**
+ * Will poll a condition function until it returns true.
+ *
+ * @param condition
+ * A condition function that must return true or false. If the
+ * condition ever throws, this is also treated as a false. The
+ * function can be a generator.
+ * @param interval
+ * The time interval to poll the condition function. Defaults
+ * to 100ms.
+ * @param attempts
+ * The number of times to poll before giving up and rejecting
+ * if the condition has not yet returned true. Defaults to 50
+ * (~5 seconds for 100ms intervals)
+ * @return Promise
+ * Resolves when condition is true.
+ * Rejects if timeout is exceeded or condition ever throws.
+ */
+ waitForCondition(condition, msg, interval=100, maxTries=50) {
+ return new Promise((resolve, reject) => {
+ let tries = 0;
+ let intervalID = setInterval(Task.async(function* () {
+ if (tries >= maxTries) {
+ clearInterval(intervalID);
+ msg += ` - timed out after ${maxTries} tries.`;
+ reject(msg);
+ return;
+ }
+
+ let conditionPassed = false;
+ try {
+ conditionPassed = yield condition();
+ } catch(e) {
+ msg += ` - threw exception: ${e}`;
+ clearInterval(intervalID);
+ reject(msg);
+ return;
+ }
+
+ if (conditionPassed) {
+ clearInterval(intervalID);
+ resolve();
+ }
+ tries++;
+ }), interval);
+ });
+ },
+
+ /**
+ * Waits for a <xul:notification> with a particular value to appear
+ * for the <xul:notificationbox> of the passed in browser.
+ *
+ * @param tabbrowser (<xul:tabbrowser>)
+ * The gBrowser that hosts the browser that should show
+ * the notification. For most tests, this will probably be
+ * gBrowser.
+ * @param browser (<xul:browser>)
+ * The browser that should be showing the notification.
+ * @param notificationValue (string)
+ * 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 win (<xul:window>)
+ * The browser window in whose global notificationbox the
+ * notification is expected to appear.
+ * @param notificationValue (string)
+ * 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) {
+ let notificationBox =
+ win.document.getElementById("high-priority-global-notificationbox");
+ return this.waitForNotificationInNotificationBox(notificationBox,
+ notificationValue);
+ },
+
+ waitForNotificationInNotificationBox(notificationBox, notificationValue) {
+ return new Promise((resolve) => {
+ let check = (event) => {
+ return event.target.value == notificationValue;
+ };
+
+ BrowserTestUtils.waitForEvent(notificationBox, "AlertActive",
+ false, check).then((event) => {
+ // The originalTarget of the AlertActive on a notificationbox
+ // will be the notification itself.
+ resolve(event.originalTarget);
+ });
+ });
+ },
+
+ /**
+ * Returns a Promise that will resolve once MozAfterPaint
+ * has been fired in the content of a browser.
+ *
+ * @param browser (<xul:browser>)
+ * The browser for which we're waiting for the MozAfterPaint
+ * event to occur in.
+ * @returns Promise
+ */
+ contentPainted(browser) {
+ return ContentTask.spawn(browser, null, function*() {
+ return new Promise((resolve) => {
+ addEventListener("MozAfterPaint", function onPaint() {
+ removeEventListener("MozAfterPaint", onPaint);
+ resolve();
+ })
+ });
+ });
+ },
+
+ _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 registerCleanupFunction (Function)
+ * 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 aboutModule (String)
+ * The name of the about page.
+ * @param pageURI (String)
+ * The URI the about: page should point to.
+ * @param flags (Number)
+ * The nsIAboutModule flags to use for registration.
+ * @returns 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));
+ },
+
+ *_removeAboutPageRegistrations() {
+ for (let aboutModule of this._knownAboutPages) {
+ yield this.unregisterAboutPage(aboutModule);
+ }
+ Services.ppmm.removeDelayedProcessScript(kAboutPageRegistrationContentScript);
+ },
+};
diff --git a/testing/mochitest/BrowserTestUtils/ContentTask.jsm b/testing/mochitest/BrowserTestUtils/ContentTask.jsm
new file mode 100644
index 000000000..a52dd90d3
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/ContentTask.jsm
@@ -0,0 +1,128 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+ "ContentTask"
+];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+const FRAME_SCRIPT = "chrome://mochikit/content/tests/BrowserTestUtils/content-task.js";
+
+/**
+ * Keeps track of whether the frame script was already loaded.
+ */
+var gFrameScriptLoaded = false;
+
+/**
+ * Mapping from message id to associated promise.
+ */
+var gPromises = new Map();
+
+/**
+ * Incrementing integer to generate unique message id.
+ */
+var gMessageID = 1;
+
+/**
+ * This object provides the public module functions.
+ */
+this.ContentTask = {
+ /**
+ * _testScope saves the current testScope from
+ * browser-test.js. This is used to implement SimpleTest functions
+ * like ok() and is() in the content process. The scope is only
+ * valid for tasks spawned in the current test, so we keep track of
+ * the ID of the first task spawned in this test (_scopeValidId).
+ */
+ _testScope: null,
+ _scopeValidId: 0,
+
+ /**
+ * Creates and starts a new task in a browser's content.
+ *
+ * @param browser A xul:browser
+ * @param arg A single serializable argument that will be passed to the
+ * task when executed on the content process.
+ * @param task
+ * - A generator or function which will be serialized and sent to
+ * the remote browser to be executed. Unlike Task.spawn, this
+ * argument may not be an iterator as it will be serialized and
+ * sent to the remote browser.
+ * @return A promise object where you can register completion callbacks to be
+ * called when the task terminates.
+ * @resolves With the final returned value of the task if it executes
+ * successfully.
+ * @rejects An error message if execution fails.
+ */
+ spawn: function ContentTask_spawn(browser, arg, task) {
+ // Load the frame script if needed.
+ if (!gFrameScriptLoaded) {
+ Services.mm.loadFrameScript(FRAME_SCRIPT, true);
+ gFrameScriptLoaded = true;
+ }
+
+ let deferred = {};
+ deferred.promise = new Promise((resolve, reject) => {
+ deferred.resolve = resolve;
+ deferred.reject = reject;
+ });
+
+ let id = gMessageID++;
+ gPromises.set(id, deferred);
+
+ browser.messageManager.sendAsyncMessage(
+ "content-task:spawn",
+ {
+ id: id,
+ runnable: task.toString(),
+ arg: arg,
+ });
+
+ return deferred.promise;
+ },
+
+ setTestScope(scope) {
+ this._testScope = scope;
+ this._scopeValidId = gMessageID;
+ },
+};
+
+var ContentMessageListener = {
+ receiveMessage(aMessage) {
+ let id = aMessage.data.id;
+
+ if (id < ContentTask._scopeValidId) {
+ throw new Error("test result returned after test finished");
+ }
+
+ if (aMessage.name == "content-task:complete") {
+ let deferred = gPromises.get(id);
+ gPromises.delete(id);
+
+ if (aMessage.data.error) {
+ deferred.reject(aMessage.data.error);
+ } else {
+ deferred.resolve(aMessage.data.result);
+ }
+ } else if (aMessage.name == "content-task:test-result") {
+ let data = aMessage.data;
+ ContentTask._testScope.ok(data.condition, data.name, null, data.stack);
+ } else if (aMessage.name == "content-task:test-info") {
+ ContentTask._testScope.info(aMessage.data.name);
+ } else if (aMessage.name == "content-task:test-todo") {
+ ContentTask._testScope.todo(aMessage.data.expr, aMessage.data.name);
+ }
+ },
+};
+
+Services.mm.addMessageListener("content-task:complete", ContentMessageListener);
+Services.mm.addMessageListener("content-task:test-result", ContentMessageListener);
+Services.mm.addMessageListener("content-task:test-info", ContentMessageListener);
diff --git a/testing/mochitest/BrowserTestUtils/ContentTaskUtils.jsm b/testing/mochitest/BrowserTestUtils/ContentTaskUtils.jsm
new file mode 100644
index 000000000..27da0de22
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/ContentTaskUtils.jsm
@@ -0,0 +1,120 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This module implements a number of utility functions that can be loaded
+ * into content scope.
+ *
+ * All asynchronous helper methods should return promises, rather than being
+ * callback based.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+ "ContentTaskUtils",
+];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Timer.jsm");
+
+this.ContentTaskUtils = {
+ /**
+ * Will poll a condition function until it returns true.
+ *
+ * @param condition
+ * A condition function that must return true or false. If the
+ * condition ever throws, this is also treated as a false.
+ * @param interval
+ * The time interval to poll the condition function. Defaults
+ * to 100ms.
+ * @param attempts
+ * The number of times to poll before giving up and rejecting
+ * if the condition has not yet returned true. Defaults to 50
+ * (~5 seconds for 100ms intervals)
+ * @return Promise
+ * Resolves when condition is true.
+ * Rejects if timeout is exceeded or condition ever throws.
+ */
+ waitForCondition(condition, msg, interval=100, maxTries=50) {
+ return new Promise((resolve, reject) => {
+ let tries = 0;
+ let intervalID = setInterval(() => {
+ if (tries >= maxTries) {
+ clearInterval(intervalID);
+ msg += ` - timed out after ${maxTries} tries.`;
+ reject(msg);
+ return;
+ }
+
+ let conditionPassed = false;
+ try {
+ conditionPassed = condition();
+ } catch(e) {
+ msg += ` - threw exception: ${e}`;
+ clearInterval(intervalID);
+ reject(msg);
+ return;
+ }
+
+ if (conditionPassed) {
+ clearInterval(intervalID);
+ resolve();
+ }
+ tries++;
+ }, interval);
+ });
+ },
+
+ /**
+ * Waits for an event to be fired on a specified element.
+ *
+ * Usage:
+ * let promiseEvent = ContentTasKUtils.waitForEvent(element, "eventName");
+ * // Do some processing here that will cause the event to be fired
+ * // ...
+ * // Now yield until the Promise is fulfilled
+ * let receivedEvent = yield promiseEvent;
+ *
+ * @param {Element} subject
+ * The element that should receive the event.
+ * @param {string} eventName
+ * Name of the event to listen to.
+ * @param {bool} capture [optional]
+ * True to use a capturing listener.
+ * @param {function} checkFn [optional]
+ * Called with the Event object as argument, should return true if the
+ * event is the expected one, or false if it should be ignored and
+ * listening should continue. If not specified, the first event with
+ * the specified name resolves the returned promise.
+ *
+ * @note Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next event, since this is probably a bug in the test.
+ *
+ * @returns {Promise}
+ * @resolves The Event object.
+ */
+ waitForEvent(subject, eventName, capture, checkFn, wantsUntrusted = false) {
+ return new Promise((resolve, reject) => {
+ subject.addEventListener(eventName, function listener(event) {
+ try {
+ if (checkFn && !checkFn(event)) {
+ return;
+ }
+ subject.removeEventListener(eventName, listener, capture);
+ resolve(event);
+ } catch (ex) {
+ try {
+ subject.removeEventListener(eventName, listener, capture);
+ } catch (ex2) {
+ // Maybe the provided object does not support removeEventListener.
+ }
+ reject(ex);
+ }
+ }, capture, wantsUntrusted);
+ });
+ },
+};
diff --git a/testing/mochitest/BrowserTestUtils/content/content-about-page-utils.js b/testing/mochitest/BrowserTestUtils/content/content-about-page-utils.js
new file mode 100644
index 000000000..974f084ea
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/content/content-about-page-utils.js
@@ -0,0 +1,76 @@
+"use strict";
+
+var {classes: Cc, interfaces: Ci, utils: Cu, manager: Cm} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const { generateUUID } = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
+
+function AboutPage(aboutHost, chromeURL, uriFlags) {
+ this.chromeURL = chromeURL;
+ this.aboutHost = aboutHost;
+ this.classID = Components.ID(generateUUID().number);
+ this.description = "BrowserTestUtils: " + aboutHost;
+ this.uriFlags = uriFlags;
+}
+
+AboutPage.prototype = {
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIAboutModule]),
+ getURIFlags(aURI) { // eslint-disable-line no-unused-vars
+ return this.uriFlags;
+ },
+
+ newChannel(aURI, aLoadInfo) {
+ let newURI = Services.io.newURI(this.chromeURL, null, null);
+ let channel = Services.io.newChannelFromURIWithLoadInfo(newURI,
+ aLoadInfo);
+ channel.originalURI = aURI;
+
+ if (this.uriFlags & Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT) {
+ channel.owner = null;
+ }
+ return channel;
+ },
+
+ createInstance(outer, iid) {
+ if (outer !== null) {
+ throw Cr.NS_ERROR_NO_AGGREGATION;
+ }
+ return this.QueryInterface(iid);
+ },
+
+ register() {
+ Cm.QueryInterface(Ci.nsIComponentRegistrar).registerFactory(
+ this.classID, this.description,
+ "@mozilla.org/network/protocol/about;1?what=" + this.aboutHost, this);
+ },
+
+ unregister() {
+ Cm.QueryInterface(Ci.nsIComponentRegistrar).unregisterFactory(
+ this.classID, this);
+ }
+};
+
+const gRegisteredPages = new Map();
+
+addMessageListener("browser-test-utils:about-registration:register", msg => {
+ let {aboutModule, pageURI, flags} = msg.data;
+ if (gRegisteredPages.has(aboutModule)) {
+ gRegisteredPages.get(aboutModule).unregister();
+ }
+ let moduleObj = new AboutPage(aboutModule, pageURI, flags);
+ moduleObj.register();
+ gRegisteredPages.set(aboutModule, moduleObj);
+ sendAsyncMessage("browser-test-utils:about-registration:registered", aboutModule);
+});
+
+addMessageListener("browser-test-utils:about-registration:unregister", msg => {
+ let aboutModule = msg.data;
+ let moduleObj = gRegisteredPages.get(aboutModule);
+ if (moduleObj) {
+ moduleObj.unregister();
+ gRegisteredPages.delete(aboutModule);
+ }
+ sendAsyncMessage("browser-test-utils:about-registration:unregistered", aboutModule);
+});
diff --git a/testing/mochitest/BrowserTestUtils/content/content-task.js b/testing/mochitest/BrowserTestUtils/content/content-task.js
new file mode 100644
index 000000000..3eb5d3d01
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/content/content-task.js
@@ -0,0 +1,71 @@
+/* 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/. */
+
+"use strict";
+
+var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Task.jsm", this);
+Cu.import("resource://testing-common/ContentTaskUtils.jsm", this);
+const AssertCls = Cu.import("resource://testing-common/Assert.jsm", null).Assert;
+
+addMessageListener("content-task:spawn", function (msg) {
+ let id = msg.data.id;
+ let source = msg.data.runnable || "()=>{}";
+
+ function getStack(aStack) {
+ let frames = [];
+ for (let frame = aStack; frame; frame = frame.caller) {
+ frames.push(frame.filename + ":" + frame.name + ":" + frame.lineNumber);
+ }
+ return frames.join("\n");
+ }
+
+ var Assert = new AssertCls((err, message, stack) => {
+ sendAsyncMessage("content-task:test-result", {
+ id: id,
+ condition: !err,
+ name: err ? err.message : message,
+ stack: getStack(err ? err.stack : stack)
+ });
+ });
+
+ var ok = Assert.ok.bind(Assert);
+ var is = Assert.equal.bind(Assert);
+ var isnot = Assert.notEqual.bind(Assert);
+
+ function todo(expr, name) {
+ sendAsyncMessage("content-task:test-todo", {id, expr, name});
+ }
+
+ function info(name) {
+ sendAsyncMessage("content-task:test-info", {id, name});
+ }
+
+ try {
+ let runnablestr = `
+ (() => {
+ return (${source});
+ })();`
+
+ let runnable = eval(runnablestr);
+ let iterator = runnable.call(this, msg.data.arg);
+ Task.spawn(iterator).then((val) => {
+ sendAsyncMessage("content-task:complete", {
+ id: id,
+ result: val,
+ });
+ }, (e) => {
+ sendAsyncMessage("content-task:complete", {
+ id: id,
+ error: e.toString(),
+ });
+ });
+ } catch (e) {
+ sendAsyncMessage("content-task:complete", {
+ id: id,
+ error: e.toString(),
+ });
+ }
+});
diff --git a/testing/mochitest/BrowserTestUtils/content/content-utils.js b/testing/mochitest/BrowserTestUtils/content/content-utils.js
new file mode 100644
index 000000000..c1e56364c
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/content/content-utils.js
@@ -0,0 +1,16 @@
+/* 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/. */
+
+"use strict";
+
+var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+addEventListener("load", function(event) {
+ let subframe = event.target != content.document;
+ sendAsyncMessage("browser-test-utils:loadEvent",
+ {subframe: subframe, url: event.target.documentURI});
+}, true);
+
diff --git a/testing/mochitest/BrowserTestUtils/moz.build b/testing/mochitest/BrowserTestUtils/moz.build
new file mode 100644
index 000000000..25ef72d55
--- /dev/null
+++ b/testing/mochitest/BrowserTestUtils/moz.build
@@ -0,0 +1,11 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+TESTING_JS_MODULES += [
+ 'BrowserTestUtils.jsm',
+ 'ContentTask.jsm',
+ 'ContentTaskUtils.jsm',
+]