/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; 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", EventDispatcher: "chrome://marionette/content/actors/MarionetteEventsParent.jsm", Log: "chrome://marionette/content/log.js", MarionettePrefs: "chrome://marionette/content/prefs.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. * * @param {URL} current * URL the browser is currently visiting. * @param {Object} options * @param {BrowsingContext=} options.browsingContext * The current browsing context. Needed for targets of _parent and _top. * @param {URL=} options.future * Destination URL, if known. * @param {target=} options.target * Link target, if known. * * @return {boolean} * Full page load would be expected if future is followed. * * @throws TypeError * If current is not defined, or any of * current or future are invalid URLs. */ navigate.isLoadEventExpected = function(current, options = {}) { const { browsingContext, future, target } = options; if (typeof current == "undefined") { throw new TypeError("Expected at least one URL"); } if (["_parent", "_top"].includes(target) && !browsingContext) { throw new TypeError( "Expected browsingContext when target is _parent or _top" ); } // Don't wait if the navigation happens in a different browsing context if ( target === "_blank" || (target === "_parent" && browsingContext.parent) || (target === "_top" && browsingContext.top != browsingContext) ) { return false; } // Assume we will go somewhere exciting if (typeof future == "undefined") { return true; } // Assume javascript: will modify the current document // but this is not an entirely safe assumption to make, // considering it could be used to set window.location if (future.protocol == "javascript:") { return false; } // If hashes are present and identical if ( current.href.includes("#") && future.href.includes("#") && current.hash === future.hash ) { return false; } 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 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 { browsingContextFn = driver.getBrowsingContext.bind(driver), loadEventExpected = true, requireBeforeUnload = true, } = options; const chromeWindow = browsingContextFn().topChromeWindow; const pageLoadStrategy = driver.capabilities.get("pageLoadStrategy"); // Return immediately if no load event is expected if (!loadEventExpected || pageLoadStrategy === PageLoadStrategy.None) { await callback(); return Promise.resolve(); } let rejectNavigation; let resolveNavigation; let seenBeforeUnload = false; let seenUnload = false; let unloadTimer; const checkDone = ({ finished, error }) => { if (finished) { if (error) { rejectNavigation(error); } else { resolveNavigation(); } } }; 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 }, message) => { let data = MarionettePrefs.useActors ? message : json; if (MarionettePrefs.useActors) { // Only care about navigation events from the actor of the current frame. // Bug 1674329: Always use the currently active browsing context, // and not the original one to not cause hangs for remoteness changes. if (data.browsingContext != browsingContextFn()) { return; } } else if ( data.browsingContext.browserId != browsingContextFn().browserId ) { return; } logger.trace(truncate`Received event ${data.type} for ${data.documentURI}`); switch (data.type) { case "beforeunload": seenBeforeUnload = true; 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, data); checkDone(result); break; } }; // In the case when the currently selected frame is closed, // there will be no further load events. Stop listening immediately. const onBrowsingContextDiscarded = (subject, topic) => { // With the currentWindowGlobal gone the browsing context hasn't been // replaced due to a remoteness change but closed. if (subject == browsingContextFn() && !subject.currentWindowGlobal) { logger.trace( "Canceled page load listener " + `because browsing context with id ${subject.id} 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 }); }; chromeWindow.addEventListener("TabClose", onUnload); chromeWindow.addEventListener("unload", onUnload); driver.dialogObserver.add(onDialogOpened); Services.obs.addObserver( onBrowsingContextDiscarded, "browsing-context-discarded" ); if (MarionettePrefs.useActors) { EventDispatcher.on("page-load", onNavigation); } else { driver.mm.addMessageListener( "Marionette:NavigationEvent", onNavigation, true ); } return new TimedPromise( async (resolve, reject) => { rejectNavigation = reject; resolveNavigation = resolve; try { await callback(); // 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 ); } } catch (e) { // Executing the callback above could destroy the actor pair before the // command returns. Such an error has to be ignored. if (e.name !== "AbortError") { checkDone({ finished: true, error: e }); } } }, { timeout: driver.timeouts.pageLoad, } ).finally(() => { // Clean-up all registered listeners and timers Services.obs.removeObserver( onBrowsingContextDiscarded, "browsing-context-discarded" ); chromeWindow.removeEventListener("TabClose", onUnload); chromeWindow.removeEventListener("unload", onUnload); driver.dialogObserver?.remove(onDialogOpened); unloadTimer?.cancel(); if (MarionettePrefs.useActors) { EventDispatcher.off("page-load", onNavigation); } else { driver.mm.removeMessageListener( "Marionette:NavigationEvent", onNavigation, true ); } }); };