/* 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 () * 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 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(" 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 () * 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 with a particular value to appear * for the 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 that is being shown. */ waitForNotificationBar(tabbrowser, browser, notificationValue) { let notificationBox = tabbrowser.getNotificationBox(browser); return this.waitForNotificationInNotificationBox( notificationBox, notificationValue ); }, /** * Waits for a with a particular value to appear * in the global 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 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} * 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} */ 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");