diff options
Diffstat (limited to 'testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm')
-rw-r--r-- | testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm | 1329 |
1 files changed, 1329 insertions, 0 deletions
diff --git a/testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm b/testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm new file mode 100644 index 000000000..eebcbb6bb --- /dev/null +++ b/testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm @@ -0,0 +1,1329 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * This module implements a number of utilities useful for browser tests. + * + * All asynchronous helper methods should return promises, rather than being + * callback based. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "BrowserTestUtils", +]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); +Cu.import("resource://testing-common/TestUtils.jsm"); +Cu.import("resource://testing-common/ContentTask.jsm"); + +Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager) + .loadFrameScript( + "chrome://mochikit/content/tests/BrowserTestUtils/content-utils.js", true); + +XPCOMUtils.defineLazyModuleGetter(this, "E10SUtils", + "resource:///modules/E10SUtils.jsm"); + +// For now, we'll allow tests to use CPOWs in this module for +// some cases. +Cu.permitCPOWsInScope(this); + +var gSendCharCount = 0; +var gSynthesizeKeyCount = 0; +var gSynthesizeCompositionCount = 0; +var gSynthesizeCompositionChangeCount = 0; + +const kAboutPageRegistrationContentScript = + "chrome://mochikit/content/tests/BrowserTestUtils/content-about-page-utils.js"; + +this.BrowserTestUtils = { + /** + * Loads a page in a new tab, executes a Task and closes the tab. + * + * @param options + * An object or string. + * If this is a string it is the url to open and will be opened in the + * currently active browser window. + * If an object it should have the following properties: + * { + * gBrowser: + * Reference to the "tabbrowser" element where the new tab should + * be opened. + * url: + * String with the URL of the page to load. + * } + * @param taskFn + * Generator function representing a Task that will be executed while + * the tab is loaded. The first argument passed to the function is a + * reference to the browser object for the new tab. + * + * @return {} Returns the value that is returned from taskFn. + * @resolves When the tab has been closed. + * @rejects Any exception from taskFn is propagated. + */ + withNewTab: Task.async(function* (options, taskFn) { + if (typeof(options) == "string") { + options = { + gBrowser: Services.wm.getMostRecentWindow("navigator:browser").gBrowser, + url: options + } + } + let tab = yield BrowserTestUtils.openNewForegroundTab(options.gBrowser, options.url); + let originalWindow = tab.ownerDocument.defaultView; + let result = yield taskFn(tab.linkedBrowser); + let finalWindow = tab.ownerDocument.defaultView; + if (originalWindow == finalWindow && !tab.closing && tab.linkedBrowser) { + yield BrowserTestUtils.removeTab(tab); + } else { + Services.console.logStringMessage( + "BrowserTestUtils.withNewTab: Tab was already closed before " + + "removeTab would have been called"); + } + return Promise.resolve(result); + }), + + /** + * Opens a new tab in the foreground. + * + * @param {tabbrowser} tabbrowser + * The tabbrowser to open the tab new in. + * @param {string} opening + * May be either a string URL to load in the tab, or a function that + * will be called to open a foreground tab. Defaults to "about:blank". + * @param {boolean} waitForLoad + * True to wait for the page in the new tab to load. Defaults to true. + * @param {boolean} waitForStateStop + * True to wait for the web progress listener to send STATE_STOP for the + * document in the tab. Defaults to false. + * + * @return {Promise} + * Resolves when the tab is ready and loaded as necessary. + * @resolves The new tab. + */ + openNewForegroundTab(tabbrowser, opening = "about:blank", aWaitForLoad = true, aWaitForStateStop = false) { + let tab; + let promises = [ + BrowserTestUtils.switchTab(tabbrowser, function () { + if (typeof opening == "function") { + opening(); + tab = tabbrowser.selectedTab; + } + else { + tabbrowser.selectedTab = tab = tabbrowser.addTab(opening); + } + }) + ]; + + if (aWaitForLoad) { + promises.push(BrowserTestUtils.browserLoaded(tab.linkedBrowser)); + } + if (aWaitForStateStop) { + promises.push(BrowserTestUtils.browserStopped(tab.linkedBrowser)); + } + + return Promise.all(promises).then(() => tab); + }, + + /** + * Switches to a tab and resolves when it is ready. + * + * @param {tabbrowser} tabbrowser + * The tabbrowser. + * @param {tab} tab + * Either a tab element to switch to or a function to perform the switch. + * + * @return {Promise} + * Resolves when the tab has been switched to. + * @resolves The tab switched to. + */ + switchTab(tabbrowser, tab) { + let promise = new Promise(resolve => { + tabbrowser.addEventListener("TabSwitchDone", function onSwitch() { + tabbrowser.removeEventListener("TabSwitchDone", onSwitch); + TestUtils.executeSoon(() => resolve(tabbrowser.selectedTab)); + }); + }); + + if (typeof tab == "function") { + tab(); + } + else { + tabbrowser.selectedTab = tab; + } + return promise; + }, + + /** + * Waits for an ongoing page load in a browser window to complete. + * + * This can be used in conjunction with any synchronous method for starting a + * load, like the "addTab" method on "tabbrowser", and must be called before + * yielding control to the event loop. This is guaranteed to work because the + * way we're listening for the load is in the content-utils.js frame script, + * and then sending an async message up, so we can't miss the message. + * + * @param {xul:browser} browser + * A xul:browser. + * @param {Boolean} includeSubFrames + * A boolean indicating if loads from subframes should be included. + * @param {optional string or function} wantLoad + * If a function, takes a URL and returns true if that's the load we're + * interested in. If a string, gives the URL of the load we're interested + * in. If not present, the first load resolves the promise. + * + * @return {Promise} + * @resolves When a load event is triggered for the browser. + */ + browserLoaded(browser, includeSubFrames=false, wantLoad=null) { + function isWanted(url) { + if (!wantLoad) { + return true; + } else if (typeof(wantLoad) == "function") { + return wantLoad(url); + } else { + // It's a string. + return wantLoad == url; + } + } + + return new Promise(resolve => { + let mm = browser.ownerDocument.defaultView.messageManager; + mm.addMessageListener("browser-test-utils:loadEvent", function onLoad(msg) { + if (msg.target == browser && (!msg.data.subframe || includeSubFrames) && + isWanted(msg.data.url)) { + mm.removeMessageListener("browser-test-utils:loadEvent", onLoad); + resolve(msg.data.url); + } + }); + }); + }, + + /** + * Waits for the selected browser to load in a new window. This + * is most useful when you've got a window that might not have + * loaded its DOM yet, and where you can't easily use browserLoaded + * on gBrowser.selectedBrowser since gBrowser doesn't yet exist. + * + * @param {win} + * A newly opened window for which we're waiting for the + * first browser load. + * + * @return {Promise} + * @resolves Once the selected browser fires its load event. + */ + firstBrowserLoaded(win, aboutBlank = true) { + let mm = win.messageManager; + return this.waitForMessage(mm, "browser-test-utils:loadEvent", (msg) => { + let selectedBrowser = win.gBrowser.selectedBrowser; + return msg.target == selectedBrowser && + (aboutBlank || selectedBrowser.currentURI.spec != "about:blank") + }); + }, + + /** + * Waits for the web progress listener associated with this tab to fire a + * STATE_STOP for the toplevel document. + * + * @param {xul:browser} browser + * A xul:browser. + * + * @return {Promise} + * @resolves When STATE_STOP reaches the tab's progress listener + */ + browserStopped(browser) { + return new Promise(resolve => { + let wpl = { + onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { + if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP && + aWebProgress.isTopLevel) { + browser.webProgress.removeProgressListener(filter); + filter.removeProgressListener(wpl); + resolve(); + }; + }, + onSecurityChange() {}, + onStatusChange() {}, + onLocationChange() {}, + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsIWebProgressListener2, + ]), + }; + const filter = Cc["@mozilla.org/appshell/component/browser-status-filter;1"] + .createInstance(Ci.nsIWebProgress); + filter.addProgressListener(wpl, Ci.nsIWebProgress.NOTIFY_ALL); + browser.webProgress.addProgressListener(filter, Ci.nsIWebProgress.NOTIFY_ALL); + }); + }, + + /** + * Waits for the next tab to open and load a given URL. + * + * The method doesn't wait for the tab contents to load. + * + * @param {tabbrowser} tabbrowser + * The tabbrowser to look for the next new tab in. + * @param {string} url + * A string URL to look for in the new tab. If null, allows any non-blank URL. + * + * @return {Promise} + * @resolves With the {xul:tab} when a tab is opened and its location changes to the given URL. + * + * NB: this method will not work if you open a new tab with e.g. BrowserOpenTab + * and the tab does not load a URL, because no onLocationChange will fire. + */ + waitForNewTab(tabbrowser, url) { + return new Promise((resolve, reject) => { + tabbrowser.tabContainer.addEventListener("TabOpen", function onTabOpen(openEvent) { + tabbrowser.tabContainer.removeEventListener("TabOpen", onTabOpen); + + let progressListener = { + onLocationChange(aBrowser) { + if (aBrowser != openEvent.target.linkedBrowser || + (url && aBrowser.currentURI.spec != url) || + (!url && aBrowser.currentURI.spec == "about:blank")) { + return; + } + + tabbrowser.removeTabsProgressListener(progressListener); + resolve(openEvent.target); + }, + }; + tabbrowser.addTabsProgressListener(progressListener); + + }); + }); + }, + + /** + * Waits for onLocationChange. + * + * @param {tabbrowser} tabbrowser + * The tabbrowser to wait for the location change on. + * @param {string} url + * The string URL to look for. The URL must match the URL in the + * location bar exactly. + * @return {Promise} + * @resolves When onLocationChange fires. + */ + waitForLocationChange(tabbrowser, url) { + return new Promise((resolve, reject) => { + let progressListener = { + onLocationChange(aBrowser) { + if ((url && aBrowser.currentURI.spec != url) || + (!url && aBrowser.currentURI.spec == "about:blank")) { + return; + } + + tabbrowser.removeTabsProgressListener(progressListener); + resolve(); + }, + }; + tabbrowser.addTabsProgressListener(progressListener); + }); + }, + + /** + * Waits for the next browser window to open and be fully loaded. + * + * @param {bool} delayedStartup (optional) + * Whether or not to wait for the browser-delayed-startup-finished + * observer notification before resolving. Defaults to true. + * @param {string} initialBrowserLoaded (optional) + * If set, we will wait until the initial browser in the new + * window has loaded a particular page. If unset, the initial + * browser may or may not have finished loading its first page + * when the resulting Promise resolves. + * @return {Promise} + * A Promise which resolves the next time that a DOM window + * opens and the delayed startup observer notification fires. + */ + waitForNewWindow: Task.async(function* (delayedStartup=true, + initialBrowserLoaded=null) { + let win = yield this.domWindowOpened(); + + let promises = [ + TestUtils.topicObserved("browser-delayed-startup-finished", + subject => subject == win), + ]; + + if (initialBrowserLoaded) { + yield this.waitForEvent(win, "DOMContentLoaded"); + + let browser = win.gBrowser.selectedBrowser; + + // Retrieve the given browser's current process type. + let process = + browser.isRemoteBrowser ? Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT + : Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + if (win.gMultiProcessBrowser && + !E10SUtils.canLoadURIInProcess(initialBrowserLoaded, process)) { + yield this.waitForEvent(browser, "XULFrameLoaderCreated"); + } + + let loadPromise = this.browserLoaded(browser, false, initialBrowserLoaded); + promises.push(loadPromise); + } + + yield Promise.all(promises); + + return win; + }), + + /** + * Loads a new URI in the given browser and waits until we really started + * loading. In e10s browser.loadURI() can be an asynchronous operation due + * to having to switch the browser's remoteness and keep its shistory data. + * + * @param {xul:browser} browser + * A xul:browser. + * @param {string} uri + * The URI to load. + * + * @return {Promise} + * @resolves When we started loading the given URI. + */ + loadURI: Task.async(function* (browser, uri) { + // Load the new URI. + browser.loadURI(uri); + + // Nothing to do in non-e10s mode. + if (!browser.ownerDocument.defaultView.gMultiProcessBrowser) { + return; + } + + // Retrieve the given browser's current process type. + let process = browser.isRemoteBrowser ? Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT + : Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + + // If the new URI can't load in the browser's current process then we + // should wait for the new frameLoader to be created. This will happen + // asynchronously when the browser's remoteness changes. + if (!E10SUtils.canLoadURIInProcess(uri, process)) { + yield this.waitForEvent(browser, "XULFrameLoaderCreated"); + } + }), + + /** + * @param win (optional) + * The window we should wait to have "domwindowopened" sent through + * the observer service for. If this is not supplied, we'll just + * resolve when the first "domwindowopened" notification is seen. + * @param {function} checkFn [optional] + * Called with the nsIDOMWindow object as argument, should return true + * if the event is the expected one, or false if it should be ignored + * and observing should continue. If not specified, the first window + * resolves the returned promise. + * @return {Promise} + * A Promise which resolves when a "domwindowopened" notification + * has been fired by the window watcher. + */ + domWindowOpened(win, checkFn) { + return new Promise(resolve => { + function observer(subject, topic, data) { + if (topic == "domwindowopened" && (!win || subject === win)) { + let observedWindow = subject.QueryInterface(Ci.nsIDOMWindow); + if (checkFn && !checkFn(observedWindow)) { + return; + } + Services.ww.unregisterNotification(observer); + resolve(observedWindow); + } + } + Services.ww.registerNotification(observer); + }); + }, + + /** + * @param win (optional) + * The window we should wait to have "domwindowclosed" sent through + * the observer service for. If this is not supplied, we'll just + * resolve when the first "domwindowclosed" notification is seen. + * @return {Promise} + * A Promise which resolves when a "domwindowclosed" notification + * has been fired by the window watcher. + */ + domWindowClosed(win) { + return new Promise((resolve) => { + function observer(subject, topic, data) { + if (topic == "domwindowclosed" && (!win || subject === win)) { + Services.ww.unregisterNotification(observer); + resolve(subject.QueryInterface(Ci.nsIDOMWindow)); + } + } + Services.ww.registerNotification(observer); + }); + }, + + /** + * @param {Object} options + * { + * private: A boolean indicating if the window should be + * private + * remote: A boolean indicating if the window should run + * remote browser tabs or not. If omitted, the window + * will choose the profile default state. + * width: Desired width of window + * height: Desired height of window + * } + * @return {Promise} + * Resolves with the new window once it is loaded. + */ + openNewBrowserWindow: Task.async(function*(options={}) { + let argString = Cc["@mozilla.org/supports-string;1"]. + createInstance(Ci.nsISupportsString); + argString.data = ""; + let features = "chrome,dialog=no,all"; + + if (options.private) { + features += ",private"; + } + + if (options.width) { + features += ",width=" + options.width; + } + if (options.height) { + features += ",height=" + options.height; + } + + if (options.hasOwnProperty("remote")) { + let remoteState = options.remote ? "remote" : "non-remote"; + features += `,${remoteState}`; + } + + let win = Services.ww.openWindow( + null, Services.prefs.getCharPref("browser.chromeURL"), "_blank", + features, argString); + + // Wait for browser-delayed-startup-finished notification, it indicates + // that the window has loaded completely and is ready to be used for + // testing. + let startupPromise = + TestUtils.topicObserved("browser-delayed-startup-finished", + subject => subject == win).then(() => win); + + let loadPromise = this.firstBrowserLoaded(win); + + yield startupPromise; + yield loadPromise; + + return win; + }), + + /** + * Closes a window. + * + * @param {Window} + * A window to close. + * + * @return {Promise} + * Resolves when the provided window has been closed. For browser + * windows, the Promise will also wait until all final SessionStore + * messages have been sent up from all browser tabs. + */ + closeWindow(win) { + let closedPromise = BrowserTestUtils.windowClosed(win); + win.close(); + return closedPromise; + }, + + /** + * Returns a Promise that resolves when a window has finished closing. + * + * @param {Window} + * The closing window. + * + * @return {Promise} + * Resolves when the provided window has been fully closed. For + * browser windows, the Promise will also wait until all final + * SessionStore messages have been sent up from all browser tabs. + */ + windowClosed(win) { + let domWinClosedPromise = BrowserTestUtils.domWindowClosed(win); + let promises = [domWinClosedPromise]; + let winType = win.document.documentElement.getAttribute("windowtype"); + + if (winType == "navigator:browser") { + let finalMsgsPromise = new Promise((resolve) => { + let browserSet = new Set(win.gBrowser.browsers); + let mm = win.getGroupMessageManager("browsers"); + + mm.addMessageListener("SessionStore:update", function onMessage(msg) { + if (browserSet.has(msg.target) && msg.data.isFinal) { + browserSet.delete(msg.target); + if (!browserSet.size) { + mm.removeMessageListener("SessionStore:update", onMessage); + // Give the TabStateFlusher a chance to react to this final + // update and for the TabStateFlusher.flushWindow promise + // to resolve before we resolve. + TestUtils.executeSoon(resolve); + } + } + }, true); + }); + + promises.push(finalMsgsPromise); + } + + return Promise.all(promises); + }, + + /** + * Waits for an event to be fired on a specified element. + * + * Usage: + * let promiseEvent = BrowserTestUtils.waitForEvent(element, "eventName"); + * // Do some processing here that will cause the event to be fired + * // ... + * // Now yield until the Promise is fulfilled + * let receivedEvent = yield promiseEvent; + * + * @param {Element} subject + * The element that should receive the event. + * @param {string} eventName + * Name of the event to listen to. + * @param {bool} capture [optional] + * True to use a capturing listener. + * @param {function} checkFn [optional] + * Called with the Event object as argument, should return true if the + * event is the expected one, or false if it should be ignored and + * listening should continue. If not specified, the first event with + * the specified name resolves the returned promise. + * @param {bool} wantsUntrusted [optional] + * True to receive synthetic events dispatched by web content. + * + * @note Because this function is intended for testing, any error in checkFn + * will cause the returned promise to be rejected instead of waiting for + * the next event, since this is probably a bug in the test. + * + * @returns {Promise} + * @resolves The Event object. + */ + waitForEvent(subject, eventName, capture, checkFn, wantsUntrusted) { + return new Promise((resolve, reject) => { + subject.addEventListener(eventName, function listener(event) { + try { + if (checkFn && !checkFn(event)) { + return; + } + subject.removeEventListener(eventName, listener, capture); + resolve(event); + } catch (ex) { + try { + subject.removeEventListener(eventName, listener, capture); + } catch (ex2) { + // Maybe the provided object does not support removeEventListener. + } + reject(ex); + } + }, capture, wantsUntrusted); + }); + }, + + /** + * Like waitForEvent, but adds the event listener to the message manager + * global for browser. + * + * @param {string} eventName + * Name of the event to listen to. + * @param {bool} capture [optional] + * Whether to use a capturing listener. + * @param {function} checkFn [optional] + * Called with the Event object as argument, should return true if the + * event is the expected one, or false if it should be ignored and + * listening should continue. If not specified, the first event with + * the specified name resolves the returned promise. + * @param {bool} wantsUntrusted [optional] + * Whether to accept untrusted events + * + * @note Because this function is intended for testing, any error in checkFn + * will cause the returned promise to be rejected instead of waiting for + * the next event, since this is probably a bug in the test. + * + * @returns {Promise} + */ + waitForContentEvent(browser, eventName, capture = false, checkFn, wantsUntrusted = false) { + let parameters = { + eventName, + capture, + checkFnSource: checkFn ? checkFn.toSource() : null, + wantsUntrusted, + }; + return ContentTask.spawn(browser, parameters, + function({ eventName, capture, checkFnSource, wantsUntrusted }) { + let checkFn; + if (checkFnSource) { + checkFn = eval(`(() => (${checkFnSource}))()`); + } + return new Promise((resolve, reject) => { + addEventListener(eventName, function listener(event) { + let completion = resolve; + try { + if (checkFn && !checkFn(event)) { + return; + } + } catch (e) { + completion = () => reject(e); + } + removeEventListener(eventName, listener, capture); + completion(); + }, capture, wantsUntrusted); + }); + }); + }, + + /** + * Like browserLoaded, but waits for an error page to appear. + * This explicitly deals with cases where the browser is not currently remote and a + * remoteness switch will occur before the error page is loaded, which is tricky + * because error pages don't fire 'regular' load events that we can rely on. + * + * @param {xul:browser} browser + * A xul:browser. + * + * @return {Promise} + * @resolves When an error page has been loaded in the browser. + */ + waitForErrorPage(browser) { + let waitForLoad = () => + this.waitForContentEvent(browser, "AboutNetErrorLoad", false, null, true); + + let win = browser.ownerDocument.defaultView; + let tab = win.gBrowser.getTabForBrowser(browser); + if (!tab || browser.isRemoteBrowser || !win.gMultiProcessBrowser) { + return waitForLoad(); + } + + // We're going to switch remoteness when loading an error page. We need to be + // quite careful in order to make sure we're adding the listener in time to + // get this event: + return new Promise((resolve, reject) => { + tab.addEventListener("TabRemotenessChange", function onTRC() { + tab.removeEventListener("TabRemotenessChange", onTRC); + waitForLoad().then(resolve, reject); + }); + }); + }, + + /** + * Versions of EventUtils.jsm synthesizeMouse functions that synthesize a + * mouse event in a child process and return promises that resolve when the + * event has fired and completed. Instead of a window, a browser is required + * to be passed to this function. + * + * @param target + * One of the following: + * - a selector string that identifies the element to target. The syntax is as + * for querySelector. + * - a CPOW element (for easier test-conversion). + * - a function to be run in the content process that returns the element to + * target + * - null, in which case the offset is from the content document's edge. + * @param {integer} offsetX + * x offset from target's left bounding edge + * @param {integer} offsetY + * y offset from target's top bounding edge + * @param {Object} event object + * Additional arguments, similar to the EventUtils.jsm version + * @param {Browser} browser + * Browser element, must not be null + * + * @returns {Promise} + * @resolves True if the mouse event was cancelled. + */ + synthesizeMouse(target, offsetX, offsetY, event, browser) + { + return new Promise((resolve, reject) => { + let mm = browser.messageManager; + mm.addMessageListener("Test:SynthesizeMouseDone", function mouseMsg(message) { + mm.removeMessageListener("Test:SynthesizeMouseDone", mouseMsg); + if (message.data.hasOwnProperty("defaultPrevented")) { + resolve(message.data.defaultPrevented); + } else { + reject(new Error(message.data.error)); + } + }); + + let cpowObject = null; + let targetFn = null; + if (typeof target == "function") { + targetFn = target.toString(); + target = null; + } else if (typeof target != "string") { + cpowObject = target; + target = null; + } + + mm.sendAsyncMessage("Test:SynthesizeMouse", + {target, targetFn, x: offsetX, y: offsetY, event: event}, + {object: cpowObject}); + }); + }, + + /** + * Wait for a message to be fired from a particular message manager + * + * @param {nsIMessageManager} messageManager + * The message manager that should be used. + * @param {String} message + * The message we're waiting for. + * @param {Function} checkFn (optional) + * Optional function to invoke to check the message. + */ + waitForMessage(messageManager, message, checkFn) { + return new Promise(resolve => { + messageManager.addMessageListener(message, function onMessage(msg) { + if (!checkFn || checkFn(msg)) { + messageManager.removeMessageListener(message, onMessage); + resolve(); + } + }); + }); + }, + + /** + * Version of synthesizeMouse that uses the center of the target as the mouse + * location. Arguments and the return value are the same. + */ + synthesizeMouseAtCenter(target, event, browser) + { + // Use a flag to indicate to center rather than having a separate message. + event.centered = true; + return BrowserTestUtils.synthesizeMouse(target, 0, 0, event, browser); + }, + + /** + * Version of synthesizeMouse that uses a client point within the child + * window instead of a target as the offset. Otherwise, the arguments and + * return value are the same as synthesizeMouse. + */ + synthesizeMouseAtPoint(offsetX, offsetY, event, browser) + { + return BrowserTestUtils.synthesizeMouse(null, offsetX, offsetY, event, browser); + }, + + /** + * Removes the given tab from its parent tabbrowser and + * waits until its final message has reached the parent. + */ + removeTab(tab, options = {}) { + let dontRemove = options && options.dontRemove; + + return new Promise(resolve => { + let {messageManager: mm, frameLoader} = tab.linkedBrowser; + mm.addMessageListener("SessionStore:update", function onMessage(msg) { + if (msg.targetFrameLoader == frameLoader && msg.data.isFinal) { + mm.removeMessageListener("SessionStore:update", onMessage); + resolve(); + } + }, true); + + if (!dontRemove && !tab.closing) { + tab.ownerDocument.defaultView.gBrowser.removeTab(tab); + } + }); + }, + + /** + * Crashes a remote browser tab and cleans up the generated minidumps. + * Resolves with the data from the .extra file (the crash annotations). + * + * @param (Browser) browser + * A remote <xul:browser> element. Must not be null. + * @param (bool) shouldShowTabCrashPage + * True if it is expected that the tab crashed page will be shown + * for this browser. If so, the Promise will only resolve once the + * tab crash page has loaded. + * + * @returns (Promise) + * @resolves An Object with key-value pairs representing the data from the + * crash report's extra file (if applicable). + */ + crashBrowser: Task.async(function*(browser, shouldShowTabCrashPage=true) { + let extra = {}; + let KeyValueParser = {}; + if (AppConstants.MOZ_CRASHREPORTER) { + Cu.import("resource://gre/modules/KeyValueParser.jsm", KeyValueParser); + } + + if (!browser.isRemoteBrowser) { + throw new Error("<xul:browser> needs to be remote in order to crash"); + } + + /** + * Returns the directory where crash dumps are stored. + * + * @return nsIFile + */ + function getMinidumpDirectory() { + let dir = Services.dirsvc.get('ProfD', Ci.nsIFile); + dir.append("minidumps"); + return dir; + } + + /** + * Removes a file from a directory. This is a no-op if the file does not + * exist. + * + * @param directory + * The nsIFile representing the directory to remove from. + * @param filename + * A string for the file to remove from the directory. + */ + function removeFile(directory, filename) { + let file = directory.clone(); + file.append(filename); + if (file.exists()) { + file.remove(false); + } + } + + // This frame script is injected into the remote browser, and used to + // intentionally crash the tab. We crash by using js-ctypes and dereferencing + // a bad pointer. The crash should happen immediately upon loading this + // frame script. + let frame_script = () => { + const Cu = Components.utils; + Cu.import("resource://gre/modules/ctypes.jsm"); + + let dies = function() { + privateNoteIntentionalCrash(); + let zero = new ctypes.intptr_t(8); + let badptr = ctypes.cast(zero, ctypes.PointerType(ctypes.int32_t)); + badptr.contents + }; + + dump("\nEt tu, Brute?\n"); + dies(); + } + + let expectedPromises = []; + + let crashCleanupPromise = new Promise((resolve, reject) => { + let observer = (subject, topic, data) => { + if (topic != "ipc:content-shutdown") { + return reject("Received incorrect observer topic: " + topic); + } + if (!(subject instanceof Ci.nsIPropertyBag2)) { + return reject("Subject did not implement nsIPropertyBag2"); + } + // we might see this called as the process terminates due to previous tests. + // We are only looking for "abnormal" exits... + if (!subject.hasKey("abnormal")) { + dump("\nThis is a normal termination and isn't the one we are looking for...\n"); + return; + } + + let dumpID; + if ('nsICrashReporter' in Ci) { + dumpID = subject.getPropertyAsAString('dumpID'); + if (!dumpID) { + return reject("dumpID was not present despite crash reporting " + + "being enabled"); + } + } + + if (dumpID) { + let minidumpDirectory = getMinidumpDirectory(); + let extrafile = minidumpDirectory.clone(); + extrafile.append(dumpID + '.extra'); + if (extrafile.exists()) { + dump(`\nNo .extra file for dumpID: ${dumpID}\n`); + if (AppConstants.MOZ_CRASHREPORTER) { + extra = KeyValueParser.parseKeyValuePairsFromFile(extrafile); + } else { + dump('\nCrashReporter not enabled - will not return any extra data\n'); + } + } + + removeFile(minidumpDirectory, dumpID + '.dmp'); + removeFile(minidumpDirectory, dumpID + '.extra'); + } + + Services.obs.removeObserver(observer, 'ipc:content-shutdown'); + dump("\nCrash cleaned up\n"); + // There might be other ipc:content-shutdown handlers that need to run before + // we want to continue, so we'll resolve on the next tick of the event loop. + TestUtils.executeSoon(() => resolve()); + }; + + Services.obs.addObserver(observer, 'ipc:content-shutdown', false); + }); + + expectedPromises.push(crashCleanupPromise); + + if (shouldShowTabCrashPage) { + expectedPromises.push(new Promise((resolve, reject) => { + browser.addEventListener("AboutTabCrashedReady", function onCrash() { + browser.removeEventListener("AboutTabCrashedReady", onCrash, false); + dump("\nabout:tabcrashed loaded and ready\n"); + resolve(); + }, false, true); + })); + } + + // This frame script will crash the remote browser as soon as it is + // evaluated. + let mm = browser.messageManager; + mm.loadFrameScript("data:,(" + frame_script.toString() + ")();", false); + + yield Promise.all(expectedPromises); + + if (shouldShowTabCrashPage) { + let gBrowser = browser.ownerDocument.defaultView.gBrowser; + let tab = gBrowser.getTabForBrowser(browser); + if (tab.getAttribute("crashed") != "true") { + throw new Error("Tab should be marked as crashed"); + } + } + + return extra; + }), + + /** + * Returns a promise that is resolved when element gains attribute (or, + * optionally, when it is set to value). + * @param {String} attr + * The attribute to wait for + * @param {Element} element + * The element which should gain the attribute + * @param {String} value (optional) + * Optional, the value the attribute should have. + * + * @returns {Promise} + */ + waitForAttribute(attr, element, value) { + let MutationObserver = element.ownerDocument.defaultView.MutationObserver; + return new Promise(resolve => { + let mut = new MutationObserver(mutations => { + if ((!value && element.getAttribute(attr)) || + (value && element.getAttribute(attr) === value)) { + resolve(); + mut.disconnect(); + return; + } + }); + + mut.observe(element, {attributeFilter: [attr]}); + }); + }, + + /** + * Version of EventUtils' `sendChar` function; it will synthesize a keypress + * event in a child process and returns a Promise that will resolve when the + * event was fired. Instead of a Window, a Browser object is required to be + * passed to this function. + * + * @param {String} char + * A character for the keypress event that is sent to the browser. + * @param {Browser} browser + * Browser element, must not be null. + * + * @returns {Promise} + * @resolves True if the keypress event was synthesized. + */ + sendChar(char, browser) { + return new Promise(resolve => { + let seq = ++gSendCharCount; + let mm = browser.messageManager; + + mm.addMessageListener("Test:SendCharDone", function charMsg(message) { + if (message.data.seq != seq) + return; + + mm.removeMessageListener("Test:SendCharDone", charMsg); + resolve(message.data.result); + }); + + mm.sendAsyncMessage("Test:SendChar", { + char: char, + seq: seq + }); + }); + }, + + /** + * Version of EventUtils' `synthesizeKey` function; it will synthesize a key + * event in a child process and returns a Promise that will resolve when the + * event was fired. Instead of a Window, a Browser object is required to be + * passed to this function. + * + * @param {String} key + * See the documentation available for EventUtils#synthesizeKey. + * @param {Object} event + * See the documentation available for EventUtils#synthesizeKey. + * @param {Browser} browser + * Browser element, must not be null. + * + * @returns {Promise} + */ + synthesizeKey(key, event, browser) { + return new Promise(resolve => { + let seq = ++gSynthesizeKeyCount; + let mm = browser.messageManager; + + mm.addMessageListener("Test:SynthesizeKeyDone", function keyMsg(message) { + if (message.data.seq != seq) + return; + + mm.removeMessageListener("Test:SynthesizeKeyDone", keyMsg); + resolve(); + }); + + mm.sendAsyncMessage("Test:SynthesizeKey", { key, event, seq }); + }); + }, + + /** + * Version of EventUtils' `synthesizeComposition` function; it will synthesize + * a composition event in a child process and returns a Promise that will + * resolve when the event was fired. Instead of a Window, a Browser object is + * required to be passed to this function. + * + * @param {Object} event + * See the documentation available for EventUtils#synthesizeComposition. + * @param {Browser} browser + * Browser element, must not be null. + * + * @returns {Promise} + * @resolves False if the composition event could not be synthesized. + */ + synthesizeComposition(event, browser) { + return new Promise(resolve => { + let seq = ++gSynthesizeCompositionCount; + let mm = browser.messageManager; + + mm.addMessageListener("Test:SynthesizeCompositionDone", function compMsg(message) { + if (message.data.seq != seq) + return; + + mm.removeMessageListener("Test:SynthesizeCompositionDone", compMsg); + resolve(message.data.result); + }); + + mm.sendAsyncMessage("Test:SynthesizeComposition", { event, seq }); + }); + }, + + /** + * Version of EventUtils' `synthesizeCompositionChange` function; it will + * synthesize a compositionchange event in a child process and returns a + * Promise that will resolve when the event was fired. Instead of a Window, a + * Browser object is required to be passed to this function. + * + * @param {Object} event + * See the documentation available for EventUtils#synthesizeCompositionChange. + * @param {Browser} browser + * Browser element, must not be null. + * + * @returns {Promise} + */ + synthesizeCompositionChange(event, browser) { + return new Promise(resolve => { + let seq = ++gSynthesizeCompositionChangeCount; + let mm = browser.messageManager; + + mm.addMessageListener("Test:SynthesizeCompositionChangeDone", function compMsg(message) { + if (message.data.seq != seq) + return; + + mm.removeMessageListener("Test:SynthesizeCompositionChangeDone", compMsg); + resolve(); + }); + + mm.sendAsyncMessage("Test:SynthesizeCompositionChange", { event, seq }); + }); + }, + + /** + * Will poll a condition function until it returns true. + * + * @param condition + * A condition function that must return true or false. If the + * condition ever throws, this is also treated as a false. The + * function can be a generator. + * @param interval + * The time interval to poll the condition function. Defaults + * to 100ms. + * @param attempts + * The number of times to poll before giving up and rejecting + * if the condition has not yet returned true. Defaults to 50 + * (~5 seconds for 100ms intervals) + * @return Promise + * Resolves when condition is true. + * Rejects if timeout is exceeded or condition ever throws. + */ + waitForCondition(condition, msg, interval=100, maxTries=50) { + return new Promise((resolve, reject) => { + let tries = 0; + let intervalID = setInterval(Task.async(function* () { + if (tries >= maxTries) { + clearInterval(intervalID); + msg += ` - timed out after ${maxTries} tries.`; + reject(msg); + return; + } + + let conditionPassed = false; + try { + conditionPassed = yield condition(); + } catch(e) { + msg += ` - threw exception: ${e}`; + clearInterval(intervalID); + reject(msg); + return; + } + + if (conditionPassed) { + clearInterval(intervalID); + resolve(); + } + tries++; + }), interval); + }); + }, + + /** + * Waits for a <xul:notification> with a particular value to appear + * for the <xul:notificationbox> of the passed in browser. + * + * @param tabbrowser (<xul:tabbrowser>) + * The gBrowser that hosts the browser that should show + * the notification. For most tests, this will probably be + * gBrowser. + * @param browser (<xul:browser>) + * The browser that should be showing the notification. + * @param notificationValue (string) + * The "value" of the notification, which is often used as + * a unique identifier. Example: "plugin-crashed". + * @return Promise + * Resolves to the <xul:notification> that is being shown. + */ + waitForNotificationBar(tabbrowser, browser, notificationValue) { + let notificationBox = tabbrowser.getNotificationBox(browser); + return this.waitForNotificationInNotificationBox(notificationBox, + notificationValue); + }, + + /** + * Waits for a <xul:notification> with a particular value to appear + * in the global <xul:notificationbox> of the given browser window. + * + * @param win (<xul:window>) + * The browser window in whose global notificationbox the + * notification is expected to appear. + * @param notificationValue (string) + * The "value" of the notification, which is often used as + * a unique identifier. Example: "captive-portal-detected". + * @return Promise + * Resolves to the <xul:notification> that is being shown. + */ + waitForGlobalNotificationBar(win, notificationValue) { + let notificationBox = + win.document.getElementById("high-priority-global-notificationbox"); + return this.waitForNotificationInNotificationBox(notificationBox, + notificationValue); + }, + + waitForNotificationInNotificationBox(notificationBox, notificationValue) { + return new Promise((resolve) => { + let check = (event) => { + return event.target.value == notificationValue; + }; + + BrowserTestUtils.waitForEvent(notificationBox, "AlertActive", + false, check).then((event) => { + // The originalTarget of the AlertActive on a notificationbox + // will be the notification itself. + resolve(event.originalTarget); + }); + }); + }, + + /** + * Returns a Promise that will resolve once MozAfterPaint + * has been fired in the content of a browser. + * + * @param browser (<xul:browser>) + * The browser for which we're waiting for the MozAfterPaint + * event to occur in. + * @returns Promise + */ + contentPainted(browser) { + return ContentTask.spawn(browser, null, function*() { + return new Promise((resolve) => { + addEventListener("MozAfterPaint", function onPaint() { + removeEventListener("MozAfterPaint", onPaint); + resolve(); + }) + }); + }); + }, + + _knownAboutPages: new Set(), + _loadedAboutContentScript: false, + /** + * Registers an about: page with particular flags in both the parent + * and any content processes. Returns a promise that resolves when + * registration is complete. + * + * @param registerCleanupFunction (Function) + * The test framework doesn't keep its cleanup stuff anywhere accessible, + * so the first argument is a reference to your cleanup registration + * function, allowing us to clean up after you if necessary. + * @param aboutModule (String) + * The name of the about page. + * @param pageURI (String) + * The URI the about: page should point to. + * @param flags (Number) + * The nsIAboutModule flags to use for registration. + * @returns Promise that resolves when registration has finished. + */ + registerAboutPage(registerCleanupFunction, aboutModule, pageURI, flags) { + // Return a promise that resolves when registration finished. + const kRegistrationMsgId = "browser-test-utils:about-registration:registered"; + let rv = this.waitForMessage(Services.ppmm, kRegistrationMsgId, msg => { + return msg.data == aboutModule; + }); + // Load a script that registers our page, then send it a message to execute the registration. + if (!this._loadedAboutContentScript) { + Services.ppmm.loadProcessScript(kAboutPageRegistrationContentScript, true); + this._loadedAboutContentScript = true; + registerCleanupFunction(this._removeAboutPageRegistrations.bind(this)); + } + Services.ppmm.broadcastAsyncMessage("browser-test-utils:about-registration:register", + {aboutModule, pageURI, flags}); + return rv.then(() => { + this._knownAboutPages.add(aboutModule); + }); + }, + + unregisterAboutPage(aboutModule) { + if (!this._knownAboutPages.has(aboutModule)) { + return Promise.reject(new Error("We don't think this about page exists!")); + } + const kUnregistrationMsgId = "browser-test-utils:about-registration:unregistered"; + let rv = this.waitForMessage(Services.ppmm, kUnregistrationMsgId, msg => { + return msg.data == aboutModule; + }); + Services.ppmm.broadcastAsyncMessage("browser-test-utils:about-registration:unregister", + aboutModule); + return rv.then(() => this._knownAboutPages.delete(aboutModule)); + }, + + *_removeAboutPageRegistrations() { + for (let aboutModule of this._knownAboutPages) { + yield this.unregisterAboutPage(aboutModule); + } + Services.ppmm.removeDelayedProcessScript(kAboutPageRegistrationContentScript); + }, +}; |