/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
EventDispatcher:
"chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs",
Log: "chrome://remote/content/shared/Log.sys.mjs",
modal: "chrome://remote/content/marionette/modal.sys.mjs",
PageLoadStrategy:
"chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
ProgressListener: "chrome://remote/content/shared/Navigate.sys.mjs",
TimedPromise: "chrome://remote/content/marionette/sync.sys.mjs",
truncate: "chrome://remote/content/shared/Format.sys.mjs",
});
XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
);
// Timeouts used to check if a new navigation has been initiated.
const TIMEOUT_BEFOREUNLOAD_EVENT = 200;
const TIMEOUT_UNLOAD_EVENT = 5000;
/** @namespace */
export const 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.
*
* @returns {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 lazy.error.InsecureCertificateError();
result.finished = true;
} else if (/about:.*(error)\?/.exec(documentURI)) {
result.error = new lazy.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 === lazy.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.
*
* @returns {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(),
// Fake user activation.
hasValidUserGestureActivation: true,
};
browsingContext.fixupAndLoadURIString(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=} options.browsingContext
* Browsing context to observe. Defaults to the current browsing context.
* @param {boolean=} options.loadEventExpected
* If false, return immediately and don't wait for
* the navigation to be completed. Defaults to true.
* @param {boolean=} options.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 browsingContext = browsingContextFn();
const chromeWindow = browsingContext.topChromeWindow;
const pageLoadStrategy = driver.currentSession.pageLoadStrategy;
// Return immediately if no load event is expected
if (!loadEventExpected) {
await callback();
return Promise.resolve();
}
// When not waiting for page load events, do not return until the navigation has actually started.
if (pageLoadStrategy === lazy.PageLoadStrategy.None) {
const listener = new lazy.ProgressListener(browsingContext.webProgress, {
resolveWhenStarted: true,
waitForExplicitStart: true,
});
const navigated = listener.start();
navigated.finally(() => {
if (listener.isStarted) {
listener.stop();
}
});
await callback();
await navigated;
return Promise.resolve();
}
let rejectNavigation;
let resolveNavigation;
let browsingContextChanged = false;
let seenBeforeUnload = false;
let seenUnload = false;
let unloadTimer;
const checkDone = ({ finished, error }) => {
if (finished) {
if (error) {
rejectNavigation(error);
} else {
resolveNavigation();
}
}
};
const onDialogOpened = action => {
if (action === lazy.modal.ACTION_OPENED) {
lazy.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.
//
// 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) {
lazy.logger.trace(
"Canceled page load listener because no navigation " +
"has been detected"
);
checkDone({ finished: true });
}
};
const onNavigation = (eventName, data) => {
const browsingContext = browsingContextFn();
// Ignore events from other browsing contexts than the selected one.
if (data.browsingContext != browsingContext) {
return;
}
lazy.logger.trace(
lazy.truncate`[${data.browsingContext.id}] 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":
// Don't require an unload event when a top-level browsing context
// change occurred.
if (!seenUnload && !browsingContextChanged) {
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, why) => {
// If the BrowsingContext is being discarded to be replaced by another
// context, we don't want to stop waiting for the pageload to complete, as
// we will continue listening to the newly created context.
if (subject == browsingContextFn() && why != "replace") {
lazy.logger.trace(
"Canceled page load listener " +
`because browsing context with id ${subject.id} has been removed`
);
checkDone({ finished: true });
}
};
// Detect changes to the top-level browsing context to not
// necessarily require an unload event.
const onBrowsingContextChanged = event => {
if (event.target === driver.curBrowser.contentBrowser) {
browsingContextChanged = true;
}
};
const onUnload = event => {
lazy.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.curBrowser.tabBrowser?.addEventListener(
"XULFrameLoaderCreated",
onBrowsingContextChanged
);
driver.dialogObserver.add(onDialogOpened);
Services.obs.addObserver(
onBrowsingContextDiscarded,
"browsing-context-discarded"
);
lazy.EventDispatcher.on("page-load", onNavigation);
return new lazy.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 });
}
}
},
{
errorMessage: "Navigation timed out",
timeout: driver.currentSession.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.curBrowser.tabBrowser?.removeEventListener(
"XULFrameLoaderCreated",
onBrowsingContextChanged
);
driver.dialogObserver?.remove(onDialogOpened);
unloadTimer?.cancel();
lazy.EventDispatcher.off("page-load", onNavigation);
});
};