449 lines
14 KiB
JavaScript
449 lines
14 KiB
JavaScript
/* 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/. */
|
|
|
|
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",
|
|
PageLoadStrategy:
|
|
"chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
|
|
ProgressListener: "chrome://remote/content/shared/Navigate.sys.mjs",
|
|
TimedPromise: "chrome://remote/content/shared/Sync.sys.mjs",
|
|
truncate: "chrome://remote/content/shared/Format.sys.mjs",
|
|
});
|
|
|
|
ChromeUtils.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 <code>future</code> 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 <code>current</code> is not defined, or any of
|
|
* <code>current</code> or <code>future</code> 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:<whatever> 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,
|
|
// Fake user activation.
|
|
hasValidUserGestureActivation: true,
|
|
// Prevent HTTPS-First upgrades.
|
|
schemelessInput: Ci.nsILoadInfo.SchemelessInputTypeSchemeful,
|
|
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
|
|
};
|
|
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();
|
|
}
|
|
listener.destroy();
|
|
});
|
|
|
|
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 onPromptClosed = (_, data) => {
|
|
if (data.detail.promptType === "beforeunload" && !data.detail.accepted) {
|
|
// If a beforeunload prompt is dismissed there will be no navigation.
|
|
lazy.logger.trace(
|
|
`Canceled page load listener because a beforeunload prompt was dismissed`
|
|
);
|
|
checkDone({ finished: true });
|
|
}
|
|
};
|
|
|
|
const onPromptOpened = (_, data) => {
|
|
if (data.prompt.promptType === "beforeunload") {
|
|
// WebDriver HTTP basically doesn't know anything about beforeunload
|
|
// prompts. As such we always ignore the prompt opened event.
|
|
return;
|
|
}
|
|
|
|
lazy.logger.trace(
|
|
`Canceled page load listener because a ${data.prompt.promptType} prompt opened`
|
|
);
|
|
checkDone({ finished: true });
|
|
};
|
|
|
|
const onTimer = () => {
|
|
// For the command "Element Click" we want to detect a potential navigation
|
|
// as early as possible. The `beforeunload` event is an indication for that
|
|
// but could still cause the navigation to get aborted by the user. As such
|
|
// wait a bit longer for the `unload` event to happen (only when the page
|
|
// load strategy is `none`), which usually will occur pretty soon after
|
|
// `beforeunload`.
|
|
//
|
|
// Note that with WebDriver BiDi enabled the `beforeunload` prompts might
|
|
// not get implicitly accepted, so lets keep the timer around until we know
|
|
// that it is really not required.
|
|
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 = () => {
|
|
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.promptListener.on("closed", onPromptClosed);
|
|
driver.promptListener.on("opened", onPromptOpened);
|
|
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.promptListener?.off("closed", onPromptClosed);
|
|
driver.promptListener?.off("opened", onPromptOpened);
|
|
unloadTimer?.cancel();
|
|
|
|
lazy.EventDispatcher.off("page-load", onNavigation);
|
|
});
|
|
};
|