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