From 2866bc388a06d086a565aca6a4bc514768eaba9a Mon Sep 17 00:00:00 2001 From: Henrik Skupin Date: Tue, 15 Sep 2020 04:53:24 +0000 Subject: [PATCH] Bug 1612831 - [marionette] Move navigation commands to parent process. r=marionette-reviewers,maja_zf Differential Revision: https://phabricator.services.mozilla.com/D80622 --- testing/marionette/driver.js | 158 +--- .../tests/unit/test_navigation.py | 7 + .../tests/unit/test_reftest.py | 5 +- testing/marionette/listener.js | 740 ++++-------------- testing/marionette/navigate.js | 301 +++++++ testing/marionette/reftest.js | 25 +- testing/marionette/sync.js | 14 - 7 files changed, 512 insertions(+), 738 deletions(-) diff --git a/testing/marionette/driver.js b/testing/marionette/driver.js index a35065f172d6..ea4967999d02 100644 --- a/testing/marionette/driver.js +++ b/testing/marionette/driver.js @@ -110,6 +110,10 @@ this.GeckoDriver = function(server) { this.sessionID = null; this.wins = new browser.Windows(); this.browsers = {}; + + // Maps permanentKey to browsing context id: WeakMap. + this._browserIds = new WeakMap(); + // points to current browser this.curBrowser = null; // top-most chrome window @@ -119,9 +123,6 @@ this.GeckoDriver = function(server) { this.chromeBrowsingContext = null; this.contentBrowsingContext = null; - this.observing = null; - this._browserIds = new WeakMap(); - // Use content context by default this.context = Context.Content; @@ -269,6 +270,8 @@ GeckoDriver.prototype.init = function() { this.mm.addMessageListener("Marionette:ListenersAttached", this); this.mm.addMessageListener("Marionette:Register", this); this.mm.addMessageListener("Marionette:switchedToFrame", this); + this.mm.addMessageListener("Marionette:NavigationEvent", this); + this.mm.addMessageListener("Marionette:Unloaded", this, true); }; GeckoDriver.prototype.uninit = function() { @@ -276,6 +279,8 @@ GeckoDriver.prototype.uninit = function() { this.mm.removeMessageListener("Marionette:ListenersAttached", this); this.mm.removeMessageListener("Marionette:Register", this); this.mm.removeMessageListener("Marionette:switchedToFrame", this); + this.mm.removeMessageListener("Marionette:NavigationEvent", this); + this.mm.removeMessageListener("Marionette:Unloaded", this); }; /** @@ -1188,29 +1193,14 @@ GeckoDriver.prototype.navigateTo = async function(cmd) { const currentURL = await this._getCurrentURL(); const loadEventExpected = navigate.isLoadEventExpected(currentURL, validURL); - const navigated = this.listener.navigateTo({ - url: validURL.href, - loadEventExpected, - pageTimeout: this.timeouts.pageLoad, - }); - - // If a process change of the frame script interrupts our page load, this - // will never return. We need to re-issue this request to correctly poll for - // readyState and send errors. - this.curBrowser.pendingCommands.push(() => { - let parameters = { - // TODO(ato): Bug 1242595 - commandID: this.listener.activeMessageId, - pageTimeout: this.timeouts.pageLoad, - startTime: new Date().getTime(), - }; - this.curBrowser.messageManager.sendAsyncMessage( - "Marionette:waitForPageLoaded", - parameters - ); - }); - - await navigated; + const browsingContext = this.getBrowsingContext({ context: Context.Content }); + await navigate.waitForNavigationCompleted( + this, + () => { + navigate.navigateTo(browsingContext, validURL); + }, + { loadEventExpected } + ); this.curBrowser.contentBrowser.focus(); }; @@ -1308,32 +1298,16 @@ GeckoDriver.prototype.goBack = async function() { assert.open(this.curBrowser); await this._handleUserPrompts(); + const browsingContext = this.getBrowsingContext({ context: Context.Content }); + // If there is no history, just return - if (!this.curBrowser.contentBrowser.webNavigation.canGoBack) { + if (!browsingContext.top.embedderElement?.canGoBack) { return; } - let lastURL = await this._getCurrentURL(); - let goBack = this.listener.goBack({ pageTimeout: this.timeouts.pageLoad }); - - // If a process change of the frame script interrupts our page load, this - // will never return. We need to re-issue this request to correctly poll for - // readyState and send errors. - this.curBrowser.pendingCommands.push(() => { - let parameters = { - // TODO(ato): Bug 1242595 - commandID: this.listener.activeMessageId, - lastSeenURL: lastURL.href, - pageTimeout: this.timeouts.pageLoad, - startTime: new Date().getTime(), - }; - this.curBrowser.messageManager.sendAsyncMessage( - "Marionette:waitForPageLoaded", - parameters - ); + await navigate.waitForNavigationCompleted(this, () => { + browsingContext.goBack(); }); - - await goBack; }; /** @@ -1352,34 +1326,16 @@ GeckoDriver.prototype.goForward = async function() { assert.open(this.curBrowser); await this._handleUserPrompts(); + const browsingContext = this.getBrowsingContext({ context: Context.Content }); + // If there is no history, just return - if (!this.curBrowser.contentBrowser.webNavigation.canGoForward) { + if (!browsingContext.top.embedderElement?.canGoForward) { return; } - let lastURL = await this._getCurrentURL(); - let goForward = this.listener.goForward({ - pageTimeout: this.timeouts.pageLoad, + await navigate.waitForNavigationCompleted(this, () => { + browsingContext.goForward(); }); - - // If a process change of the frame script interrupts our page load, this - // will never return. We need to re-issue this request to correctly poll for - // readyState and send errors. - this.curBrowser.pendingCommands.push(() => { - let parameters = { - // TODO(ato): Bug 1242595 - commandID: this.listener.activeMessageId, - lastSeenURL: lastURL.href, - pageTimeout: this.timeouts.pageLoad, - startTime: new Date().getTime(), - }; - this.curBrowser.messageManager.sendAsyncMessage( - "Marionette:waitForPageLoaded", - parameters - ); - }); - - await goForward; }; /** @@ -1398,25 +1354,13 @@ GeckoDriver.prototype.refresh = async function() { assert.open(this.getCurrentWindow()); await this._handleUserPrompts(); - let refresh = this.listener.refresh({ pageTimeout: this.timeouts.pageLoad }); + // We need to move to the top frame before navigating + await this.listener.switchToFrame(); - // If a process change of the frame script interrupts our page load, this - // will never return. We need to re-issue this request to correctly poll for - // readyState and send errors. - this.curBrowser.pendingCommands.push(() => { - let parameters = { - // TODO(ato): Bug 1242595 - commandID: this.listener.activeMessageId, - pageTimeout: this.timeouts.pageLoad, - startTime: new Date().getTime(), - }; - this.curBrowser.messageManager.sendAsyncMessage( - "Marionette:waitForPageLoaded", - parameters - ); + const browsingContext = this.getBrowsingContext({ context: Context.Content }); + await navigate.waitForNavigationCompleted(this, () => { + navigate.refresh(browsingContext); }); - - await refresh; }; /** @@ -2197,28 +2141,19 @@ GeckoDriver.prototype.clickElement = async function(cmd) { break; case Context.Content: - let click = this.listener.clickElement({ - webElRef: webEl.toJSON(), - pageTimeout: this.timeouts.pageLoad, - }); + const target = await this.listener.getElementAttribute(webEl, "target"); - // If a process change of the frame script interrupts our page load, - // this will never return. We need to re-issue this request to correctly - // poll for readyState and send errors. - this.curBrowser.pendingCommands.push(() => { - let parameters = { - // TODO(ato): Bug 1242595 - commandID: this.listener.activeMessageId, - pageTimeout: this.timeouts.pageLoad, - startTime: new Date().getTime(), - }; - this.curBrowser.messageManager.sendAsyncMessage( - "Marionette:waitForPageLoaded", - parameters - ); - }); - - await click; + await navigate.waitForNavigationCompleted( + this, + async () => { + await this.listener.clickElement(webEl); + }, + { + browsingContext: this.getBrowsingContext(), + requireBeforeUnload: false, + loadEventExpected: target !== "_blank", + } + ); break; default: @@ -2982,13 +2917,6 @@ GeckoDriver.prototype.deleteSession = function() { this.chromeBrowsingContext = null; this.contentBrowsingContext = null; - if (this.observing !== null) { - for (let topic in this.observing) { - Services.obs.removeObserver(this.observing[topic], topic); - } - this.observing = null; - } - if (this.dialogObserver) { this.dialogObserver.cleanup(); this.dialogObserver = null; diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py b/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py index 84c30f91ca06..0e8f284d30e1 100644 --- a/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py @@ -292,6 +292,13 @@ class TestNavigate(BaseNavigationTestCase): self.marionette.navigate("about:blank") + def test_about_newtab(self): + with self.marionette.using_prefs({"browser.newtabpage.enabled": True}): + self.marionette.navigate("about:newtab") + + self.marionette.navigate(self.test_page_remote) + self.marionette.find_element(By.ID, "testDiv") + @run_if_manage_instance("Only runnable if Marionette manages the instance") def test_focus_after_navigation(self): self.marionette.restart() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py b/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py index eb53f5b70f7f..768d69b4c9c3 100644 --- a/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py @@ -4,8 +4,8 @@ from __future__ import absolute_import, print_function -from marionette_driver.errors import InvalidArgumentException, UnsupportedOperationException -from marionette_harness import MarionetteTestCase +from marionette_driver.errors import UnsupportedOperationException +from marionette_harness import MarionetteTestCase, skip class TestReftest(MarionetteTestCase): @@ -30,6 +30,7 @@ class TestReftest(MarionetteTestCase): super(TestReftest, self).tearDown() + @skip("Bug 1648444 - Unexpected page unload when refreshing about:blank") def test_basic(self): self.marionette._send_message("reftest:setup", {"screenshot": "unexpected"}) rv = self.marionette._send_message("reftest:run", diff --git a/testing/marionette/listener.js b/testing/marionette/listener.js index 86fb29657329..80996f663c3f 100644 --- a/testing/marionette/listener.js +++ b/testing/marionette/listener.js @@ -43,6 +43,7 @@ XPCOMUtils.defineLazyModuleGetters(this, { XPCOMUtils.defineLazyGetter(this, "logger", () => Log.getWithPrefix(contentId)); XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); +const contentFrameMessageManager = this; const contentId = content.docShell.browsingContext.id; const curContainer = { @@ -90,405 +91,6 @@ const eventObservers = new ContentEventObserverService( sendAsyncMessage.bind(this) ); -/** - * The load listener singleton helps to keep track of active page load - * activities, and can be used by any command which might cause a navigation - * to happen. In the specific case of a process change of the frame script it - * allows to continue observing the current page load. - */ -const loadListener = { - commandID: null, - seenBeforeUnload: false, - seenUnload: false, - timeout: null, - timerPageLoad: null, - timerPageUnload: null, - - /** - * Start listening for page unload/load events. - * - * @param {number} commandID - * ID of the currently handled message between the driver and - * listener. - * @param {number} timeout - * Timeout in seconds the method has to wait for the page being - * finished loading. - * @param {number} startTime - * Unix timestap when the navitation request got triggered. - * @param {boolean=} waitForUnloaded - * If true wait for page unload events, otherwise only for page - * load events. - */ - start(commandID, timeout, startTime, waitForUnloaded = true) { - this.commandID = commandID; - this.timeout = timeout; - - this.seenBeforeUnload = false; - this.seenUnload = false; - - this.timerPageLoad = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); - this.timerPageUnload = null; - - // In case the frame script has been moved to a differnt process, - // wait the remaining time - timeout = startTime + timeout - new Date().getTime(); - - if (timeout <= 0) { - this.notify(this.timerPageLoad); - return; - } - - if (waitForUnloaded) { - addEventListener("beforeunload", this, true); - addEventListener("hashchange", this, true); - addEventListener("pagehide", this, true); - addEventListener("popstate", this, true); - addEventListener("unload", this, true); - - Services.obs.addObserver(this, "outer-window-destroyed"); - } else { - // The frame script has been moved to a differnt content process. - // Due to the time it takes to re-register the browser in Marionette, - // it can happen that page load events are missed before the listeners - // are getting attached again. By checking the document readyState the - // command can return immediately if the page load is already done. - let readyState = content.document.readyState; - let documentURI = content.document.documentURI; - logger.trace(truncate`Check readyState ${readyState} for ${documentURI}`); - // If the page load has already finished, don't setup listeners and - // timers but return immediatelly. - if (this.handleReadyState(readyState, documentURI)) { - return; - } - - addEventListener("DOMContentLoaded", loadListener, true); - addEventListener("pageshow", loadListener, true); - } - - this.timerPageLoad.initWithCallback( - this, - timeout, - Ci.nsITimer.TYPE_ONE_SHOT - ); - }, - - /** - * Stop listening for page unload/load events. - */ - stop() { - if (this.timerPageLoad) { - this.timerPageLoad.cancel(); - } - - if (this.timerPageUnload) { - this.timerPageUnload.cancel(); - } - - removeEventListener("beforeunload", this, true); - removeEventListener("hashchange", this, true); - removeEventListener("pagehide", this, true); - removeEventListener("popstate", this, true); - removeEventListener("DOMContentLoaded", this, true); - removeEventListener("pageshow", this, true); - removeEventListener("unload", this, true); - - // In case the observer was added before the frame script has been moved - // to a different process, it will no longer be available. Exceptions can - // be ignored. - try { - Services.obs.removeObserver(this, "outer-window-destroyed"); - } catch (e) {} - }, - - /** - * Callback for registered DOM events. - */ - handleEvent(event) { - // Only care about events from the currently selected browsing context, - // whereby some of those do not bubble up to the window. - if ( - event.target != curContainer.frame && - event.target != curContainer.frame.document - ) { - return; - } - - let location = event.target.documentURI || event.target.location.href; - logger.trace(truncate`Received DOM event ${event.type} for ${location}`); - - switch (event.type) { - case "beforeunload": - this.seenBeforeUnload = true; - break; - - case "unload": - this.seenUnload = true; - break; - - case "pagehide": - this.seenUnload = true; - - removeEventListener("hashchange", this, true); - removeEventListener("pagehide", this, true); - removeEventListener("popstate", this, true); - - // Now wait until the target page has been loaded - addEventListener("DOMContentLoaded", this, true); - addEventListener("pageshow", this, true); - break; - - case "hashchange": - case "popstate": - this.stop(); - sendOk(this.commandID); - break; - - case "DOMContentLoaded": - case "pageshow": - this.handleReadyState( - event.target.readyState, - event.target.documentURI - ); - break; - } - }, - - /** - * Checks the value of readyState for the current page - * load activity, and resolves the command if the load - * has been finished. It also takes care of the selected - * page load strategy. - * - * @param {string} readyState - * Current ready state of the document. - * @param {string} documentURI - * Current document URI of the document. - * - * @return {boolean} - * True if the page load has been finished. - */ - handleReadyState(readyState, documentURI) { - let finished = false; - - switch (readyState) { - case "interactive": - if (documentURI.startsWith("about:certerror")) { - this.stop(); - sendError(new error.InsecureCertificateError(), this.commandID); - finished = true; - } else if (/about:.*(error)\?/.exec(documentURI)) { - this.stop(); - sendError( - new error.UnknownError(`Reached error page: ${documentURI}`), - this.commandID - ); - finished = true; - - // Return early with a page load strategy of eager, and also - // special-case about:blocked pages which should be treated as - // non-error pages but do not raise a pageshow event. about:blank - // is also treaded specifically here, because it gets temporary - // loaded for new content processes, and we only want to rely on - // complete loads for it. - } else if ( - (capabilities.get("pageLoadStrategy") === PageLoadStrategy.Eager && - documentURI != "about:blank") || - /about:blocked\?/.exec(documentURI) - ) { - this.stop(); - sendOk(this.commandID); - finished = true; - } - - break; - - case "complete": - this.stop(); - sendOk(this.commandID); - finished = true; - - break; - } - - return finished; - }, - - /** - * Callback for navigation timeout timer. - */ - notify(timer) { - switch (timer) { - case this.timerPageUnload: - // In the case when a document has a beforeunload handler - // registered, the currently active command will return immediately - // due to the modal dialog observer in proxy.js. - // - // Otherwise the timeout waiting for the document to start - // navigating is increased by 5000 ms to ensure a possible load - // event is not missed. In the common case such an event should - // occur pretty soon after beforeunload, and we optimise for this. - if (this.seenBeforeUnload) { - this.seenBeforeUnload = null; - this.timerPageUnload.initWithCallback( - this, - 5000, - Ci.nsITimer.TYPE_ONE_SHOT - ); - - // If no page unload has been detected, ensure to properly stop - // the load listener, and return from the currently active command. - } else if (!this.seenUnload) { - logger.debug( - "Canceled page load listener because no navigation " + - "has been detected" - ); - this.stop(); - sendOk(this.commandID); - } - break; - - case this.timerPageLoad: - this.stop(); - sendError( - new error.TimeoutError( - `Timeout loading page after ${this.timeout}ms` - ), - this.commandID - ); - break; - } - }, - - observe(subject, topic) { - logger.trace(`Received observer notification ${topic}`); - - const winId = subject.QueryInterface(Ci.nsISupportsPRUint64).data; - const bc = BrowsingContext.get(curContainer.id); - - switch (topic) { - // In the case when the currently selected frame is closed, - // there will be no further load events. Stop listening immediately. - case "outer-window-destroyed": - if (bc.window.windowUtils.deprecatedOuterWindowID == winId) { - this.stop(); - sendOk(this.commandID); - } - break; - } - }, - - /** - * Continue to listen for page load events after the frame script has been - * moved to a different content process. - * - * @param {number} commandID - * ID of the currently handled message between the driver and - * listener. - * @param {number} timeout - * Timeout in milliseconds the method has to wait for the page - * being finished loading. - * @param {number} startTime - * Unix timestap when the navitation request got triggered. - */ - waitForLoadAfterFramescriptReload(commandID, timeout, startTime) { - this.start(commandID, timeout, startTime, false); - }, - - /** - * Use a trigger callback to initiate a page load, and attach listeners if - * a page load is expected. - * - * @param {function} trigger - * Callback that triggers the page load. - * @param {number} commandID - * ID of the currently handled message between the driver and listener. - * @param {number} pageTimeout - * Timeout in milliseconds the method has to wait for the page - * finished loading. - * @param {boolean=} loadEventExpected - * Optional flag, which indicates that navigate has to wait for the page - * finished loading. - * @param {string=} url - * Optional URL, which is used to check if a page load is expected. - */ - async navigate( - trigger, - commandID, - timeout, - loadEventExpected = true, - useUnloadTimer = false - ) { - // Only wait if the page load strategy is not `none` - loadEventExpected = - loadEventExpected && - capabilities.get("pageLoadStrategy") !== PageLoadStrategy.None; - - if (loadEventExpected) { - let startTime = new Date().getTime(); - this.start(commandID, timeout, startTime, true); - } - - await trigger(); - - try { - if (!loadEventExpected) { - sendOk(commandID); - return; - } - - // If requested setup a timer to detect a possible page load - if (useUnloadTimer) { - this.timerPageUnload = Cc["@mozilla.org/timer;1"].createInstance( - Ci.nsITimer - ); - this.timerPageUnload.initWithCallback( - this, - 200, - Ci.nsITimer.TYPE_ONE_SHOT - ); - } - } catch (e) { - if (loadEventExpected) { - this.stop(); - } - - sendError(e, commandID); - } - }, -}; - -/** - * Called when listener is first started up. The listener sends its - * unique window ID and its current URI to the actor. If the actor returns - * an ID, we start the listeners. Otherwise, nothing happens. - */ -function registerSelf() { - logger.trace("Frame script loaded"); - - curContainer.frame = content; - - sandboxes.clear(); - legacyactions.mouseEventsOnly = false; - action.inputStateMap = new Map(); - action.inputsToCancel = []; - - let reply = sendSyncMessage("Marionette:Register", { - frameId: contentId, - }); - if (reply.length == 0) { - logger.error("No reply from Marionette:Register"); - return; - } - - if (reply[0].frameId === contentId) { - logger.trace("Frame script registered"); - startListeners(); - sendAsyncMessage("Marionette:ListenersAttached", { - frameId: contentId, - }); - } -} - // Eventually we will not have a closure for every single command, // but use a generic dispatch for all listener commands. // @@ -524,6 +126,7 @@ function dispatch(fn) { }; } +let clickElementFn = dispatch(clickElement); let getActiveElementFn = dispatch(getActiveElement); let getBrowsingContextIdFn = dispatch(getBrowsingContextId); let getCurrentUrlFn = dispatch(getCurrentUrl); @@ -553,10 +156,11 @@ let sendKeysToElementFn = dispatch(sendKeysToElement); let reftestWaitFn = dispatch(reftestWait); function startListeners() { + eventDispatcher.enable(); + addMessageListener("Marionette:actionChain", actionChainFn); - addMessageListener("Marionette:cancelRequest", cancelRequest); addMessageListener("Marionette:clearElement", clearElementFn); - addMessageListener("Marionette:clickElement", clickElement); + addMessageListener("Marionette:clickElement", clickElementFn); addMessageListener("Marionette:Deregister", deregister); addMessageListener("Marionette:DOM:AddEventListener", domAddEventListener); addMessageListener( @@ -581,15 +185,11 @@ function startListeners() { ); addMessageListener("Marionette:getPageSource", getPageSourceFn); addMessageListener("Marionette:getScreenshotRect", getScreenshotRectFn); - addMessageListener("Marionette:goBack", goBack); - addMessageListener("Marionette:goForward", goForward); addMessageListener("Marionette:isElementDisplayed", isElementDisplayedFn); addMessageListener("Marionette:isElementEnabled", isElementEnabledFn); addMessageListener("Marionette:isElementSelected", isElementSelectedFn); addMessageListener("Marionette:multiAction", multiActionFn); - addMessageListener("Marionette:navigateTo", navigateTo); addMessageListener("Marionette:performActions", performActionsFn); - addMessageListener("Marionette:refresh", refresh); addMessageListener("Marionette:reftestWait", reftestWaitFn); addMessageListener("Marionette:releaseActions", releaseActionsFn); addMessageListener("Marionette:sendKeysToElement", sendKeysToElementFn); @@ -598,14 +198,14 @@ function startListeners() { addMessageListener("Marionette:switchToFrame", switchToFrame); addMessageListener("Marionette:switchToParentFrame", switchToParentFrame); addMessageListener("Marionette:switchToShadowRoot", switchToShadowRootFn); - addMessageListener("Marionette:waitForPageLoaded", waitForPageLoaded); } function deregister() { + eventDispatcher.disable(); + removeMessageListener("Marionette:actionChain", actionChainFn); - removeMessageListener("Marionette:cancelRequest", cancelRequest); removeMessageListener("Marionette:clearElement", clearElementFn); - removeMessageListener("Marionette:clickElement", clickElement); + removeMessageListener("Marionette:clickElement", clickElementFn); removeMessageListener("Marionette:Deregister", deregister); removeMessageListener("Marionette:execute", executeFn); removeMessageListener("Marionette:executeInSandbox", executeInSandboxFn); @@ -634,15 +234,11 @@ function deregister() { ); removeMessageListener("Marionette:getPageSource", getPageSourceFn); removeMessageListener("Marionette:getScreenshotRect", getScreenshotRectFn); - removeMessageListener("Marionette:goBack", goBack); - removeMessageListener("Marionette:goForward", goForward); removeMessageListener("Marionette:isElementDisplayed", isElementDisplayedFn); removeMessageListener("Marionette:isElementEnabled", isElementEnabledFn); removeMessageListener("Marionette:isElementSelected", isElementSelectedFn); removeMessageListener("Marionette:multiAction", multiActionFn); - removeMessageListener("Marionette:navigateTo", navigateTo); removeMessageListener("Marionette:performActions", performActionsFn); - removeMessageListener("Marionette:refresh", refresh); removeMessageListener("Marionette:releaseActions", releaseActionsFn); removeMessageListener("Marionette:sendKeysToElement", sendKeysToElementFn); removeMessageListener("Marionette:Session:Delete", deleteSession); @@ -650,7 +246,6 @@ function deregister() { removeMessageListener("Marionette:switchToFrame", switchToFrame); removeMessageListener("Marionette:switchToParentFrame", switchToParentFrame); removeMessageListener("Marionette:switchToShadowRoot", switchToShadowRootFn); - removeMessageListener("Marionette:waitForPageLoaded", waitForPageLoaded); } function deleteSession() { @@ -1070,149 +665,6 @@ function multiAction(args, maxLen) { setDispatch(concurrentEvent, pendingTouches); } -/** - * Cancel the polling and remove the event listener associated with a - * current navigation request in case we're interupted by an onbeforeunload - * handler and navigation doesn't complete. - */ -function cancelRequest() { - loadListener.stop(); -} - -/** - * This implements the latter part of a get request (for the case we need - * to resume one when the frame script has been moved to a different content - * process in the middle of a navigate request). This is most of of the work - * of a navigate request, but doesn't assume DOMContentLoaded is yet to fire. - * - * @param {number} commandID - * ID of the currently handled message between the driver and - * listener. - * @param {number} pageTimeout - * Timeout in seconds the method has to wait for the page being - * finished loading. - * @param {number} startTime - * Unix timestap when the navitation request got triggered. - */ -function waitForPageLoaded(msg) { - let { commandID, pageTimeout, startTime } = msg.json; - loadListener.waitForLoadAfterFramescriptReload( - commandID, - pageTimeout, - startTime - ); -} - -/** - * Navigate to the given URL. The operation will be performed on the - * current browsing context, which means it handles the case where we - * navigate within an iframe. All other navigation is handled by the driver - * (in chrome space). - */ -async function navigateTo(msg) { - let { commandID, pageTimeout, url, loadEventExpected } = msg.json; - - try { - await loadListener.navigate( - () => { - curContainer.frame.location = url; - }, - commandID, - pageTimeout, - loadEventExpected - ); - } catch (e) { - sendError(e, commandID); - } -} - -/** - * Cause the browser to traverse one step backward in the joint history - * of the current browsing context. - * - * @param {number} commandID - * ID of the currently handled message between the driver and - * listener. - * @param {number} pageTimeout - * Timeout in milliseconds the method has to wait for the page being - * finished loading. - */ -async function goBack(msg) { - let { commandID, pageTimeout } = msg.json; - - try { - await loadListener.navigate( - () => { - curContainer.frame.history.back(); - }, - commandID, - pageTimeout - ); - } catch (e) { - sendError(e, commandID); - } -} - -/** - * Cause the browser to traverse one step forward in the joint history - * of the current browsing context. - * - * @param {number} commandID - * ID of the currently handled message between the driver and - * listener. - * @param {number} pageTimeout - * Timeout in milliseconds the method has to wait for the page being - * finished loading. - */ -async function goForward(msg) { - let { commandID, pageTimeout } = msg.json; - - try { - await loadListener.navigate( - () => { - curContainer.frame.history.forward(); - }, - commandID, - pageTimeout - ); - } catch (e) { - sendError(e, commandID); - } -} - -/** - * Causes the browser to reload the page in in current top-level browsing - * context. - * - * @param {number} commandID - * ID of the currently handled message between the driver and - * listener. - * @param {number} pageTimeout - * Timeout in milliseconds the method has to wait for the page being - * finished loading. - */ -async function refresh(msg) { - let { commandID, pageTimeout } = msg.json; - - try { - // We need to move to the top frame before navigating - curContainer.frame = content; - sendSyncMessage("Marionette:switchedToFrame", { - browsingContextId: curContainer.id, - }); - - await loadListener.navigate( - () => { - curContainer.frame.location.reload(true); - }, - commandID, - pageTimeout - ); - } catch (e) { - sendError(e, commandID); - } -} - /** * Get source of the current browsing context's DOM. */ @@ -1289,45 +741,15 @@ function getCurrentUrl() { /** * Send click event to element. * - * @param {number} commandID - * ID of the currently handled message between the driver and - * listener. - * @param {WebElement} webElRef - * Reference to the web element to click. - * @param {number} pageTimeout - * Timeout in milliseconds the method has to wait for the page being - * finished loading. + * @param {WebElement} el + * Element to click. */ -async function clickElement(msg) { - let { commandID, webElRef, pageTimeout } = msg.json; - - try { - let webEl = WebElement.fromJSON(webElRef); - let el = seenEls.get(webEl, curContainer.frame); - - let loadEventExpected = true; - let target = getElementAttribute(el, "target"); - - if (target === "_blank") { - loadEventExpected = false; - } - - await loadListener.navigate( - () => { - return interaction.clickElement( - el, - capabilities.get("moz:accessibilityChecks"), - capabilities.get("moz:webdriverClick") - ); - }, - commandID, - pageTimeout, - loadEventExpected, - true - ); - } catch (e) { - sendError(e, commandID); - } +function clickElement(el) { + return interaction.clickElement( + el, + capabilities.get("moz:accessibilityChecks"), + capabilities.get("moz:webdriverClick") + ); } function getElementAttribute(el, name) { @@ -1828,5 +1250,135 @@ function domRemoveEventListener(msg) { eventObservers.remove(msg.json.type); } +const eventDispatcher = { + enabled: false, + + enable() { + if (this.enabled) { + return; + } + + addEventListener("unload", this, false); + + addEventListener("beforeunload", this, true); + addEventListener("pagehide", this, true); + addEventListener("popstate", this, true); + + addEventListener("DOMContentLoaded", this, true); + addEventListener("hashchange", this, true); + addEventListener("pageshow", this, true); + + Services.obs.addObserver(this, "webnavigation-destroy"); + + this.enabled = true; + }, + + disable() { + if (!this.enabled) { + return; + } + + removeEventListener("unload", this, false); + + removeEventListener("beforeunload", this, true); + removeEventListener("pagehide", this, true); + removeEventListener("popstate", this, true); + + removeEventListener("DOMContentLoaded", this, true); + removeEventListener("hashchange", this, true); + removeEventListener("pageshow", this, true); + + // In case the observer was added before the frame script has been moved + // to a different process, it will no longer be available. Exceptions can + // be ignored. + try { + Services.obs.removeObserver(this, "webnavigation-destroy"); + } catch (e) {} + + this.enabled = false; + }, + + handleEvent(event) { + const { target, type } = event; + + // An unload event indicates that the framescript died because of a process + // change, or that the tab / window has been closed. + if (type === "unload" && target === contentFrameMessageManager) { + logger.trace(`Frame script unloaded`); + sendAsyncMessage("Marionette:Unloaded", { + browsingContext: content.docShell.browsingContext, + }); + return; + } + + // Only care about events from the currently selected browsing context, + // whereby some of those do not bubble up to the window. + if (![curContainer.frame, curContainer.frame.document].includes(target)) { + return; + } + + if (type === "pagehide") { + // The content window has been replaced. Immediately register the page + // load events again so that we don't miss possible load events + addEventListener("DOMContentLoaded", this, true); + addEventListener("pageshow", this, true); + } + + sendAsyncMessage("Marionette:NavigationEvent", { + browsingContext: content.docShell.browsingContext, + documentURI: target.documentURI, + readyState: target.readyState, + type, + }); + }, + + observe(subject, topic) { + subject.QueryInterface(Ci.nsIDocShell); + + const browsingContext = subject.browsingContext; + const isFrame = browsingContext !== subject.browsingContext.top; + + // The currently selected iframe has been closed + if (isFrame && browsingContext.id === curContainer.id) { + logger.trace(`Frame with id ${browsingContext.id} got removed`); + sendAsyncMessage("Marionette:FrameRemoved", { + browsingContextId: browsingContext.id, + }); + } + }, +}; + +/** + * Called when listener is first started up. The listener sends its + * unique window ID and its current URI to the actor. If the actor returns + * an ID, we start the listeners. Otherwise, nothing happens. + */ +function registerSelf() { + logger.trace("Frame script loaded"); + + curContainer.frame = content; + + sandboxes.clear(); + legacyactions.mouseEventsOnly = false; + action.inputStateMap = new Map(); + action.inputsToCancel = []; + + let reply = sendSyncMessage("Marionette:Register", { + frameId: contentId, + }); + + if (reply.length == 0) { + logger.error("No reply from Marionette:Register"); + return; + } + + if (reply[0].frameId === contentId) { + startListeners(); + sendAsyncMessage("Marionette:ListenersAttached", { + frameId: contentId, + }); + } +} + // Call register self when we get loaded registerSelf(); diff --git a/testing/marionette/navigate.js b/testing/marionette/navigate.js index b9b97e2a74bc..a5a5eaa89387 100644 --- a/testing/marionette/navigate.js +++ b/testing/marionette/navigate.js @@ -6,9 +6,85 @@ const EXPORTED_SYMBOLS = ["navigate"]; +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + error: "chrome://marionette/content/error.js", + Log: "chrome://marionette/content/log.js", + modal: "chrome://marionette/content/modal.js", + PageLoadStrategy: "chrome://marionette/content/capabilities.js", + TimedPromise: "chrome://marionette/content/sync.js", + truncate: "chrome://marionette/content/format.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", Log.get); + +// Timeouts used to check if a new navigation has been initiated. +const TIMEOUT_BEFOREUNLOAD_EVENT = 200; +const TIMEOUT_UNLOAD_EVENT = 5000; + /** @namespace */ this.navigate = {}; +/** + * Checks the value of readyState for the current page + * load activity, and resolves the command if the load + * has been finished. It also takes care of the selected + * page load strategy. + * + * @param {PageLoadStrategy} pageLoadStrategy + * Strategy when navigation is considered as finished. + * @param {object} eventData + * @param {string} eventData.documentURI + * Current document URI of the document. + * @param {string} eventData.readyState + * Current ready state of the document. + * + * @return {boolean} + * True if the page load has been finished. + */ +function checkReadyState(pageLoadStrategy, eventData = {}) { + const { documentURI, readyState } = eventData; + + const result = { error: null, finished: false }; + + switch (readyState) { + case "interactive": + if (documentURI.startsWith("about:certerror")) { + result.error = new error.InsecureCertificateError(); + result.finished = true; + } else if (/about:.*(error)\?/.exec(documentURI)) { + result.error = new error.UnknownError( + `Reached error page: ${documentURI}` + ); + result.finished = true; + + // Return early with a page load strategy of eager, and also + // special-case about:blocked pages which should be treated as + // non-error pages but do not raise a pageshow event. about:blank + // is also treaded specifically here, because it gets temporary + // loaded for new content processes, and we only want to rely on + // complete loads for it. + } else if ( + (pageLoadStrategy === PageLoadStrategy.Eager && + documentURI != "about:blank") || + /about:blocked\?/.exec(documentURI) + ) { + result.finished = true; + } + break; + + case "complete": + result.finished = true; + break; + } + + return result; +} + /** * Determines if we expect to get a DOM load event (DOMContentLoaded) * on navigating to the future URL. @@ -54,3 +130,228 @@ navigate.isLoadEventExpected = function(current, future = undefined) { return true; }; + +/** + * Load the given URL in the specified browsing context. + * + * @param {CanonicalBrowsingContext} browsingContext + * Browsing context to load the URL into. + * @param {string} url + * URL to navigate to. + */ +navigate.navigateTo = async function(browsingContext, url) { + const opts = { + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_IS_LINK, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }; + browsingContext.loadURI(url, opts); +}; + +/** + * Reload the page. + * + * @param {CanonicalBrowsingContext} browsingContext + * Browsing context to refresh. + */ +navigate.refresh = async function(browsingContext) { + const flags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + browsingContext.reload(flags); +}; + +/** + * Execute a callback and wait for a possible navigation to complete + * + * @param {GeckoDriver} driver + * Reference to driver instance. + * @param {Function} callback + * Callback to execute that might trigger a navigation. + * @param {Object} options + * @param {BrowsingContext=} browsingContext + * Browsing context to observe. Defaults to the current top-level + * browsing context. + * @param {boolean=} loadEventExpected + * If false, return immediately and don't wait for + * the navigation to be completed. Defaults to true. + * @param {boolean=} requireBeforeUnload + * If false and no beforeunload event is fired, abort waiting + * for the navigation. Defaults to true. + */ +navigate.waitForNavigationCompleted = async function waitForNavigationCompleted( + driver, + callback, + options = {} +) { + const { + browsingContext = driver.getBrowsingContext({ top: true }), + loadEventExpected = true, + requireBeforeUnload = true, + } = options; + + const pageLoadStrategy = driver.capabilities.get("pageLoadStrategy"); + const chromeWindow = browsingContext.topChromeWindow; + + // Return immediately if no load event is expected + if (!loadEventExpected || pageLoadStrategy === PageLoadStrategy.None) { + return Promise.resolve(); + } + + return new TimedPromise( + async (resolve, reject) => { + const frameRemovedMessage = "Marionette:FrameRemoved"; + const navigationMessage = "Marionette:NavigationEvent"; + + let seenBeforeUnload = false; + let seenUnload = false; + + let unloadTimer; + + const checkDone = ({ finished, error }) => { + if (finished) { + chromeWindow.removeEventListener("TabClose", onUnload); + chromeWindow.removeEventListener("unload", onUnload); + driver.dialogObserver.remove(onDialogOpened); + driver.mm.removeMessageListener( + frameRemovedMessage, + onFrameRemoved, + true + ); + driver.mm.removeMessageListener( + navigationMessage, + onNavigation, + true + ); + unloadTimer?.cancel(); + + if (error) { + reject(error); + } else { + resolve(); + } + } + }; + + const onDialogOpened = (action, dialog, win) => { + // Only care about modals of the currently selected window. + if (win !== chromeWindow) { + return; + } + + if (action === modal.ACTION_OPENED) { + logger.trace("Canceled page load listener because a dialog opened"); + checkDone({ finished: true }); + } + }; + + const onTimer = timer => { + // In the case when a document has a beforeunload handler + // registered, the currently active command will return immediately + // due to the modal dialog observer in proxy.js. + // + // Otherwise the timeout waiting for the document to start + // navigating is increased by 5000 ms to ensure a possible load + // event is not missed. In the common case such an event should + // occur pretty soon after beforeunload, and we optimise for this. + if (seenBeforeUnload) { + seenBeforeUnload = false; + unloadTimer.initWithCallback( + onTimer, + TIMEOUT_UNLOAD_EVENT, + Ci.nsITimer.TYPE_ONE_SHOT + ); + + // If no page unload has been detected, ensure to properly stop + // the load listener, and return from the currently active command. + } else if (!seenUnload) { + logger.trace( + "Canceled page load listener because no navigation " + + "has been detected" + ); + checkDone({ finished: true }); + } + }; + + const onNavigation = ({ json }) => { + if (json.browsingContext.browserId != browsingContext.browserId) { + return; + } + + logger.trace( + truncate`Received message ${json.type} for ${json.documentURI}` + ); + + switch (json.type) { + case "beforeunload": + seenBeforeUnload = true; + seenUnload = false; + break; + + case "pagehide": + seenUnload = true; + break; + + case "hashchange": + case "popstate": + checkDone({ finished: true }); + break; + + case "DOMContentLoaded": + case "pageshow": + if (!seenUnload) { + return; + } + const result = checkReadyState(pageLoadStrategy, json); + checkDone(result); + break; + } + }; + + // In the case when the currently selected frame is closed, + // there will be no further load events. Stop listening immediately. + const onFrameRemoved = ({ json }) => { + if (json.browsingContextId != browsingContext.id) { + return; + } + + logger.trace( + "Canceled page load listener because current frame has been removed" + ); + checkDone({ finished: true }); + }; + + const onUnload = event => { + logger.trace( + "Canceled page load listener " + + "because the top-browsing context has been closed" + ); + checkDone({ finished: true }); + }; + + // Certain commands like clickElement can cause a navigation. Setup a timer + // to check if a "beforeunload" event has been emitted within the given + // time frame. If not resolve the Promise. + if (!requireBeforeUnload) { + unloadTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + unloadTimer.initWithCallback( + onTimer, + TIMEOUT_BEFOREUNLOAD_EVENT, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } + + chromeWindow.addEventListener("TabClose", onUnload); + chromeWindow.addEventListener("unload", onUnload); + driver.dialogObserver.add(onDialogOpened); + driver.mm.addMessageListener(frameRemovedMessage, onFrameRemoved, true); + driver.mm.addMessageListener(navigationMessage, onNavigation, true); + + try { + await callback(); + } catch (e) { + checkDone({ finished: true, error: e }); + } + }, + { + timeout: driver.timeouts.pageLoad, + } + ); +}; diff --git a/testing/marionette/reftest.js b/testing/marionette/reftest.js index 5c1986ec9f9b..32eb0ec38fe6 100644 --- a/testing/marionette/reftest.js +++ b/testing/marionette/reftest.js @@ -20,6 +20,7 @@ XPCOMUtils.defineLazyModuleGetters(this, { capture: "chrome://marionette/content/capture.js", error: "chrome://marionette/content/error.js", Log: "chrome://marionette/content/log.js", + navigate: "chrome://marionette/content/navigate.js", print: "chrome://marionette/content/print.js", }); @@ -124,11 +125,9 @@ reftest.Runner = class { if (Services.appinfo.OS == "Android") { logger.debug("Using current window"); reftestWin = this.parentWindow; - await this.driver.listener.navigateTo({ - commandID: this.driver.listener.activeMessageId, - pageTimeout: timeout, - url: "about:blank", - loadEventExpected: true, + await navigate.waitForNavigationCompleted(this.driver, () => { + const browsingContext = this.driver.getBrowsingContext(); + navigate.navigateTo(browsingContext, "about:blank"); }); } else { logger.debug("Using separate window"); @@ -616,14 +615,14 @@ max-width: ${width}px; max-height: ${height}px`; } async loadTestUrl(win, url, timeout) { + const browsingContext = this.driver.getBrowsingContext({ top: true }); + logger.debug(`Starting load of ${url}`); - let navigateOpts = { - commandId: this.driver.listener.activeMessageId, - pageTimeout: timeout, - }; if (this.lastURL === url) { logger.debug(`Refreshing page`); - await this.driver.listener.refresh(navigateOpts); + await navigate.waitForNavigationCompleted(this.driver, () => { + navigate.refresh(browsingContext); + }); } else { // HACK: DocumentLoadListener currently doesn't know how to // process-switch loads in a non-tabbed . We need to manually @@ -632,14 +631,14 @@ max-width: ${width}px; max-height: ${height}px`; // // See bug 1636169. this.updateBrowserRemotenessByURL(win.gBrowser, url); + navigate.navigateTo(browsingContext, url); - navigateOpts.url = url; - navigateOpts.loadEventExpected = false; - await this.driver.listener.navigateTo(navigateOpts); this.lastURL = url; } this.ensureFocus(win); + + // TODO: Move all the wait logic into the parent process (bug 1648444) await this.driver.listener.reftestWait(url, this.useRemoteTabs); } diff --git a/testing/marionette/sync.js b/testing/marionette/sync.js index e77f41dd9214..e67b33d8b804 100644 --- a/testing/marionette/sync.js +++ b/testing/marionette/sync.js @@ -50,20 +50,6 @@ function executeSoon(func) { Services.tm.dispatchToMainThread(func); } -/** - * @callback Condition - * - * @param {function(*)} resolve - * To be called when the condition has been met. Will return the - * resolved value. - * @param {function} reject - * To be called when the condition has not been met. Will cause - * the condition to be revaluated or time out. - * - * @return {*} - * The value from calling ``resolve``. - */ - /** * Runs a Promise-like function off the main thread until it is resolved * through ``resolve`` or ``rejected`` callbacks. The function is