diff options
Diffstat (limited to 'remote/shared')
155 files changed, 30137 insertions, 0 deletions
diff --git a/remote/shared/AppInfo.sys.mjs b/remote/shared/AppInfo.sys.mjs new file mode 100644 index 0000000000..9e354503ef --- /dev/null +++ b/remote/shared/AppInfo.sys.mjs @@ -0,0 +1,78 @@ +/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const ID_FIREFOX = "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"; +const ID_THUNDERBIRD = "{3550f703-e582-4d05-9a08-453d09bdfdc6}"; + +/** + * Extends Services.appinfo with further properties that are + * used by different protocols as handled by the Remote Agent. + * + * @typedef {object} RemoteAgent.AppInfo + * @property {boolean} isAndroid - Whether the application runs on Android. + * @property {boolean} isLinux - Whether the application runs on Linux. + * @property {boolean} isMac - Whether the application runs on Mac OS. + * @property {boolean} isWindows - Whether the application runs on Windows. + * @property {boolean} isFirefox - Whether the application is Firefox. + * @property {boolean} isThunderbird - Whether the application is Thunderbird. + * + * @since 88 + */ +export const AppInfo = new Proxy( + {}, + { + get(target, prop, receiver) { + if (target.hasOwnProperty(prop)) { + return target[prop]; + } + + return Services.appinfo[prop]; + }, + } +); + +// Platform support + +ChromeUtils.defineLazyGetter(AppInfo, "isAndroid", () => { + return Services.appinfo.OS === "Android"; +}); + +ChromeUtils.defineLazyGetter(AppInfo, "isLinux", () => { + return Services.appinfo.OS === "Linux"; +}); + +ChromeUtils.defineLazyGetter(AppInfo, "isMac", () => { + return Services.appinfo.OS === "Darwin"; +}); + +ChromeUtils.defineLazyGetter(AppInfo, "isWindows", () => { + return Services.appinfo.OS === "WINNT"; +}); + +// Application type + +ChromeUtils.defineLazyGetter(AppInfo, "isFirefox", () => { + return Services.appinfo.ID == ID_FIREFOX; +}); + +ChromeUtils.defineLazyGetter(AppInfo, "isThunderbird", () => { + return Services.appinfo.ID == ID_THUNDERBIRD; +}); + +export function getTimeoutMultiplier() { + if ( + AppConstants.DEBUG || + AppConstants.MOZ_CODE_COVERAGE || + AppConstants.ASAN + ) { + return 4; + } + if (AppConstants.TSAN) { + return 8; + } + + return 1; +} diff --git a/remote/shared/Browser.sys.mjs b/remote/shared/Browser.sys.mjs new file mode 100644 index 0000000000..c8bff1f55a --- /dev/null +++ b/remote/shared/Browser.sys.mjs @@ -0,0 +1,102 @@ +/* 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, { + pprint: "chrome://remote/content/shared/Format.sys.mjs", + waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs", +}); + +/** + * Quits the application with the provided flags. + * + * Optional {@link nsIAppStartup} flags may be provided as + * an array of masks, and these will be combined by ORing + * them with a bitmask. The available masks are defined in + * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIAppStartup. + * + * Crucially, only one of the *Quit flags can be specified. The |eRestart| + * flag may be bit-wise combined with one of the *Quit flags to cause + * the application to restart after it quits. + * + * @param {Array.<string>=} flags + * Constant name of masks to pass to |Services.startup.quit|. + * If empty or undefined, |nsIAppStartup.eAttemptQuit| is used. + * @param {boolean=} safeMode + * Optional flag to indicate that the application has to + * be restarted in safe mode. + * @param {boolean=} isWindowless + * Optional flag to indicate that the browser was started in windowless mode. + * + * @returns {Object<string,boolean>} + * Dictionary containing information that explains the shutdown reason. + * The value for `cause` contains the shutdown kind like "shutdown" or + * "restart", while `forced` will indicate if it was a normal or forced + * shutdown of the application. "in_app" is always set to indicate that + * it is a shutdown triggered from within the application. + */ +export async function quit(flags = [], safeMode = false, isWindowless = false) { + if (flags.includes("eSilently")) { + if (!isWindowless) { + throw new Error( + `Silent restarts only allowed with "moz:windowless" capability set` + ); + } + if (!flags.includes("eRestart")) { + throw new TypeError(`"silently" only works with restart flag`); + } + } + + const quits = ["eConsiderQuit", "eAttemptQuit", "eForceQuit"]; + + let quitSeen; + let mode = 0; + if (flags.length) { + for (let k of flags) { + if (!(k in Ci.nsIAppStartup)) { + throw new TypeError(lazy.pprint`Expected ${k} in ${Ci.nsIAppStartup}`); + } + + if (quits.includes(k)) { + if (quitSeen) { + throw new TypeError(`${k} cannot be combined with ${quitSeen}`); + } + quitSeen = k; + } + + mode |= Ci.nsIAppStartup[k]; + } + } + + if (!quitSeen) { + mode |= Ci.nsIAppStartup.eAttemptQuit; + } + + // Notify all windows that an application quit has been requested. + const cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers(cancelQuit, "quit-application-requested"); + + // If the shutdown of the application is prevented force quit it instead. + if (cancelQuit.data) { + mode |= Ci.nsIAppStartup.eForceQuit; + } + + // Delay response until the application is about to quit. + const quitApplication = lazy.waitForObserverTopic("quit-application"); + + if (safeMode) { + Services.startup.restartInSafeMode(mode); + } else { + Services.startup.quit(mode); + } + + return { + cause: (await quitApplication).data, + forced: cancelQuit.data, + in_app: true, + }; +} diff --git a/remote/shared/Capture.sys.mjs b/remote/shared/Capture.sys.mjs new file mode 100644 index 0000000000..ec34d09aba --- /dev/null +++ b/remote/shared/Capture.sys.mjs @@ -0,0 +1,203 @@ +/* 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, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +const CONTEXT_2D = "2d"; +const BG_COLOUR = "rgb(255,255,255)"; +const MAX_CANVAS_DIMENSION = 32767; +const MAX_CANVAS_AREA = 472907776; +const PNG_MIME = "image/png"; +const XHTML_NS = "http://www.w3.org/1999/xhtml"; + +/** + * Provides primitives to capture screenshots. + * + * @namespace + */ +export const capture = {}; + +capture.Format = { + Base64: 0, + Hash: 1, +}; + +/** + * Draw a rectangle off the framebuffer. + * + * @param {DOMWindow} win + * The DOM window used for the framebuffer, and providing the interfaces + * for creating an HTMLCanvasElement. + * @param {BrowsingContext} browsingContext + * The BrowsingContext from which the snapshot should be taken. + * @param {number} left + * The left, X axis offset of the rectangle. + * @param {number} top + * The top, Y axis offset of the rectangle. + * @param {number} width + * The width dimension of the rectangle to paint. + * @param {number} height + * The height dimension of the rectangle to paint. + * @param {object=} options + * @param {HTMLCanvasElement=} options.canvas + * Optional canvas to reuse for the screenshot. + * @param {number=} options.flags + * Optional integer representing flags to pass to drawWindow; these + * are defined on CanvasRenderingContext2D. + * @param {number=} options.dX + * Horizontal offset between the browser window and content area. Defaults to 0. + * @param {number=} options.dY + * Vertical offset between the browser window and content area. Defaults to 0. + * @param {boolean=} options.readback + * If true, read back a snapshot of the pixel data currently in the + * compositor/window. Defaults to false. + * + * @returns {HTMLCanvasElement} + * The canvas on which the selection from the window's framebuffer + * has been painted on. + */ +capture.canvas = async function ( + win, + browsingContext, + left, + top, + width, + height, + { canvas = null, flags = null, dX = 0, dY = 0, readback = false } = {} +) { + // FIXME(bug 1761032): This looks a bit sketchy, overrideDPPX doesn't + // influence rendering... + const scale = win.browsingContext.overrideDPPX || win.devicePixelRatio; + + let canvasHeight = height * scale; + let canvasWidth = width * scale; + + // Cap the screenshot size for width and height at 2^16 pixels, + // which is the maximum allowed canvas size. Higher dimensions will + // trigger exceptions in Gecko. + if (canvasWidth > MAX_CANVAS_DIMENSION) { + lazy.logger.warn( + "Limiting screen capture width to maximum allowed " + + MAX_CANVAS_DIMENSION + + " pixels" + ); + width = Math.floor(MAX_CANVAS_DIMENSION / scale); + canvasWidth = width * scale; + } + + if (canvasHeight > MAX_CANVAS_DIMENSION) { + lazy.logger.warn( + "Limiting screen capture height to maximum allowed " + + MAX_CANVAS_DIMENSION + + " pixels" + ); + height = Math.floor(MAX_CANVAS_DIMENSION / scale); + canvasHeight = height * scale; + } + + // If the area is larger, reduce the height to keep the full width. + if (canvasWidth * canvasHeight > MAX_CANVAS_AREA) { + lazy.logger.warn( + "Limiting screen capture area to maximum allowed " + + MAX_CANVAS_AREA + + " pixels" + ); + height = Math.floor(MAX_CANVAS_AREA / (canvasWidth * scale)); + canvasHeight = height * scale; + } + + if (canvas === null) { + canvas = win.document.createElementNS(XHTML_NS, "canvas"); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + } + + const ctx = canvas.getContext(CONTEXT_2D); + + if (readback) { + if (flags === null) { + flags = + ctx.DRAWWINDOW_DRAW_CARET | + ctx.DRAWWINDOW_DRAW_VIEW | + ctx.DRAWWINDOW_USE_WIDGET_LAYERS; + } + + // drawWindow doesn't take scaling into account. + ctx.scale(scale, scale); + ctx.drawWindow(win, left + dX, top + dY, width, height, BG_COLOUR, flags); + } else { + let rect = new DOMRect(left, top, width, height); + let snapshot = await browsingContext.currentWindowGlobal.drawSnapshot( + rect, + scale, + BG_COLOUR + ); + + ctx.drawImage(snapshot, 0, 0); + + // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies + // of the bitmap will exist in memory. Force the removal of the snapshot + // because it is no longer needed. + snapshot.close(); + } + + return canvas; +}; + +/** + * Encode the contents of an HTMLCanvasElement to a Base64 encoded string. + * + * @param {HTMLCanvasElement} canvas + * The canvas to encode. + * + * @returns {string} + * A Base64 encoded string. + */ +capture.toBase64 = function (canvas) { + let u = canvas.toDataURL(PNG_MIME); + return u.substring(u.indexOf(",") + 1); +}; + +/** + * Hash the contents of an HTMLCanvasElement to a SHA-256 hex digest. + * + * @param {HTMLCanvasElement} canvas + * The canvas to encode. + * + * @returns {string} + * A hex digest of the SHA-256 hash of the base64 encoded string. + */ +capture.toHash = function (canvas) { + let u = capture.toBase64(canvas); + let buffer = new TextEncoder().encode(u); + return crypto.subtle.digest("SHA-256", buffer).then(hash => hex(hash)); +}; + +/** + * Convert buffer into to hex. + * + * @param {ArrayBuffer} buffer + * The buffer containing the data to convert to hex. + * + * @returns {string} + * A hex digest of the input buffer. + */ +function hex(buffer) { + let hexCodes = []; + let view = new DataView(buffer); + for (let i = 0; i < view.byteLength; i += 4) { + let value = view.getUint32(i); + let stringValue = value.toString(16); + let padding = "00000000"; + let paddedValue = (padding + stringValue).slice(-padding.length); + hexCodes.push(paddedValue); + } + return hexCodes.join(""); +} diff --git a/remote/shared/ChallengeHeaderParser.sys.mjs b/remote/shared/ChallengeHeaderParser.sys.mjs new file mode 100644 index 0000000000..7cb73a4146 --- /dev/null +++ b/remote/shared/ChallengeHeaderParser.sys.mjs @@ -0,0 +1,74 @@ +/* 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/. */ + +/** + * Parse the parameter in a name/value pair and remove quotes. + * + * @param {string} paramValue + * A string representing a challenge parameter. + * + * @returns {object} + * An object with name and value string properties. + */ +function parseChallengeParameter(paramValue) { + const [name, value] = paramValue.split("="); + return { name, value: value?.replace(/["']/g, "") }; +} + +/** + * Simple parser for authenticate (WWW-Authenticate or Proxy-Authenticate) + * headers. + * + * Bug 1857847: Replace with Necko's ChallengeParser once exposed to JS. + * + * @param {string} headerValue + * The value of an authenticate header. + * + * @returns {Array<object>} + * Array of challenge objects containing two properties: + * - {string} scheme: The scheme for the challenge + * - {Array<object>} params: Array of { name, value } objects representing + * all the parameters of the challenge. + */ +export function parseChallengeHeader(headerValue) { + const challenges = []; + const parts = headerValue.split(",").map(part => part.trim()); + + let scheme = null; + let params = []; + + const schemeRegex = /^(\w+)(?:\s+(.*))?$/; + for (const part of parts) { + const matches = part.match(schemeRegex); + if (matches !== null) { + // This is a new scheme. + if (scheme !== null) { + // If we have a challenge recorded, add it to the array. + challenges.push({ scheme, params }); + } + + // Reset the state for a new scheme. + scheme = matches[1]; + params = []; + if (matches[2]) { + params.push(parseChallengeParameter(matches[2])); + } + } else { + if (scheme === null) { + // A scheme should always be found before parameters, this header + // probably needs a more careful parsing solution. + return []; + } + + params.push(parseChallengeParameter(part)); + } + } + + if (scheme !== null) { + // If we have a challenge recorded, add it to the array. + challenges.push({ scheme, params }); + } + + return challenges; +} diff --git a/remote/shared/DOM.sys.mjs b/remote/shared/DOM.sys.mjs new file mode 100644 index 0000000000..664f02328c --- /dev/null +++ b/remote/shared/DOM.sys.mjs @@ -0,0 +1,1219 @@ +/* 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, { + atom: "chrome://remote/content/marionette/atom.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + PollPromise: "chrome://remote/content/marionette/sync.sys.mjs", +}); + +const ORDERED_NODE_ITERATOR_TYPE = 5; +const FIRST_ORDERED_NODE_TYPE = 9; + +const DOCUMENT_FRAGMENT_NODE = 11; +const ELEMENT_NODE = 1; + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +/** XUL elements that support checked property. */ +const XUL_CHECKED_ELS = new Set(["button", "checkbox", "toolbarbutton"]); + +/** XUL elements that support selected property. */ +const XUL_SELECTED_ELS = new Set([ + "menu", + "menuitem", + "menuseparator", + "radio", + "richlistitem", + "tab", +]); + +/** + * This module provides shared functionality for dealing with DOM- + * and web elements in Marionette. + * + * A web element is an abstraction used to identify an element when it + * is transported across the protocol, between remote- and local ends. + * + * Each element has an associated web element reference (a UUID) that + * uniquely identifies the the element across all browsing contexts. The + * web element reference for every element representing the same element + * is the same. + * + * @namespace + */ +export const dom = {}; + +dom.Strategy = { + ClassName: "class name", + Selector: "css selector", + ID: "id", + Name: "name", + LinkText: "link text", + PartialLinkText: "partial link text", + TagName: "tag name", + XPath: "xpath", +}; + +/** + * Find a single element or a collection of elements starting at the + * document root or a given node. + * + * If |timeout| is above 0, an implicit search technique is used. + * This will wait for the duration of <var>timeout</var> for the + * element to appear in the DOM. + * + * See the {@link dom.Strategy} enum for a full list of supported + * search strategies that can be passed to <var>strategy</var>. + * + * @param {Object<string, WindowProxy>} container + * Window object. + * @param {string} strategy + * Search strategy whereby to locate the element(s). + * @param {string} selector + * Selector search pattern. The selector must be compatible with + * the chosen search <var>strategy</var>. + * @param {object=} options + * @param {boolean=} options.all + * If true, a multi-element search selector is used and a sequence of + * elements will be returned, otherwise a single element. Defaults to false. + * @param {Element=} options.startNode + * Element to use as the root of the search. + * @param {number=} options.timeout + * Duration to wait before timing out the search. If <code>all</code> + * is false, a {@link NoSuchElementError} is thrown if unable to + * find the element within the timeout duration. + * + * @returns {Promise.<(Element|Array.<Element>)>} + * Single element or a sequence of elements. + * + * @throws InvalidSelectorError + * If <var>strategy</var> is unknown. + * @throws InvalidSelectorError + * If <var>selector</var> is malformed. + * @throws NoSuchElementError + * If a single element is requested, this error will throw if the + * element is not found. + */ +dom.find = function (container, strategy, selector, options = {}) { + const { all = false, startNode, timeout = 0 } = options; + + let searchFn; + if (all) { + searchFn = findElements.bind(this); + } else { + searchFn = findElement.bind(this); + } + + return new Promise((resolve, reject) => { + let findElements = new lazy.PollPromise( + async (resolve, reject) => { + try { + let res = await find_(container, strategy, selector, searchFn, { + all, + startNode, + }); + if (res.length) { + resolve(Array.from(res)); + } else { + reject([]); + } + } catch (e) { + reject(e); + } + }, + { timeout } + ); + + findElements.then(foundEls => { + // the following code ought to be moved into findElement + // and findElements when bug 1254486 is addressed + if (!all && (!foundEls || !foundEls.length)) { + let msg = `Unable to locate element: ${selector}`; + reject(new lazy.error.NoSuchElementError(msg)); + } + + if (all) { + resolve(foundEls); + } + resolve(foundEls[0]); + }, reject); + }); +}; + +async function find_( + container, + strategy, + selector, + searchFn, + { startNode = null, all = false } = {} +) { + let rootNode; + + if (dom.isShadowRoot(startNode)) { + rootNode = startNode.ownerDocument; + } else { + rootNode = container.frame.document; + } + + if (!startNode) { + startNode = rootNode; + } + + let res; + try { + res = await searchFn(strategy, selector, rootNode, startNode); + } catch (e) { + throw new lazy.error.InvalidSelectorError( + `Given ${strategy} expression "${selector}" is invalid: ${e}` + ); + } + + if (res) { + if (all) { + return res; + } + return [res]; + } + return []; +} + +/** + * Find a single element by XPath expression. + * + * @param {Document} document + * Document root. + * @param {Element} startNode + * Where in the DOM hiearchy to begin searching. + * @param {string} expression + * XPath search expression. + * + * @returns {Node} + * First element matching <var>expression</var>. + */ +dom.findByXPath = function (document, startNode, expression) { + let iter = document.evaluate( + expression, + startNode, + null, + FIRST_ORDERED_NODE_TYPE, + null + ); + return iter.singleNodeValue; +}; + +/** + * Find elements by XPath expression. + * + * @param {Document} document + * Document root. + * @param {Element} startNode + * Where in the DOM hierarchy to begin searching. + * @param {string} expression + * XPath search expression. + * + * @returns {Iterable.<Node>} + * Iterator over nodes matching <var>expression</var>. + */ +dom.findByXPathAll = function* (document, startNode, expression) { + let iter = document.evaluate( + expression, + startNode, + null, + ORDERED_NODE_ITERATOR_TYPE, + null + ); + let el = iter.iterateNext(); + while (el) { + yield el; + el = iter.iterateNext(); + } +}; + +/** + * Find all hyperlinks descendant of <var>startNode</var> which + * link text is <var>linkText</var>. + * + * @param {Element} startNode + * Where in the DOM hierarchy to begin searching. + * @param {string} linkText + * Link text to search for. + * + * @returns {Iterable.<HTMLAnchorElement>} + * Sequence of link elements which text is <var>s</var>. + */ +dom.findByLinkText = function (startNode, linkText) { + return filterLinks(startNode, async link => { + const visibleText = await lazy.atom.getVisibleText(link, link.ownerGlobal); + return visibleText.trim() === linkText; + }); +}; + +/** + * Find all hyperlinks descendant of <var>startNode</var> which + * link text contains <var>linkText</var>. + * + * @param {Element} startNode + * Where in the DOM hierachy to begin searching. + * @param {string} linkText + * Link text to search for. + * + * @returns {Iterable.<HTMLAnchorElement>} + * Iterator of link elements which text containins + * <var>linkText</var>. + */ +dom.findByPartialLinkText = function (startNode, linkText) { + return filterLinks(startNode, async link => { + const visibleText = await lazy.atom.getVisibleText(link, link.ownerGlobal); + + return visibleText.includes(linkText); + }); +}; + +/** + * Filters all hyperlinks that are descendant of <var>startNode</var> + * by <var>predicate</var>. + * + * @param {Element} startNode + * Where in the DOM hierarchy to begin searching. + * @param {function(HTMLAnchorElement): boolean} predicate + * Function that determines if given link should be included in + * return value or filtered away. + * + * @returns {Array.<HTMLAnchorElement>} + * Array of link elements matching <var>predicate</var>. + */ +async function filterLinks(startNode, predicate) { + const links = []; + + for (const link of getLinks(startNode)) { + if (await predicate(link)) { + links.push(link); + } + } + + return links; +} + +/** + * Finds a single element. + * + * @param {dom.Strategy} strategy + * Selector strategy to use. + * @param {string} selector + * Selector expression. + * @param {Document} document + * Document root. + * @param {Element=} startNode + * Optional Element from which to start searching. + * + * @returns {Element} + * Found element. + * + * @throws {InvalidSelectorError} + * If strategy <var>using</var> is not recognised. + * @throws {Error} + * If selector expression <var>selector</var> is malformed. + */ +async function findElement( + strategy, + selector, + document, + startNode = undefined +) { + switch (strategy) { + case dom.Strategy.ID: { + if (startNode.getElementById) { + return startNode.getElementById(selector); + } + let expr = `.//*[@id="${selector}"]`; + return dom.findByXPath(document, startNode, expr); + } + + case dom.Strategy.Name: { + if (startNode.getElementsByName) { + return startNode.getElementsByName(selector)[0]; + } + let expr = `.//*[@name="${selector}"]`; + return dom.findByXPath(document, startNode, expr); + } + + case dom.Strategy.ClassName: + return startNode.getElementsByClassName(selector)[0]; + + case dom.Strategy.TagName: + return startNode.getElementsByTagName(selector)[0]; + + case dom.Strategy.XPath: + return dom.findByXPath(document, startNode, selector); + + case dom.Strategy.LinkText: { + const links = getLinks(startNode); + for (const link of links) { + const visibleText = await lazy.atom.getVisibleText( + link, + link.ownerGlobal + ); + if (visibleText.trim() === selector) { + return link; + } + } + return undefined; + } + + case dom.Strategy.PartialLinkText: { + const links = getLinks(startNode); + for (const link of links) { + const visibleText = await lazy.atom.getVisibleText( + link, + link.ownerGlobal + ); + if (visibleText.includes(selector)) { + return link; + } + } + return undefined; + } + + case dom.Strategy.Selector: + try { + return startNode.querySelector(selector); + } catch (e) { + throw new lazy.error.InvalidSelectorError( + `${e.message}: "${selector}"` + ); + } + } + + throw new lazy.error.InvalidSelectorError(`No such strategy: ${strategy}`); +} + +/** + * Find multiple elements. + * + * @param {dom.Strategy} strategy + * Selector strategy to use. + * @param {string} selector + * Selector expression. + * @param {Document} document + * Document root. + * @param {Element=} startNode + * Optional Element from which to start searching. + * + * @returns {Array.<Element>} + * Found elements. + * + * @throws {InvalidSelectorError} + * If strategy <var>strategy</var> is not recognised. + * @throws {Error} + * If selector expression <var>selector</var> is malformed. + */ +async function findElements( + strategy, + selector, + document, + startNode = undefined +) { + switch (strategy) { + case dom.Strategy.ID: + selector = `.//*[@id="${selector}"]`; + + // fall through + case dom.Strategy.XPath: + return [...dom.findByXPathAll(document, startNode, selector)]; + + case dom.Strategy.Name: + if (startNode.getElementsByName) { + return startNode.getElementsByName(selector); + } + return [ + ...dom.findByXPathAll(document, startNode, `.//*[@name="${selector}"]`), + ]; + + case dom.Strategy.ClassName: + return startNode.getElementsByClassName(selector); + + case dom.Strategy.TagName: + return startNode.getElementsByTagName(selector); + + case dom.Strategy.LinkText: + return [...(await dom.findByLinkText(startNode, selector))]; + + case dom.Strategy.PartialLinkText: + return [...(await dom.findByPartialLinkText(startNode, selector))]; + + case dom.Strategy.Selector: + return startNode.querySelectorAll(selector); + + default: + throw new lazy.error.InvalidSelectorError( + `No such strategy: ${strategy}` + ); + } +} + +function getLinks(startNode) { + // DocumentFragment doesn't have `getElementsByTagName` so using `querySelectorAll`. + if (dom.isShadowRoot(startNode)) { + return startNode.querySelectorAll("a"); + } + return startNode.getElementsByTagName("a"); +} + +/** + * Finds the closest parent node of <var>startNode</var> matching a CSS + * <var>selector</var> expression. + * + * @param {Node} startNode + * Cycle through <var>startNode</var>'s parent nodes in tree-order + * and return the first match to <var>selector</var>. + * @param {string} selector + * CSS selector expression. + * + * @returns {Node=} + * First match to <var>selector</var>, or null if no match was found. + */ +dom.findClosest = function (startNode, selector) { + let node = startNode; + while (node.parentNode && node.parentNode.nodeType == ELEMENT_NODE) { + node = node.parentNode; + if (node.matches(selector)) { + return node; + } + } + return null; +}; + +/** + * Determines if <var>obj<var> is an HTML or JS collection. + * + * @param {object} seq + * Type to determine. + * + * @returns {boolean} + * True if <var>seq</va> is a collection. + */ +dom.isCollection = function (seq) { + switch (Object.prototype.toString.call(seq)) { + case "[object Arguments]": + case "[object Array]": + case "[object DOMTokenList]": + case "[object FileList]": + case "[object HTMLAllCollection]": + case "[object HTMLCollection]": + case "[object HTMLFormControlsCollection]": + case "[object HTMLOptionsCollection]": + case "[object NodeList]": + return true; + + default: + return false; + } +}; + +/** + * Determines if <var>shadowRoot</var> is detached. + * + * A ShadowRoot is detached if its node document is not the active document + * or if the element node referred to as its host is stale. + * + * @param {ShadowRoot} shadowRoot + * ShadowRoot to check for detached state. + * + * @returns {boolean} + * True if <var>shadowRoot</var> is detached, false otherwise. + */ +dom.isDetached = function (shadowRoot) { + return !shadowRoot.ownerDocument.isActive() || dom.isStale(shadowRoot.host); +}; + +/** + * Determines if <var>el</var> is stale. + * + * An element is stale if its node document is not the active document + * or if it is not connected. + * + * @param {Element} el + * Element to check for staleness. + * + * @returns {boolean} + * True if <var>el</var> is stale, false otherwise. + */ +dom.isStale = function (el) { + if (!el.ownerGlobal) { + // Without a valid inner window the document is basically closed. + return true; + } + + return !el.ownerDocument.isActive() || !el.isConnected; +}; + +/** + * Determine if <var>el</var> is selected or not. + * + * This operation only makes sense on + * <tt><input type=checkbox></tt>, + * <tt><input type=radio></tt>, + * and <tt>>option></tt> elements. + * + * @param {Element} el + * Element to test if selected. + * + * @returns {boolean} + * True if element is selected, false otherwise. + */ +dom.isSelected = function (el) { + if (!el) { + return false; + } + + if (dom.isXULElement(el)) { + if (XUL_CHECKED_ELS.has(el.tagName)) { + return el.checked; + } else if (XUL_SELECTED_ELS.has(el.tagName)) { + return el.selected; + } + } else if (dom.isDOMElement(el)) { + if (el.localName == "input" && ["checkbox", "radio"].includes(el.type)) { + return el.checked; + } else if (el.localName == "option") { + return el.selected; + } + } + + return false; +}; + +/** + * An element is considered read only if it is an + * <code><input></code> or <code><textarea></code> + * element whose <code>readOnly</code> content IDL attribute is set. + * + * @param {Element} el + * Element to test is read only. + * + * @returns {boolean} + * True if element is read only. + */ +dom.isReadOnly = function (el) { + return ( + dom.isDOMElement(el) && + ["input", "textarea"].includes(el.localName) && + el.readOnly + ); +}; + +/** + * An element is considered disabled if it is a an element + * that can be disabled, or it belongs to a container group which + * <code>disabled</code> content IDL attribute affects it. + * + * @param {Element} el + * Element to test for disabledness. + * + * @returns {boolean} + * True if element, or its container group, is disabled. + */ +dom.isDisabled = function (el) { + if (!dom.isDOMElement(el)) { + return false; + } + + switch (el.localName) { + case "option": + case "optgroup": + if (el.disabled) { + return true; + } + let parent = dom.findClosest(el, "optgroup,select"); + return dom.isDisabled(parent); + + case "button": + case "input": + case "select": + case "textarea": + return el.disabled; + + default: + return false; + } +}; + +/** + * Denotes elements that can be used for typing and clearing. + * + * Elements that are considered WebDriver-editable are non-readonly + * and non-disabled <code><input></code> elements in the Text, + * Search, URL, Telephone, Email, Password, Date, Month, Date and + * Time Local, Number, Range, Color, and File Upload states, and + * <code><textarea></code> elements. + * + * @param {Element} el + * Element to test. + * + * @returns {boolean} + * True if editable, false otherwise. + */ +dom.isMutableFormControl = function (el) { + if (!dom.isDOMElement(el)) { + return false; + } + if (dom.isReadOnly(el) || dom.isDisabled(el)) { + return false; + } + + if (el.localName == "textarea") { + return true; + } + + if (el.localName != "input") { + return false; + } + + switch (el.type) { + case "color": + case "date": + case "datetime-local": + case "email": + case "file": + case "month": + case "number": + case "password": + case "range": + case "search": + case "tel": + case "text": + case "time": + case "url": + case "week": + return true; + + default: + return false; + } +}; + +/** + * An editing host is a node that is either an HTML element with a + * <code>contenteditable</code> attribute, or the HTML element child + * of a document whose <code>designMode</code> is enabled. + * + * @param {Element} el + * Element to determine if is an editing host. + * + * @returns {boolean} + * True if editing host, false otherwise. + */ +dom.isEditingHost = function (el) { + return ( + dom.isDOMElement(el) && + (el.isContentEditable || el.ownerDocument.designMode == "on") + ); +}; + +/** + * Determines if an element is editable according to WebDriver. + * + * An element is considered editable if it is not read-only or + * disabled, and one of the following conditions are met: + * + * <ul> + * <li>It is a <code><textarea></code> element. + * + * <li>It is an <code><input></code> element that is not of + * the <code>checkbox</code>, <code>radio</code>, <code>hidden</code>, + * <code>submit</code>, <code>button</code>, or <code>image</code> types. + * + * <li>It is content-editable. + * + * <li>It belongs to a document in design mode. + * </ul> + * + * @param {Element} el + * Element to test if editable. + * + * @returns {boolean} + * True if editable, false otherwise. + */ +dom.isEditable = function (el) { + if (!dom.isDOMElement(el)) { + return false; + } + + if (dom.isReadOnly(el) || dom.isDisabled(el)) { + return false; + } + + return dom.isMutableFormControl(el) || dom.isEditingHost(el); +}; + +/** + * This function generates a pair of coordinates relative to the viewport + * given a target element and coordinates relative to that element's + * top-left corner. + * + * @param {Node} node + * Target node. + * @param {number=} xOffset + * Horizontal offset relative to target's top-left corner. + * Defaults to the centre of the target's bounding box. + * @param {number=} yOffset + * Vertical offset relative to target's top-left corner. Defaults to + * the centre of the target's bounding box. + * + * @returns {Object<string, number>} + * X- and Y coordinates. + * + * @throws TypeError + * If <var>xOffset</var> or <var>yOffset</var> are not numbers. + */ +dom.coordinates = function (node, xOffset = undefined, yOffset = undefined) { + let box = node.getBoundingClientRect(); + + if (typeof xOffset == "undefined" || xOffset === null) { + xOffset = box.width / 2.0; + } + if (typeof yOffset == "undefined" || yOffset === null) { + yOffset = box.height / 2.0; + } + + if (typeof yOffset != "number" || typeof xOffset != "number") { + throw new TypeError("Offset must be a number"); + } + + return { + x: box.left + xOffset, + y: box.top + yOffset, + }; +}; + +/** + * This function returns true if the node is in the viewport. + * + * @param {Element} el + * Target element. + * @param {number=} x + * Horizontal offset relative to target. Defaults to the centre of + * the target's bounding box. + * @param {number=} y + * Vertical offset relative to target. Defaults to the centre of + * the target's bounding box. + * + * @returns {boolean} + * True if if <var>el</var> is in viewport, false otherwise. + */ +dom.inViewport = function (el, x = undefined, y = undefined) { + let win = el.ownerGlobal; + let c = dom.coordinates(el, x, y); + let vp = { + top: win.pageYOffset, + left: win.pageXOffset, + bottom: win.pageYOffset + win.innerHeight, + right: win.pageXOffset + win.innerWidth, + }; + + return ( + vp.left <= c.x + win.pageXOffset && + c.x + win.pageXOffset <= vp.right && + vp.top <= c.y + win.pageYOffset && + c.y + win.pageYOffset <= vp.bottom + ); +}; + +/** + * Gets the element's container element. + * + * An element container is defined by the WebDriver + * specification to be an <tt><option></tt> element in a + * <a href="https://html.spec.whatwg.org/#concept-element-contexts">valid + * element context</a>, meaning that it has an ancestral element + * that is either <tt><datalist></tt> or <tt><select></tt>. + * + * If the element does not have a valid context, its container element + * is itself. + * + * @param {Element} el + * Element to get the container of. + * + * @returns {Element} + * Container element of <var>el</var>. + */ +dom.getContainer = function (el) { + // Does <option> or <optgroup> have a valid context, + // meaning is it a child of <datalist> or <select>? + if (["option", "optgroup"].includes(el.localName)) { + return dom.findClosest(el, "datalist,select") || el; + } + + return el; +}; + +/** + * An element is in view if it is a member of its own pointer-interactable + * paint tree. + * + * This means an element is considered to be in view, but not necessarily + * pointer-interactable, if it is found somewhere in the + * <code>elementsFromPoint</code> list at <var>el</var>'s in-view + * centre coordinates. + * + * Before running the check, we change <var>el</var>'s pointerEvents + * style property to "auto", since elements without pointer events + * enabled do not turn up in the paint tree we get from + * document.elementsFromPoint. This is a specialisation that is only + * relevant when checking if the element is in view. + * + * @param {Element} el + * Element to check if is in view. + * + * @returns {boolean} + * True if <var>el</var> is inside the viewport, or false otherwise. + */ +dom.isInView = function (el) { + let originalPointerEvents = el.style.pointerEvents; + + try { + el.style.pointerEvents = "auto"; + const tree = dom.getPointerInteractablePaintTree(el); + + // Bug 1413493 - <tr> is not part of the returned paint tree yet. As + // workaround check the visibility based on the first contained cell. + if (el.localName === "tr" && el.cells && el.cells.length) { + return tree.includes(el.cells[0]); + } + + return tree.includes(el); + } finally { + el.style.pointerEvents = originalPointerEvents; + } +}; + +/** + * This function throws the visibility of the element error if the element is + * not displayed or the given coordinates are not within the viewport. + * + * @param {Element} el + * Element to check if visible. + * @param {number=} x + * Horizontal offset relative to target. Defaults to the centre of + * the target's bounding box. + * @param {number=} y + * Vertical offset relative to target. Defaults to the centre of + * the target's bounding box. + * + * @returns {boolean} + * True if visible, false otherwise. + */ +dom.isVisible = async function (el, x = undefined, y = undefined) { + let win = el.ownerGlobal; + + if (!(await lazy.atom.isElementDisplayed(el, win))) { + return false; + } + + if (el.tagName.toLowerCase() == "body") { + return true; + } + + if (!dom.inViewport(el, x, y)) { + dom.scrollIntoView(el); + if (!dom.inViewport(el)) { + return false; + } + } + return true; +}; + +/** + * A pointer-interactable element is defined to be the first + * non-transparent element, defined by the paint order found at the centre + * point of its rectangle that is inside the viewport, excluding the size + * of any rendered scrollbars. + * + * An element is obscured if the pointer-interactable paint tree at its + * centre point is empty, or the first element in this tree is not an + * inclusive descendant of itself. + * + * @param {DOMElement} el + * Element determine if is pointer-interactable. + * + * @returns {boolean} + * True if element is obscured, false otherwise. + */ +dom.isObscured = function (el) { + let tree = dom.getPointerInteractablePaintTree(el); + return !el.contains(tree[0]); +}; + +// TODO(ato): Only used by deprecated action API +// https://bugzil.la/1354578 +/** + * Calculates the in-view centre point of an element's client rect. + * + * The portion of an element that is said to be _in view_, is the + * intersection of two squares: the first square being the initial + * viewport, and the second a DOM element. From this square we + * calculate the in-view _centre point_ and convert it into CSS pixels. + * + * Although Gecko's system internals allow click points to be + * given in floating point precision, the DOM operates in CSS pixels. + * When the in-view centre point is later used to retrieve a coordinate's + * paint tree, we need to ensure to operate in the same language. + * + * As a word of warning, there appears to be inconsistencies between + * how `DOMElement.elementsFromPoint` and `DOMWindowUtils.sendMouseEvent` + * internally rounds (ceils/floors) coordinates. + * + * @param {DOMRect} rect + * Element off a DOMRect sequence produced by calling + * `getClientRects` on an {@link Element}. + * @param {WindowProxy} win + * Current window global. + * + * @returns {Map.<string, number>} + * X and Y coordinates that denotes the in-view centre point of + * `rect`. + */ +dom.getInViewCentrePoint = function (rect, win) { + const { floor, max, min } = Math; + + // calculate the intersection of the rect that is inside the viewport + let visible = { + left: max(0, min(rect.x, rect.x + rect.width)), + right: min(win.innerWidth, max(rect.x, rect.x + rect.width)), + top: max(0, min(rect.y, rect.y + rect.height)), + bottom: min(win.innerHeight, max(rect.y, rect.y + rect.height)), + }; + + // arrive at the centre point of the visible rectangle + let x = (visible.left + visible.right) / 2.0; + let y = (visible.top + visible.bottom) / 2.0; + + // convert to CSS pixels, as centre point can be float + x = floor(x); + y = floor(y); + + return { x, y }; +}; + +/** + * Produces a pointer-interactable elements tree from a given element. + * + * The tree is defined by the paint order found at the centre point of + * the element's rectangle that is inside the viewport, excluding the size + * of any rendered scrollbars. + * + * @param {DOMElement} el + * Element to determine if is pointer-interactable. + * + * @returns {Array.<DOMElement>} + * Sequence of elements in paint order. + */ +dom.getPointerInteractablePaintTree = function (el) { + const win = el.ownerGlobal; + const rootNode = el.getRootNode(); + + // pointer-interactable elements tree, step 1 + if (!el.isConnected) { + return []; + } + + // steps 2-3 + let rects = el.getClientRects(); + if (!rects.length) { + return []; + } + + // step 4 + let centre = dom.getInViewCentrePoint(rects[0], win); + + // step 5 + return rootNode.elementsFromPoint(centre.x, centre.y); +}; + +// TODO(ato): Not implemented. +// In fact, it's not defined in the spec. +dom.isKeyboardInteractable = () => true; + +/** + * Attempts to scroll into view |el|. + * + * @param {DOMElement} el + * Element to scroll into view. + */ +dom.scrollIntoView = function (el) { + if (el.scrollIntoView) { + el.scrollIntoView({ block: "end", inline: "nearest" }); + } +}; + +/** + * Ascertains whether <var>obj</var> is a DOM-, SVG-, or XUL element. + * + * @param {object} obj + * Object thought to be an <code>Element</code> or + * <code>XULElement</code>. + * + * @returns {boolean} + * True if <var>obj</var> is an element, false otherwise. + */ +dom.isElement = function (obj) { + return dom.isDOMElement(obj) || dom.isXULElement(obj); +}; + +/** + * Returns the shadow root of an element. + * + * @param {Element} el + * Element thought to have a <code>shadowRoot</code> + * @returns {ShadowRoot} + * Shadow root of the element. + */ +dom.getShadowRoot = function (el) { + const shadowRoot = el.openOrClosedShadowRoot; + if (!shadowRoot) { + throw new lazy.error.NoSuchShadowRootError(); + } + return shadowRoot; +}; + +/** + * Ascertains whether <var>node</var> is a shadow root. + * + * @param {ShadowRoot} node + * The node that will be checked to see if it has a shadow root + * + * @returns {boolean} + * True if <var>node</var> is a shadow root, false otherwise. + */ +dom.isShadowRoot = function (node) { + return ( + node && + node.nodeType === DOCUMENT_FRAGMENT_NODE && + node.containingShadowRoot == node + ); +}; + +/** + * Ascertains whether <var>obj</var> is a DOM element. + * + * @param {object} obj + * Object to check. + * + * @returns {boolean} + * True if <var>obj</var> is a DOM element, false otherwise. + */ +dom.isDOMElement = function (obj) { + return obj && obj.nodeType == ELEMENT_NODE && !dom.isXULElement(obj); +}; + +/** + * Ascertains whether <var>obj</var> is a XUL element. + * + * @param {object} obj + * Object to check. + * + * @returns {boolean} + * True if <var>obj</var> is a XULElement, false otherwise. + */ +dom.isXULElement = function (obj) { + return obj && obj.nodeType === ELEMENT_NODE && obj.namespaceURI === XUL_NS; +}; + +/** + * Ascertains whether <var>node</var> is in a privileged document. + * + * @param {Node} node + * Node to check. + * + * @returns {boolean} + * True if <var>node</var> is in a privileged document, + * false otherwise. + */ +dom.isInPrivilegedDocument = function (node) { + return !!node?.nodePrincipal?.isSystemPrincipal; +}; + +/** + * Ascertains whether <var>obj</var> is a <code>WindowProxy</code>. + * + * @param {object} obj + * Object to check. + * + * @returns {boolean} + * True if <var>obj</var> is a DOM window. + */ +dom.isDOMWindow = function (obj) { + // TODO(ato): This should use Object.prototype.toString.call(node) + // but it's not clear how to write a good xpcshell test for that, + // seeing as we stub out a WindowProxy. + return ( + typeof obj == "object" && + obj !== null && + typeof obj.toString == "function" && + obj.toString() == "[object Window]" && + obj.self === obj + ); +}; + +const boolEls = { + audio: ["autoplay", "controls", "loop", "muted"], + button: ["autofocus", "disabled", "formnovalidate"], + details: ["open"], + dialog: ["open"], + fieldset: ["disabled"], + form: ["novalidate"], + iframe: ["allowfullscreen"], + img: ["ismap"], + input: [ + "autofocus", + "checked", + "disabled", + "formnovalidate", + "multiple", + "readonly", + "required", + ], + keygen: ["autofocus", "disabled"], + menuitem: ["checked", "default", "disabled"], + ol: ["reversed"], + optgroup: ["disabled"], + option: ["disabled", "selected"], + script: ["async", "defer"], + select: ["autofocus", "disabled", "multiple", "required"], + textarea: ["autofocus", "disabled", "readonly", "required"], + track: ["default"], + video: ["autoplay", "controls", "loop", "muted"], +}; + +/** + * Tests if the attribute is a boolean attribute on element. + * + * @param {Element} el + * Element to test if <var>attr</var> is a boolean attribute on. + * @param {string} attr + * Attribute to test is a boolean attribute. + * + * @returns {boolean} + * True if the attribute is boolean, false otherwise. + */ +dom.isBooleanAttribute = function (el, attr) { + if (!dom.isDOMElement(el)) { + return false; + } + + // global boolean attributes that apply to all HTML elements, + // except for custom elements + const customElement = !el.localName.includes("-"); + if ((attr == "hidden" || attr == "itemscope") && customElement) { + return true; + } + + if (!boolEls.hasOwnProperty(el.localName)) { + return false; + } + return boolEls[el.localName].includes(attr); +}; diff --git a/remote/shared/Format.sys.mjs b/remote/shared/Format.sys.mjs new file mode 100644 index 0000000000..5da8bc9161 --- /dev/null +++ b/remote/shared/Format.sys.mjs @@ -0,0 +1,186 @@ +/* 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, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "truncateLog", + "remote.log.truncate", + false +); + +const ELEMENT_NODE = 1; +const MAX_STRING_LENGTH = 250; + +/** + * Pretty-print values passed to template strings. + * + * Usage:: + * + * let bool = {value: true}; + * pprint`Expected boolean, got ${bool}`; + * => 'Expected boolean, got [object Object] {"value": true}' + * + * let htmlElement = document.querySelector("input#foo"); + * pprint`Expected element ${htmlElement}`; + * => 'Expected element <input id="foo" class="bar baz" type="input">' + * + * pprint`Current window: ${window}`; + * => '[object Window https://www.mozilla.org/]' + */ +export function pprint(ss, ...values) { + function pretty(val) { + let proto = Object.prototype.toString.call(val); + if ( + typeof val == "object" && + val !== null && + "nodeType" in val && + val.nodeType === ELEMENT_NODE + ) { + return prettyElement(val); + } else if (["[object Window]", "[object ChromeWindow]"].includes(proto)) { + return prettyWindowGlobal(val); + } else if (proto == "[object Attr]") { + return prettyAttr(val); + } + return prettyObject(val); + } + + function prettyElement(el) { + let attrs = ["id", "class", "href", "name", "src", "type"]; + + let idents = ""; + for (let attr of attrs) { + if (el.hasAttribute(attr)) { + idents += ` ${attr}="${el.getAttribute(attr)}"`; + } + } + + return `<${el.localName}${idents}>`; + } + + function prettyWindowGlobal(win) { + let proto = Object.prototype.toString.call(win); + return `[${proto.substring(1, proto.length - 1)} ${win.location}]`; + } + + function prettyAttr(obj) { + return `[object Attr ${obj.name}="${obj.value}"]`; + } + + function prettyObject(obj) { + let proto = Object.prototype.toString.call(obj); + let s = ""; + try { + s = JSON.stringify(obj); + } catch (e) { + if (e instanceof TypeError) { + s = `<${e.message}>`; + } else { + throw e; + } + } + return `${proto} ${s}`; + } + + let res = []; + for (let i = 0; i < ss.length; i++) { + res.push(ss[i]); + if (i < values.length) { + let s; + try { + s = pretty(values[i]); + } catch (e) { + lazy.logger.warn("Problem pretty printing:", e); + s = typeof values[i]; + } + res.push(s); + } + } + return res.join(""); +} + +/** + * Template literal that truncates string values in arbitrary objects. + * + * Given any object, the template will walk the object and truncate + * any strings it comes across to a reasonable limit. This is suitable + * when you have arbitrary data and data integrity is not important. + * + * The strings are truncated in the middle so that the beginning and + * the end is preserved. This will make a long, truncated string look + * like "X <...> Y", where X and Y are half the number of characters + * of the maximum string length from either side of the string. + * + * + * Usage:: + * + * truncate`Hello ${"x".repeat(260)}!`; + * // Hello xxx ... xxx! + * + * Functions named `toJSON` or `toString` on objects will be called. + */ +export function truncate(strings, ...values) { + function walk(obj) { + const typ = Object.prototype.toString.call(obj); + + switch (typ) { + case "[object Undefined]": + case "[object Null]": + case "[object Boolean]": + case "[object Number]": + return obj; + + case "[object String]": + if (lazy.truncateLog && obj.length > MAX_STRING_LENGTH) { + let s1 = obj.substring(0, MAX_STRING_LENGTH / 2); + let s2 = obj.substring(obj.length - MAX_STRING_LENGTH / 2); + return `${s1} ... ${s2}`; + } + return obj; + + case "[object Array]": + return obj.map(walk); + + // arbitrary object + default: + if ( + Object.getOwnPropertyNames(obj).includes("toString") && + typeof obj.toString == "function" + ) { + return walk(obj.toString()); + } + + let rv = {}; + for (let prop in obj) { + rv[prop] = walk(obj[prop]); + } + return rv; + } + } + + let res = []; + for (let i = 0; i < strings.length; ++i) { + res.push(strings[i]); + if (i < values.length) { + let obj = walk(values[i]); + let t = Object.prototype.toString.call(obj); + if (t == "[object Array]" || t == "[object Object]") { + res.push(JSON.stringify(obj)); + } else { + res.push(obj); + } + } + } + return res.join(""); +} diff --git a/remote/shared/Log.sys.mjs b/remote/shared/Log.sys.mjs new file mode 100644 index 0000000000..f1b3706391 --- /dev/null +++ b/remote/shared/Log.sys.mjs @@ -0,0 +1,71 @@ +/* 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 { Log as StdLog } from "resource://gre/modules/Log.sys.mjs"; + +const PREF_REMOTE_LOG_LEVEL = "remote.log.level"; + +const lazy = {}; + +// Lazy getter which returns a cached value of the remote log level. Should be +// used for static getters used to guard hot paths for logging, eg +// isTraceLevelOrMore. +ChromeUtils.defineLazyGetter(lazy, "logLevel", () => + Services.prefs.getCharPref(PREF_REMOTE_LOG_LEVEL, StdLog.Level.Fatal) +); + +/** E10s compatible wrapper for the standard logger from Log.sys.mjs. */ +export class Log { + static TYPES = { + CDP: "CDP", + MARIONETTE: "Marionette", + REMOTE_AGENT: "RemoteAgent", + WEBDRIVER_BIDI: "WebDriver BiDi", + }; + + /** + * Get a logger instance. For each provided type, a dedicated logger instance + * will be returned, but all loggers are relying on the same preference. + * + * @param {string} type + * The type of logger to use. Protocol-specific modules should use the + * corresponding logger type. Eg. files under /marionette should use + * Log.TYPES.MARIONETTE. + */ + static get(type = Log.TYPES.REMOTE_AGENT) { + const logger = StdLog.repository.getLogger(type); + if (!logger.ownAppenders.length) { + logger.addAppender(new StdLog.DumpAppender()); + logger.manageLevelFromPref(PREF_REMOTE_LOG_LEVEL); + } + return logger; + } + + /** + * Check if the current log level matches the Debug log level, or any level + * above that. This should be used to guard logger.debug calls and avoid + * instanciating logger instances unnecessarily. + */ + static get isDebugLevelOrMore() { + // Debug is assigned 20, more verbose log levels have lower values. + return StdLog.Level[lazy.logLevel] <= StdLog.Level.Debug; + } + + /** + * Check if the current log level matches the Trace log level, or any level + * above that. This should be used to guard logger.trace calls and avoid + * instanciating logger instances unnecessarily. + */ + static get isTraceLevelOrMore() { + // Trace is assigned 10, more verbose log levels have lower values. + return StdLog.Level[lazy.logLevel] <= StdLog.Level.Trace; + } + + static get verbose() { + // we can't use Preferences.sys.mjs before first paint, + // see ../browser/base/content/test/performance/browser_startup.js + const level = Services.prefs.getStringPref(PREF_REMOTE_LOG_LEVEL, "Info"); + return StdLog.Level[level] >= StdLog.Level.Info; + } +} diff --git a/remote/shared/MobileTabBrowser.sys.mjs b/remote/shared/MobileTabBrowser.sys.mjs new file mode 100644 index 0000000000..b61a1f9a9b --- /dev/null +++ b/remote/shared/MobileTabBrowser.sys.mjs @@ -0,0 +1,86 @@ +/* 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, { + GeckoViewTabUtil: "resource://gre/modules/GeckoViewTestUtils.sys.mjs", + + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", +}); + +// GeckoView shim for Desktop's gBrowser +export class MobileTabBrowser { + constructor(window) { + this.window = window; + } + + get tabs() { + return [this.window.tab]; + } + + get selectedTab() { + return this.window.tab; + } + + set selectedTab(tab) { + if (tab != this.selectedTab) { + throw new Error("GeckoView only supports a single tab"); + } + + // Synthesize a custom TabSelect event to indicate that a tab has been + // selected even when we don't change it. + const event = this.window.CustomEvent("TabSelect", { + bubbles: true, + cancelable: false, + detail: { + previousTab: this.selectedTab, + }, + }); + this.window.document.dispatchEvent(event); + } + + get selectedBrowser() { + return this.selectedTab.linkedBrowser; + } + + addEventListener() { + this.window.addEventListener(...arguments); + } + + /** + * Create a new tab. + * + * @param {string} uriString + * The URI string to load within the newly opened tab. + * + * @returns {Promise<Tab>} + * The created tab. + * @throws {Error} + * Throws an error if the tab cannot be created. + */ + addTab(uriString) { + return lazy.GeckoViewTabUtil.createNewTab(uriString); + } + + getTabForBrowser(browser) { + if (browser != this.selectedBrowser) { + throw new Error("GeckoView only supports a single tab"); + } + + return this.selectedTab; + } + + removeEventListener() { + this.window.removeEventListener(...arguments); + } + + removeTab(tab) { + if (tab != this.selectedTab) { + throw new Error("GeckoView only supports a single tab"); + } + + return lazy.windowManager.closeWindow(this.window); + } +} diff --git a/remote/shared/Navigate.sys.mjs b/remote/shared/Navigate.sys.mjs new file mode 100644 index 0000000000..9b72c0dfbf --- /dev/null +++ b/remote/shared/Navigate.sys.mjs @@ -0,0 +1,435 @@ +/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + + Deferred: "chrome://remote/content/shared/Sync.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + truncate: "chrome://remote/content/shared/Format.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.REMOTE_AGENT) +); + +// Define a custom multiplier to apply to the unload timer on various platforms. +// This multiplier should only reflect the navigation performance of the +// platform and not the overall performance. +ChromeUtils.defineLazyGetter(lazy, "UNLOAD_TIMEOUT_MULTIPLIER", () => { + if (AppConstants.MOZ_CODE_COVERAGE) { + // Navigation on ccov platforms can be extremely slow because new processes + // need to be instrumented for coverage on startup. + return 16; + } + + if (AppConstants.ASAN || AppConstants.DEBUG || AppConstants.TSAN) { + // Use an extended timeout on slow platforms. + return 8; + } + + return 1; +}); + +export const DEFAULT_UNLOAD_TIMEOUT = 200; + +/** + * Returns the multiplier used for the unload timer. Useful for tests which + * assert the behavior of this timeout. + */ +export function getUnloadTimeoutMultiplier() { + return lazy.UNLOAD_TIMEOUT_MULTIPLIER; +} + +// Used to keep weak references of webProgressListeners alive. +const webProgressListeners = new Set(); + +/** + * Wait until the initial load of the given WebProgress is done. + * + * @param {WebProgress} webProgress + * The WebProgress instance to observe. + * @param {object=} options + * @param {boolean=} options.resolveWhenStarted + * Flag to indicate that the Promise has to be resolved when the + * page load has been started. Otherwise wait until the page has + * finished loading. Defaults to `false`. + * @param {number=} options.unloadTimeout + * Time to allow before the page gets unloaded. See ProgressListener options. + * @returns {Promise} + * Promise which resolves when the page load is in the expected state. + * Values as returned: + * - {nsIURI} currentURI The current URI of the page + * - {nsIURI} targetURI Target URI of the navigation + */ +export async function waitForInitialNavigationCompleted( + webProgress, + options = {} +) { + const { resolveWhenStarted = false, unloadTimeout } = options; + + const browsingContext = webProgress.browsingContext; + + // Start the listener right away to avoid race conditions. + const listener = new ProgressListener(webProgress, { + resolveWhenStarted, + unloadTimeout, + }); + const navigated = listener.start(); + + // Right after a browsing context has been attached it could happen that + // no window global has been set yet. Consider this as nothing has been + // loaded yet. + let isInitial = true; + if (browsingContext.currentWindowGlobal) { + isInitial = browsingContext.currentWindowGlobal.isInitialDocument; + } + + // If the current document is not the initial "about:blank" and is also + // no longer loading, assume the navigation is done and return. + if (!isInitial && !listener.isLoadingDocument) { + lazy.logger.trace( + lazy.truncate`[${browsingContext.id}] Document already finished loading: ${browsingContext.currentURI?.spec}` + ); + + // Will resolve the navigated promise. + listener.stop(); + } + + await navigated; + + return { + currentURI: listener.currentURI, + targetURI: listener.targetURI, + }; +} + +/** + * WebProgressListener to observe for page loads. + */ +export class ProgressListener { + #expectNavigation; + #resolveWhenStarted; + #unloadTimeout; + #waitForExplicitStart; + #webProgress; + + #deferredNavigation; + #seenStartFlag; + #targetURI; + #unloadTimerId; + + /** + * Create a new WebProgressListener instance. + * + * @param {WebProgress} webProgress + * The web progress to attach the listener to. + * @param {object=} options + * @param {boolean=} options.expectNavigation + * Flag to indicate that a navigation is guaranteed to happen. + * When set to `true`, the ProgressListener will ignore options.unloadTimeout + * and will only resolve when the expected navigation happens. + * Defaults to `false`. + * @param {boolean=} options.resolveWhenStarted + * Flag to indicate that the Promise has to be resolved when the + * page load has been started. Otherwise wait until the page has + * finished loading. Defaults to `false`. + * @param {number=} options.unloadTimeout + * Time to allow before the page gets unloaded. Defaults to 200ms on + * regular platforms. A multiplier will be applied on slower platforms + * (eg. debug, ccov...). + * Ignored if options.expectNavigation is set to `true` + * @param {boolean=} options.waitForExplicitStart + * Flag to indicate that the Promise can only resolve after receiving a + * STATE_START state change. In other words, if the webProgress is already + * navigating, the Promise will only resolve for the next navigation. + * Defaults to `false`. + */ + constructor(webProgress, options = {}) { + const { + expectNavigation = false, + resolveWhenStarted = false, + unloadTimeout = DEFAULT_UNLOAD_TIMEOUT, + waitForExplicitStart = false, + } = options; + + this.#expectNavigation = expectNavigation; + this.#resolveWhenStarted = resolveWhenStarted; + this.#unloadTimeout = unloadTimeout * lazy.UNLOAD_TIMEOUT_MULTIPLIER; + this.#waitForExplicitStart = waitForExplicitStart; + this.#webProgress = webProgress; + + this.#deferredNavigation = null; + this.#seenStartFlag = false; + this.#targetURI = null; + this.#unloadTimerId = null; + } + + get #messagePrefix() { + return `[${this.browsingContext.id}] ${this.constructor.name}`; + } + + get browsingContext() { + return this.#webProgress.browsingContext; + } + + get currentURI() { + return this.#webProgress.browsingContext.currentURI; + } + + get isLoadingDocument() { + return this.#webProgress.isLoadingDocument; + } + + get isStarted() { + return !!this.#deferredNavigation; + } + + get targetURI() { + return this.#targetURI; + } + + #checkLoadingState(request, options = {}) { + const { isStart = false, isStop = false, status = 0 } = options; + + this.#trace(`Check loading state: isStart=${isStart} isStop=${isStop}`); + if (isStart && !this.#seenStartFlag) { + this.#seenStartFlag = true; + + this.#targetURI = this.#getTargetURI(request); + + this.#trace(`state=start: ${this.targetURI?.spec}`); + + if (this.#unloadTimerId !== null) { + lazy.clearTimeout(this.#unloadTimerId); + this.#trace("Cleared the unload timer"); + this.#unloadTimerId = null; + } + + if (this.#resolveWhenStarted) { + this.#trace("Request to stop listening when navigation started"); + this.stop(); + return; + } + } + + if (isStop && this.#seenStartFlag) { + // Treat NS_ERROR_PARSED_DATA_CACHED as a success code + // since navigation happened and content has been loaded. + if ( + !Components.isSuccessCode(status) && + status != Cr.NS_ERROR_PARSED_DATA_CACHED + ) { + if ( + status == Cr.NS_BINDING_ABORTED && + this.browsingContext.currentWindowGlobal.isInitialDocument + ) { + this.#trace( + "Ignore aborted navigation error to the initial document, real document will be loaded." + ); + return; + } + + // The navigation request caused an error. + const errorName = ChromeUtils.getXPCOMErrorName(status); + this.#trace( + `state=stop: error=0x${status.toString(16)} (${errorName})` + ); + this.stop({ error: new Error(errorName) }); + return; + } + + this.#trace(`state=stop: ${this.currentURI.spec}`); + + // If a non initial page finished loading the navigation is done. + if (!this.browsingContext.currentWindowGlobal.isInitialDocument) { + this.stop(); + return; + } + + // Otherwise wait for a potential additional page load. + this.#trace( + "Initial document loaded. Wait for a potential further navigation." + ); + this.#seenStartFlag = false; + this.#setUnloadTimer(); + } + } + + #getTargetURI(request) { + try { + return request.QueryInterface(Ci.nsIChannel).originalURI; + } catch (e) {} + + return null; + } + + #setUnloadTimer() { + if (this.#expectNavigation) { + this.#trace("Skip setting the unload timer"); + } else { + this.#trace(`Setting unload timer (${this.#unloadTimeout}ms)`); + + this.#unloadTimerId = lazy.setTimeout(() => { + this.#trace(`No navigation detected: ${this.currentURI?.spec}`); + // Assume the target is the currently loaded URI. + this.#targetURI = this.currentURI; + this.stop(); + }, this.#unloadTimeout); + } + } + + #trace(message) { + lazy.logger.trace(lazy.truncate`${this.#messagePrefix} ${message}`); + } + + onStateChange(progress, request, flag, status) { + this.#checkLoadingState(request, { + isStart: flag & Ci.nsIWebProgressListener.STATE_START, + isStop: flag & Ci.nsIWebProgressListener.STATE_STOP, + status, + }); + } + + onLocationChange(progress, request, location, flag) { + // If an error page has been loaded abort the navigation. + if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) { + this.#trace(`location=errorPage: ${location.spec}`); + this.stop({ error: new Error("Address restricted") }); + return; + } + + // If location has changed in the same document the navigation is done. + if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) { + this.#targetURI = location; + this.#trace(`location=sameDocument: ${this.targetURI?.spec}`); + this.stop(); + } + } + + /** + * Start observing web progress changes. + * + * @returns {Promise} + * A promise that will resolve when the navigation has been finished. + */ + start() { + if (this.#deferredNavigation) { + throw new Error(`Progress listener already started`); + } + + this.#trace( + `Start: expectNavigation=${this.#expectNavigation} resolveWhenStarted=${ + this.#resolveWhenStarted + } unloadTimeout=${this.#unloadTimeout} waitForExplicitStart=${ + this.#waitForExplicitStart + }` + ); + + if (this.#webProgress.isLoadingDocument) { + this.#targetURI = this.#getTargetURI(this.#webProgress.documentRequest); + this.#trace(`Document already loading ${this.#targetURI?.spec}`); + + if (this.#resolveWhenStarted && !this.#waitForExplicitStart) { + this.#trace( + "Resolve on document loading if not waiting for a load or a new navigation" + ); + return Promise.resolve(); + } + } + + this.#deferredNavigation = new lazy.Deferred(); + + // Enable all location change and state notifications to get informed about an upcoming load + // as early as possible. + this.#webProgress.addProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_ALL + ); + + webProgressListeners.add(this); + + if (this.#webProgress.isLoadingDocument && !this.#waitForExplicitStart) { + this.#checkLoadingState(this.#webProgress.documentRequest, { + isStart: true, + }); + } else { + // If the document is not loading yet wait some time for the navigation + // to be started. + this.#setUnloadTimer(); + } + + return this.#deferredNavigation.promise; + } + + /** + * Stop observing web progress changes. + * + * @param {object=} options + * @param {Error=} options.error + * If specified the navigation promise will be rejected with this error. + */ + stop(options = {}) { + const { error } = options; + + this.#trace(`Stop: has error=${!!error}`); + + if (!this.#deferredNavigation) { + throw new Error("Progress listener not yet started"); + } + + lazy.clearTimeout(this.#unloadTimerId); + this.#unloadTimerId = null; + + this.#webProgress.removeProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_ALL + ); + webProgressListeners.delete(this); + + if (!this.#targetURI) { + // If no target URI has been set yet it should be the current URI + this.#targetURI = this.browsingContext.currentURI; + } + + if (error) { + this.#deferredNavigation.reject(error); + } else { + this.#deferredNavigation.resolve(); + } + + this.#deferredNavigation = null; + } + + /** + * Stop the progress listener if and only if we already detected a navigation + * start. + * + * @param {object=} options + * @param {Error=} options.error + * If specified the navigation promise will be rejected with this error. + */ + stopIfStarted(options) { + this.#trace(`Stop if started: seenStartFlag=${this.#seenStartFlag}`); + if (this.#seenStartFlag) { + this.stop(options); + } + } + + toString() { + return `[object ${this.constructor.name}]`; + } + + get QueryInterface() { + return ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]); + } +} diff --git a/remote/shared/NavigationManager.sys.mjs b/remote/shared/NavigationManager.sys.mjs new file mode 100644 index 0000000000..1f19ef3c0d --- /dev/null +++ b/remote/shared/NavigationManager.sys.mjs @@ -0,0 +1,414 @@ +/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + registerNavigationListenerActor: + "chrome://remote/content/shared/js-window-actors/NavigationListenerActor.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + truncate: "chrome://remote/content/shared/Format.sys.mjs", + unregisterNavigationListenerActor: + "chrome://remote/content/shared/js-window-actors/NavigationListenerActor.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * @typedef {object} BrowsingContextDetails + * @property {string} browsingContextId - The browsing context id. + * @property {string} browserId - The id of the Browser owning the browsing + * context. + * @property {BrowsingContext=} context - The BrowsingContext itself, if + * available. + * @property {boolean} isTopBrowsingContext - Whether the browsing context is + * top level. + */ + +/** + * @typedef {object} NavigationInfo + * @property {boolean} finished - Whether the navigation is finished or not. + * @property {string} navigationId - The UUID for the navigation. + * @property {string} navigable - The UUID for the navigable. + * @property {string} url - The target url for the navigation. + */ + +/** + * The NavigationRegistry is responsible for monitoring all navigations happening + * in the browser. + * + * It relies on a JSWindowActor pair called NavigationListener{Parent|Child}, + * found under remote/shared/js-window-actors. As a simple overview, the + * NavigationListenerChild will monitor navigations in all window globals using + * content process WebProgressListener, and will forward each relevant update to + * the NavigationListenerParent + * + * The NavigationRegistry singleton holds the map of navigations, from navigable + * to NavigationInfo. It will also be called by NavigationListenerParent + * whenever a navigation event happens. + * + * This singleton is not exported outside of this class, and consumers instead + * need to use the NavigationManager class. The NavigationRegistry keeps track + * of how many NavigationListener instances are currently listening in order to + * know if the NavigationListenerActor should be registered or not. + * + * The NavigationRegistry exposes an API to retrieve the current or last + * navigation for a given navigable, and also forwards events to notify about + * navigation updates to individual NavigationManager instances. + * + * @class NavigationRegistry + */ +class NavigationRegistry extends EventEmitter { + #managers; + #navigations; + #navigationIds; + + constructor() { + super(); + + // Set of NavigationManager instances currently used. + this.#managers = new Set(); + + // Maps navigable to NavigationInfo. + this.#navigations = new WeakMap(); + + // Maps navigable id to navigation id. Only used to pre-register navigation + // ids before the actual event is detected. + this.#navigationIds = new Map(); + } + + /** + * Retrieve the last known navigation data for a given browsing context. + * + * @param {BrowsingContext} context + * The browsing context for which the navigation event was recorded. + * @returns {NavigationInfo|null} + * The last known navigation data, or null. + */ + getNavigationForBrowsingContext(context) { + if (!lazy.TabManager.isValidCanonicalBrowsingContext(context)) { + // Bail out if the provided context is not a valid CanonicalBrowsingContext + // instance. + return null; + } + + const navigable = lazy.TabManager.getNavigableForBrowsingContext(context); + if (!this.#navigations.has(navigable)) { + return null; + } + + return this.#navigations.get(navigable); + } + + /** + * Start monitoring navigations in all browsing contexts. This will register + * the NavigationListener JSWindowActor and will initialize them in all + * existing browsing contexts. + */ + startMonitoring(listener) { + if (this.#managers.size == 0) { + lazy.registerNavigationListenerActor(); + } + + this.#managers.add(listener); + } + + /** + * Stop monitoring navigations. This will unregister the NavigationListener + * JSWindowActor and clear the information collected about navigations so far. + */ + stopMonitoring(listener) { + if (!this.#managers.has(listener)) { + return; + } + + this.#managers.delete(listener); + if (this.#managers.size == 0) { + lazy.unregisterNavigationListenerActor(); + // Clear the map. + this.#navigations = new WeakMap(); + } + } + + /** + * Called when a same-document navigation is recorded from the + * NavigationListener actors. + * + * This entry point is only intended to be called from + * NavigationListenerParent, to avoid setting up observers or listeners, + * which are unnecessary since NavigationManager has to be a singleton. + * + * @param {object} data + * @param {BrowsingContext} data.context + * The browsing context for which the navigation event was recorded. + * @param {string} data.url + * The URL as string for the navigation. + * @returns {NavigationInfo} + * The navigation created for this same-document navigation. + */ + notifyLocationChanged(data) { + const { contextDetails, url } = data; + + const context = this.#getContextFromContextDetails(contextDetails); + const navigable = lazy.TabManager.getNavigableForBrowsingContext(context); + const navigableId = lazy.TabManager.getIdForBrowsingContext(context); + + const navigationId = this.#getOrCreateNavigationId(navigableId); + const navigation = { finished: true, navigationId, url }; + this.#navigations.set(navigable, navigation); + + // Same document navigations are immediately done, fire a single event. + this.emit("location-changed", { navigationId, navigableId, url }); + + return navigation; + } + + /** + * Called when a navigation-started event is recorded from the + * NavigationListener actors. + * + * This entry point is only intended to be called from + * NavigationListenerParent, to avoid setting up observers or listeners, + * which are unnecessary since NavigationManager has to be a singleton. + * + * @param {object} data + * @param {BrowsingContextDetails} data.contextDetails + * The details about the browsing context for this navigation. + * @param {string} data.url + * The URL as string for the navigation. + * @returns {NavigationInfo} + * The created navigation or the ongoing navigation, if applicable. + */ + notifyNavigationStarted(data) { + const { contextDetails, url } = data; + + const context = this.#getContextFromContextDetails(contextDetails); + const navigable = lazy.TabManager.getNavigableForBrowsingContext(context); + const navigableId = lazy.TabManager.getIdForBrowsingContext(context); + + let navigation = this.#navigations.get(navigable); + if (navigation && !navigation.finished) { + // If we are already monitoring a navigation for this navigable, for which + // we did not receive a navigation-stopped event, this navigation + // is already tracked and we don't want to create another id & event. + lazy.logger.trace( + `[${navigableId}] Skipping already tracked navigation, navigationId: ${navigation.navigationId}` + ); + return navigation; + } + + const navigationId = this.#getOrCreateNavigationId(navigableId); + navigation = { finished: false, navigationId, url }; + this.#navigations.set(navigable, navigation); + + lazy.logger.trace( + lazy.truncate`[${navigableId}] Navigation started for url: ${url} (${navigationId})` + ); + + this.emit("navigation-started", { navigationId, navigableId, url }); + + return navigation; + } + + /** + * Called when a navigation-stopped event is recorded from the + * NavigationListener actors. + * + * @param {object} data + * @param {BrowsingContextDetails} data.contextDetails + * The details about the browsing context for this navigation. + * @param {string} data.url + * The URL as string for the navigation. + * @returns {NavigationInfo} + * The stopped navigation if any, or null. + */ + notifyNavigationStopped(data) { + const { contextDetails, url } = data; + + const context = this.#getContextFromContextDetails(contextDetails); + const navigable = lazy.TabManager.getNavigableForBrowsingContext(context); + const navigableId = lazy.TabManager.getIdForBrowsingContext(context); + + const navigation = this.#navigations.get(navigable); + if (!navigation) { + lazy.logger.trace( + lazy.truncate`[${navigableId}] No navigation found to stop for url: ${url}` + ); + return null; + } + + if (navigation.finished) { + lazy.logger.trace( + `[${navigableId}] Navigation already marked as finished, navigationId: ${navigation.navigationId}` + ); + return navigation; + } + + lazy.logger.trace( + lazy.truncate`[${navigableId}] Navigation finished for url: ${url} (${navigation.navigationId})` + ); + + navigation.finished = true; + + this.emit("navigation-stopped", { + navigationId: navigation.navigationId, + navigableId, + url, + }); + + return navigation; + } + + /** + * Register a navigation id to be used for the next navigation for the + * provided browsing context details. + * + * @param {object} data + * @param {BrowsingContextDetails} data.contextDetails + * The details about the browsing context for this navigation. + * @returns {string} + * The UUID created the upcoming navigation. + */ + registerNavigationId(data) { + const { contextDetails } = data; + const context = this.#getContextFromContextDetails(contextDetails); + const navigableId = lazy.TabManager.getIdForBrowsingContext(context); + + const navigationId = lazy.generateUUID(); + this.#navigationIds.set(navigableId, navigationId); + + return navigationId; + } + + #getContextFromContextDetails(contextDetails) { + if (contextDetails.context) { + return contextDetails.context; + } + + return contextDetails.isTopBrowsingContext + ? BrowsingContext.getCurrentTopByBrowserId(contextDetails.browserId) + : BrowsingContext.get(contextDetails.browsingContextId); + } + + #getOrCreateNavigationId(navigableId) { + let navigationId; + if (this.#navigationIds.has(navigableId)) { + navigationId = this.#navigationIds.get(navigableId, navigationId); + this.#navigationIds.delete(navigableId); + } else { + navigationId = lazy.generateUUID(); + } + return navigationId; + } +} + +// Create a private NavigationRegistry singleton. +const navigationRegistry = new NavigationRegistry(); + +/** + * See NavigationRegistry.notifyLocationChanged. + * + * This entry point is only intended to be called from NavigationListenerParent, + * to avoid setting up observers or listeners, which are unnecessary since + * NavigationRegistry has to be a singleton. + */ +export function notifyLocationChanged(data) { + return navigationRegistry.notifyLocationChanged(data); +} + +/** + * See NavigationRegistry.notifyNavigationStarted. + * + * This entry point is only intended to be called from NavigationListenerParent, + * to avoid setting up observers or listeners, which are unnecessary since + * NavigationRegistry has to be a singleton. + */ +export function notifyNavigationStarted(data) { + return navigationRegistry.notifyNavigationStarted(data); +} + +/** + * See NavigationRegistry.notifyNavigationStopped. + * + * This entry point is only intended to be called from NavigationListenerParent, + * to avoid setting up observers or listeners, which are unnecessary since + * NavigationRegistry has to be a singleton. + */ +export function notifyNavigationStopped(data) { + return navigationRegistry.notifyNavigationStopped(data); +} + +export function registerNavigationId(data) { + return navigationRegistry.registerNavigationId(data); +} + +/** + * The NavigationManager exposes the NavigationRegistry data via a class which + * needs to be individually instantiated by each consumer. This allow to track + * how many consumers need navigation data at any point so that the + * NavigationRegistry can register or unregister the underlying JSWindowActors + * correctly. + * + * @fires navigation-started + * The NavigationManager emits "navigation-started" when a new navigation is + * detected, with the following object as payload: + * - {string} navigationId - The UUID for the navigation. + * - {string} navigableId - The UUID for the navigable. + * - {string} url - The target url for the navigation. + * @fires navigation-stopped + * The NavigationManager emits "navigation-stopped" when a known navigation + * is stopped, with the following object as payload: + * - {string} navigationId - The UUID for the navigation. + * - {string} navigableId - The UUID for the navigable. + * - {string} url - The target url for the navigation. + */ +export class NavigationManager extends EventEmitter { + #monitoring; + + constructor() { + super(); + + this.#monitoring = false; + } + + destroy() { + this.stopMonitoring(); + } + + getNavigationForBrowsingContext(context) { + return navigationRegistry.getNavigationForBrowsingContext(context); + } + + startMonitoring() { + if (this.#monitoring) { + return; + } + + this.#monitoring = true; + navigationRegistry.startMonitoring(this); + navigationRegistry.on("navigation-started", this.#onNavigationEvent); + navigationRegistry.on("location-changed", this.#onNavigationEvent); + navigationRegistry.on("navigation-stopped", this.#onNavigationEvent); + } + + stopMonitoring() { + if (!this.#monitoring) { + return; + } + + this.#monitoring = false; + navigationRegistry.stopMonitoring(this); + navigationRegistry.off("navigation-started", this.#onNavigationEvent); + navigationRegistry.off("location-changed", this.#onNavigationEvent); + navigationRegistry.off("navigation-stopped", this.#onNavigationEvent); + } + + #onNavigationEvent = (eventName, data) => { + this.emit(eventName, data); + }; +} diff --git a/remote/shared/PDF.sys.mjs b/remote/shared/PDF.sys.mjs new file mode 100644 index 0000000000..10fc2b0bae --- /dev/null +++ b/remote/shared/PDF.sys.mjs @@ -0,0 +1,244 @@ +/* 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, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +export const print = { + maxScaleValue: 2.0, + minScaleValue: 0.1, +}; + +print.defaults = { + // The size of the page in centimeters. + page: { + width: 21.59, + height: 27.94, + }, + margin: { + top: 1.0, + bottom: 1.0, + left: 1.0, + right: 1.0, + }, + orientationValue: ["landscape", "portrait"], +}; + +print.addDefaultSettings = function (settings) { + const { + background = false, + margin = {}, + orientation = "portrait", + page = {}, + pageRanges = [], + scale = 1.0, + shrinkToFit = true, + } = settings; + + lazy.assert.object(page, `Expected "page" to be a object, got ${page}`); + lazy.assert.object(margin, `Expected "margin" to be a object, got ${margin}`); + + if (!("width" in page)) { + page.width = print.defaults.page.width; + } + + if (!("height" in page)) { + page.height = print.defaults.page.height; + } + + if (!("top" in margin)) { + margin.top = print.defaults.margin.top; + } + + if (!("bottom" in margin)) { + margin.bottom = print.defaults.margin.bottom; + } + + if (!("right" in margin)) { + margin.right = print.defaults.margin.right; + } + + if (!("left" in margin)) { + margin.left = print.defaults.margin.left; + } + + return { + background, + margin, + orientation, + page, + pageRanges, + scale, + shrinkToFit, + }; +}; + +print.getPrintSettings = function (settings) { + const psService = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( + Ci.nsIPrintSettingsService + ); + + let cmToInches = cm => cm / 2.54; + const printSettings = psService.createNewPrintSettings(); + printSettings.isInitializedFromPrinter = true; + printSettings.isInitializedFromPrefs = true; + printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF; + printSettings.printerName = "marionette"; + printSettings.printSilent = true; + + // Setting the paperSizeUnit to kPaperSizeMillimeters doesn't work on mac + printSettings.paperSizeUnit = Ci.nsIPrintSettings.kPaperSizeInches; + printSettings.paperWidth = cmToInches(settings.page.width); + printSettings.paperHeight = cmToInches(settings.page.height); + printSettings.usePageRuleSizeAsPaperSize = true; + + printSettings.marginBottom = cmToInches(settings.margin.bottom); + printSettings.marginLeft = cmToInches(settings.margin.left); + printSettings.marginRight = cmToInches(settings.margin.right); + printSettings.marginTop = cmToInches(settings.margin.top); + + printSettings.printBGColors = settings.background; + printSettings.printBGImages = settings.background; + printSettings.scaling = settings.scale; + printSettings.shrinkToFit = settings.shrinkToFit; + + printSettings.headerStrCenter = ""; + printSettings.headerStrLeft = ""; + printSettings.headerStrRight = ""; + printSettings.footerStrCenter = ""; + printSettings.footerStrLeft = ""; + printSettings.footerStrRight = ""; + + // Override any os-specific unwriteable margins + printSettings.unwriteableMarginTop = 0; + printSettings.unwriteableMarginLeft = 0; + printSettings.unwriteableMarginBottom = 0; + printSettings.unwriteableMarginRight = 0; + + if (settings.orientation === "landscape") { + printSettings.orientation = Ci.nsIPrintSettings.kLandscapeOrientation; + } + + if (settings.pageRanges?.length) { + printSettings.pageRanges = parseRanges(settings.pageRanges); + } + + return printSettings; +}; + +/** + * Convert array of strings of the form ["1-3", "2-4", "7", "9-"] to an flat array of + * limits, like [1, 4, 7, 7, 9, 2**31 - 1] (meaning 1-4, 7, 9-end) + * + * @param {Array.<string|number>} ranges + * Page ranges to print, e.g., ['1-5', '8', '11-13']. + * Defaults to the empty string, which means print all pages. + * + * @returns {Array.<number>} + * Even-length array containing page range limits + */ +function parseRanges(ranges) { + const MAX_PAGES = 0x7fffffff; + + if (ranges.length === 0) { + return []; + } + + let allLimits = []; + + for (let range of ranges) { + let limits; + if (typeof range !== "string") { + // We got a single integer so the limits are just that page + lazy.assert.positiveInteger(range); + limits = [range, range]; + } else { + // We got a string presumably of the form <int> | <int>? "-" <int>? + const msg = `Expected a range of the form <int> or <int>-<int>, got ${range}`; + + limits = range.split("-").map(x => x.trim()); + lazy.assert.that(o => [1, 2].includes(o.length), msg)(limits); + + // Single numbers map to a range with that page at the start and the end + if (limits.length == 1) { + limits.push(limits[0]); + } + + // Need to check that both limits are strings conisting only of + // decimal digits (or empty strings) + const assertNumeric = lazy.assert.that(o => /^\d*$/.test(o), msg); + limits.every(x => assertNumeric(x)); + + // Convert from strings representing numbers to actual numbers + // If we don't have an upper bound, choose something very large; + // the print code will later truncate this to the number of pages + limits = limits.map((limitStr, i) => { + if (limitStr == "") { + return i == 0 ? 1 : MAX_PAGES; + } + return parseInt(limitStr); + }); + } + lazy.assert.that( + x => x[0] <= x[1], + "Lower limit ${parts[0]} is higher than upper limit ${parts[1]}" + )(limits); + + allLimits.push(limits); + } + // Order by lower limit + allLimits.sort((a, b) => a[0] - b[0]); + let parsedRanges = [allLimits.shift()]; + for (let limits of allLimits) { + let prev = parsedRanges[parsedRanges.length - 1]; + let prevMax = prev[1]; + let [min, max] = limits; + if (min <= prevMax) { + // min is inside previous range, so extend the max if needed + if (max > prevMax) { + prev[1] = max; + } + } else { + // Otherwise we have a new range + parsedRanges.push(limits); + } + } + + let rv = parsedRanges.flat(); + lazy.logger.debug(`Got page ranges [${rv.join(", ")}]`); + return rv; +} + +print.printToBinaryString = async function (browsingContext, printSettings) { + // Create a stream to write to. + const stream = Cc["@mozilla.org/storagestream;1"].createInstance( + Ci.nsIStorageStream + ); + stream.init(4096, 0xffffffff); + + printSettings.outputDestination = + Ci.nsIPrintSettings.kOutputDestinationStream; + printSettings.outputStream = stream.getOutputStream(0); + + await browsingContext.print(printSettings); + + const inputStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + + inputStream.setInputStream(stream.newInputStream(0)); + + const available = inputStream.available(); + const bytes = inputStream.readBytes(available); + + stream.close(); + + return bytes; +}; diff --git a/remote/shared/Prompt.sys.mjs b/remote/shared/Prompt.sys.mjs new file mode 100644 index 0000000000..bacf24c5d1 --- /dev/null +++ b/remote/shared/Prompt.sys.mjs @@ -0,0 +1,233 @@ +/* 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, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +const COMMON_DIALOG = "chrome://global/content/commonDialog.xhtml"; + +/** @namespace */ +export const modal = { + ACTION_CLOSED: "closed", + ACTION_OPENED: "opened", +}; + +/** + * Check for already existing modal or tab modal dialogs and + * return the first one. + * + * @param {browser.Context} context + * Reference to the browser context to check for existent dialogs. + * + * @returns {modal.Dialog} + * Returns instance of the Dialog class, or `null` if no modal dialog + * is present. + */ +modal.findPrompt = function (context) { + // First check if there is a modal dialog already present for the + // current browser window. + for (let win of Services.wm.getEnumerator(null)) { + // TODO: Use BrowserWindowTracker.getTopWindow for modal dialogs without + // an opener. + if ( + win.document.documentURI === COMMON_DIALOG && + win.opener && + win.opener === context.window + ) { + lazy.logger.trace("Found open window modal prompt"); + return new modal.Dialog(() => context, win); + } + } + + if (lazy.AppInfo.isAndroid) { + const geckoViewPrompts = context.window.prompts(); + if (geckoViewPrompts.length) { + lazy.logger.trace("Found open GeckoView prompt"); + const prompt = geckoViewPrompts[0]; + return new modal.Dialog(() => context, prompt); + } + } + + const contentBrowser = context.contentBrowser; + + // If no modal dialog has been found yet, also check for tab and content modal + // dialogs for the current tab. + // + // TODO: Find an adequate implementation for Firefox on Android (bug 1708105) + if (contentBrowser?.tabDialogBox) { + let dialogs = contentBrowser.tabDialogBox.getTabDialogManager().dialogs; + if (dialogs.length) { + lazy.logger.trace("Found open tab modal prompt"); + return new modal.Dialog(() => context, dialogs[0].frameContentWindow); + } + + dialogs = contentBrowser.tabDialogBox.getContentDialogManager().dialogs; + + // Even with the dialog manager handing back a dialog, the `Dialog` property + // gets lazily added. If it's not set yet, ignore the dialog for now. + if (dialogs.length && dialogs[0].frameContentWindow.Dialog) { + lazy.logger.trace("Found open content prompt"); + return new modal.Dialog(() => context, dialogs[0].frameContentWindow); + } + } + + // If no modal dialog has been found yet, check for old non SubDialog based + // content modal dialogs. Even with those deprecated in Firefox 89 we should + // keep supporting applications that don't have them implemented yet. + if (contentBrowser?.tabModalPromptBox) { + const prompts = contentBrowser.tabModalPromptBox.listPrompts(); + if (prompts.length) { + lazy.logger.trace("Found open old-style content prompt"); + return new modal.Dialog(() => context, null); + } + } + + return null; +}; + +/** + * Represents a modal dialog. + * + * @param {function(): browser.Context} curBrowserFn + * Function that returns the current |browser.Context|. + * @param {DOMWindow} dialog + * DOMWindow of the dialog. + */ +modal.Dialog = class { + constructor(curBrowserFn, dialog) { + this.curBrowserFn_ = curBrowserFn; + this.win_ = Cu.getWeakReference(dialog); + } + + get args() { + if (lazy.AppInfo.isAndroid) { + return this.window.args; + } + let tm = this.tabModal; + return tm ? tm.args : null; + } + + get curBrowser_() { + return this.curBrowserFn_(); + } + + get isOpen() { + if (lazy.AppInfo.isAndroid) { + return this.window !== null; + } + if (!this.ui) { + return false; + } + return true; + } + + get isWindowModal() { + return [ + Services.prompt.MODAL_TYPE_WINDOW, + Services.prompt.MODAL_TYPE_INTERNAL_WINDOW, + ].includes(this.args.modalType); + } + + get tabModal() { + let win = this.window; + if (win) { + return win.Dialog; + } + return this.curBrowser_.getTabModal(); + } + + get promptType() { + const promptType = this.args.promptType; + + if (promptType === "confirmEx" && this.args.inPermitUnload) { + return "beforeunload"; + } + + return promptType; + } + + get ui() { + let tm = this.tabModal; + return tm ? tm.ui : null; + } + + /** + * For Android, this returns a GeckoViewPrompter, which can be used to control prompts. + * Otherwise, this returns the ChromeWindow associated with an open dialog window if + * it is currently attached to the DOM. + */ + get window() { + if (this.win_) { + let win = this.win_.get(); + if (win && (lazy.AppInfo.isAndroid || win.parent)) { + return win; + } + } + return null; + } + + set text(inputText) { + if (lazy.AppInfo.isAndroid) { + this.window.setInputText(inputText); + } else { + // see toolkit/components/prompts/content/commonDialog.js + let { loginTextbox } = this.ui; + loginTextbox.value = inputText; + } + } + + accept() { + if (lazy.AppInfo.isAndroid) { + // GeckoView does not have a UI, so the methods are called directly + this.window.acceptPrompt(); + } else { + const { button0 } = this.ui; + button0.click(); + } + } + + dismiss() { + if (lazy.AppInfo.isAndroid) { + // GeckoView does not have a UI, so the methods are called directly + this.window.dismissPrompt(); + } else { + const { button0, button1 } = this.ui; + (button1 ? button1 : button0).click(); + } + } + + /** + * Returns text of the prompt. + * + * @returns {string | Promise} + * Returns string on desktop and Promise on Android. + */ + async getText() { + if (lazy.AppInfo.isAndroid) { + const textPromise = await this.window.getPromptText(); + return textPromise; + } + return this.ui.infoBody.textContent; + } + + /** + * Returns text of the prompt input. + * + * @returns {string} + * Returns string on desktop and Promise on Android. + */ + async getInputText() { + if (lazy.AppInfo.isAndroid) { + const textPromise = await this.window.getInputText(); + return textPromise; + } + return this.ui.loginTextbox.value; + } +}; diff --git a/remote/shared/Realm.sys.mjs b/remote/shared/Realm.sys.mjs new file mode 100644 index 0000000000..5bf4a2fa3a --- /dev/null +++ b/remote/shared/Realm.sys.mjs @@ -0,0 +1,382 @@ +/* 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, { + addDebuggerToGlobal: "resource://gre/modules/jsdebugger.sys.mjs", + + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "dbg", () => { + // eslint-disable-next-line mozilla/reject-globalThis-modification + lazy.addDebuggerToGlobal(globalThis); + return new Debugger(); +}); + +/** + * @typedef {string} RealmType + */ + +/** + * Enum of realm types. + * + * @readonly + * @enum {RealmType} + */ +export const RealmType = { + AudioWorklet: "audio-worklet", + DedicatedWorker: "dedicated-worker", + PaintWorklet: "paint-worklet", + ServiceWorker: "service-worker", + SharedWorker: "shared-worker", + Window: "window", + Worker: "worker", + Worklet: "worklet", +}; + +/** + * Base class that wraps any kind of WebDriver BiDi realm. + */ +export class Realm { + #handleObjectMap; + #id; + + constructor() { + this.#id = lazy.generateUUID(); + + // Map of unique handles (UUIDs) to objects belonging to this realm. + this.#handleObjectMap = new Map(); + } + + destroy() { + this.#handleObjectMap = null; + } + + /** + * Get the browsing context of the realm instance. + */ + get browsingContext() { + return null; + } + + /** + * Get the unique identifier of the realm instance. + * + * @returns {string} The unique identifier. + */ + get id() { + return this.#id; + } + + /** + * A getter to get a realm origin. + * + * It's required to be implemented in the sub class. + */ + get origin() { + throw new Error("Not implemented"); + } + + /** + * Ensure the provided object can be used within this realm. + + * @param {object} obj + * Any non-primitive object. + + * @returns {object} + * An object usable in the current realm. + */ + cloneIntoRealm(obj) { + return obj; + } + + /** + * Remove the reference corresponding to the provided unique handle. + * + * @param {string} handle + * The unique handle of an object reference tracked in this realm. + */ + removeObjectHandle(handle) { + this.#handleObjectMap.delete(handle); + } + + /** + * Get a new unique handle for the provided object, creating a strong + * reference on the object. + * + * @param {object} object + * Any non-primitive object. + * @returns {string} The unique handle created for this strong reference. + */ + getHandleForObject(object) { + const handle = lazy.generateUUID(); + this.#handleObjectMap.set(handle, object); + return handle; + } + + /** + * Get the basic realm information. + * + * @returns {BaseRealmInfo} + */ + getInfo() { + return { + realm: this.#id, + origin: this.origin, + }; + } + + /** + * Retrieve the object corresponding to the provided unique handle. + * + * @param {string} handle + * The unique handle of an object reference tracked in this realm. + * @returns {object} object + * Any non-primitive object. + */ + getObjectForHandle(handle) { + return this.#handleObjectMap.get(handle); + } +} + +/** + * Wrapper for Window realms including sandbox objects. + */ +export class WindowRealm extends Realm { + #realmAutomationFeaturesEnabled; + #globalObject; + #globalObjectReference; + #isSandbox; + #sandboxName; + #userActivationEnabled; + #window; + + static type = RealmType.Window; + + /** + * + * @param {Window} window + * The window global to wrap. + * @param {object} options + * @param {string=} options.sandboxName + * Name of the sandbox to create if specified. Defaults to `null`. + */ + constructor(window, options = {}) { + const { sandboxName = null } = options; + + super(); + + this.#isSandbox = sandboxName !== null; + this.#sandboxName = sandboxName; + this.#window = window; + this.#globalObject = this.#isSandbox ? this.#createSandbox() : this.#window; + this.#globalObjectReference = lazy.dbg.makeGlobalObjectReference( + this.#globalObject + ); + this.#realmAutomationFeaturesEnabled = false; + this.#userActivationEnabled = false; + } + + destroy() { + if (this.#realmAutomationFeaturesEnabled) { + lazy.dbg.disableAsyncStack(this.#globalObject); + lazy.dbg.disableUnlimitedStacksCapturing(this.#globalObject); + this.#realmAutomationFeaturesEnabled = false; + } + + this.#globalObjectReference = null; + this.#globalObject = null; + this.#window = null; + + super.destroy(); + } + + get browsingContext() { + return this.#window.browsingContext; + } + + get globalObjectReference() { + return this.#globalObjectReference; + } + + get isSandbox() { + return this.#isSandbox; + } + + get origin() { + return this.#window.origin; + } + + get userActivationEnabled() { + return this.#userActivationEnabled; + } + + set userActivationEnabled(enable) { + if (enable === this.#userActivationEnabled) { + return; + } + + const document = this.#window.document; + if (enable) { + document.notifyUserGestureActivation(); + } else { + document.clearUserGestureActivation(); + } + + this.#userActivationEnabled = enable; + } + + #createDebuggerObject(obj) { + return this.#globalObjectReference.makeDebuggeeValue(obj); + } + + #createSandbox() { + const win = this.#window; + const opts = { + sameZoneAs: win, + sandboxPrototype: win, + wantComponents: false, + wantXrays: true, + }; + + return new Cu.Sandbox(win, opts); + } + + #enableRealmAutomationFeatures() { + if (!this.#realmAutomationFeaturesEnabled) { + lazy.dbg.enableAsyncStack(this.#globalObject); + lazy.dbg.enableUnlimitedStacksCapturing(this.#globalObject); + this.#realmAutomationFeaturesEnabled = true; + } + } + + /** + * Clone the provided object into the scope of this Realm (either a window + * global, or a sandbox). + * + * @param {object} obj + * Any non-primitive object. + * + * @returns {object} + * The cloned object. + */ + cloneIntoRealm(obj) { + return Cu.cloneInto(obj, this.#globalObject, { cloneFunctions: true }); + } + + /** + * Evaluates a provided expression in the context of the current realm. + * + * @param {string} expression + * The expression to evaluate. + * + * @returns {object} + * - evaluationStatus {EvaluationStatus} One of "normal", "throw". + * - exceptionDetails {ExceptionDetails=} the details of the exception if + * the evaluation status was "throw". + * - result {RemoteValue=} the result of the evaluation serialized as a + * RemoteValue if the evaluation status was "normal". + */ + executeInGlobal(expression) { + this.#enableRealmAutomationFeatures(); + return this.#globalObjectReference.executeInGlobal(expression, { + url: this.#window.document.baseURI, + }); + } + + /** + * Call a function in the context of the current realm. + * + * @param {string} functionDeclaration + * The body of the function to call. + * @param {Array<object>} functionArguments + * The arguments to pass to the function call. + * @param {object} thisParameter + * The value of the `this` keyword for the function call. + * + * @returns {object} + * - evaluationStatus {EvaluationStatus} One of "normal", "throw". + * - exceptionDetails {ExceptionDetails=} the details of the exception if + * the evaluation status was "throw". + * - result {RemoteValue=} the result of the evaluation serialized as a + * RemoteValue if the evaluation status was "normal". + */ + executeInGlobalWithBindings( + functionDeclaration, + functionArguments, + thisParameter + ) { + this.#enableRealmAutomationFeatures(); + const expression = `(${functionDeclaration}).apply(__bidi_this, __bidi_args)`; + + const args = this.cloneIntoRealm([]); + for (const arg of functionArguments) { + args.push(arg); + } + + return this.#globalObjectReference.executeInGlobalWithBindings( + expression, + { + __bidi_args: this.#createDebuggerObject(args), + __bidi_this: this.#createDebuggerObject(thisParameter), + }, + { + url: this.#window.document.baseURI, + } + ); + } + + /** + * Get the realm information. + * + * @returns {object} + * - context {BrowsingContext} The browsing context, associated with the realm. + * - id {string} The realm unique identifier. + * - origin {string} The serialization of an origin. + * - sandbox {string=} The name of the sandbox. + * - type {RealmType.Window} The window realm type. + */ + getInfo() { + const baseInfo = super.getInfo(); + const info = { + ...baseInfo, + context: this.#window.browsingContext, + type: WindowRealm.type, + }; + + if (this.#isSandbox) { + info.sandbox = this.#sandboxName; + } + + return info; + } + + /** + * Log an error caused by a script evaluation. + * + * @param {string} message + * The error message. + * @param {Stack} stack + * The JavaScript stack trace. + */ + reportError(message, stack) { + const { column, line, source: sourceLine } = stack; + + const scriptErrorClass = Cc["@mozilla.org/scripterror;1"]; + const scriptError = scriptErrorClass.createInstance(Ci.nsIScriptError); + + scriptError.initWithWindowID( + message, + this.#window.document.baseURI, + sourceLine, + line, + column, + Ci.nsIScriptError.errorFlag, + "content javascript", + this.#window.windowGlobalChild.innerWindowId + ); + Services.console.logMessage(scriptError); + } +} diff --git a/remote/shared/RecommendedPreferences.sys.mjs b/remote/shared/RecommendedPreferences.sys.mjs new file mode 100644 index 0000000000..d0a7739e52 --- /dev/null +++ b/remote/shared/RecommendedPreferences.sys.mjs @@ -0,0 +1,440 @@ +/* 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, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "useRecommendedPrefs", + "remote.prefs.recommended", + false +); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +// Ensure we are in the parent process. +if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) { + throw new Error( + "RecommendedPreferences should only be loaded in the parent process" + ); +} + +// ALL CHANGES TO THIS LIST MUST HAVE REVIEW FROM A WEBDRIVER PEER! +// +// Preferences are set for automation on startup, unless +// remote.prefs.recommended has been set to false. +// +// Note: Clients do not always use the latest version of the application. As +// such backward compatibility has to be ensured at least for the last three +// releases. + +// INSTRUCTIONS TO ADD A NEW PREFERENCE +// +// Preferences for remote control and automation can be set from several entry +// points: +// - remote/shared/RecommendedPreferences.sys.mjs +// - remote/test/puppeteer/packages/browsers/src/browser-data/firefox.ts +// - testing/geckodriver/src/prefs.rs +// - testing/marionette/client/marionette_driver/geckoinstance.py +// +// The preferences in `firefox.ts`, `prefs.rs` and `geckoinstance.py` +// will be applied before the application starts, and should typically be used +// for preferences which cannot be updated during the lifetime of the application. +// +// The preferences in `RecommendedPreferences.sys.mjs` are applied after +// the application has started, which means that the application must apply this +// change dynamically and behave correctly. Note that you can also define +// protocol specific preferences (CDP, WebDriver, ...) which are merged with the +// COMMON_PREFERENCES from `RecommendedPreferences.sys.mjs`. +// +// Additionally, users relying on the Marionette Python client (ie. using +// geckoinstance.py) set `remote.prefs.recommended = false`. This means that +// preferences from `RecommendedPreferences.sys.mjs` are not applied and have to +// be added to the list of preferences in that Python file. Note that there are +// several lists of preferences, either common or specific to a given application +// (Firefox Desktop, Fennec, Thunderbird). +// +// Depending on how users interact with the Remote Agent, they will use different +// combinations of preferences. So it's important to update the preferences files +// so that all users have the proper preferences. +// +// When adding a new preference, follow this guide to decide where to add it: +// - Add the preference to `geckoinstance.py` +// - If the preference has to be set before startup: +// - Add the preference to `prefs.rs` +// - Add the preference `browser-data/firefox.ts` in the puppeteer folder +// - Create a PR to upstream the change on `browser-data/firefox.ts` to puppeteer +// - Otherwise, if the preference can be set after startup: +// - Add the preference to `RecommendedPreferences.sys.mjs` +const COMMON_PREFERENCES = new Map([ + // Make sure Shield doesn't hit the network. + ["app.normandy.api_url", ""], + + // Disable automatically upgrading Firefox + // + // Note: This preference should have already been set by the client when + // creating the profile. But if not and to absolutely make sure that updates + // of Firefox aren't downloaded and applied, enforce its presence. + ["app.update.disabledForTesting", true], + + // Increase the APZ content response timeout in tests to 1 minute. + // This is to accommodate the fact that test environments tends to be + // slower than production environments (with the b2g emulator being + // the slowest of them all), resulting in the production timeout value + // sometimes being exceeded and causing false-positive test failures. + // + // (bug 1176798, bug 1177018, bug 1210465) + ["apz.content_response_timeout", 60000], + + // Don't show the content blocking introduction panel. + // We use a larger number than the default 22 to have some buffer + // This can be removed once Firefox 69 and 68 ESR and are no longer supported. + ["browser.contentblocking.introCount", 99], + + // Indicate that the download panel has been shown once so that + // whichever download test runs first doesn't show the popup + // inconsistently. + ["browser.download.panel.shown", true], + + // Make sure Topsites doesn't hit the network to retrieve sponsored tiles. + ["browser.newtabpage.activity-stream.showSponsoredTopSites", false], + + // Always display a blank page + ["browser.newtabpage.enabled", false], + + // Background thumbnails in particular cause grief, and disabling + // thumbnails in general cannot hurt + ["browser.pagethumbnails.capturing_disabled", true], + + // Disable geolocation ping(#1) + ["browser.region.network.url", ""], + + // Disable safebrowsing components. + // + // These should also be set in the profile prior to starting Firefox, + // as it is picked up at runtime. + ["browser.safebrowsing.blockedURIs.enabled", false], + ["browser.safebrowsing.downloads.enabled", false], + ["browser.safebrowsing.malware.enabled", false], + ["browser.safebrowsing.phishing.enabled", false], + + // Disable updates to search engines. + // + // Should be set in profile. + ["browser.search.update", false], + + // Do not restore the last open set of tabs if the browser has crashed + ["browser.sessionstore.resume_from_crash", false], + + // Don't check for the default web browser during startup. + // + // These should also be set in the profile prior to starting Firefox, + // as it is picked up at runtime. + ["browser.shell.checkDefaultBrowser", false], + + // Disable session restore infobar + ["browser.startup.couldRestoreSession.count", -1], + + // Do not redirect user when a milstone upgrade of Firefox is detected + ["browser.startup.homepage_override.mstone", "ignore"], + + // Don't unload tabs when available memory is running low + ["browser.tabs.unloadOnLowMemory", false], + + // Do not warn when closing all open tabs + ["browser.tabs.warnOnClose", false], + + // Do not warn when closing all other open tabs + ["browser.tabs.warnOnCloseOtherTabs", false], + + // Do not warn when multiple tabs will be opened + ["browser.tabs.warnOnOpen", false], + + // Don't show the Bookmarks Toolbar on any tab (the above pref that + // disables the New Tab Page ends up showing the toolbar on about:blank). + ["browser.toolbars.bookmarks.visibility", "never"], + + // Make sure Topsites doesn't hit the network to retrieve tiles from Contile. + ["browser.topsites.contile.enabled", false], + + // Disable first run splash page on Windows 10 + ["browser.usedOnWindows10.introURL", ""], + + // Turn off Merino suggestions in the location bar so as not to trigger + // network connections. + ["browser.urlbar.merino.endpointURL", ""], + + // Turn off search suggestions in the location bar so as not to trigger + // network connections. + ["browser.urlbar.suggest.searches", false], + + // Do not warn on quitting Firefox + ["browser.warnOnQuit", false], + + // Do not show datareporting policy notifications which can + // interfere with tests + [ + "datareporting.healthreport.documentServerURI", + "http://%(server)s/dummy/healthreport/", + ], + ["datareporting.healthreport.logging.consoleEnabled", false], + ["datareporting.healthreport.service.enabled", false], + ["datareporting.healthreport.service.firstRun", false], + ["datareporting.healthreport.uploadEnabled", false], + ["datareporting.policy.dataSubmissionEnabled", false], + ["datareporting.policy.dataSubmissionPolicyAccepted", false], + ["datareporting.policy.dataSubmissionPolicyBypassNotification", true], + + // Disable popup-blocker + ["dom.disable_open_during_load", false], + + // Enabling the support for File object creation in the content process + ["dom.file.createInChild", true], + + // Disable delayed user input event handling + ["dom.input_events.security.minNumTicks", 0], + ["dom.input_events.security.minTimeElapsedInMS", 0], + + // Disable the ProcessHangMonitor + ["dom.ipc.reportProcessHangs", false], + + // Disable slow script dialogues + ["dom.max_chrome_script_run_time", 0], + ["dom.max_script_run_time", 0], + + // Disable location change rate limitation + ["dom.navigation.locationChangeRateLimit.count", 0], + + // DOM Push + ["dom.push.connection.enabled", false], + + // Screen Orientation API + ["dom.screenorientation.allow-lock", true], + + // Disable dialog abuse if alerts are triggered too quickly. + ["dom.successive_dialog_time_limit", 0], + + // Only load extensions from the application and user profile + // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + // + // Should be set in profile. + ["extensions.autoDisableScopes", 0], + ["extensions.enabledScopes", 5], + + // Disable metadata caching for installed add-ons by default + ["extensions.getAddons.cache.enabled", false], + + // Disable installing any distribution extensions or add-ons. + // Should be set in profile. + ["extensions.installDistroAddons", false], + + // Turn off extension updates so they do not bother tests + ["extensions.update.enabled", false], + ["extensions.update.notifyUser", false], + + // Make sure opening about:addons will not hit the network + ["extensions.getAddons.discovery.api_url", "data:, "], + + // Redirect various extension update URLs + [ + "extensions.blocklist.detailsURL", + "http://%(server)s/extensions-dummy/blocklistDetailsURL", + ], + [ + "extensions.blocklist.itemURL", + "http://%(server)s/extensions-dummy/blocklistItemURL", + ], + ["extensions.hotfix.url", "http://%(server)s/extensions-dummy/hotfixURL"], + [ + "extensions.systemAddon.update.url", + "http://%(server)s/dummy-system-addons.xml", + ], + [ + "extensions.update.background.url", + "http://%(server)s/extensions-dummy/updateBackgroundURL", + ], + ["extensions.update.url", "http://%(server)s/extensions-dummy/updateURL"], + + // Make sure opening about: addons won't hit the network + ["extensions.getAddons.discovery.api_url", "data:, "], + [ + "extensions.getAddons.get.url", + "http://%(server)s/extensions-dummy/repositoryGetURL", + ], + [ + "extensions.getAddons.search.browseURL", + "http://%(server)s/extensions-dummy/repositoryBrowseURL", + ], + + // Allow the application to have focus even it runs in the background + ["focusmanager.testmode", true], + + // Disable useragent updates + ["general.useragent.updates.enabled", false], + + // Disable geolocation ping(#2) + ["geo.provider.network.url", ""], + + // Always use network provider for geolocation tests so we bypass the + // macOS dialog raised by the corelocation provider + ["geo.provider.testing", true], + + // Do not scan Wifi + ["geo.wifi.scan", false], + + // Disable Firefox accounts ping + ["identity.fxaccounts.auth.uri", "https://{server}/dummy/fxa"], + + // Disable connectivity service pings + ["network.connectivity-service.enabled", false], + + // Do not prompt with long usernames or passwords in URLs + ["network.http.phishy-userpass-length", 255], + + // Do not prompt for temporary redirects + ["network.http.prompt-temp-redirect", false], + + // Do not automatically switch between offline and online + ["network.manage-offline-status", false], + + // Make sure SNTP requests do not hit the network + ["network.sntp.pools", "%(server)s"], + + // Privacy and Tracking Protection + ["privacy.trackingprotection.enabled", false], + + // Don't do network connections for mitm priming + ["security.certerrors.mitm.priming.enabled", false], + + // Local documents have access to all other local documents, + // including directory listings + ["security.fileuri.strict_origin_policy", false], + + // Tests do not wait for the notification button security delay + ["security.notification_enable_delay", 0], + + // Do not download intermediate certificates + ["security.remote_settings.intermediates.enabled", false], + + // Ensure remote settings do not hit the network + ["services.settings.server", "data:,#remote-settings-dummy/v1"], + + // Do not automatically fill sign-in forms with known usernames and + // passwords + ["signon.autofillForms", false], + + // Disable password capture, so that tests that include forms are not + // influenced by the presence of the persistent doorhanger notification + ["signon.rememberSignons", false], + + // Disable first-run welcome page + ["startup.homepage_welcome_url", "about:blank"], + ["startup.homepage_welcome_url.additional", ""], + + // Prevent starting into safe mode after application crashes + ["toolkit.startup.max_resumed_crashes", -1], + + // Disable all telemetry pings + ["toolkit.telemetry.server", "https://%(server)s/telemetry-dummy/"], + + // Disable window occlusion on Windows, which can prevent webdriver commands + // such as WebDriver:FindElements from working properly (Bug 1802473). + ["widget.windows.window_occlusion_tracking.enabled", false], +]); + +export const RecommendedPreferences = { + alteredPrefs: new Set(), + + isInitialized: false, + + /** + * Apply the provided map of preferences. + * + * Note, that they will be automatically reset on application shutdown. + * + * @param {Map<string, object>=} preferences + * Map of preference name to preference value. + */ + applyPreferences(preferences) { + if (!lazy.useRecommendedPrefs) { + // If remote.prefs.recommended is set to false, do not set any preference + // here. Needed for our Firefox CI. + return; + } + + // Only apply common recommended preferences on first call to + // applyPreferences. + if (!this.isInitialized) { + // Merge common preferences and optionally provided preferences in a + // single map. Hereby the extra preferences have higher priority. + if (preferences) { + preferences = new Map([...COMMON_PREFERENCES, ...preferences]); + } else { + preferences = COMMON_PREFERENCES; + } + + Services.obs.addObserver(this, "quit-application"); + this.isInitialized = true; + } + + for (const [k, v] of preferences) { + if (!Services.prefs.prefHasUserValue(k)) { + lazy.logger.debug(`Setting recommended pref ${k} to ${v}`); + + switch (typeof v) { + case "string": + Services.prefs.setStringPref(k, v); + break; + case "boolean": + Services.prefs.setBoolPref(k, v); + break; + case "number": + Services.prefs.setIntPref(k, v); + break; + default: + throw new TypeError(`Invalid preference type: ${typeof v}`); + } + + // Keep track all the altered preferences to restore them on + // quit-application. + this.alteredPrefs.add(k); + } + } + }, + + observe(subject, topic) { + if (topic === "quit-application") { + Services.obs.removeObserver(this, "quit-application"); + this.restoreAllPreferences(); + } + }, + + /** + * Restore all the altered preferences. + */ + restoreAllPreferences() { + this.restorePreferences(this.alteredPrefs); + this.isInitialized = false; + }, + + /** + * Restore provided preferences. + * + * @param {Map} preferences + * Map of preferences that should be restored. + */ + restorePreferences(preferences) { + for (const k of preferences.keys()) { + lazy.logger.debug(`Resetting recommended pref ${k}`); + Services.prefs.clearUserPref(k); + this.alteredPrefs.delete(k); + } + }, +}; diff --git a/remote/shared/RemoteError.sys.mjs b/remote/shared/RemoteError.sys.mjs new file mode 100644 index 0000000000..2e326d5d18 --- /dev/null +++ b/remote/shared/RemoteError.sys.mjs @@ -0,0 +1,19 @@ +/* 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/. */ + +/** + * Base class for all remote protocol errors. + */ +export class RemoteError extends Error { + get isRemoteError() { + return true; + } + + /** + * Convert to a serializable object. Should be implemented by subclasses. + */ + toJSON() { + throw new Error("Not implemented"); + } +} diff --git a/remote/shared/Stack.sys.mjs b/remote/shared/Stack.sys.mjs new file mode 100644 index 0000000000..d0c7f9407d --- /dev/null +++ b/remote/shared/Stack.sys.mjs @@ -0,0 +1,73 @@ +/* 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/. */ + +/** + * An object that contains details of a stack frame. + * + * @typedef {object} StackFrame + * @see nsIStackFrame + * + * @property {string=} asyncCause + * Type of asynchronous call by which this frame was invoked. + * @property {number} columnNumber + * The column number for this stack frame. + * @property {string} filename + * The source URL for this stack frame. + * @property {string} function + * SpiderMonkey’s inferred name for this stack frame’s function, or null. + * @property {number} lineNumber + * The line number for this stack frame (starts with 1). + * @property {number} sourceId + * The process-unique internal integer ID of this source. + */ + +/** + * Return a list of stack frames from the given stack. + * + * Convert stack objects to the JSON attributes expected by consumers. + * + * @param {object} stack + * The native stack object to process. + * + * @returns {Array<StackFrame>=} + */ +export function getFramesFromStack(stack) { + if (!stack || (Cu && Cu.isDeadWrapper(stack))) { + // If the global from which this error came from has been nuked, + // stack is going to be a dead wrapper. + return null; + } + + const frames = []; + while (stack) { + frames.push({ + asyncCause: stack.asyncCause, + columnNumber: stack.column, + filename: stack.source, + functionName: stack.functionDisplayName || "", + lineNumber: stack.line, + sourceId: stack.sourceId, + }); + + stack = stack.parent || stack.asyncParent; + } + + return frames; +} + +/** + * Check if a frame is from chrome scope. + * + * @param {object} frame + * The frame to check + * + * @returns {boolean} + * True, if frame is from chrome scope + */ +export function isChromeFrame(frame) { + return ( + frame.filename.startsWith("chrome://") || + frame.filename.startsWith("resource://") + ); +} diff --git a/remote/shared/Sync.sys.mjs b/remote/shared/Sync.sys.mjs new file mode 100644 index 0000000000..7b14f8b2c8 --- /dev/null +++ b/remote/shared/Sync.sys.mjs @@ -0,0 +1,335 @@ +/* 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", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +const { TYPE_ONE_SHOT, TYPE_REPEATING_SLACK } = Ci.nsITimer; + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.REMOTE_AGENT) +); + +/** + * Throttle until the `window` has performed an animation frame. + * + * @param {ChromeWindow} win + * Window to request the animation frame from. + * + * @returns {Promise} + */ +export function AnimationFramePromise(win) { + const animationFramePromise = new Promise(resolve => { + win.requestAnimationFrame(resolve); + }); + + // Abort if the underlying window gets closed + const windowClosedPromise = new PollPromise(resolve => { + if (win.closed) { + resolve(); + } + }); + + return Promise.race([animationFramePromise, windowClosedPromise]); +} + +/** + * Create a helper object to defer a promise. + * + * @returns {object} + * An object that returns the following properties: + * - fulfilled Flag that indicates that the promise got resolved + * - pending Flag that indicates a not yet fulfilled/rejected promise + * - promise The actual promise + * - reject Callback to reject the promise + * - rejected Flag that indicates that the promise got rejected + * - resolve Callback to resolve the promise + */ +export function Deferred() { + const deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.fulfilled = false; + deferred.pending = true; + deferred.rejected = false; + + deferred.resolve = (...args) => { + deferred.fulfilled = true; + deferred.pending = false; + resolve(...args); + }; + + deferred.reject = (...args) => { + deferred.pending = false; + deferred.rejected = true; + reject(...args); + }; + }); + + return deferred; +} + +/** + * Wait for an event to be fired on a specified element. + * + * The returned promise is guaranteed to not resolve before the + * next event tick after the event listener is called, so that all + * other event listeners for the element are executed before the + * handler is executed. For example: + * + * const promise = new EventPromise(element, "myEvent"); + * // same event tick here + * await promise; + * // next event tick here + * + * @param {Element} subject + * The element that should receive the event. + * @param {string} eventName + * Case-sensitive string representing the event name to listen for. + * @param {object=} options + * @param {boolean=} options.capture + * Indicates the event will be despatched to this subject, + * before it bubbles down to any EventTarget beneath it in the + * DOM tree. Defaults to false. + * @param {Function=} options.checkFn + * 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. Defaults to null. + * @param {number=} options.timeout + * Timeout duration in milliseconds, if provided. + * If specified, then the returned promise will be rejected with + * TimeoutError, if not already resolved, after this duration has elapsed. + * If not specified, then no timeout is used. Defaults to null. + * @param {boolean=} options.mozSystemGroup + * Determines whether to add listener to the system group. Defaults to + * false. + * @param {boolean=} options.wantUntrusted + * Receive synthetic events despatched by web content. Defaults to false. + * + * @returns {Promise<Event>} + * Either fulfilled with the first described event, satisfying + * options.checkFn if specified, or rejected with TimeoutError after + * options.timeout milliseconds if specified. + * + * @throws {TypeError} + * @throws {RangeError} + */ +export function EventPromise(subject, eventName, options = {}) { + const { + capture = false, + checkFn = null, + timeout = null, + mozSystemGroup = false, + wantUntrusted = false, + } = options; + if ( + !subject || + !("addEventListener" in subject) || + typeof eventName != "string" || + typeof capture != "boolean" || + (checkFn && typeof checkFn != "function") || + (timeout !== null && typeof timeout != "number") || + typeof mozSystemGroup != "boolean" || + typeof wantUntrusted != "boolean" + ) { + throw new TypeError(); + } + if (timeout < 0) { + throw new RangeError(); + } + + return new Promise((resolve, reject) => { + let timer; + + function cleanUp() { + subject.removeEventListener(eventName, listener, capture); + timer?.cancel(); + } + + function listener(event) { + lazy.logger.trace(`Received DOM event ${event.type} for ${event.target}`); + try { + if (checkFn && !checkFn(event)) { + return; + } + } catch (e) { + // Treat an exception in the callback as a falsy value + lazy.logger.warn(`Event check failed: ${e.message}`); + } + + cleanUp(); + executeSoon(() => resolve(event)); + } + + subject.addEventListener(eventName, listener, { + capture, + mozSystemGroup, + wantUntrusted, + }); + + if (timeout !== null) { + timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + cleanUp(); + reject( + new lazy.error.TimeoutError( + `EventPromise timed out after ${timeout} ms` + ) + ); + }, + timeout, + TYPE_ONE_SHOT + ); + } + }); +} + +/** + * Wait for the next tick in the event loop to execute a callback. + * + * @param {Function} fn + * Function to be executed. + */ +export function executeSoon(fn) { + if (typeof fn != "function") { + throw new TypeError(); + } + + Services.tm.dispatchToMainThread(fn); +} + +/** + * Runs a Promise-like function off the main thread until it is resolved + * through ``resolve`` or ``rejected`` callbacks. The function is + * guaranteed to be run at least once, irregardless of the timeout. + * + * The ``func`` is evaluated every ``interval`` for as long as its + * runtime duration does not exceed ``interval``. Evaluations occur + * sequentially, meaning that evaluations of ``func`` are queued if + * the runtime evaluation duration of ``func`` is greater than ``interval``. + * + * ``func`` is given two arguments, ``resolve`` and ``reject``, + * of which one must be called for the evaluation to complete. + * Calling ``resolve`` with an argument indicates that the expected + * wait condition was met and will return the passed value to the + * caller. Conversely, calling ``reject`` will evaluate ``func`` + * again until the ``timeout`` duration has elapsed or ``func`` throws. + * The passed value to ``reject`` will also be returned to the caller + * once the wait has expired. + * + * Usage:: + * + * let els = new PollPromise((resolve, reject) => { + * let res = document.querySelectorAll("p"); + * if (res.length > 0) { + * resolve(Array.from(res)); + * } else { + * reject([]); + * } + * }, {timeout: 1000}); + * + * @param {Condition} func + * Function to run off the main thread. + * @param {object=} options + * @param {string=} options.errorMessage + * Message to use to send a warning if ``timeout`` is over. + * Defaults to `PollPromise timed out`. + * @param {number=} options.timeout + * Desired timeout if wanted. If 0 or less than the runtime evaluation + * time of ``func``, ``func`` is guaranteed to run at least once. + * Defaults to using no timeout. + * @param {number=} options.interval + * Duration between each poll of ``func`` in milliseconds. + * Defaults to 10 milliseconds. + * + * @returns {Promise.<*>} + * Yields the value passed to ``func``'s + * ``resolve`` or ``reject`` callbacks. + * + * @throws {*} + * If ``func`` throws, its error is propagated. + * @throws {TypeError} + * If `timeout` or `interval`` are not numbers. + * @throws {RangeError} + * If `timeout` or `interval` are not unsigned integers. + */ +export function PollPromise(func, options = {}) { + const { + errorMessage = "PollPromise timed out", + interval = 10, + timeout = null, + } = options; + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + let didTimeOut = false; + + if (typeof func != "function") { + throw new TypeError(); + } + if (timeout != null && typeof timeout != "number") { + throw new TypeError(); + } + if (typeof interval != "number") { + throw new TypeError(); + } + if ( + (timeout && (!Number.isInteger(timeout) || timeout < 0)) || + !Number.isInteger(interval) || + interval < 0 + ) { + throw new RangeError(); + } + + return new Promise((resolve, reject) => { + let start, end; + + if (Number.isInteger(timeout)) { + start = new Date().getTime(); + end = start + timeout; + } + + let evalFn = () => { + new Promise(func) + .then(resolve, rejected => { + if (typeof rejected != "undefined") { + throw rejected; + } + + // return if there is a timeout and set to 0, + // allowing |func| to be evaluated at least once + if ( + typeof end != "undefined" && + (start == end || new Date().getTime() >= end) + ) { + didTimeOut = true; + resolve(rejected); + } + }) + .catch(reject); + }; + + // the repeating slack timer waits |interval| + // before invoking |evalFn| + evalFn(); + + timer.init(evalFn, interval, TYPE_REPEATING_SLACK); + }).then( + res => { + if (didTimeOut) { + lazy.logger.warn(`${errorMessage} after ${timeout} ms`); + } + timer.cancel(); + return res; + }, + err => { + timer.cancel(); + throw err; + } + ); +} diff --git a/remote/shared/TabManager.sys.mjs b/remote/shared/TabManager.sys.mjs new file mode 100644 index 0000000000..c7170f8810 --- /dev/null +++ b/remote/shared/TabManager.sys.mjs @@ -0,0 +1,455 @@ +/* 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, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + BrowsingContextListener: + "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs", + EventPromise: "chrome://remote/content/shared/Sync.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + MobileTabBrowser: "chrome://remote/content/shared/MobileTabBrowser.sys.mjs", + UserContextManager: + "chrome://remote/content/shared/UserContextManager.sys.mjs", +}); + +class TabManagerClass { + #browserUniqueIds; + #contextListener; + #navigableIds; + + constructor() { + // Maps browser's permanentKey to uuid: WeakMap.<Object, string> + this.#browserUniqueIds = new WeakMap(); + + // Maps browsing contexts to uuid: WeakMap.<BrowsingContext, string>. + // It's required as a fallback, since in the case when a context was discarded + // embedderElement is gone, and we cannot retrieve + // the context id from this.#browserUniqueIds. + this.#navigableIds = new WeakMap(); + + this.#contextListener = new lazy.BrowsingContextListener(); + this.#contextListener.on("attached", this.#onContextAttached); + this.#contextListener.startListening(); + + this.browsers.forEach(browser => { + if (this.isValidCanonicalBrowsingContext(browser.browsingContext)) { + this.#navigableIds.set( + browser.browsingContext, + this.getIdForBrowsingContext(browser.browsingContext) + ); + } + }); + } + + /** + * Retrieve all the browser elements from tabs as contained in open windows. + * + * @returns {Array<XULBrowser>} + * All the found <xul:browser>s. Will return an empty array if + * no windows and tabs can be found. + */ + get browsers() { + const browsers = []; + + for (const win of this.windows) { + for (const tab of this.getTabsForWindow(win)) { + const contentBrowser = this.getBrowserForTab(tab); + if (contentBrowser !== null) { + browsers.push(contentBrowser); + } + } + } + + return browsers; + } + + get windows() { + return Services.wm.getEnumerator(null); + } + + /** + * Array of unique browser ids (UUIDs) for all content browsers of all + * windows. + * + * TODO: Similarly to getBrowserById, we should improve the performance of + * this getter in Bug 1750065. + * + * @returns {Array<string>} + * Array of UUIDs for all content browsers. + */ + get allBrowserUniqueIds() { + const browserIds = []; + + for (const win of this.windows) { + // Only return handles for browser windows + for (const tab of this.getTabsForWindow(win)) { + const contentBrowser = this.getBrowserForTab(tab); + const winId = this.getIdForBrowser(contentBrowser); + if (winId !== null) { + browserIds.push(winId); + } + } + } + + return browserIds; + } + + /** + * Get the <code><xul:browser></code> for the specified tab. + * + * @param {Tab} tab + * The tab whose browser needs to be returned. + * + * @returns {XULBrowser} + * The linked browser for the tab or null if no browser can be found. + */ + getBrowserForTab(tab) { + if (tab && "linkedBrowser" in tab) { + return tab.linkedBrowser; + } + + return null; + } + + /** + * Return the tab browser for the specified chrome window. + * + * @param {ChromeWindow} win + * Window whose <code>tabbrowser</code> needs to be accessed. + * + * @returns {Tab} + * Tab browser or null if it's not a browser window. + */ + getTabBrowser(win) { + if (lazy.AppInfo.isAndroid) { + return new lazy.MobileTabBrowser(win); + } else if (lazy.AppInfo.isFirefox) { + return win.gBrowser; + } + + return null; + } + + /** + * Create a new tab. + * + * @param {object} options + * @param {boolean=} options.focus + * Set to true if the new tab should be focused (selected). Defaults to + * false. `false` value is not properly supported on Android, additional + * focus of previously selected tab is required after initial navigation. + * @param {Tab=} options.referenceTab + * The reference tab after which the new tab will be added. If no + * reference tab is provided, the new tab will be added after all the + * other tabs. + * @param {string=} options.userContextId + * A user context id from UserContextManager. + * @param {window=} options.window + * The window where the new tab will open. Defaults to Services.wm.getMostRecentWindow + * if no window is provided. Will be ignored if referenceTab is provided. + */ + async addTab(options = {}) { + let { + focus = false, + referenceTab = null, + userContextId = null, + window = Services.wm.getMostRecentWindow(null), + } = options; + + let index; + if (referenceTab != null) { + // If a reference tab was specified, the window should be the window + // owning the reference tab. + window = this.getWindowForTab(referenceTab); + } + + if (referenceTab != null) { + index = this.getTabsForWindow(window).indexOf(referenceTab) + 1; + } + + const tabBrowser = this.getTabBrowser(window); + + const tab = await tabBrowser.addTab("about:blank", { + index, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + userContextId: lazy.UserContextManager.getInternalIdById(userContextId), + }); + + if (focus) { + await this.selectTab(tab); + } + + return tab; + } + + /** + * Retrieve the browser element corresponding to the provided unique id, + * previously generated via getIdForBrowser. + * + * TODO: To avoid creating strong references on browser elements and + * potentially leaking those elements, this method loops over all windows and + * all tabs. It should be replaced by a faster implementation in Bug 1750065. + * + * @param {string} id + * A browser unique id created by getIdForBrowser. + * @returns {XULBrowser} + * The <xul:browser> corresponding to the provided id. Will return null if + * no matching browser element is found. + */ + getBrowserById(id) { + for (const win of this.windows) { + for (const tab of this.getTabsForWindow(win)) { + const contentBrowser = this.getBrowserForTab(tab); + if (this.getIdForBrowser(contentBrowser) == id) { + return contentBrowser; + } + } + } + return null; + } + + /** + * Retrieve the browsing context corresponding to the provided unique id. + * + * @param {string} id + * A browsing context unique id (created by getIdForBrowsingContext). + * @returns {BrowsingContext=} + * The browsing context found for this id, null if none was found. + */ + getBrowsingContextById(id) { + const browser = this.getBrowserById(id); + if (browser) { + return browser.browsingContext; + } + + return BrowsingContext.get(id); + } + + /** + * Retrieve the unique id for the given xul browser element. The id is a + * dynamically generated uuid associated with the permanentKey property of the + * given browser element. This method is preferable over getIdForBrowsingContext + * in case of working with browser element of a tab, since we can not guarantee + * that browsing context is attached to it. + * + * @param {XULBrowser} browserElement + * The <xul:browser> for which we want to retrieve the id. + * @returns {string} The unique id for this browser. + */ + getIdForBrowser(browserElement) { + if (browserElement === null) { + return null; + } + + const key = browserElement.permanentKey; + if (key === undefined) { + return null; + } + + if (!this.#browserUniqueIds.has(key)) { + this.#browserUniqueIds.set(key, lazy.generateUUID()); + } + return this.#browserUniqueIds.get(key); + } + + /** + * Retrieve the id of a Browsing Context. + * + * For a top-level browsing context a custom unique id will be returned. + * + * @param {BrowsingContext=} browsingContext + * The browsing context to get the id from. + * + * @returns {string} + * The id of the browsing context. + */ + getIdForBrowsingContext(browsingContext) { + if (!browsingContext) { + return null; + } + + if (!browsingContext.parent) { + // Top-level browsing contexts have their own custom unique id. + // If a context was discarded, embedderElement is already gone, + // so use navigable id instead. + return browsingContext.embedderElement + ? this.getIdForBrowser(browsingContext.embedderElement) + : this.#navigableIds.get(browsingContext); + } + + return browsingContext.id.toString(); + } + + /** + * Get the navigable for the given browsing context. + * + * Because Gecko doesn't support the Navigable concept in content + * scope the content browser could be used to uniquely identify + * top-level browsing contexts. + * + * @param {BrowsingContext} browsingContext + * + * @returns {BrowsingContext|XULBrowser} The navigable + * + * @throws {TypeError} + * If `browsingContext` is not a CanonicalBrowsingContext instance. + */ + getNavigableForBrowsingContext(browsingContext) { + if (!this.isValidCanonicalBrowsingContext(browsingContext)) { + throw new TypeError( + `Expected browsingContext to be a CanonicalBrowsingContext, got ${browsingContext}` + ); + } + + if (browsingContext.isContent && browsingContext.parent === null) { + return browsingContext.embedderElement; + } + + return browsingContext; + } + + getTabCount() { + let count = 0; + for (const win of this.windows) { + // For browser windows count the tabs. Otherwise take the window itself. + const tabsLength = this.getTabsForWindow(win).length; + count += tabsLength ? tabsLength : 1; + } + return count; + } + + /** + * Retrieve the tab owning a Browsing Context. + * + * @param {BrowsingContext=} browsingContext + * The browsing context to get the tab from. + * + * @returns {Tab|null} + * The tab owning the Browsing Context. + */ + getTabForBrowsingContext(browsingContext) { + const browser = browsingContext?.top.embedderElement; + if (!browser) { + return null; + } + + const tabBrowser = this.getTabBrowser(browser.ownerGlobal); + return tabBrowser.getTabForBrowser(browser); + } + + /** + * Retrieve the list of tabs for a given window. + * + * @param {ChromeWindow} win + * Window whose <code>tabs</code> need to be returned. + * + * @returns {Array<Tab>} + * The list of tabs. Will return an empty list if tab browser is not available + * or tabs are undefined. + */ + getTabsForWindow(win) { + const tabBrowser = this.getTabBrowser(win); + // For web-platform reftests a faked tabbrowser is used, + // which does not actually have tabs. + if (tabBrowser && tabBrowser.tabs) { + return tabBrowser.tabs; + } + return []; + } + + getWindowForTab(tab) { + // `.linkedBrowser.ownerGlobal` works both with Firefox Desktop and Mobile. + // Other accessors (eg `.ownerGlobal` or `.browser.ownerGlobal`) fail on one + // of the platforms. + return tab.linkedBrowser.ownerGlobal; + } + + /** + * Check if the given argument is a valid canonical browsing context and was not + * discarded. + * + * @param {BrowsingContext} browsingContext + * The browsing context to check. + * + * @returns {boolean} + * True if the browsing context is valid, false otherwise. + */ + isValidCanonicalBrowsingContext(browsingContext) { + return ( + CanonicalBrowsingContext.isInstance(browsingContext) && + !browsingContext.isDiscarded + ); + } + + /** + * Remove the given tab. + * + * @param {Tab} tab + * Tab to remove. + */ + async removeTab(tab) { + if (!tab) { + return; + } + + const ownerWindow = this.getWindowForTab(tab); + const tabBrowser = this.getTabBrowser(ownerWindow); + await tabBrowser.removeTab(tab); + } + + /** + * Select the given tab. + * + * @param {Tab} tab + * Tab to select. + * + * @returns {Promise} + * Promise that resolves when the given tab has been selected. + */ + async selectTab(tab) { + if (!tab) { + return Promise.resolve(); + } + + const ownerWindow = this.getWindowForTab(tab); + const tabBrowser = this.getTabBrowser(ownerWindow); + + if (tab === tabBrowser.selectedTab) { + return Promise.resolve(); + } + + const selected = new lazy.EventPromise(ownerWindow, "TabSelect"); + tabBrowser.selectedTab = tab; + + await selected; + + // Sometimes at that point window is not focused. + if (Services.focus.activeWindow != ownerWindow) { + const activated = new lazy.EventPromise(ownerWindow, "activate"); + ownerWindow.focus(); + return activated; + } + + return Promise.resolve(); + } + + supportsTabs() { + return lazy.AppInfo.isAndroid || lazy.AppInfo.isFirefox; + } + + #onContextAttached = (eventName, data = {}) => { + const { browsingContext } = data; + if (this.isValidCanonicalBrowsingContext(browsingContext)) { + this.#navigableIds.set( + browsingContext, + this.getIdForBrowsingContext(browsingContext) + ); + } + }; +} + +// Expose a shared singleton. +export const TabManager = new TabManagerClass(); diff --git a/remote/shared/UUID.sys.mjs b/remote/shared/UUID.sys.mjs new file mode 100644 index 0000000000..b77ed7a562 --- /dev/null +++ b/remote/shared/UUID.sys.mjs @@ -0,0 +1,14 @@ +/* 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/. */ + +/** + * Creates a unique UUID without enclosing curly brackets + * Example: '86c832d2-cf1c-4001-b3e0-8628fdd41b29' + * + * @returns {string} + * The generated UUID as a string. + */ +export function generateUUID() { + return Services.uuid.generateUUID().toString().slice(1, -1); +} diff --git a/remote/shared/UserContextManager.sys.mjs b/remote/shared/UserContextManager.sys.mjs new file mode 100644 index 0000000000..679b24b2bc --- /dev/null +++ b/remote/shared/UserContextManager.sys.mjs @@ -0,0 +1,214 @@ +/* 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, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", + + ContextualIdentityListener: + "chrome://remote/content/shared/listeners/ContextualIdentityListener.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", +}); + +const DEFAULT_CONTEXT_ID = "default"; +const DEFAULT_INTERNAL_ID = 0; + +/** + * A UserContextManager instance keeps track of all public user contexts and + * maps their internal platform. + * + * This class is exported for test purposes. Otherwise the UserContextManager + * singleton should be used. + */ +export class UserContextManagerClass { + #contextualIdentityListener; + #userContextIds; + + constructor() { + // Map from internal ids (numbers) from the ContextualIdentityService to + // opaque UUIDs (string). + this.#userContextIds = new Map(); + + // The default user context is always using 0 as internal user context id + // and should be exposed as "default" instead of a randomly generated id. + this.#userContextIds.set(DEFAULT_INTERNAL_ID, DEFAULT_CONTEXT_ID); + + // Register other (non-default) public contexts. + lazy.ContextualIdentityService.getPublicIdentities().forEach(identity => + this.#registerIdentity(identity) + ); + + this.#contextualIdentityListener = new lazy.ContextualIdentityListener(); + this.#contextualIdentityListener.on("created", this.#onIdentityCreated); + this.#contextualIdentityListener.on("deleted", this.#onIdentityDeleted); + this.#contextualIdentityListener.startListening(); + } + + destroy() { + this.#contextualIdentityListener.off("created", this.#onIdentityCreated); + this.#contextualIdentityListener.off("deleted", this.#onIdentityDeleted); + this.#contextualIdentityListener.destroy(); + + this.#userContextIds = null; + } + + /** + * Retrieve the user context id corresponding to the default user context. + * + * @returns {string} + * The default user context id. + */ + get defaultUserContextId() { + return DEFAULT_CONTEXT_ID; + } + + /** + * Creates a new user context. + * + * @param {string} prefix + * The prefix to use for the name of the user context. + * + * @returns {string} + * The user context id of the new user context. + */ + createContext(prefix = "remote") { + // Prepare the opaque id and name beforehand. + const userContextId = lazy.generateUUID(); + const name = `${prefix}-${userContextId}`; + + // Create the user context. + const identity = lazy.ContextualIdentityService.create(name); + const internalId = identity.userContextId; + + // An id has been set already by the contextual-identity-created observer. + // Override it with `userContextId` to match the container name. + this.#userContextIds.set(internalId, userContextId); + + return userContextId; + } + + /** + * Retrieve the user context id corresponding to the provided browsing context. + * + * @param {BrowsingContext} browsingContext + * The browsing context to get the user context id from. + * + * @returns {string} + * The corresponding user context id. + * + * @throws {TypeError} + * If `browsingContext` is not a CanonicalBrowsingContext instance. + */ + getIdByBrowsingContext(browsingContext) { + if (!CanonicalBrowsingContext.isInstance(browsingContext)) { + throw new TypeError( + `Expected browsingContext to be a CanonicalBrowsingContext, got ${browsingContext}` + ); + } + + return this.getIdByInternalId( + browsingContext.originAttributes.userContextId + ); + } + + /** + * Retrieve the user context id corresponding to the provided internal id. + * + * @param {number} internalId + * The internal user context id. + * + * @returns {string|null} + * The corresponding user context id or null if the user context does not + * exist. + */ + getIdByInternalId(internalId) { + if (this.#userContextIds.has(internalId)) { + return this.#userContextIds.get(internalId); + } + return null; + } + + /** + * Retrieve the internal id corresponding to the provided user + * context id. + * + * @param {string} userContextId + * The user context id. + * + * @returns {number|null} + * The internal user context id or null if the user context does not + * exist. + */ + getInternalIdById(userContextId) { + for (const [internalId, id] of this.#userContextIds) { + if (userContextId == id) { + return internalId; + } + } + return null; + } + + /** + * Returns an array of all known user context ids. + * + * @returns {Array<string>} + * The array of user context ids. + */ + getUserContextIds() { + return Array.from(this.#userContextIds.values()); + } + + /** + * Checks if the provided user context id is known by this UserContextManager. + * + * @param {string} userContextId + * The id of the user context to check. + */ + hasUserContextId(userContextId) { + return this.getUserContextIds().includes(userContextId); + } + + /** + * Removes a user context and closes all related container tabs. + * + * @param {string} userContextId + * The id of the user context to remove. + * @param {object=} options + * @param {boolean=} options.closeContextTabs + * Pass true if the tabs owned by the user context should also be closed. + * Defaults to false. + */ + removeUserContext(userContextId, options = {}) { + const { closeContextTabs = false } = options; + + if (!this.hasUserContextId(userContextId)) { + return; + } + + const internalId = this.getInternalIdById(userContextId); + if (closeContextTabs) { + lazy.ContextualIdentityService.closeContainerTabs(internalId); + } + lazy.ContextualIdentityService.remove(internalId); + } + + #onIdentityCreated = (eventName, data) => { + this.#registerIdentity(data.identity); + }; + + #onIdentityDeleted = (eventName, data) => { + this.#userContextIds.delete(data.identity.userContextId); + }; + + #registerIdentity(identity) { + // Note: the id for identities created via UserContextManagerClass.createContext + // are overridden in createContext. + this.#userContextIds.set(identity.userContextId, lazy.generateUUID()); + } +} + +// Expose a shared singleton. +export const UserContextManager = new UserContextManagerClass(); diff --git a/remote/shared/WebSocketConnection.sys.mjs b/remote/shared/WebSocketConnection.sys.mjs new file mode 100644 index 0000000000..c9ef050dc5 --- /dev/null +++ b/remote/shared/WebSocketConnection.sys.mjs @@ -0,0 +1,171 @@ +/* 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, { + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + truncate: "chrome://remote/content/shared/Format.sys.mjs", + WebSocketTransport: + "chrome://remote/content/server/WebSocketTransport.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "truncateLog", + "remote.log.truncate", + false +); + +const MAX_LOG_LENGTH = 2500; + +export class WebSocketConnection { + /** + * @param {WebSocket} webSocket + * The WebSocket server connection to wrap. + * @param {Connection} httpdConnection + * Reference to the httpd.js's connection needed for clean-up. + */ + constructor(webSocket, httpdConnection) { + this.id = lazy.generateUUID(); + + this.httpdConnection = httpdConnection; + + this.transport = new lazy.WebSocketTransport(webSocket); + this.transport.hooks = this; + this.transport.ready(); + + lazy.logger.debug(`${this.constructor.name} ${this.id} accepted`); + } + + #log(direction, data) { + if (lazy.Log.isDebugLevelOrMore) { + function replacer(key, value) { + if (typeof value === "string") { + return lazy.truncate`${value}`; + } + return value; + } + + let payload = JSON.stringify( + data, + replacer, + lazy.Log.verbose ? "\t" : null + ); + + if (lazy.truncateLog && payload.length > MAX_LOG_LENGTH) { + // Even if we truncate individual values, the resulting message might be + // huge if we are serializing big objects with many properties or items. + // Truncate the overall message to avoid issues in logs. + const truncated = payload.substring(0, MAX_LOG_LENGTH); + payload = `${truncated} [... truncated after ${MAX_LOG_LENGTH} characters]`; + } + + lazy.logger.debug( + `${this.constructor.name} ${this.id} ${direction} ${payload}` + ); + } + } + + /** + * Close the WebSocket connection. + */ + close() { + this.transport.close(); + } + + /** + * Register a new Session to forward the messages to. + * + * Needs to be implemented in the sub class. + * + * @param {Session} session + * The session to register. + */ + registerSession(session) { + throw new Error("Not implemented"); + } + + /** + * Send the JSON-serializable object to the client. + * + * @param {object} data + * The object to be sent. + */ + send(data) { + this.#log("<-", data); + this.transport.send(data); + } + + /** + * Send an error back to the client. + * + * Needs to be implemented in the sub class. + */ + sendError() { + throw new Error("Not implemented"); + } + + /** + * Send an event back to the client. + * + * Needs to be implemented in the sub class. + */ + sendEvent() { + throw new Error("Not implemented"); + } + + /** + * Send the result of a call to a method back to the client. + * + * Needs to be implemented in the sub class. + */ + sendResult() { + throw new Error("Not implemented"); + } + + toString() { + return `[object ${this.constructor.name} ${this.id}]`; + } + + // Transport hooks + + /** + * Called by the `transport` when the connection is closed. + */ + onConnectionClose(status) { + lazy.logger.debug(`${this.constructor.name} ${this.id} closed`); + } + + /** + * Called when the socket is closed. + */ + onSocketClose() { + // In addition to the WebSocket transport, we also have to close the + // connection used internally within httpd.js. Otherwise the server doesn't + // shut down correctly, and keeps these Connection instances alive. + this.httpdConnection.close(); + } + + /** + * Receive a packet from the WebSocket layer. + * + * This packet is sent by a WebSocket client and is meant to execute + * a particular method. + * + * Needs to be implemented in the sub class. + * + * @param {object} packet + * JSON-serializable object sent by the client. + */ + async onPacket(packet) { + this.#log("->", packet); + } +} diff --git a/remote/shared/WindowManager.sys.mjs b/remote/shared/WindowManager.sys.mjs new file mode 100644 index 0000000000..94b1ed13c1 --- /dev/null +++ b/remote/shared/WindowManager.sys.mjs @@ -0,0 +1,288 @@ +/* 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, { + URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs", + + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + EventPromise: "chrome://remote/content/shared/Sync.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + TimedPromise: "chrome://remote/content/marionette/sync.sys.mjs", + UserContextManager: + "chrome://remote/content/shared/UserContextManager.sys.mjs", + waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs", +}); + +/** + * Provides helpers to interact with Window objects. + * + * @class WindowManager + */ +class WindowManager { + constructor() { + // Maps ChromeWindow to uuid: WeakMap.<Object, string> + this._chromeWindowHandles = new WeakMap(); + } + + get chromeWindowHandles() { + const chromeWindowHandles = []; + + for (const win of this.windows) { + chromeWindowHandles.push(this.getIdForWindow(win)); + } + + return chromeWindowHandles; + } + + get windows() { + return Services.wm.getEnumerator(null); + } + + /** + * Find a specific window matching the provided window handle. + * + * @param {string} handle + * The unique handle of either a chrome window or a content browser, as + * returned by :js:func:`#getIdForBrowser` or :js:func:`#getIdForWindow`. + * + * @returns {object} A window properties object, + * @see :js:func:`GeckoDriver#getWindowProperties` + */ + findWindowByHandle(handle) { + for (const win of this.windows) { + // In case the wanted window is a chrome window, we are done. + const chromeWindowId = this.getIdForWindow(win); + if (chromeWindowId == handle) { + return this.getWindowProperties(win); + } + + // Otherwise check if the chrome window has a tab browser, and that it + // contains a tab with the wanted window handle. + const tabBrowser = lazy.TabManager.getTabBrowser(win); + if (tabBrowser && tabBrowser.tabs) { + for (let i = 0; i < tabBrowser.tabs.length; ++i) { + let contentBrowser = lazy.TabManager.getBrowserForTab( + tabBrowser.tabs[i] + ); + let contentWindowId = lazy.TabManager.getIdForBrowser(contentBrowser); + + if (contentWindowId == handle) { + return this.getWindowProperties(win, { tabIndex: i }); + } + } + } + } + + return null; + } + + /** + * A set of properties describing a window and that should allow to uniquely + * identify it. The described window can either be a Chrome Window or a + * Content Window. + * + * @typedef {object} WindowProperties + * @property {Window} win - The Chrome Window containing the window. + * When describing a Chrome Window, this is the window itself. + * @property {string} id - The unique id of the containing Chrome Window. + * @property {boolean} hasTabBrowser - `true` if the Chrome Window has a + * tabBrowser. + * @property {number} tabIndex - Optional, the index of the specific tab + * within the window. + */ + + /** + * Returns a WindowProperties object, that can be used with :js:func:`GeckoDriver#setWindowHandle`. + * + * @param {Window} win + * The Chrome Window for which we want to create a properties object. + * @param {object} options + * @param {number} options.tabIndex + * Tab index of a specific Content Window in the specified Chrome Window. + * @returns {WindowProperties} A window properties object. + */ + getWindowProperties(win, options = {}) { + if (!Window.isInstance(win)) { + throw new TypeError("Invalid argument, expected a Window object"); + } + + return { + win, + id: this.getIdForWindow(win), + hasTabBrowser: !!lazy.TabManager.getTabBrowser(win), + tabIndex: options.tabIndex, + }; + } + + /** + * Retrieves an id for the given chrome window. The id is a dynamically + * generated uuid associated with the window object. + * + * @param {window} win + * The window object for which we want to retrieve the id. + * @returns {string} The unique id for this chrome window. + */ + getIdForWindow(win) { + if (!this._chromeWindowHandles.has(win)) { + this._chromeWindowHandles.set(win, lazy.generateUUID()); + } + return this._chromeWindowHandles.get(win); + } + + /** + * Close the specified window. + * + * @param {window} win + * The window to close. + * @returns {Promise} + * A promise which is resolved when the current window has been closed. + */ + async closeWindow(win) { + const destroyed = lazy.waitForObserverTopic("xul-window-destroyed", { + checkFn: () => win && win.closed, + }); + + win.close(); + + return destroyed; + } + + /** + * Focus the specified window. + * + * @param {window} win + * The window to focus. + * @returns {Promise} + * A promise which is resolved when the window has been focused. + */ + async focusWindow(win) { + if (Services.focus.activeWindow != win) { + let activated = new lazy.EventPromise(win, "activate"); + let focused = new lazy.EventPromise(win, "focus", { capture: true }); + + win.focus(); + + await Promise.all([activated, focused]); + } + } + + /** + * Open a new browser window. + * + * @param {object=} options + * @param {boolean=} options.focus + * If true, the opened window will receive the focus. Defaults to false. + * @param {boolean=} options.isPrivate + * If true, the opened window will be a private window. Defaults to false. + * @param {ChromeWindow=} options.openerWindow + * Use this window as the opener of the new window. Defaults to the + * topmost window. + * @param {string=} options.userContextId + * The id of the user context which should own the initial tab of the new + * window. + * @returns {Promise} + * A promise resolving to the newly created chrome window. + */ + async openBrowserWindow(options = {}) { + let { + focus = false, + isPrivate = false, + openerWindow = null, + userContextId = null, + } = options; + + switch (lazy.AppInfo.name) { + case "Firefox": + if (openerWindow === null) { + // If no opener was provided, fallback to the topmost window. + openerWindow = Services.wm.getMostRecentBrowserWindow(); + } + + if (!openerWindow) { + throw new lazy.error.UnsupportedOperationError( + `openWindow() could not find a valid opener window` + ); + } + + // Open new browser window, and wait until it is fully loaded. + // Also wait for the window to be focused and activated to prevent a + // race condition when promptly focusing to the original window again. + const browser = await new Promise(resolveOnContentBrowserCreated => + lazy.URILoadingHelper.openTrustedLinkIn( + openerWindow, + "about:blank", + "window", + { + private: isPrivate, + resolveOnContentBrowserCreated, + userContextId: + lazy.UserContextManager.getInternalIdById(userContextId), + } + ) + ); + + // TODO: Both for WebDriver BiDi and classic, opening a new window + // should not run the focus steps. When focus is false we should avoid + // focusing the new window completely. See Bug 1766329 + + if (focus) { + // Focus the currently selected tab. + browser.focus(); + } else { + // If the new window shouldn't get focused, set the + // focus back to the opening window. + await this.focusWindow(openerWindow); + } + + return browser.ownerGlobal; + + default: + throw new lazy.error.UnsupportedOperationError( + `openWindow() not supported in ${lazy.AppInfo.name}` + ); + } + } + + /** + * Wait until the initial application window has been opened and loaded. + * + * @returns {Promise<WindowProxy>} + * A promise that resolved to the application window. + */ + waitForInitialApplicationWindowLoaded() { + return new lazy.TimedPromise( + async resolve => { + // This call includes a fallback to "mail3:pane" as well. + const win = Services.wm.getMostRecentBrowserWindow(); + + const windowLoaded = lazy.waitForObserverTopic( + "browser-delayed-startup-finished", + { + checkFn: subject => (win !== null ? subject == win : true), + } + ); + + // The current window has already been finished loading. + if (win && win.document.readyState == "complete") { + resolve(win); + return; + } + + // Wait for the next browser/mail window to open and finished loading. + const { subject } = await windowLoaded; + resolve(subject); + }, + { + errorMessage: "No applicable application window found", + } + ); + } +} + +// Expose a shared singleton. +export const windowManager = new WindowManager(); diff --git a/remote/shared/js-window-actors/NavigationListenerActor.sys.mjs b/remote/shared/js-window-actors/NavigationListenerActor.sys.mjs new file mode 100644 index 0000000000..13335177c6 --- /dev/null +++ b/remote/shared/js-window-actors/NavigationListenerActor.sys.mjs @@ -0,0 +1,80 @@ +/* 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, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +let registered = false; +export function isNavigationListenerActorRegistered() { + return registered; +} + +/** + * Register the NavigationListener actor that will keep track of all ongoing + * navigations. + */ +export function registerNavigationListenerActor() { + if (registered) { + return; + } + + try { + ChromeUtils.registerWindowActor("NavigationListener", { + kind: "JSWindowActor", + parent: { + esModuleURI: + "chrome://remote/content/shared/js-window-actors/NavigationListenerParent.sys.mjs", + }, + child: { + esModuleURI: + "chrome://remote/content/shared/js-window-actors/NavigationListenerChild.sys.mjs", + events: { + DOMWindowCreated: {}, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], + }); + registered = true; + + // Ensure the navigation listener is started in existing contexts. + for (const browser of lazy.TabManager.browsers) { + if (!browser?.browsingContext) { + continue; + } + + for (const context of browser.browsingContext.getAllBrowsingContextsInSubtree()) { + if (!context.currentWindowGlobal) { + continue; + } + + context.currentWindowGlobal + .getActor("NavigationListener") + // Note that "createActor" is not explicitly referenced in the child + // actor, this is only used to trigger the creation of the actor. + .sendAsyncMessage("createActor"); + } + } + } catch (e) { + if (e.name === "NotSupportedError") { + lazy.logger.warn(`NavigationListener actor is already registered!`); + } else { + throw e; + } + } +} + +export function unregisterNavigationListenerActor() { + if (!registered) { + return; + } + ChromeUtils.unregisterWindowActor("NavigationListener"); + registered = false; +} diff --git a/remote/shared/js-window-actors/NavigationListenerChild.sys.mjs b/remote/shared/js-window-actors/NavigationListenerChild.sys.mjs new file mode 100644 index 0000000000..a2cd8ccf10 --- /dev/null +++ b/remote/shared/js-window-actors/NavigationListenerChild.sys.mjs @@ -0,0 +1,167 @@ +/* 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, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + truncate: "chrome://remote/content/shared/Format.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +export class NavigationListenerChild extends JSWindowActorChild { + #listener; + #webProgress; + + constructor() { + super(); + + this.#listener = { + onLocationChange: this.#onLocationChange, + onStateChange: this.#onStateChange, + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + this.#webProgress = null; + } + + actorCreated() { + this.#webProgress = this.manager.browsingContext.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + + this.#webProgress.addProgressListener( + this.#listener, + Ci.nsIWebProgress.NOTIFY_LOCATION | + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT + ); + } + + didDestroy() { + try { + this.#webProgress.removeProgressListener(this.#listener); + } catch (e) { + // Ignore potential errors if the window global was already destroyed. + } + } + + // Note: we rely on events and messages to trigger the actor creation, but + // all the logic is in the actorCreated callback. The handleEvent and + // receiveMessage methods are only there as placeholders to avoid errors. + + /** + * See note above + */ + handleEvent(event) {} + + /** + * See note above + */ + receiveMessage(message) {} + + /** + * A browsing context might be replaced before reaching the parent process, + * instead we serialize enough information to retrieve the navigable in the + * parent process. + * + * If the browsing context is top level, then the browserId can be used to + * find the browser element and the new browsing context. + * Otherwise (frames) the browsing context should not be replaced and the + * browsing context id should be enough to find the browsing context. + * + * @param {BrowsingContext} browsingContext + * The browsing context for which we want to get details. + * @returns {object} + * An object that returns the following properties: + * - browserId: browser id for this browsing context + * - browsingContextId: browsing context id + * - isTopBrowsingContext: flag that indicates if the browsing context is + * top level + * + */ + #getBrowsingContextDetails(browsingContext) { + return { + browserId: browsingContext.browserId, + browsingContextId: browsingContext.id, + isTopBrowsingContext: browsingContext.parent === null, + }; + } + + #getTargetURI(request) { + try { + return request.QueryInterface(Ci.nsIChannel).originalURI; + } catch (e) {} + + return null; + } + + #onLocationChange = (progress, request, location, stateFlags) => { + if (stateFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) { + const context = progress.browsingContext; + + lazy.logger.trace( + `[${context.id}] NavigationListener onLocationChange,` + + lazy.truncate` location: ${location.spec}` + ); + + this.sendAsyncMessage("NavigationListenerChild:locationChanged", { + contextDetails: this.#getBrowsingContextDetails(context), + url: location.spec, + }); + } + }; + + #onStateChange = (progress, request, stateFlags, status) => { + const context = progress.browsingContext; + const targetURI = this.#getTargetURI(request); + + const isBindingAborted = status == Cr.NS_BINDING_ABORTED; + const isStart = !!(stateFlags & Ci.nsIWebProgressListener.STATE_START); + const isStop = !!(stateFlags & Ci.nsIWebProgressListener.STATE_STOP); + + if (lazy.Log.isTraceLevelOrMore) { + const isNetwork = !!( + stateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK + ); + lazy.logger.trace( + `[${context.id}] NavigationListener onStateChange,` + + ` stateFlags: ${stateFlags}, status: ${status}, isStart: ${isStart},` + + ` isStop: ${isStop}, isNetwork: ${isNetwork},` + + ` isBindingAborted: ${isBindingAborted},` + + lazy.truncate` targetURI: ${targetURI?.spec}` + ); + } + + try { + if (isStart) { + this.sendAsyncMessage("NavigationListenerChild:navigationStarted", { + contextDetails: this.#getBrowsingContextDetails(context), + url: targetURI?.spec, + }); + + return; + } + + if (isStop && !isBindingAborted) { + // Skip NS_BINDING_ABORTED state changes as this can happen during a + // browsing context + process change and we should get the real stop state + // change from the correct process later. + this.sendAsyncMessage("NavigationListenerChild:navigationStopped", { + contextDetails: this.#getBrowsingContextDetails(context), + url: targetURI?.spec, + }); + } + } catch (e) { + if (e.name === "InvalidStateError") { + // We'll arrive here if we no longer have our manager, so we can + // just swallow this error. + return; + } + throw e; + } + }; +} diff --git a/remote/shared/js-window-actors/NavigationListenerParent.sys.mjs b/remote/shared/js-window-actors/NavigationListenerParent.sys.mjs new file mode 100644 index 0000000000..334f9953d6 --- /dev/null +++ b/remote/shared/js-window-actors/NavigationListenerParent.sys.mjs @@ -0,0 +1,58 @@ +/* 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, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + notifyLocationChanged: + "chrome://remote/content/shared/NavigationManager.sys.mjs", + notifyNavigationStarted: + "chrome://remote/content/shared/NavigationManager.sys.mjs", + notifyNavigationStopped: + "chrome://remote/content/shared/NavigationManager.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +export class NavigationListenerParent extends JSWindowActorParent { + async receiveMessage(message) { + try { + switch (message.name) { + case "NavigationListenerChild:locationChanged": { + lazy.notifyLocationChanged({ + contextDetails: message.data.contextDetails, + url: message.data.url, + }); + break; + } + case "NavigationListenerChild:navigationStarted": { + lazy.notifyNavigationStarted({ + contextDetails: message.data.contextDetails, + url: message.data.url, + }); + break; + } + case "NavigationListenerChild:navigationStopped": { + lazy.notifyNavigationStopped({ + contextDetails: message.data.contextDetails, + url: message.data.url, + }); + break; + } + default: + throw new Error("Unsupported message:" + message.name); + } + } catch (e) { + if (e instanceof TypeError) { + // Avoid error spam from errors due to unavailable browsing contexts. + lazy.logger.trace( + `Failed to handle a navigation listener message: ${e.message}` + ); + } else { + throw e; + } + } + } +} diff --git a/remote/shared/listeners/BrowsingContextListener.sys.mjs b/remote/shared/listeners/BrowsingContextListener.sys.mjs new file mode 100644 index 0000000000..d4e3539ca9 --- /dev/null +++ b/remote/shared/listeners/BrowsingContextListener.sys.mjs @@ -0,0 +1,122 @@ +/* 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, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +const OBSERVER_TOPIC_ATTACHED = "browsing-context-attached"; +const OBSERVER_TOPIC_DISCARDED = "browsing-context-discarded"; + +const OBSERVER_TOPIC_SET_EMBEDDER = "browsing-context-did-set-embedder"; + +/** + * The BrowsingContextListener can be used to listen for notifications coming + * from browsing contexts that get attached or discarded. + * + * Example: + * ``` + * const listener = new BrowsingContextListener(); + * listener.on("attached", onAttached); + * listener.startListening(); + * + * const onAttached = (eventName, data = {}) => { + * const { browsingContext, why } = data; + * ... + * }; + * ``` + * + * @fires message + * The BrowsingContextListener emits "attached" and "discarded" events, + * with the following object as payload: + * - {BrowsingContext} browsingContext + * Browsing context the notification relates to. + * - {string} why + * Usually "attach" or "discard", but will contain "replace" if the + * browsing context gets replaced by a cross-group navigation. + */ +export class BrowsingContextListener { + #listening; + #topContextsToAttach; + + /** + * Create a new BrowsingContextListener instance. + */ + constructor() { + lazy.EventEmitter.decorate(this); + + // A map that temporarily holds attached top-level browsing contexts until + // their embedder element is set, which is required to successfully + // retrieve a unique id for the content browser by the TabManager. + this.#topContextsToAttach = new Map(); + + this.#listening = false; + } + + destroy() { + this.stopListening(); + this.#topContextsToAttach = null; + } + + observe(subject, topic, data) { + switch (topic) { + case OBSERVER_TOPIC_ATTACHED: + // Delay emitting the event for top-level browsing contexts until + // the embedder element has been set. + if (!subject.parent) { + this.#topContextsToAttach.set(subject, data); + return; + } + + this.emit("attached", { browsingContext: subject, why: data }); + break; + + case OBSERVER_TOPIC_DISCARDED: + // Remove a recently attached top-level browsing context if it's + // immediately discarded. + if (this.#topContextsToAttach.has(subject)) { + this.#topContextsToAttach.delete(subject); + } + + this.emit("discarded", { browsingContext: subject, why: data }); + break; + + case OBSERVER_TOPIC_SET_EMBEDDER: + const why = this.#topContextsToAttach.get(subject); + if (why !== undefined) { + this.emit("attached", { browsingContext: subject, why }); + this.#topContextsToAttach.delete(subject); + } + break; + } + } + + startListening() { + if (this.#listening) { + return; + } + + Services.obs.addObserver(this, OBSERVER_TOPIC_ATTACHED); + Services.obs.addObserver(this, OBSERVER_TOPIC_DISCARDED); + Services.obs.addObserver(this, OBSERVER_TOPIC_SET_EMBEDDER); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + Services.obs.removeObserver(this, OBSERVER_TOPIC_ATTACHED); + Services.obs.removeObserver(this, OBSERVER_TOPIC_DISCARDED); + Services.obs.removeObserver(this, OBSERVER_TOPIC_SET_EMBEDDER); + + this.#topContextsToAttach.clear(); + + this.#listening = false; + } +} diff --git a/remote/shared/listeners/ConsoleAPIListener.sys.mjs b/remote/shared/listeners/ConsoleAPIListener.sys.mjs new file mode 100644 index 0000000000..7f5c850945 --- /dev/null +++ b/remote/shared/listeners/ConsoleAPIListener.sys.mjs @@ -0,0 +1,124 @@ +/* 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, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "ConsoleAPIStorage", () => { + return Cc["@mozilla.org/consoleAPI-storage;1"].getService( + Ci.nsIConsoleAPIStorage + ); +}); + +/** + * The ConsoleAPIListener can be used to listen for messages coming from console + * API usage in a given windowGlobal, eg. console.log, console.error, ... + * + * Example: + * ``` + * const listener = new ConsoleAPIListener(innerWindowId); + * listener.on("message", onConsoleAPIMessage); + * listener.startListening(); + * + * const onConsoleAPIMessage = (eventName, data = {}) => { + * const { arguments: msgArguments, level, stacktrace, timeStamp } = data; + * ... + * }; + * ``` + * + * @fires message + * The ConsoleAPIListener emits "message" events, with the following object as + * payload: + * - {Array<Object>} arguments - Arguments as passed-in when the method was called. + * - {String} level - Importance, one of `info`, `warn`, `error`, `debug`, `trace`. + * - {Array<Object>} stacktrace - List of stack frames, starting from most recent. + * - {Number} timeStamp - Timestamp when the method was called. + */ +export class ConsoleAPIListener { + #emittedMessages; + #innerWindowId; + #listening; + + /** + * Create a new ConsoleAPIListener instance. + * + * @param {number} innerWindowId + * The inner window id to filter the messages for. + */ + constructor(innerWindowId) { + lazy.EventEmitter.decorate(this); + + this.#emittedMessages = new Set(); + this.#innerWindowId = innerWindowId; + this.#listening = false; + } + + destroy() { + this.stopListening(); + this.#emittedMessages = null; + } + + startListening() { + if (this.#listening) { + return; + } + + lazy.ConsoleAPIStorage.addLogEventListener( + this.#onConsoleAPIMessage, + Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal) + ); + + // Emit cached messages after registering the listener, to make sure we + // don't miss any message. + this.#emitCachedMessages(); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + lazy.ConsoleAPIStorage.removeLogEventListener(this.#onConsoleAPIMessage); + this.#listening = false; + } + + #emitCachedMessages() { + const cachedMessages = lazy.ConsoleAPIStorage.getEvents( + this.#innerWindowId + ); + for (const message of cachedMessages) { + this.#onConsoleAPIMessage(message); + } + } + + #onConsoleAPIMessage = message => { + const messageObject = message.wrappedJSObject; + + // Bail if this message was already emitted, useful to filter out cached + // messages already received by the consumer. + if (this.#emittedMessages.has(messageObject)) { + return; + } + + this.#emittedMessages.add(messageObject); + + if (messageObject.innerID !== this.#innerWindowId) { + // If the message doesn't match the innerWindowId of the current context + // ignore it. + return; + } + + this.emit("message", { + arguments: messageObject.arguments, + level: messageObject.level, + stacktrace: messageObject.stacktrace, + timeStamp: messageObject.timeStamp, + }); + }; +} diff --git a/remote/shared/listeners/ConsoleListener.sys.mjs b/remote/shared/listeners/ConsoleListener.sys.mjs new file mode 100644 index 0000000000..0344cf2be2 --- /dev/null +++ b/remote/shared/listeners/ConsoleListener.sys.mjs @@ -0,0 +1,154 @@ +/* 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, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + + getFramesFromStack: "chrome://remote/content/shared/Stack.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * The ConsoleListener can be used to listen for console messages related to + * Javascript errors, certain warnings which all happen within a specific + * windowGlobal. Consumers can listen for the message types "error", + * "warn" and "info". + * + * Example: + * ``` + * const onJavascriptError = (eventName, data = {}) => { + * const { level, message, stacktrace, timestamp } = data; + * ... + * }; + * + * const listener = new ConsoleListener(innerWindowId); + * listener.on("error", onJavascriptError); + * listener.startListening(); + * ... + * listener.stopListening(); + * ``` + * + * @fires message + * The ConsoleListener emits "error", "warn" and "info" events, with the + * following object as payload: + * - {String} level - Importance, one of `info`, `warn`, `error`, + * `debug`, `trace`. + * - {String} message - Actual message from the console entry. + * - {Array<StackFrame>} stacktrace - List of stack frames, + * starting from most recent. + * - {Number} timeStamp - Timestamp when the method was called. + */ +export class ConsoleListener { + #emittedMessages; + #innerWindowId; + #listening; + + /** + * Create a new ConsoleListener instance. + * + * @param {number} innerWindowId + * The inner window id to filter the messages for. + */ + constructor(innerWindowId) { + lazy.EventEmitter.decorate(this); + + this.#emittedMessages = new Set(); + this.#innerWindowId = innerWindowId; + this.#listening = false; + } + + get listening() { + return this.#listening; + } + + destroy() { + this.stopListening(); + this.#emittedMessages = null; + } + + startListening() { + if (this.#listening) { + return; + } + + Services.console.registerListener(this.#onConsoleMessage); + + // Emit cached messages after registering the listener, to make sure we + // don't miss any message. + this.#emitCachedMessages(); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + Services.console.unregisterListener(this.#onConsoleMessage); + this.#listening = false; + } + + #emitCachedMessages() { + const cachedMessages = Services.console.getMessageArray() || []; + + for (const message of cachedMessages) { + this.#onConsoleMessage(message); + } + } + + #onConsoleMessage = message => { + if (!(message instanceof Ci.nsIScriptError)) { + // For now ignore basic nsIConsoleMessage instances, which are only + // relevant to Chrome code and do not have a valid window reference. + return; + } + + // Bail if this message was already emitted, useful to filter out cached + // messages already received by the consumer. + if (this.#emittedMessages.has(message)) { + return; + } + + this.#emittedMessages.add(message); + + if (message.innerWindowID !== this.#innerWindowId) { + // If the message doesn't match the innerWindowId of the current context + // ignore it. + return; + } + + const { errorFlag, warningFlag, infoFlag } = Ci.nsIScriptError; + let level; + + if ((message.flags & warningFlag) == warningFlag) { + level = "warn"; + } else if ((message.flags & infoFlag) == infoFlag) { + level = "info"; + } else if ((message.flags & errorFlag) == errorFlag) { + level = "error"; + } else { + lazy.logger.warn( + `Not able to process console message with unknown flags ${message.flags}` + ); + return; + } + + // Send event when actively listening. + this.emit(level, { + level, + message: message.errorMessage, + stacktrace: lazy.getFramesFromStack(message.stack), + timeStamp: message.timeStamp, + }); + }; + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIConsoleListener"]); + } +} diff --git a/remote/shared/listeners/ContextualIdentityListener.sys.mjs b/remote/shared/listeners/ContextualIdentityListener.sys.mjs new file mode 100644 index 0000000000..d93b44ed77 --- /dev/null +++ b/remote/shared/listeners/ContextualIdentityListener.sys.mjs @@ -0,0 +1,85 @@ +/* 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, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +const OBSERVER_TOPIC_CREATED = "contextual-identity-created"; +const OBSERVER_TOPIC_DELETED = "contextual-identity-deleted"; + +/** + * The ContextualIdentityListener can be used to listen for notifications about + * contextual identities (containers) being created or deleted. + * + * Example: + * ``` + * const listener = new ContextualIdentityListener(); + * listener.on("created", onCreated); + * listener.startListening(); + * + * const onCreated = (eventName, data = {}) => { + * const { identity } = data; + * ... + * }; + * ``` + * + * @fires message + * The ContextualIdentityListener emits "created" and "deleted" events, + * with the following object as payload: + * - {object} identity + * The contextual identity which was created or deleted. + */ +export class ContextualIdentityListener { + #listening; + + /** + * Create a new BrowsingContextListener instance. + */ + constructor() { + lazy.EventEmitter.decorate(this); + + this.#listening = false; + } + + destroy() { + this.stopListening(); + } + + observe(subject, topic, data) { + switch (topic) { + case OBSERVER_TOPIC_CREATED: + this.emit("created", { identity: subject.wrappedJSObject }); + break; + + case OBSERVER_TOPIC_DELETED: + this.emit("deleted", { identity: subject.wrappedJSObject }); + break; + } + } + + startListening() { + if (this.#listening) { + return; + } + + Services.obs.addObserver(this, OBSERVER_TOPIC_CREATED); + Services.obs.addObserver(this, OBSERVER_TOPIC_DELETED); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + Services.obs.removeObserver(this, OBSERVER_TOPIC_CREATED); + Services.obs.removeObserver(this, OBSERVER_TOPIC_DELETED); + + this.#listening = false; + } +} diff --git a/remote/shared/listeners/LoadListener.sys.mjs b/remote/shared/listeners/LoadListener.sys.mjs new file mode 100644 index 0000000000..cccfca7a90 --- /dev/null +++ b/remote/shared/listeners/LoadListener.sys.mjs @@ -0,0 +1,103 @@ +/* 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, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +/** + * The LoadListener can be used to listen for load events. + * + * Example: + * ``` + * const listener = new LoadListener(); + * listener.on("DOMContentLoaded", onDOMContentLoaded); + * listener.startListening(); + * + * const onDOMContentLoaded = (eventName, data = {}) => { + * const { target } = data; + * ... + * }; + * ``` + * + * @fires message + * The LoadListener emits "DOMContentLoaded" and "load" events, + * with the following object as payload: + * - {Document} target + * The target document. + */ +export class LoadListener { + #abortController; + #window; + + /** + * Create a new LoadListener instance. + */ + constructor(win) { + lazy.EventEmitter.decorate(this); + + // Use an abort controller instead of removeEventListener because destroy + // might be called close to the window global destruction. + this.#abortController = null; + + this.#window = win; + } + + destroy() { + this.stopListening(); + } + + startListening() { + if (this.#abortController) { + return; + } + + this.#abortController = new AbortController(); + + // Events are attached to the windowRoot instead of the regular window to + // avoid issues with document.open (Bug 1822772). + this.#window.windowRoot.addEventListener( + "DOMContentLoaded", + this.#onDOMContentLoaded, + { + capture: true, + mozSystemGroup: true, + signal: this.#abortController.signal, + } + ); + + this.#window.windowRoot.addEventListener("load", this.#onLoad, { + capture: true, + mozSystemGroup: true, + signal: this.#abortController.signal, + }); + } + + stopListening() { + if (!this.#abortController) { + return; + } + + this.#abortController.abort(); + this.#abortController = null; + } + + #onDOMContentLoaded = event => { + // Check that this event was emitted for the relevant window, because events + // from inner frames can bubble to the windowRoot. + if (event.target.defaultView === this.#window) { + this.emit("DOMContentLoaded", { target: event.target }); + } + }; + + #onLoad = event => { + // Check that this event was emitted for the relevant window, because events + // from inner frames can bubble to the windowRoot. + if (event.target.defaultView === this.#window) { + this.emit("load", { target: event.target }); + } + }; +} diff --git a/remote/shared/listeners/NavigationListener.sys.mjs b/remote/shared/listeners/NavigationListener.sys.mjs new file mode 100644 index 0000000000..c911bb53f6 --- /dev/null +++ b/remote/shared/listeners/NavigationListener.sys.mjs @@ -0,0 +1,90 @@ +/* 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, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +/** + * The NavigationListener simply wraps a NavigationManager instance and exposes + * it with a convenient listener API, more consistent with the rest of the + * remote codebase. NavigationManager is a singleton per session so it can't + * be instanciated for each and every consumer. + * + * Example: + * ``` + * const onNavigationStarted = (eventName, data = {}) => { + * const { level, message, stacktrace, timestamp } = data; + * ... + * }; + * + * const listener = new NavigationListener(this.messageHandler.navigationManager); + * listener.on("navigation-started", onNavigationStarted); + * listener.startListening(); + * ... + * listener.stopListening(); + * ``` + * + * @fires message + * The NavigationListener emits "navigation-started", "location-changed" and + * "navigation-stopped" events, with the following object as payload: + * - {string} navigationId - The UUID for the navigation. + * - {string} navigableId - The UUID for the navigable. + * - {string} url - The target url for the navigation. + */ +export class NavigationListener { + #listening; + #navigationManager; + + /** + * Create a new NavigationListener instance. + * + * @param {NavigationManager} navigationManager + * The underlying NavigationManager for this listener. + */ + constructor(navigationManager) { + lazy.EventEmitter.decorate(this); + + this.#listening = false; + this.#navigationManager = navigationManager; + } + + get listening() { + return this.#listening; + } + + destroy() { + this.stopListening(); + } + + startListening() { + if (this.#listening) { + return; + } + + this.#navigationManager.on("navigation-started", this.#forwardEvent); + this.#navigationManager.on("navigation-stopped", this.#forwardEvent); + this.#navigationManager.on("location-changed", this.#forwardEvent); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + this.#navigationManager.off("navigation-started", this.#forwardEvent); + this.#navigationManager.off("navigation-stopped", this.#forwardEvent); + this.#navigationManager.off("location-changed", this.#forwardEvent); + + this.#listening = false; + } + + #forwardEvent = (name, data) => { + this.emit(name, data); + }; +} diff --git a/remote/shared/listeners/NetworkEventRecord.sys.mjs b/remote/shared/listeners/NetworkEventRecord.sys.mjs new file mode 100644 index 0000000000..a41f3edd7d --- /dev/null +++ b/remote/shared/listeners/NetworkEventRecord.sys.mjs @@ -0,0 +1,455 @@ +/* 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, { + NetworkUtils: + "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs", + + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +/** + * The NetworkEventRecord implements the interface expected from network event + * owners for consumers of the DevTools NetworkObserver. + * + * The NetworkEventRecord emits the before-request-sent event on behalf of the + * NetworkListener instance which created it. + */ +export class NetworkEventRecord { + #contextId; + #fromCache; + #isMainDocumentChannel; + #networkListener; + #redirectCount; + #requestChannel; + #requestData; + #requestId; + #responseChannel; + #responseData; + #wrappedChannel; + + /** + * + * @param {object} networkEvent + * The initial network event information (see createNetworkEvent() in + * NetworkUtils.sys.mjs). + * @param {nsIChannel} channel + * The nsIChannel behind this network event. + * @param {NetworkListener} networkListener + * The NetworkListener which created this NetworkEventRecord. + */ + constructor(networkEvent, channel, networkListener) { + this.#requestChannel = channel; + this.#responseChannel = null; + + this.#fromCache = networkEvent.fromCache; + this.#isMainDocumentChannel = channel.isMainDocumentChannel; + + this.#wrappedChannel = ChannelWrapper.get(channel); + + this.#networkListener = networkListener; + + // The context ids computed by TabManager have the lifecycle of a navigable + // and can be reused for all the events emitted from this record. + this.#contextId = this.#getContextId(); + + // The wrappedChannel id remains identical across redirects, whereas + // nsIChannel.channelId is different for each and every request. + this.#requestId = this.#wrappedChannel.id.toString(); + + const { cookies, headers } = + lazy.NetworkUtils.fetchRequestHeadersAndCookies(channel); + + // See the RequestData type definition for the full list of properties that + // should be set on this object. + this.#requestData = { + bodySize: null, + cookies, + headers, + headersSize: networkEvent.rawHeaders ? networkEvent.rawHeaders.length : 0, + method: channel.requestMethod, + request: this.#requestId, + timings: {}, + url: channel.URI.spec, + }; + + // See the ResponseData type definition for the full list of properties that + // should be set on this object. + this.#responseData = { + // encoded size (body) + bodySize: null, + content: { + // decoded size + size: null, + }, + // encoded size (headers) + headersSize: null, + url: channel.URI.spec, + }; + + // NetworkObserver creates a network event when request headers have been + // parsed. + // According to the BiDi spec, we should emit beforeRequestSent when adding + // request headers, see https://whatpr.org/fetch/1540.html#http-network-or-cache-fetch + // step 8.17 + // Bug 1802181: switch the NetworkObserver to an event-based API. + this.#emitBeforeRequestSent(); + + // If the request is already blocked, we will not receive further updates, + // emit a network.fetchError event immediately. + if (networkEvent.blockedReason) { + this.#emitFetchError(); + } + } + + /** + * Add network request POST data. + * + * Required API for a NetworkObserver event owner. + * + * @param {object} postData + * The request POST data. + */ + addRequestPostData(postData) { + // Only the postData size is needed for RemoteAgent consumers. + this.#requestData.bodySize = postData.size; + } + + /** + * Add the initial network response information. + * + * Required API for a NetworkObserver event owner. + * + * + * @param {object} options + * @param {nsIChannel} options.channel + * The channel. + * @param {boolean} options.fromCache + * @param {string} options.rawHeaders + */ + addResponseStart(options) { + const { channel, fromCache, rawHeaders = "" } = options; + this.#responseChannel = channel; + + const { headers } = + lazy.NetworkUtils.fetchResponseHeadersAndCookies(channel); + + const headersSize = rawHeaders.length; + this.#responseData = { + ...this.#responseData, + bodySize: 0, + bytesReceived: headersSize, + fromCache: this.#fromCache || !!fromCache, + headers, + headersSize, + mimeType: this.#getMimeType(), + protocol: lazy.NetworkUtils.getProtocol(channel), + status: channel.responseStatus, + statusText: channel.responseStatusText, + }; + + // This should be triggered when all headers have been received, matching + // the WebDriverBiDi response started trigger in `4.6. HTTP-network fetch` + // from the fetch specification, based on the PR visible at + // https://github.com/whatwg/fetch/pull/1540 + this.#emitResponseStarted(); + } + + /** + * Add connection security information. + * + * Required API for a NetworkObserver event owner. + * + * Not used for RemoteAgent. + * + * @param {object} info + * The object containing security information. + * @param {boolean} isRacing + * True if the corresponding channel raced the cache and network requests. + */ + addSecurityInfo(info, isRacing) {} + + /** + * Add network event timings. + * + * Required API for a NetworkObserver event owner. + * + * Not used for RemoteAgent. + * + * @param {number} total + * The total time for the request. + * @param {object} timings + * The har-like timings. + * @param {object} offsets + * The har-like timings, but as offset from the request start. + */ + addEventTimings(total, timings, offsets) {} + + /** + * Add response cache entry. + * + * Required API for a NetworkObserver event owner. + * + * Not used for RemoteAgent. + * + * @param {object} options + * An object which contains a single responseCache property. + */ + addResponseCache(options) {} + + /** + * Add response content. + * + * Required API for a NetworkObserver event owner. + * + * @param {object} response + * An object which represents the response content. + * @param {object} responseInfo + * Additional meta data about the response. + */ + addResponseContent(response, responseInfo) { + // Update content-related sizes with the latest data from addResponseContent. + this.#responseData = { + ...this.#responseData, + bodySize: response.bodySize, + bytesReceived: response.transferredSize, + content: { + size: response.decodedBodySize, + }, + }; + + if (responseInfo.blockedReason) { + this.#emitFetchError(); + } else { + this.#emitResponseCompleted(); + } + } + + /** + * Add server timings. + * + * Required API for a NetworkObserver event owner. + * + * Not used for RemoteAgent. + * + * @param {Array} serverTimings + * The server timings. + */ + addServerTimings(serverTimings) {} + + /** + * Add service worker timings. + * + * Required API for a NetworkObserver event owner. + * + * Not used for RemoteAgent. + * + * @param {object} serviceWorkerTimings + * The server timings. + */ + addServiceWorkerTimings(serviceWorkerTimings) {} + + onAuthPrompt(authDetails, authCallbacks) { + this.#emitAuthRequired(authCallbacks); + } + + /** + * Convert the provided request timing to a timing relative to the beginning + * of the request. All timings are numbers representing high definition + * timestamps. + * + * @param {number} timing + * High definition timestamp for a request timing relative from the time + * origin. + * @param {number} requestTime + * High definition timestamp for the request start time relative from the + * time origin. + * @returns {number} + * High definition timestamp for the request timing relative to the start + * time of the request, or 0 if the provided timing was 0. + */ + #convertTimestamp(timing, requestTime) { + if (timing == 0) { + return 0; + } + + return timing - requestTime; + } + + #emitAuthRequired(authCallbacks) { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("auth-required", { + authCallbacks, + contextId: this.#contextId, + isNavigationRequest: this.#isMainDocumentChannel, + redirectCount: this.#redirectCount, + requestChannel: this.#requestChannel, + requestData: this.#requestData, + responseChannel: this.#responseChannel, + responseData: this.#responseData, + timestamp: Date.now(), + }); + } + + #emitBeforeRequestSent() { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("before-request-sent", { + contextId: this.#contextId, + isNavigationRequest: this.#isMainDocumentChannel, + redirectCount: this.#redirectCount, + requestChannel: this.#requestChannel, + requestData: this.#requestData, + timestamp: Date.now(), + }); + } + + #emitFetchError() { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("fetch-error", { + contextId: this.#contextId, + // TODO: Update with a proper error text. Bug 1873037. + errorText: ChromeUtils.getXPCOMErrorName(this.#requestChannel.status), + isNavigationRequest: this.#isMainDocumentChannel, + redirectCount: this.#redirectCount, + requestChannel: this.#requestChannel, + requestData: this.#requestData, + timestamp: Date.now(), + }); + } + + #emitResponseCompleted() { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("response-completed", { + contextId: this.#contextId, + isNavigationRequest: this.#isMainDocumentChannel, + redirectCount: this.#redirectCount, + requestChannel: this.#requestChannel, + requestData: this.#requestData, + responseChannel: this.#responseChannel, + responseData: this.#responseData, + timestamp: Date.now(), + }); + } + + #emitResponseStarted() { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("response-started", { + contextId: this.#contextId, + isNavigationRequest: this.#isMainDocumentChannel, + redirectCount: this.#redirectCount, + requestChannel: this.#requestChannel, + requestData: this.#requestData, + responseChannel: this.#responseChannel, + responseData: this.#responseData, + timestamp: Date.now(), + }); + } + + #getBrowsingContext() { + const id = lazy.NetworkUtils.getChannelBrowsingContextID( + this.#requestChannel + ); + return BrowsingContext.get(id); + } + + /** + * Retrieve the navigable id for the current browsing context associated to + * the requests' channel. Network events are recorded in the parent process + * so we always expect to be able to use TabManager.getIdForBrowsingContext. + * + * @returns {string} + * The navigable id corresponding to the given browsing context. + */ + #getContextId() { + return lazy.TabManager.getIdForBrowsingContext(this.#getBrowsingContext()); + } + + #getMimeType() { + // TODO: DevTools NetworkObserver is computing a similar value in + // addResponseContent, but uses an inconsistent implementation in + // addResponseStart. This approach can only be used as early as in + // addResponseHeaders. We should move this logic to the NetworkObserver and + // expose mimeType in addResponseStart. Bug 1809670. + let mimeType = ""; + + try { + mimeType = this.#wrappedChannel.contentType; + const contentCharset = this.#requestChannel.contentCharset; + if (contentCharset) { + mimeType += `;charset=${contentCharset}`; + } + } catch (e) { + // Ignore exceptions when reading contentType/contentCharset + } + + return mimeType; + } + + #getTimingsFromTimedChannel(timedChannel) { + const { + channelCreationTime, + redirectStartTime, + redirectEndTime, + dispatchFetchEventStartTime, + cacheReadStartTime, + domainLookupStartTime, + domainLookupEndTime, + connectStartTime, + connectEndTime, + secureConnectionStartTime, + requestStartTime, + responseStartTime, + responseEndTime, + } = timedChannel; + + // fetchStart should be the post-redirect start time, which should be the + // first non-zero timing from: dispatchFetchEventStart, cacheReadStart and + // domainLookupStart. See https://www.w3.org/TR/navigation-timing-2/#processing-model + const fetchStartTime = + dispatchFetchEventStartTime || + cacheReadStartTime || + domainLookupStartTime; + + // Bug 1805478: Per spec, the origin time should match Performance API's + // timeOrigin for the global which initiated the request. This is not + // available in the parent process, so for now we will use 0. + const timeOrigin = 0; + + return { + timeOrigin, + requestTime: this.#convertTimestamp(channelCreationTime, timeOrigin), + redirectStart: this.#convertTimestamp(redirectStartTime, timeOrigin), + redirectEnd: this.#convertTimestamp(redirectEndTime, timeOrigin), + fetchStart: this.#convertTimestamp(fetchStartTime, timeOrigin), + dnsStart: this.#convertTimestamp(domainLookupStartTime, timeOrigin), + dnsEnd: this.#convertTimestamp(domainLookupEndTime, timeOrigin), + connectStart: this.#convertTimestamp(connectStartTime, timeOrigin), + connectEnd: this.#convertTimestamp(connectEndTime, timeOrigin), + tlsStart: this.#convertTimestamp(secureConnectionStartTime, timeOrigin), + tlsEnd: this.#convertTimestamp(connectEndTime, timeOrigin), + requestStart: this.#convertTimestamp(requestStartTime, timeOrigin), + responseStart: this.#convertTimestamp(responseStartTime, timeOrigin), + responseEnd: this.#convertTimestamp(responseEndTime, timeOrigin), + }; + } + + /** + * Update the timings and the redirect count from the nsITimedChannel + * corresponding to the current channel. This should be called before emitting + * any event from this class. + */ + #updateDataFromTimedChannel() { + const timedChannel = this.#requestChannel.QueryInterface( + Ci.nsITimedChannel + ); + this.#redirectCount = timedChannel.redirectCount; + this.#requestData.timings = this.#getTimingsFromTimedChannel(timedChannel); + } +} diff --git a/remote/shared/listeners/NetworkListener.sys.mjs b/remote/shared/listeners/NetworkListener.sys.mjs new file mode 100644 index 0000000000..500d2005dc --- /dev/null +++ b/remote/shared/listeners/NetworkListener.sys.mjs @@ -0,0 +1,109 @@ +/* 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, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + NetworkObserver: + "resource://devtools/shared/network-observer/NetworkObserver.sys.mjs", + + NetworkEventRecord: + "chrome://remote/content/shared/listeners/NetworkEventRecord.sys.mjs", +}); + +/** + * The NetworkListener listens to all network activity from the parent + * process. + * + * Example: + * ``` + * const listener = new NetworkListener(); + * listener.on("before-request-sent", onBeforeRequestSent); + * listener.startListening(); + * + * const onBeforeRequestSent = (eventName, data = {}) => { + * const { cntextId, redirectCount, requestData, requestId, timestamp } = data; + * ... + * }; + * ``` + * + * @fires before-request-sent + * The NetworkListener emits "before-request-sent" events, with the + * following object as payload: + * - {number} browsingContextId - The browsing context id of the browsing + * context where this request was performed. + * - {number} redirectCount - The request's redirect count. + * - {RequestData} requestData - The request's data as expected by + * WebDriver BiDi. + * - {string} requestId - The id of the request, consistent across + * redirects. + * - {number} timestamp - Timestamp when the event was generated. + */ +export class NetworkListener { + #devtoolsNetworkObserver; + #listening; + + constructor() { + lazy.EventEmitter.decorate(this); + + this.#listening = false; + } + + destroy() { + this.stopListening(); + } + + startListening() { + if (this.#listening) { + return; + } + + this.#devtoolsNetworkObserver = new lazy.NetworkObserver({ + ignoreChannelFunction: this.#ignoreChannelFunction, + onNetworkEvent: this.#onNetworkEvent, + }); + + // Enable the auth prompt listening to support the auth-required event and + // phase. + this.#devtoolsNetworkObserver.setAuthPromptListenerEnabled(true); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + this.#devtoolsNetworkObserver.destroy(); + this.#devtoolsNetworkObserver = null; + + this.#listening = false; + } + + #ignoreChannelFunction = channel => { + // Bug 1826210: Ignore file channels which don't support the same APIs as + // regular HTTP channels. + if (channel instanceof Ci.nsIFileChannel) { + return true; + } + + // Ignore chrome-privileged or DevTools-initiated requests + if ( + channel.loadInfo?.loadingDocument === null && + (channel.loadInfo.loadingPrincipal === + Services.scriptSecurityManager.getSystemPrincipal() || + channel.loadInfo.isInDevToolsContext) + ) { + return true; + } + + return false; + }; + + #onNetworkEvent = (networkEvent, channel) => { + return new lazy.NetworkEventRecord(networkEvent, channel, this); + }; +} diff --git a/remote/shared/listeners/PromptListener.sys.mjs b/remote/shared/listeners/PromptListener.sys.mjs new file mode 100644 index 0000000000..e04c766970 --- /dev/null +++ b/remote/shared/listeners/PromptListener.sys.mjs @@ -0,0 +1,285 @@ +/* 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, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + modal: "chrome://remote/content/shared/Prompt.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * The PromptListener listens to the DialogObserver events. + * + * Example: + * ``` + * const listener = new PromptListener(); + * listener.on("opened", onPromptOpened); + * listener.startListening(); + * + * const onPromptOpened = (eventName, data = {}) => { + * const { contentBrowser, prompt } = data; + * ... + * }; + * ``` + * + * @fires message + * The PromptListener emits "opened" events, + * with the following object as payload: + * - {XULBrowser} contentBrowser + * The <xul:browser> which hold the <var>prompt</var>. + * - {modal.Dialog} prompt + * Returns instance of the Dialog class. + * + * The PromptListener emits "closed" events, + * with the following object as payload: + * - {XULBrowser} contentBrowser + * The <xul:browser> which is the target of the event. + * - {object} detail + * {boolean=} detail.accepted + * Returns true if a user prompt was accepted + * and false if it was dismissed. + * {string=} detail.userText + * The user text specified in a prompt. + */ +export class PromptListener { + #curBrowserFn; + #listening; + + constructor(curBrowserFn) { + lazy.EventEmitter.decorate(this); + + // curBrowserFn is used only for Marionette (WebDriver classic). + this.#curBrowserFn = curBrowserFn; + this.#listening = false; + } + + destroy() { + this.stopListening(); + } + + /** + * Waits for the prompt to be closed. + * + * @returns {Promise} + * Promise that resolves when the prompt is closed. + */ + async dialogClosed() { + return new Promise(resolve => { + const dialogClosed = () => { + this.off("closed", dialogClosed); + resolve(); + }; + + this.on("closed", dialogClosed); + }); + } + + /** + * Handles `DOMModalDialogClosed` events. + */ + handleEvent(event) { + lazy.logger.trace(`Received event ${event.type}`); + + const chromeWin = event.target.opener + ? event.target.opener.ownerGlobal + : event.target.ownerGlobal; + const curBrowser = this.#curBrowserFn && this.#curBrowserFn(); + + // For Marionette (WebDriver classic) we only care about events which come + // the currently selected browser. + if (curBrowser && chromeWin != curBrowser.window) { + return; + } + + let contentBrowser; + if (lazy.AppInfo.isAndroid) { + const tabBrowser = lazy.TabManager.getTabBrowser(event.target); + // Since on Android we always have only one tab we can just check + // the selected tab. + const tab = tabBrowser.selectedTab; + contentBrowser = lazy.TabManager.getBrowserForTab(tab); + } else { + contentBrowser = event.target; + } + + const detail = {}; + + // At the moment the event details are present for GeckoView and on desktop + // only for Services.prompt.MODAL_TYPE_CONTENT prompts. + if (event.detail) { + const { areLeaving, value } = event.detail; + // `areLeaving` returns undefined for alerts, for confirms and prompts + // it returns true if a user prompt was accepted and false if it was dismissed. + detail.accepted = areLeaving === undefined ? true : areLeaving; + if (value) { + detail.userText = value; + } + } + + this.emit("closed", { + contentBrowser, + detail, + }); + } + + /** + * Observes the following notifications: + * `common-dialog-loaded` - when a modal dialog loaded on desktop, + * `domwindowopened` - when a new chrome window opened, + * `geckoview-prompt-show` - when a modal dialog opened on Android. + */ + observe(subject, topic) { + lazy.logger.trace(`Received observer notification ${topic}`); + + let curBrowser = this.#curBrowserFn && this.#curBrowserFn(); + switch (topic) { + case "common-dialog-loaded": + if (curBrowser) { + if ( + !this.#hasCommonDialog( + curBrowser.contentBrowser, + curBrowser.window, + subject + ) + ) { + return; + } + } else { + const chromeWin = subject.opener + ? subject.opener.ownerGlobal + : subject.ownerGlobal; + + for (const tab of lazy.TabManager.getTabsForWindow(chromeWin)) { + const contentBrowser = lazy.TabManager.getBrowserForTab(tab); + const window = lazy.TabManager.getWindowForTab(tab); + + if (this.#hasCommonDialog(contentBrowser, window, subject)) { + curBrowser = { + contentBrowser, + window, + }; + + break; + } + } + } + this.emit("opened", { + contentBrowser: curBrowser.contentBrowser, + prompt: new lazy.modal.Dialog(() => curBrowser, subject), + }); + + break; + + case "domwindowopened": + subject.addEventListener("DOMModalDialogClosed", this); + break; + + case "geckoview-prompt-show": + for (let win of Services.wm.getEnumerator(null)) { + const prompt = win.prompts().find(item => item.id == subject.id); + if (prompt) { + const tabBrowser = lazy.TabManager.getTabBrowser(win); + // Since on Android we always have only one tab we can just check + // the selected tab. + const tab = tabBrowser.selectedTab; + const contentBrowser = lazy.TabManager.getBrowserForTab(tab); + const window = lazy.TabManager.getWindowForTab(tab); + + // Do not send the event if the curBrowser is specified, + // and it's different from prompt browser. + if (curBrowser && contentBrowser !== curBrowser.contentBrowser) { + continue; + } + + this.emit("opened", { + contentBrowser, + prompt: new lazy.modal.Dialog( + () => ({ + contentBrowser, + window, + }), + prompt + ), + }); + return; + } + } + break; + } + } + + startListening() { + if (this.#listening) { + return; + } + + this.#register(); + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + this.#unregister(); + this.#listening = false; + } + + #hasCommonDialog(contentBrowser, window, prompt) { + const modalType = prompt.Dialog.args.modalType; + if ( + modalType === Services.prompt.MODAL_TYPE_TAB || + modalType === Services.prompt.MODAL_TYPE_CONTENT + ) { + // Find the container of the dialog in the parent document, and ensure + // it is a descendant of the same container as the content browser. + const container = contentBrowser.closest(".browserSidebarContainer"); + + return container.contains(prompt.docShell.chromeEventHandler); + } + + return prompt.ownerGlobal == window || prompt.opener?.ownerGlobal == window; + } + + #register() { + Services.obs.addObserver(this, "common-dialog-loaded"); + Services.obs.addObserver(this, "domwindowopened"); + Services.obs.addObserver(this, "geckoview-prompt-show"); + + // Register event listener and save already open prompts for all already open windows. + for (const win of Services.wm.getEnumerator(null)) { + win.addEventListener("DOMModalDialogClosed", this); + } + } + + #unregister() { + const removeObserver = observerName => { + try { + Services.obs.removeObserver(this, observerName); + } catch (e) { + lazy.logger.debug(`Failed to remove observer "${observerName}"`); + } + }; + + for (const observerName of [ + "common-dialog-loaded", + "domwindowopened", + "geckoview-prompt-show", + ]) { + removeObserver(observerName); + } + + // Unregister event listener for all open windows + for (const win of Services.wm.getEnumerator(null)) { + win.removeEventListener("DOMModalDialogClosed", this); + } + } +} diff --git a/remote/shared/listeners/test/browser/browser.toml b/remote/shared/listeners/test/browser/browser.toml new file mode 100644 index 0000000000..d462bf1e82 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser.toml @@ -0,0 +1,21 @@ +[DEFAULT] +tags = "remote" +subsuite = "remote" +support-files = ["head.js"] +prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"] + +["browser_BrowsingContextListener.js"] + +["browser_ConsoleAPIListener.js"] + +["browser_ConsoleAPIListener_cached_messages.js"] + +["browser_ConsoleListener.js"] + +["browser_ConsoleListener_cached_messages.js"] + +["browser_ContextualIdentityListener.js"] + +["browser_NetworkListener.js"] + +["browser_PromptListener.js"] diff --git a/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js b/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js new file mode 100644 index 0000000000..9a08df7857 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js @@ -0,0 +1,117 @@ +/* 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 { BrowsingContextListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs" +); + +add_task(async function test_attachedOnNewTab() { + const listener = new BrowsingContextListener(); + const attached = listener.once("attached"); + + listener.startListening(); + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + const { browsingContext, why } = await attached; + + is( + browsingContext.id, + tab.linkedBrowser.browsingContext.id, + "Received expected browsing context" + ); + is(why, "attach", "Browsing context has been attached"); + + listener.stopListening(); + gBrowser.removeTab(tab); +}); + +add_task(async function test_attachedValidEmbedderElement() { + const listener = new BrowsingContextListener(); + + let hasEmbedderElement = false; + listener.on( + "attached", + (evtName, { browsingContext }) => { + hasEmbedderElement = !!browsingContext.embedderElement; + }, + { once: true } + ); + + listener.startListening(); + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + ok( + hasEmbedderElement, + "Attached browsing context has a valid embedder element" + ); + + listener.stopListening(); + gBrowser.removeTab(tab); +}); + +add_task(async function test_discardedOnCloseTab() { + const listener = new BrowsingContextListener(); + const discarded = listener.once("discarded"); + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + const browsingContext = tab.linkedBrowser.browsingContext; + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + listener.startListening(); + gBrowser.removeTab(tab); + const { browsingContext: discardedBrowsingContext, why } = await discarded; + + is( + discardedBrowsingContext.id, + browsingContext.id, + "Received expected browsing context" + ); + is(why, "discard", "Browsing context has been discarded"); + + listener.stopListening(); +}); + +add_task(async function test_replaceTopLevelOnNavigation() { + const listener = new BrowsingContextListener(); + const attached = listener.once("attached"); + const discarded = listener.once("discarded"); + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + const browsingContext = tab.linkedBrowser.browsingContext; + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + listener.startListening(); + + await loadURL(tab.linkedBrowser, "about:mozilla"); + + const discardEvent = await discarded; + const attachEvent = await attached; + + is( + discardEvent.browsingContext.id, + browsingContext.id, + "Received expected browsing context for discarded" + ); + is(discardEvent.why, "replace", "Browsing context has been replaced"); + + is( + attachEvent.browsingContext.id, + tab.linkedBrowser.browsingContext.id, + "Received expected browsing context for attached" + ); + is(discardEvent.why, "replace", "Browsing context has been replaced"); + + isnot( + discardEvent.browsingContext, + attachEvent.browsingContext, + "Got different browsing contexts" + ); + + listener.stopListening(); + gBrowser.removeTab(gBrowser.selectedTab); +}); diff --git a/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js new file mode 100644 index 0000000000..ccff78c7a0 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js @@ -0,0 +1,162 @@ +/* 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 TESTS = [ + { method: "log", args: ["log1"] }, + { method: "log", args: ["log2", "log3"] }, + { method: "log", args: [[1, 2, 3], { someProperty: "someValue" }] }, + { method: "warn", args: ["warn1"] }, + { method: "error", args: ["error1"] }, + { method: "info", args: ["info1"] }, + { method: "debug", args: ["debug1"] }, + { method: "trace", args: ["trace1"] }, + { method: "assert", args: [false, "assert1"] }, +]; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_method_and_arguments() { + for (const { method, args } of TESTS) { + // Use a dedicated tab for each test to avoid cached messages. + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + info(`Test ConsoleApiListener for ${JSON.stringify({ method, args })}`); + + const listenerId = await listenToConsoleAPIMessage(); + await useConsoleInContent(method, args); + const { + arguments: msgArguments, + level, + timeStamp, + stacktrace, + } = await getConsoleAPIMessage(listenerId); + + if (method == "assert") { + // console.assert() consumes first argument. + args.shift(); + } + + is( + msgArguments.length, + args.length, + "Message event has the expected number of arguments" + ); + for (let i = 0; i < args.length; i++) { + Assert.deepEqual( + msgArguments[i], + args[i], + `Message event has the expected argument at index ${i}` + ); + } + is(level, method, "Message event has the expected level"); + ok(Number.isInteger(timeStamp), "Message event has a valid timestamp"); + + if (["assert", "error", "warn", "trace"].includes(method)) { + // Check stacktrace if method is allowed to contain one. + if (method === "warn") { + todo( + Array.isArray(stacktrace), + "stacktrace is of expected type Array (Bug 1744705)" + ); + } else { + ok(Array.isArray(stacktrace), "stacktrace is of expected type Array"); + Assert.greaterOrEqual( + stacktrace.length, + 1, + "stack trace contains at least one frame" + ); + } + } else { + is(typeof stacktrace, "undefined", "stack trace is is not present"); + } + + gBrowser.removeTab(gBrowser.selectedTab); + } +}); + +add_task(async function test_stacktrace() { + const script = ` + function foo() { console.error("cheese"); } + function bar() { foo(); } + bar(); + `; + + const listenerId = await listenToConsoleAPIMessage(); + await createScriptNode(script); + const { stacktrace } = await getConsoleAPIMessage(listenerId); + + ok(Array.isArray(stacktrace), "stacktrace is of expected type Array"); + + // First 3 frames are from the test script. + Assert.greaterOrEqual( + stacktrace.length, + 3, + "stack trace contains at least 3 frames" + ); + checkStackFrame(stacktrace[0], "about:blank", "foo", 2, 30); + checkStackFrame(stacktrace[1], "about:blank", "bar", 3, 22); + checkStackFrame(stacktrace[2], "about:blank", "", 4, 5); +}); + +function useConsoleInContent(method, args) { + info(`Call console API: console.${method}("${args.join('", "')}");`); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [method, args], + (_method, _args) => { + content.console[_method].apply(content.console, _args); + } + ); +} + +function listenToConsoleAPIMessage() { + info("Listen to a console api message in content"); + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleAPIListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs" + ); + const consoleAPIListener = new ConsoleAPIListener(innerWindowId); + const onMessage = consoleAPIListener.once("message"); + consoleAPIListener.startListening(); + + const listenerId = Math.random(); + content[listenerId] = { consoleAPIListener, onMessage }; + return listenerId; + }); +} + +function getConsoleAPIMessage(listenerId) { + info("Retrieve the message event captured for listener: " + listenerId); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [listenerId], + async _listenerId => { + const { consoleAPIListener, onMessage } = content[_listenerId]; + const message = await onMessage; + + consoleAPIListener.destroy(); + + return message; + } + ); +} + +function checkStackFrame( + frame, + filename, + functionName, + lineNumber, + columnNumber +) { + is(frame.filename, filename, "Received expected filename for frame"); + is( + frame.functionName, + functionName, + "Received expected function name for frame" + ); + is(frame.lineNumber, lineNumber, "Received expected line for frame"); + is(frame.columnNumber, columnNumber, "Received expected column for frame"); +} diff --git a/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js new file mode 100644 index 0000000000..dae35a0b9a --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js @@ -0,0 +1,100 @@ +/* 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 TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_cached_messages() { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleAPIListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs" + ); + + info("Log two messages before starting the ConsoleAPIListener"); + content.console.log("message_1"); + content.console.log("message_2"); + + const listener = new ConsoleAPIListener(innerWindowId); + const messages = []; + + // We will keep the onMessage callback attached to the ConsoleAPIListener + // during the whole test to catch all the emitted events. + const onMessage = (evtName, message) => messages.push(message.arguments[0]); + + listener.on("message", onMessage); + listener.startListening(); + + info("Wait until the 2 cached messages have been emitted"); + await ContentTaskUtils.waitForCondition(() => messages.length == 2); + is(messages[0], "message_1"); + is(messages[1], "message_2"); + + info("Stop listening and log another message"); + listener.stopListening(); + content.backup = { listener, messages, onMessage }; + }); + + // Force a GC to check that old cached messages which have been garbage + // collected are not re-displayed. + await doGC(); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const { listener, messages, onMessage } = content.backup; + content.console.log("message_3"); + + info("Start listening again and check the previous message is emitted"); + listener.startListening(); + await ContentTaskUtils.waitForCondition(() => messages.length == 3); + is(messages[2], "message_3"); + + info("Log another message and wait until it is emitted"); + content.console.log("message_4"); + await ContentTaskUtils.waitForCondition(() => messages.length == 4); + is(messages[3], "message_4"); + + listener.off("message", onMessage); + listener.destroy(); + + is(messages.length, 4, "Received 4 messages in total"); + }); + + info("Reload the current tab and check only new messages are emitted"); + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleAPIListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs" + ); + + info("Log a message before creating the ConsoleAPIListener"); + content.console.log("new_message_1"); + + const listener = new ConsoleAPIListener(innerWindowId); + const newMessages = []; + const onMessage = (evtName, message) => + newMessages.push(message.arguments[0]); + listener.on("message", onMessage); + + info("Start listening and wait for the cached message"); + listener.startListening(); + await ContentTaskUtils.waitForCondition(() => newMessages.length == 1); + is(newMessages[0], "new_message_1"); + + info("Log another message and wait until it is emitted"); + content.console.log("new_message_2"); + await ContentTaskUtils.waitForCondition(() => newMessages.length == 2); + is(newMessages[1], "new_message_2"); + + listener.off("message", onMessage); + listener.destroy(); + + is(newMessages.length, 2, "Received 2 messages in total"); + }); + + gBrowser.removeTab(gBrowser.selectedTab); +}); diff --git a/remote/shared/listeners/test/browser/browser_ConsoleListener.js b/remote/shared/listeners/test/browser/browser_ConsoleListener.js new file mode 100644 index 0000000000..41936a6c0d --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_ConsoleListener.js @@ -0,0 +1,148 @@ +/* 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/. */ + +add_task(async function test_message_properties() { + const listenerId = await listenToConsoleMessage("error"); + await logConsoleMessage({ message: "foo" }); + const { level, message, timeStamp, stack } = await getConsoleMessage( + listenerId + ); + + is(level, "error", "Received expected log level"); + is(message, "foo", "Received expected log message"); + // Services.console.logMessage() doesn't include a stack. + is(stack, undefined, "No stack present"); + is(typeof timeStamp, "number", "timestamp is of expected type number"); + + // Clear the console to avoid side effects with other tests in this file. + await clearConsole(); +}); + +add_task(async function test_level() { + for (const level of ["error", "info", "warn"]) { + const listenerId = await listenToConsoleMessage(level); + await logConsoleMessage({ message: "foo", level }); + const message = await getConsoleMessage(listenerId); + + is(message.level, level, "Received expected log level"); + } + + // Clear the console to avoid side effects with other tests in this file. + await clearConsole(); +}); + +add_task(async function test_stacktrace() { + const script = ` + function foo() { throw new Error("cheese"); } + function bar() { foo(); } + bar(); + `; + + const listenerId = await listenToConsoleMessage("error"); + await createScriptNode(script); + const { message, level, stacktrace } = await getConsoleMessage(listenerId); + is(level, "error", "Received expected log level"); + is(message, "Error: cheese", "Received expected log message"); + ok(Array.isArray(stacktrace), "frames is of expected type Array"); + Assert.greaterOrEqual(stacktrace.length, 4, "Got at least 4 stack frames"); + + // First 3 stack frames are from the injected script and one more frame comes + // from head.js (chrome scope) where we inject the script. + checkStackFrame(stacktrace[0], "about:blank", "foo", 2, 28); + checkStackFrame(stacktrace[1], "about:blank", "bar", 3, 22); + checkStackFrame(stacktrace[2], "about:blank", "", 4, 5); + checkStackFrame( + stacktrace[3], + "chrome://mochitests/content/browser/remote/shared/listeners/test/browser/head.js", + "", + 34, + 29 + ); + + // Clear the console to avoid side effects with other tests in this file. + await clearConsole(); +}); + +function logConsoleMessage(options = {}) { + info(`Log console message ${options.message}`); + return SpecialPowers.spawn(gBrowser.selectedBrowser, [options], _options => { + const { level = "error" } = _options; + + const levelToFlags = { + error: Ci.nsIScriptError.errorFlag, + info: Ci.nsIScriptError.infoFlag, + warn: Ci.nsIScriptError.warningFlag, + }; + + const scriptError = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + scriptError.initWithWindowID( + _options.message, + _options.sourceName || "sourceName", + null, + _options.lineNumber || 0, + _options.columnNumber || 0, + levelToFlags[level], + _options.category || "javascript", + content.windowGlobalChild.innerWindowId + ); + + Services.console.logMessage(scriptError); + }); +} + +function listenToConsoleMessage(level) { + info("Listen to a console message in content"); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [level], + async _level => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs" + ); + const consoleListener = new ConsoleListener(innerWindowId); + const onMessage = consoleListener.once(_level); + consoleListener.startListening(); + + const listenerId = Math.random(); + content[listenerId] = { consoleListener, onMessage }; + return listenerId; + } + ); +} + +function getConsoleMessage(listenerId) { + info("Retrieve the message event captured for listener: " + listenerId); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [listenerId], + async _listenerId => { + const { consoleListener, onMessage } = content[_listenerId]; + const message = await onMessage; + + consoleListener.destroy(); + + return message; + } + ); +} + +function checkStackFrame( + frame, + filename, + functionName, + lineNumber, + columnNumber +) { + is(frame.filename, filename, "Received expected filename for frame"); + is( + frame.functionName, + functionName, + "Received expected function name for frame" + ); + is(frame.lineNumber, lineNumber, "Received expected line for frame"); + is(frame.columnNumber, columnNumber, "Received expected column for frame"); +} diff --git a/remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js b/remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js new file mode 100644 index 0000000000..1020aee661 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js @@ -0,0 +1,82 @@ +/* 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 TEST_PAGE = + "https://example.com/document-builder.sjs?html=<meta charset=utf8></meta>"; + +add_task(async function test_cached_javascript_errors() { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + await createScriptNode(`(() => {throw "error1"})()`); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs" + ); + + const listener = new ConsoleListener(innerWindowId); + + const errors = []; + // Do not push the whole error object in the array. It would create a strong + // reference preventing from reproducing GC-related bugs. + const onError = (evtName, error) => errors.push(error.message); + listener.on("error", onError); + + const waitForMessage = listener.once("error"); + listener.startListening(); + const error = await waitForMessage; + is(error.message, "uncaught exception: error1"); + is(errors.length, 1); + + listener.stopListening(); + content.backup = { listener, errors, onError }; + }); + + // Force a GC to check that old cached messages which have been garbage + // collected are not re-displayed. + await doGC(); + await createScriptNode(`(() => {throw "error2"})()`); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const { listener, errors, onError } = content.backup; + + const waitForMessage = listener.once("error"); + listener.startListening(); + const { message } = await waitForMessage; + is(message, "uncaught exception: error2"); + is(errors.length, 2); + + listener.off("error", onError); + listener.destroy(); + }); + + info("Reload the current tab and check only new messages are emitted"); + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + + await createScriptNode(`(() => {throw "error3"})()`); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs" + ); + + const listener = new ConsoleListener(innerWindowId); + + const errors = []; + const onError = (evtName, error) => errors.push(error.message); + listener.on("error", onError); + + const waitForMessage = listener.once("error"); + listener.startListening(); + const error = await waitForMessage; + is(error.message, "uncaught exception: error3"); + is(errors.length, 1); + + listener.off("error", onError); + listener.destroy(); + }); + + gBrowser.removeTab(gBrowser.selectedTab); +}); diff --git a/remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js b/remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js new file mode 100644 index 0000000000..df783a5688 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js @@ -0,0 +1,38 @@ +/* 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 { ContextualIdentityListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ContextualIdentityListener.sys.mjs" +); + +add_task(async function test_createdOnNewContextualIdentity() { + const listener = new ContextualIdentityListener(); + const created = listener.once("created"); + + listener.startListening(); + + ContextualIdentityService.create("test_name"); + + const { identity } = await created; + is(identity.name, "test_name", "Received expected identity"); + + listener.stopListening(); + + ContextualIdentityService.remove(identity.userContextId); +}); + +add_task(async function test_deletedOnRemovedContextualIdentity() { + const listener = new ContextualIdentityListener(); + const deleted = listener.once("deleted"); + + listener.startListening(); + + const testIdentity = ContextualIdentityService.create("test_name"); + ContextualIdentityService.remove(testIdentity.userContextId); + + const { identity } = await deleted; + is(identity.name, "test_name", "Received expected identity"); + + listener.stopListening(); +}); diff --git a/remote/shared/listeners/test/browser/browser_NetworkListener.js b/remote/shared/listeners/test/browser/browser_NetworkListener.js new file mode 100644 index 0000000000..78865f6b80 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_NetworkListener.js @@ -0,0 +1,100 @@ +/* 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 { NetworkListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/NetworkListener.sys.mjs" +); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +add_task(async function test_beforeRequestSent() { + const listener = new NetworkListener(); + const events = []; + const onEvent = (name, data) => events.push(data); + listener.on("before-request-sent", onEvent); + + const tab1 = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab1.linkedBrowser); + const contextId1 = TabManager.getIdForBrowser(tab1.linkedBrowser); + + const tab2 = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab2" + ); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser); + const contextId2 = TabManager.getIdForBrowser(tab2.linkedBrowser); + + listener.startListening(); + + await fetch(tab1.linkedBrowser, "https://example.com/?1"); + ok(events.length == 1, "One event was received"); + assertNetworkEvent(events[0], contextId1, "https://example.com/?1"); + + info("Check that events are no longer emitted after calling stopListening"); + listener.stopListening(); + await fetch(tab1.linkedBrowser, "https://example.com/?2"); + ok(events.length == 1, "No new event was received"); + + listener.startListening(); + await fetch(tab1.linkedBrowser, "https://example.com/?3"); + ok(events.length == 2, "A new event was received"); + assertNetworkEvent(events[1], contextId1, "https://example.com/?3"); + + info("Check network event from the new tab"); + await fetch(tab2.linkedBrowser, "https://example.com/?4"); + ok(events.length == 3, "A new event was received"); + assertNetworkEvent(events[2], contextId2, "https://example.com/?4"); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + listener.off("before-request-sent", onEvent); + listener.destroy(); +}); + +add_task(async function test_beforeRequestSent_newTab() { + const listener = new NetworkListener(); + const onBeforeRequestSent = listener.once("before-request-sent"); + listener.startListening(); + + info("Check network event related to loading a new tab"); + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const contextId = TabManager.getIdForBrowser(tab.linkedBrowser); + const event = await onBeforeRequestSent; + + assertNetworkEvent( + event, + contextId, + "https://example.com/document-builder.sjs?html=tab" + ); + gBrowser.removeTab(tab); +}); + +add_task(async function test_fetchError() { + const listener = new NetworkListener(); + const onFetchError = listener.once("fetch-error"); + listener.startListening(); + + info("Check fetchError event when loading a new tab"); + const tab = BrowserTestUtils.addTab(gBrowser, "https://not_a_valid_url/"); + BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const contextId = TabManager.getIdForBrowser(tab.linkedBrowser); + const event = await onFetchError; + + assertNetworkEvent(event, contextId, "https://not_a_valid_url/"); + is(event.errorText, "NS_ERROR_UNKNOWN_HOST"); + gBrowser.removeTab(tab); +}); + +function assertNetworkEvent(event, expectedContextId, expectedUrl) { + is(event.contextId, expectedContextId, "Event has the expected context id"); + is(event.requestData.url, expectedUrl, "Event has the expected url"); +} diff --git a/remote/shared/listeners/test/browser/browser_PromptListener.js b/remote/shared/listeners/test/browser/browser_PromptListener.js new file mode 100644 index 0000000000..0d3f23db3f --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_PromptListener.js @@ -0,0 +1,173 @@ +/* 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 { PromptListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/PromptListener.sys.mjs" +); + +add_task(async function test_without_curBrowser() { + const listener = new PromptListener(); + const opened = listener.once("opened"); + const closed = listener.once("closed"); + + listener.startListening(); + + const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.confirm('test'))`); + const dialogWin = await dialogPromise; + + const openedEvent = await opened; + + is(openedEvent.prompt.window, dialogWin, "Received expected prompt"); + + dialogWin.document.querySelector("dialog").acceptDialog(); + + const closedEvent = await closed; + + is(closedEvent.detail.accepted, true, "Received correct event details"); + + listener.destroy(); +}); + +add_task(async function test_with_curBrowser() { + const listener = new PromptListener(() => ({ + contentBrowser: gBrowser.selectedBrowser, + window, + })); + const opened = listener.once("opened"); + const closed = listener.once("closed"); + + listener.startListening(); + + const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.confirm('test'))`); + const dialogWin = await dialogPromise; + + const openedEvent = await opened; + + is(openedEvent.prompt.window, dialogWin, "Received expected prompt"); + + dialogWin.document.querySelector("dialog").acceptDialog(); + + const closedEvent = await closed; + + is(closedEvent.detail.accepted, true, "Received correct event details"); + + listener.destroy(); +}); + +add_task(async function test_close_event_details() { + const listener = new PromptListener(); + let closed = listener.once("closed"); + + listener.startListening(); + + let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.prompt('Enter your name:'))`); + let dialogWin = await dialogPromise; + + dialogWin.document.getElementById("loginTextbox").value = "Test"; + dialogWin.document.querySelector("dialog").acceptDialog(); + + let closedEvent = await closed; + + is( + closedEvent.detail.accepted, + true, + "Received correct `accepted` value in event details" + ); + is( + closedEvent.detail.userText, + "Test", + "Received correct `userText` value in event details" + ); + + closed = listener.once("closed"); + + dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.prompt('Enter your name:'))`); + dialogWin = await dialogPromise; + + dialogWin.document.getElementById("loginTextbox").value = "Test"; + dialogWin.document.querySelector("dialog").cancelDialog(); + + closedEvent = await closed; + + is( + closedEvent.detail.accepted, + false, + "Received correct `accepted` value in event details" + ); + is( + closedEvent.detail.userText, + undefined, + "Received correct `userText` value in event details" + ); + + listener.destroy(); +}); + +add_task(async function test_dialogClosed() { + const listener = new PromptListener(); + + listener.startListening(); + + let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.alert('test'))`); + let dialogWin = await dialogPromise; + let closed = listener.dialogClosed(); + + dialogWin.document.querySelector("dialog").acceptDialog(); + + await closed; + + is(true, true, "Close promise got resolved"); + + dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.alert('test'))`); + dialogWin = await dialogPromise; + closed = listener.dialogClosed(); + + dialogWin.document.querySelector("dialog").cancelDialog(); + + await closed; + + is(true, true, "Close promise got resolved"); + + listener.destroy(); +}); + +add_task(async function test_events_in_another_browser() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const selectedBrowser = win.gBrowser.selectedBrowser; + const listener = new PromptListener(() => ({ + contentBrowser: selectedBrowser, + window: selectedBrowser.ownerGlobal, + })); + const events = []; + const onEvent = (name, data) => events.push(data); + listener.on("opened", onEvent); + listener.on("closed", onEvent); + + listener.startListening(); + + const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(); + await createScriptNode(`setTimeout(() => window.confirm('test'))`); + const dialogWin = await dialogPromise; + + ok(events.length === 0, "No event was received"); + + dialogWin.document.querySelector("dialog").acceptDialog(); + + // Wait a bit to make sure that the event didn't come. + await new Promise(resolve => { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, 500); + }); + + ok(events.length === 0, "No event was received"); + + listener.destroy(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/remote/shared/listeners/test/browser/head.js b/remote/shared/listeners/test/browser/head.js new file mode 100644 index 0000000000..1691a6f59b --- /dev/null +++ b/remote/shared/listeners/test/browser/head.js @@ -0,0 +1,89 @@ +/* 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"; + +async function clearConsole() { + for (const tab of gBrowser.tabs) { + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + Services.console.reset(); + }); + } + Services.console.reset(); +} + +/** + * Execute the provided script content by generating a dynamic script tag and + * inserting it in the page for the current selected browser. + * + * @param {string} script + * The script to execute. + * @returns {Promise} + * A promise that resolves when the script node was added and removed from + * the content page. + */ +function createScriptNode(script) { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [script], + function (_script) { + var script = content.document.createElement("script"); + script.append(content.document.createTextNode(_script)); + content.document.body.append(script); + } + ); +} + +registerCleanupFunction(async () => { + await clearConsole(); +}); + +async function doGC() { + // Run GC and CC a few times to make sure that as much as possible is freed. + const numCycles = 3; + for (let i = 0; i < numCycles; i++) { + Cu.forceGC(); + Cu.forceCC(); + await new Promise(resolve => Cu.schedulePreciseShrinkingGC(resolve)); + } + + const MemoryReporter = Cc[ + "@mozilla.org/memory-reporter-manager;1" + ].getService(Ci.nsIMemoryReporterManager); + await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve)); +} + +/** + * Load the provided url in an existing browser. + * Returns a promise which will resolve when the page is loaded. + * + * @param {Browser} browser + * The browser element where the URL should be loaded. + * @param {string} url + * The URL to load. + */ +async function loadURL(browser, url) { + const loaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, url); + return loaded; +} + +/** + * Create a fetch request to `url` from the content page loaded in the provided + * `browser`. + * + * + * @param {Browser} browser + * The browser element where the fetch should be performed. + * @param {string} url + * The URL to fetch. + */ +function fetch(browser, url) { + return SpecialPowers.spawn(browser, [url], async _url => { + const response = await content.fetch(_url); + // Wait for response.text() to resolve as well to make sure the response + // has completed before returning. + await response.text(); + }); +} diff --git a/remote/shared/messagehandler/Errors.sys.mjs b/remote/shared/messagehandler/Errors.sys.mjs new file mode 100644 index 0000000000..69c65acd09 --- /dev/null +++ b/remote/shared/messagehandler/Errors.sys.mjs @@ -0,0 +1,90 @@ +/* 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 { RemoteError } from "chrome://remote/content/shared/RemoteError.sys.mjs"; + +class MessageHandlerError extends RemoteError { + /** + * @param {(string|Error)=} x + * Optional string describing error situation or Error instance + * to propagate. + */ + constructor(x) { + super(x); + this.name = this.constructor.name; + this.status = "message handler error"; + + // Error's ctor does not preserve x' stack + if (typeof x?.stack !== "undefined") { + this.stack = x.stack; + } + } + + get isMessageHandlerError() { + return true; + } + + /** + * @returns {Object<string, string>} + * JSON serialisation of error prototype. + */ + toJSON() { + return { + error: this.status, + message: this.message || "", + stacktrace: this.stack || "", + }; + } + + /** + * Unmarshals a JSON error representation to the appropriate MessageHandler + * error type. + * + * @param {Object<string, string>} json + * Error object. + * + * @returns {Error} + * Error prototype. + */ + static fromJSON(json) { + if (typeof json.error == "undefined") { + let s = JSON.stringify(json); + throw new TypeError("Undeserialisable error type: " + s); + } + if (!STATUSES.has(json.error)) { + throw new TypeError("Not of MessageHandlerError descent: " + json.error); + } + + let cls = STATUSES.get(json.error); + let err = new cls(); + if ("message" in json) { + err.message = json.message; + } + if ("stacktrace" in json) { + err.stack = json.stacktrace; + } + return err; + } +} + +/** + * A command could not be handled by the message handler network. + */ +class UnsupportedCommandError extends MessageHandlerError { + constructor(message) { + super(message); + this.status = "unsupported message handler command"; + } +} + +const STATUSES = new Map([ + ["message handler error", MessageHandlerError], + ["unsupported message handler command", UnsupportedCommandError], +]); + +/** @namespace */ +export const error = { + MessageHandlerError, + UnsupportedCommandError, +}; diff --git a/remote/shared/messagehandler/EventsDispatcher.sys.mjs b/remote/shared/messagehandler/EventsDispatcher.sys.mjs new file mode 100644 index 0000000000..9620febcc1 --- /dev/null +++ b/remote/shared/messagehandler/EventsDispatcher.sys.mjs @@ -0,0 +1,260 @@ +/* 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, { + ContextDescriptorType: + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + SessionDataCategory: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs", + SessionDataMethod: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * Helper to listen to events which rely on SessionData. + * In order to support the EventsDispatcher, a module emitting events should + * subscribe and unsubscribe to those events based on SessionData updates + * and should use the "event" SessionData category. + */ +export class EventsDispatcher { + // The MessageHandler owning this EventsDispatcher. + #messageHandler; + + /** + * @typedef {object} EventListenerInfo + * @property {ContextDescriptor} contextDescriptor + * The ContextDescriptor to which those callbacks are associated + * @property {Set<Function>} callbacks + * The callbacks to trigger when an event matching the ContextDescriptor + * is received. + */ + + // Map from event name to map of strings (context keys) to EventListenerInfo. + #listenersByEventName; + + /** + * Create a new EventsDispatcher instance. + * + * @param {MessageHandler} messageHandler + * The MessageHandler owning this EventsDispatcher. + */ + constructor(messageHandler) { + this.#messageHandler = messageHandler; + + this.#listenersByEventName = new Map(); + } + + destroy() { + for (const event of this.#listenersByEventName.keys()) { + this.#messageHandler.off(event, this.#onMessageHandlerEvent); + } + + this.#listenersByEventName = null; + } + + /** + * Check for existing listeners for a given event name and a given context. + * + * @param {string} name + * Name of the event to check. + * @param {ContextInfo} contextInfo + * ContextInfo identifying the context to check. + * + * @returns {boolean} + * True if there is a registered listener matching the provided arguments. + */ + hasListener(name, contextInfo) { + if (!this.#listenersByEventName.has(name)) { + return false; + } + + const listeners = this.#listenersByEventName.get(name); + for (const { contextDescriptor } of listeners.values()) { + if (this.#matchesContext(contextInfo, contextDescriptor)) { + return true; + } + } + return false; + } + + /** + * Stop listening for an event relying on SessionData and relayed by the + * message handler. + * + * @param {string} event + * Name of the event to unsubscribe from. + * @param {ContextDescriptor} contextDescriptor + * Context descriptor for this event. + * @param {Function} callback + * Event listener callback. + * @returns {Promise} + * Promise which resolves when the event fully unsubscribed, including + * propagating the necessary session data. + */ + async off(event, contextDescriptor, callback) { + return this.update([{ event, contextDescriptor, callback, enable: false }]); + } + + /** + * Listen for an event relying on SessionData and relayed by the message + * handler. + * + * @param {string} event + * Name of the event to subscribe to. + * @param {ContextDescriptor} contextDescriptor + * Context descriptor for this event. + * @param {Function} callback + * Event listener callback. + * @returns {Promise} + * Promise which resolves when the event fully subscribed to, including + * propagating the necessary session data. + */ + async on(event, contextDescriptor, callback) { + return this.update([{ event, contextDescriptor, callback, enable: true }]); + } + + /** + * An object that holds information about subscription/unsubscription + * of an event. + * + * @typedef Subscription + * + * @param {string} event + * Name of the event to subscribe/unsubscribe to. + * @param {ContextDescriptor} contextDescriptor + * Context descriptor for this event. + * @param {Function} callback + * Event listener callback. + * @param {boolean} enable + * True, if we need to subscribe to an event. + * Otherwise false. + */ + + /** + * Start or stop listening to a list of events relying on SessionData + * and relayed by the message handler. + * + * @param {Array<Subscription>} subscriptions + * The list of information to subscribe/unsubscribe to. + * + * @returns {Promise} + * Promise which resolves when the events fully subscribed/unsubscribed to, + * including propagating the necessary session data. + */ + async update(subscriptions) { + const sessionDataItemUpdates = []; + subscriptions.forEach(({ event, contextDescriptor, callback, enable }) => { + if (enable) { + // Setup listeners. + if (!this.#listenersByEventName.has(event)) { + this.#listenersByEventName.set(event, new Map()); + this.#messageHandler.on(event, this.#onMessageHandlerEvent); + } + + const key = this.#getContextKey(contextDescriptor); + const listeners = this.#listenersByEventName.get(event); + if (listeners.has(key)) { + const { callbacks } = listeners.get(key); + callbacks.add(callback); + } else { + const callbacks = new Set([callback]); + listeners.set(key, { callbacks, contextDescriptor }); + + sessionDataItemUpdates.push({ + ...this.#getSessionDataItem(event, contextDescriptor), + method: lazy.SessionDataMethod.Add, + }); + } + } else { + // Remove listeners. + const listeners = this.#listenersByEventName.get(event); + if (!listeners) { + return; + } + + const key = this.#getContextKey(contextDescriptor); + if (!listeners.has(key)) { + return; + } + + const { callbacks } = listeners.get(key); + if (callbacks.has(callback)) { + callbacks.delete(callback); + if (callbacks.size === 0) { + listeners.delete(key); + if (listeners.size === 0) { + this.#messageHandler.off(event, this.#onMessageHandlerEvent); + this.#listenersByEventName.delete(event); + } + + sessionDataItemUpdates.push({ + ...this.#getSessionDataItem(event, contextDescriptor), + method: lazy.SessionDataMethod.Remove, + }); + } + } + } + }); + + // Update all sessionData at once. + await this.#messageHandler.updateSessionData(sessionDataItemUpdates); + } + + #getContextKey(contextDescriptor) { + const { id, type } = contextDescriptor; + return `${type}-${id}`; + } + + #getSessionDataItem(event, contextDescriptor) { + const [moduleName] = event.split("."); + return { + moduleName, + category: lazy.SessionDataCategory.Event, + contextDescriptor, + values: [event], + }; + } + + #matchesContext(contextInfo, contextDescriptor) { + if (contextDescriptor.type === lazy.ContextDescriptorType.All) { + return true; + } + + if ( + contextDescriptor.type === lazy.ContextDescriptorType.TopBrowsingContext + ) { + const eventBrowsingContext = lazy.TabManager.getBrowsingContextById( + contextInfo.contextId + ); + return eventBrowsingContext?.browserId === contextDescriptor.id; + } + + return false; + } + + #onMessageHandlerEvent = (name, event, contextInfo) => { + const listeners = this.#listenersByEventName.get(name); + for (const { callbacks, contextDescriptor } of listeners.values()) { + if (!this.#matchesContext(contextInfo, contextDescriptor)) { + continue; + } + + for (const callback of callbacks) { + try { + callback(name, event); + } catch (e) { + lazy.logger.debug( + `Error while executing callback for ${name}: ${e.message}` + ); + } + } + } + }; +} diff --git a/remote/shared/messagehandler/MessageHandler.sys.mjs b/remote/shared/messagehandler/MessageHandler.sys.mjs new file mode 100644 index 0000000000..18ec6b820c --- /dev/null +++ b/remote/shared/messagehandler/MessageHandler.sys.mjs @@ -0,0 +1,355 @@ +/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/messagehandler/Errors.sys.mjs", + EventsDispatcher: + "chrome://remote/content/shared/messagehandler/EventsDispatcher.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + ModuleCache: + "chrome://remote/content/shared/messagehandler/ModuleCache.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * A ContextDescriptor object provides information to decide if a broadcast or + * a session data item should be applied to a specific MessageHandler context. + * + * @typedef {object} ContextDescriptor + * @property {ContextDescriptorType} type + * The type of context + * @property {string=} id + * Unique id of a given context for the provided type. + * For ContextDescriptorType.All, id can be ommitted. + * For ContextDescriptorType.TopBrowsingContext, the id should be the + * browserId corresponding to a top-level browsing context. + */ + +/** + * Enum of ContextDescriptor types. + * + * @enum {string} + */ +export const ContextDescriptorType = { + All: "All", + TopBrowsingContext: "TopBrowsingContext", +}; + +/** + * A ContextInfo identifies a given context that can be linked to a MessageHandler + * instance. It should be used to identify events coming from this context. + * + * It can either be provided by the MessageHandler itself, when the event is + * emitted from the context it relates to. + * + * Or it can be assembled manually, for instance when emitting an event which + * relates to a window global from the root layer (eg browsingContext.contextCreated). + * + * @typedef {object} ContextInfo + * @property {string} contextId + * Unique id of the MessageHandler corresponding to this context. + * @property {string} type + * One of MessageHandler.type. + */ + +/** + * MessageHandler instances are dedicated to handle both Commands and Events + * to enable automation and introspection for remote control protocols. + * + * MessageHandler instances are designed to form a network, where each instance + * should allow to inspect a specific context (eg. a BrowsingContext, a Worker, + * etc). Those instances might live in different processes and threads but + * should be linked together by the usage of a single sessionId, shared by all + * the instances of a single MessageHandler network. + * + * MessageHandler instances will be dynamically spawned depending on which + * Command or which Event needs to be processed and should therefore not be + * explicitly created by consumers, nor used directly. + * + * The only exception is the ROOT MessageHandler. This MessageHandler will be + * the entry point to send commands to the rest of the network. It will also + * emit all the relevant events captured by the network. + * + * However, even to create this ROOT MessageHandler, consumers should use the + * RootMessageHandlerRegistry. This singleton will ensure that MessageHandler + * instances are properly registered and can be retrieved based on a given + * session id as well as some other context information. + */ +export class MessageHandler extends EventEmitter { + #context; + #contextId; + #eventsDispatcher; + #moduleCache; + #registry; + #sessionId; + + /** + * Create a new MessageHandler instance. + * + * @param {string} sessionId + * ID of the session the handler is used for. + * @param {object} context + * The context linked to this MessageHandler instance. + * @param {MessageHandlerRegistry} registry + * The MessageHandlerRegistry which owns this MessageHandler instance. + */ + constructor(sessionId, context, registry) { + super(); + + this.#moduleCache = new lazy.ModuleCache(this); + + this.#sessionId = sessionId; + this.#context = context; + this.#contextId = this.constructor.getIdFromContext(context); + this.#eventsDispatcher = new lazy.EventsDispatcher(this); + this.#registry = registry; + } + + get context() { + return this.#context; + } + + get contextId() { + return this.#contextId; + } + + get eventsDispatcher() { + return this.#eventsDispatcher; + } + + get moduleCache() { + return this.#moduleCache; + } + + get name() { + return [this.sessionId, this.constructor.type, this.contextId].join("-"); + } + + get registry() { + return this.#registry; + } + + get sessionId() { + return this.#sessionId; + } + + destroy() { + lazy.logger.trace( + `MessageHandler ${this.constructor.type} for session ${this.sessionId} is being destroyed` + ); + this.#eventsDispatcher.destroy(); + this.#moduleCache.destroy(); + + // At least the MessageHandlerRegistry will be expecting this event in order + // to remove the instance from the registry when destroyed. + this.emit("message-handler-destroyed", this); + } + + /** + * Emit a message handler event. + * + * Such events should bubble up to the root of a MessageHandler network. + * + * @param {string} name + * Name of the event. Protocol level events should be of the + * form [module name].[event name]. + * @param {object} data + * The event's data. + * @param {ContextInfo=} contextInfo + * The event's context info, used to identify the origin of the event. + * If not provided, the context info of the current MessageHandler will be + * used. + */ + emitEvent(name, data, contextInfo) { + // If no contextInfo field is provided on the event, extract it from the + // MessageHandler instance. + contextInfo = contextInfo || this.#getContextInfo(); + + // Events are emitted both under their own name for consumers listening to + // a specific and as `message-handler-event` for consumers which need to + // catch all events. + this.emit(name, data, contextInfo); + this.emit("message-handler-event", { + name, + contextInfo, + data, + sessionId: this.sessionId, + }); + } + + /** + * @typedef {object} CommandDestination + * @property {string} type + * One of MessageHandler.type. + * @property {string=} id + * Unique context identifier. The format depends on the type. + * For WINDOW_GLOBAL destinations, this is a browsing context id. + * Optional, should only be provided if `contextDescriptor` is missing. + * @property {ContextDescriptor=} contextDescriptor + * Descriptor used to match several contexts, which will all receive the + * command. + * Optional, should only be provided if `id` is missing. + */ + + /** + * @typedef {object} Command + * @property {string} commandName + * The name of the command to execute. + * @property {string} moduleName + * The name of the module. + * @property {object} params + * Optional command parameters. + * @property {CommandDestination} destination + * The destination describing a debuggable context. + * @property {boolean=} retryOnAbort + * Optional. When true, commands will be retried upon AbortError, which + * can occur when the underlying JSWindowActor pair is destroyed. + * Defaults to `false`. + */ + + /** + * Retrieve all module classes matching the moduleName and destination. + * See `getAllModuleClasses` (ModuleCache.jsm) for more details. + * + * @param {string} moduleName + * The name of the module. + * @param {Destination} destination + * The destination. + * @returns {Array.<class<Module>|null>} + * An array of Module classes. + */ + getAllModuleClasses(moduleName, destination) { + return this.#moduleCache.getAllModuleClasses(moduleName, destination); + } + + /** + * Handle a command, either in one of the modules owned by this MessageHandler + * or in a another MessageHandler after forwarding the command. + * + * @param {Command} command + * The command that should be either handled in this layer or forwarded to + * the next layer leading to the destination. + * @returns {Promise} A Promise that will resolve with the return value of the + * command once it has been executed. + */ + handleCommand(command) { + const { moduleName, commandName, params, destination } = command; + lazy.logger.trace( + `Received command ${moduleName}.${commandName} for destination ${destination.type}` + ); + + if (!this.supportsCommand(moduleName, commandName, destination)) { + throw new lazy.error.UnsupportedCommandError( + `${moduleName}.${commandName} not supported for destination ${destination?.type}` + ); + } + + const module = this.#moduleCache.getModuleInstance(moduleName, destination); + if (module && module.supportsMethod(commandName)) { + return module[commandName](params, destination); + } + + return this.forwardCommand(command); + } + + toString() { + return `[object ${this.constructor.name} ${this.name}]`; + } + + /** + * Execute the required initialization steps, inlcluding apply the initial session data items + * provided to this MessageHandler on startup. Implementation is specific to each MessageHandler class. + * + * By default the implementation is a no-op. + * + * @param {Array<SessionDataItem>} sessionDataItems + * Initial session data items for this MessageHandler. + */ + async initialize(sessionDataItems) {} + + /** + * Returns the module path corresponding to this MessageHandler class. + * + * Needs to be implemented in the sub class. + */ + static get modulePath() { + throw new Error("Not implemented"); + } + + /** + * Returns the type corresponding to this MessageHandler class. + * + * Needs to be implemented in the sub class. + */ + static get type() { + throw new Error("Not implemented"); + } + + /** + * Returns the id corresponding to a context compatible with this + * MessageHandler class. + * + * Needs to be implemented in the sub class. + */ + static getIdFromContext(context) { + throw new Error("Not implemented"); + } + + /** + * Forward a command to other MessageHandlers. + * + * Needs to be implemented in the sub class. + */ + forwardCommand(command) { + throw new Error("Not implemented"); + } + + /** + * Check if contextDescriptor matches the context linked + * to this MessageHandler instance. + * + * Needs to be implemented in the sub class. + */ + matchesContext(contextDescriptor) { + throw new Error("Not implemented"); + } + + /** + * Check if the given command is supported in the module + * for the destination + * + * @param {string} moduleName + * The name of the module. + * @param {string} commandName + * The name of the command. + * @param {Destination} destination + * The destination. + * @returns {boolean} + * True if the command is supported. + */ + supportsCommand(moduleName, commandName, destination) { + return this.getAllModuleClasses(moduleName, destination).some(cls => + cls.supportsMethod(commandName) + ); + } + + /** + * Return the context information for this MessageHandler instance, which + * can be used to identify the origin of an event. + * + * @returns {ContextInfo} + * The context information for this MessageHandler. + */ + #getContextInfo() { + return { + contextId: this.contextId, + type: this.constructor.type, + }; + } +} diff --git a/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs b/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs new file mode 100644 index 0000000000..6a09173f50 --- /dev/null +++ b/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs @@ -0,0 +1,236 @@ +/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + readSessionData: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs", + RootMessageHandler: + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * Map of MessageHandler type to MessageHandler subclass. + */ +ChromeUtils.defineLazyGetter( + lazy, + "MessageHandlerClasses", + () => + new Map([ + [lazy.RootMessageHandler.type, lazy.RootMessageHandler], + [lazy.WindowGlobalMessageHandler.type, lazy.WindowGlobalMessageHandler], + ]) +); + +/** + * Get the MessageHandler subclass corresponding to the provided type. + + * @param {string} type + * MessageHandler type, one of MessageHandler.type. + * @returns {Class} + * A MessageHandler subclass + * @throws {Error} + * Throws if no MessageHandler subclass is found for the provided type. + */ +export function getMessageHandlerClass(type) { + if (!lazy.MessageHandlerClasses.has(type)) { + throw new Error(`No MessageHandler class available for type "${type}"`); + } + return lazy.MessageHandlerClasses.get(type); +} + +/** + * The MessageHandlerRegistry allows to create and retrieve MessageHandler + * instances for different session ids. + * + * A MessageHandlerRegistry instance is bound to a specific MessageHandler type + * and context. All MessageHandler instances created by the same registry will + * use the type and context of the registry, but each will be associated to a + * different session id. + * + * The registry is useful to retrieve the appropriate MessageHandler instance + * after crossing a technical boundary (eg process, thread...). + */ +export class MessageHandlerRegistry extends EventEmitter { + /* + * @param {String} type + * MessageHandler type, one of MessageHandler.type. + * @param {Object} context + * The context object, which depends on the type. + */ + constructor(type, context) { + super(); + + this._messageHandlerClass = getMessageHandlerClass(type); + this._context = context; + this._type = type; + + /** + * Map of session id to MessageHandler instance + */ + this._messageHandlersMap = new Map(); + + this._onMessageHandlerDestroyed = + this._onMessageHandlerDestroyed.bind(this); + this._onMessageHandlerEvent = this._onMessageHandlerEvent.bind(this); + } + + /** + * Create all message handlers for the current context, based on the content + * of the session data. + * This should typically be called when the context is ready to be used and + * to receive/send commands. + */ + createAllMessageHandlers() { + const data = lazy.readSessionData(); + for (const [sessionId, sessionDataItems] of data) { + // Create a message handler for this context for each active message + // handler session. + // TODO: In the future, to support debugging use cases we might want to + // only create a message handler if there is relevant data. + // For automation scenarios, this is less critical. + this._createMessageHandler(sessionId, sessionDataItems); + } + } + + destroy() { + this._messageHandlersMap.forEach(messageHandler => { + messageHandler.destroy(); + }); + } + + /** + * Retrieve all MessageHandler instances held in this registry, for all + * session IDs. + * + * @returns {Iterable.<MessageHandler>} + * Iterator of MessageHandler instances + */ + getAllMessageHandlers() { + return this._messageHandlersMap.values(); + } + + /** + * Retrieve an existing MessageHandler instance matching the provided session + * id. Returns null if no MessageHandler was found. + * + * @param {string} sessionId + * ID of the session the handler is used for. + * @returns {MessageHandler=} + * A MessageHandler instance, null if not found. + */ + getExistingMessageHandler(sessionId) { + return this._messageHandlersMap.get(sessionId); + } + + /** + * Retrieve the MessageHandler instance registered for the provided session + * id. Will create and register a MessageHander if no instance was found. + * + * @param {string} sessionId + * ID of the session the handler is used for. + * @returns {MessageHandler} + * A MessageHandler instance. + */ + getOrCreateMessageHandler(sessionId) { + let messageHandler = this.getExistingMessageHandler(sessionId); + if (!messageHandler) { + messageHandler = this._createMessageHandler(sessionId); + } + + return messageHandler; + } + + /** + * Retrieve an already registered RootMessageHandler instance matching the + * provided sessionId. + * + * @param {string} sessionId + * ID of the session the handler is used for. + * @returns {RootMessageHandler} + * A RootMessageHandler instance. + * @throws {Error} + * If no root MessageHandler can be found for the provided session id. + */ + getRootMessageHandler(sessionId) { + const rootMessageHandler = this.getExistingMessageHandler( + sessionId, + lazy.RootMessageHandler.type + ); + if (!rootMessageHandler) { + throw new Error( + `Unable to find a root MessageHandler for session id ${sessionId}` + ); + } + return rootMessageHandler; + } + + toString() { + return `[object ${this.constructor.name}]`; + } + + /** + * Create a new MessageHandler instance. + * + * @param {string} sessionId + * ID of the session the handler will be used for. + * @param {Array<SessionDataItem>=} sessionDataItems + * Optional array of session data items to be applied automatically to the + * MessageHandler. + * @returns {MessageHandler} + * A new MessageHandler instance. + */ + _createMessageHandler(sessionId, sessionDataItems) { + const messageHandler = new this._messageHandlerClass( + sessionId, + this._context, + this + ); + + messageHandler.on( + "message-handler-destroyed", + this._onMessageHandlerDestroyed + ); + messageHandler.on("message-handler-event", this._onMessageHandlerEvent); + + messageHandler.initialize(sessionDataItems); + + this._messageHandlersMap.set(sessionId, messageHandler); + + lazy.logger.trace( + `Created MessageHandler ${this._type} for session ${sessionId}` + ); + + return messageHandler; + } + + // Event handlers + + _onMessageHandlerDestroyed(eventName, messageHandler) { + messageHandler.off( + "message-handler-destroyed", + this._onMessageHandlerDestroyed + ); + messageHandler.off("message-handler-event", this._onMessageHandlerEvent); + this._messageHandlersMap.delete(messageHandler.sessionId); + + lazy.logger.trace( + `Unregistered MessageHandler ${messageHandler.constructor.type} for session ${messageHandler.sessionId}` + ); + } + + _onMessageHandlerEvent(eventName, messageHandlerEvent) { + // The registry simply re-emits MessageHandler events so that consumers + // don't have to attach listeners to individual MessageHandler instances. + this.emit("message-handler-registry-event", messageHandlerEvent); + } +} diff --git a/remote/shared/messagehandler/Module.sys.mjs b/remote/shared/messagehandler/Module.sys.mjs new file mode 100644 index 0000000000..30b26938e2 --- /dev/null +++ b/remote/shared/messagehandler/Module.sys.mjs @@ -0,0 +1,135 @@ +/* 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", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "disabledExperimentalAPI", () => { + return !Services.prefs.getBoolPref("remote.experimental.enabled"); +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +export class Module { + #messageHandler; + + /** + * Create a new module instance. + * + * @param {MessageHandler} messageHandler + * The MessageHandler instance which owns this Module instance. + */ + constructor(messageHandler) { + this.#messageHandler = messageHandler; + } + + /** + * Clean-up the module instance. + */ + destroy() { + lazy.logger.warn( + `Module ${this.constructor.name} is missing a destroy method` + ); + } + + /** + * Emit a message handler event. + * + * Such events should bubble up to the root of a MessageHandler network. + * + * @param {string} name + * Name of the event. Protocol level events should be of the + * form [module name].[event name]. + * @param {object} data + * The event's data. + * @param {ContextInfo=} contextInfo + * The event's context info, see MessageHandler:emitEvent. Optional. + */ + emitEvent(name, data, contextInfo) { + this.messageHandler.emitEvent(name, data, contextInfo); + } + + /** + * Intercept an event and modify the payload. + * + * It's required to be implemented in windowglobal-in-root modules. + * + * @param {string} name + * Name of the event. + * @param {object} payload + * The event's payload. + * @returns {object} + * The modified event payload. + */ + interceptEvent(name, payload) { + throw new Error( + `Could not intercept event ${name}, interceptEvent is not implemented in windowglobal-in-root module` + ); + } + + /** + * Assert if experimental commands are enabled. + * + * @param {string} methodName + * Name of the command. + * + * @throws {UnknownCommandError} + * If experimental commands are disabled. + */ + assertExperimentalCommandsEnabled(methodName) { + // TODO: 1778987. Move it to a BiDi specific place. + if (lazy.disabledExperimentalAPI) { + throw new lazy.error.UnknownCommandError(methodName); + } + } + + /** + * Assert if experimental events are enabled. + * + * @param {string} moduleName + * Name of the module. + * + * @param {string} event + * Name of the event. + * + * @throws {InvalidArgumentError} + * If experimental events are disabled. + */ + assertExperimentalEventsEnabled(moduleName, event) { + // TODO: 1778987. Move it to a BiDi specific place. + if (lazy.disabledExperimentalAPI) { + throw new lazy.error.InvalidArgumentError( + `Module ${moduleName} does not support event ${event}` + ); + } + } + + /** + * Instance shortcut for supportsMethod to avoid reaching the constructor for + * consumers which directly deal with an instance. + */ + supportsMethod(methodName) { + return this.constructor.supportsMethod(methodName); + } + + get messageHandler() { + return this.#messageHandler; + } + + static get supportedEvents() { + return []; + } + + static supportsEvent(event) { + return this.supportedEvents.includes(event); + } + + static supportsMethod(methodName) { + return typeof this.prototype[methodName] === "function"; + } +} diff --git a/remote/shared/messagehandler/ModuleCache.sys.mjs b/remote/shared/messagehandler/ModuleCache.sys.mjs new file mode 100644 index 0000000000..6cff8dff60 --- /dev/null +++ b/remote/shared/messagehandler/ModuleCache.sys.mjs @@ -0,0 +1,263 @@ +/* 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, { + getMessageHandlerClass: + "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + RootMessageHandler: + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", +}); + +const protocols = { + bidi: {}, + test: {}, +}; +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(protocols.bidi, { + // Additional protocols might use a different registry for their modules, + // in which case this will no longer be a constant but will instead depend on + // the protocol owning the MessageHandler. See Bug 1722464. + modules: + "chrome://remote/content/webdriver-bidi/modules/ModuleRegistry.sys.mjs", +}); +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(protocols.test, { + modules: + "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * ModuleCache instances are dedicated to lazily create and cache the instances + * of all the modules related to a specific MessageHandler instance. + * + * ModuleCache also implements the logic to resolve the path to the file for a + * given module, which depends both on the current MessageHandler context and on + * the expected destination. + * + * In order to implement module logic in any context, separate module files + * should be created for each situation. For instance, for a given module, + * - ${MODULES_FOLDER}/root/{ModuleName}.sys.mjs contains the implementation for + * commands intended for the destination ROOT, and will be created for a ROOT + * MessageHandler only. Typically, they will run in the parent process. + * - ${MODULES_FOLDER}/windowglobal/{ModuleName}.sys.mjs contains the implementation + * for commands intended for a WINDOW_GLOBAL destination, and will be created + * for a WINDOW_GLOBAL MessageHandler only. Those will usually run in a + * content process. + * - ${MODULES_FOLDER}/windowglobal-in-root/{ModuleName}.sys.mjs also handles + * commands intended for a WINDOW_GLOBAL destination, but they will be created + * for the ROOT MessageHandler and will run in the parent process. This can be + * useful if some code has to be executed in the parent process, even though + * the final destination is a WINDOW_GLOBAL. + * - And so on, as more MessageHandler types get added, more combinations will + * follow based on the same pattern: + * - {contextName}/{ModuleName}.sys.mjs + * - or {destinationType}-in-{currentType}/{ModuleName}.sys.mjs + * + * All those implementations are optional. If a module cannot be found, based on + * the logic detailed above, the MessageHandler will assume that the command + * should simply be forwarded to the next layer of the network. + */ +export class ModuleCache { + #messageHandler; + #messageHandlerType; + #modules; + #protocol; + + /* + * @param {MessageHandler} messageHandler + * The MessageHandler instance which owns this ModuleCache instance. + */ + constructor(messageHandler) { + this.#messageHandler = messageHandler; + this.#messageHandlerType = messageHandler.constructor.type; + + // Map of absolute module paths to module instances. + this.#modules = new Map(); + + // Use the module class from the WebDriverBiDi ModuleRegistry if we + // are not using test modules. + this.#protocol = Services.prefs.getBoolPref( + "remote.messagehandler.modulecache.useBrowserTestRoot", + false + ) + ? protocols.test + : protocols.bidi; + } + + /** + * Destroy all instantiated modules. + */ + destroy() { + this.#modules.forEach(module => module?.destroy()); + } + + /** + * Retrieve all module classes matching the provided module name to reach the + * provided destination, from the current context. + * + * This corresponds to the path a command can take to reach its destination. + * A command's method must be implemented in one of the classes returned by + * getAllModuleClasses in order to be successfully handled. + * + * @param {string} moduleName + * The name of the module. + * @param {Destination} destination + * The destination. + * @returns {Array<class<Module>|null>} + * An array of Module classes. + */ + getAllModuleClasses(moduleName, destination) { + const destinationType = destination.type; + const classes = [ + this.#getModuleClass( + moduleName, + this.#messageHandlerType, + destinationType + ), + ]; + + // Bug 1733242: Extend the implementation of this method to handle workers. + // It assumes layers have at most one level of nesting, for instance + // "root -> windowglobal", but it wouldn't work for something such as + // "root -> windowglobal -> worker". + if (destinationType !== this.#messageHandlerType) { + classes.push( + this.#getModuleClass(moduleName, destinationType, destinationType) + ); + } + + return classes.filter(cls => !!cls); + } + + /** + * Get a module instance corresponding to the provided moduleName and + * destination. If no existing module can be found in the cache, ModuleCache + * will attempt to import the module file and create a new instance, which + * will then be cached and returned for subsequent calls. + * + * @param {string} moduleName + * The name of the module which should implement the command. + * @param {CommandDestination} destination + * The destination of the command for which we need to instantiate a + * module. See MessageHandler.sys.mjs for the CommandDestination typedef. + * @returns {object=} + * A module instance corresponding to the provided moduleName and + * destination, or null if it could not be instantiated. + */ + getModuleInstance(moduleName, destination) { + const key = `${moduleName}-${destination.type}`; + + if (this.#modules.has(key)) { + // If there is already a cached instance (potentially null) for the + // module name + destination type pair, return it. + return this.#modules.get(key); + } + + const ModuleClass = this.#getModuleClass( + moduleName, + this.#messageHandlerType, + destination.type + ); + + let module = null; + if (ModuleClass) { + module = new ModuleClass(this.#messageHandler); + } + + this.#modules.set(key, module); + return module; + } + + /** + * Check if the given module exists for the destination. + * + * @param {string} moduleName + * The name of the module. + * @param {Destination} destination + * The destination. + * @returns {boolean} + * True if the module exists. + */ + hasModuleClass(moduleName, destination) { + const classes = this.getAllModuleClasses(moduleName, destination); + return !!classes.length; + } + + toString() { + return `[object ${this.constructor.name} ${this.#messageHandler.name}]`; + } + + /** + * Retrieve the module class matching the provided module name and folder. + * + * @param {string} moduleName + * The name of the module to get the class for. + * @param {string} originType + * The MessageHandler type from where the command comes. + * @param {string} destinationType + * The MessageHandler type where the command should go to. + * @returns {Class=} + * The class corresponding to the module name and folder, null if no match + * was found. + * @throws {Error} + * If the provided module folder is unexpected. + */ + #getModuleClass = function (moduleName, originType, destinationType) { + if ( + destinationType === lazy.RootMessageHandler.type && + originType !== destinationType + ) { + // If we are trying to reach the root layer from a lower layer, no module + // class should attempt to handle the command in the current layer and + // the command should be forwarded unconditionally. + return null; + } + + const moduleFolder = this.#getModuleFolder(originType, destinationType); + if (!this.#protocol.modules[moduleFolder]) { + throw new Error( + `Invalid module folder "${moduleFolder}", expected one of "${Object.keys( + this.#protocol.modules + )}"` + ); + } + + let moduleClass = null; + if (this.#protocol.modules[moduleFolder][moduleName]) { + moduleClass = this.#protocol.modules[moduleFolder][moduleName]; + } + + if (moduleClass) { + lazy.logger.trace( + `Module ${moduleFolder}/${moduleName}.sys.mjs found for ${destinationType}` + ); + } else { + lazy.logger.trace( + `Module ${moduleFolder}/${moduleName}.sys.mjs not found for ${destinationType}` + ); + } + + return moduleClass; + }; + + #getModuleFolder(originType, destinationType) { + const originPath = lazy.getMessageHandlerClass(originType).modulePath; + if (originType === destinationType) { + // If the command is targeting the current type, the module is expected to + // be in eg "windowglobal/${moduleName}.sys.mjs". + return originPath; + } + + // If the command is targeting another type, the module is expected to + // be in a composed folder eg "windowglobal-in-root/${moduleName}.sys.mjs". + const destinationPath = + lazy.getMessageHandlerClass(destinationType).modulePath; + return `${destinationPath}-in-${originPath}`; + } +} diff --git a/remote/shared/messagehandler/RootMessageHandler.sys.mjs b/remote/shared/messagehandler/RootMessageHandler.sys.mjs new file mode 100644 index 0000000000..06a8cd6f18 --- /dev/null +++ b/remote/shared/messagehandler/RootMessageHandler.sys.mjs @@ -0,0 +1,237 @@ +/* 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 { MessageHandler } from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NavigationManager: "chrome://remote/content/shared/NavigationManager.sys.mjs", + RootTransport: + "chrome://remote/content/shared/messagehandler/transports/RootTransport.sys.mjs", + SessionData: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs", + SessionDataMethod: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +/** + * A RootMessageHandler is the root node of a MessageHandler network. It lives + * in the parent process. It can forward commands to MessageHandlers in other + * layers (at the moment WindowGlobalMessageHandlers in content processes). + */ +export class RootMessageHandler extends MessageHandler { + #navigationManager; + #realms; + #rootTransport; + #sessionData; + + /** + * Returns the RootMessageHandler module path. + * + * @returns {string} + */ + static get modulePath() { + return "root"; + } + + /** + * Returns the RootMessageHandler type. + * + * @returns {string} + */ + static get type() { + return "ROOT"; + } + + /** + * The ROOT MessageHandler is unique for a given MessageHandler network + * (ie for a given sessionId). Reuse the type as context id here. + */ + static getIdFromContext(context) { + return RootMessageHandler.type; + } + + /** + * Create a new RootMessageHandler instance. + * + * @param {string} sessionId + * ID of the session the handler is used for. + */ + constructor(sessionId) { + super(sessionId, null); + + this.#rootTransport = new lazy.RootTransport(this); + this.#sessionData = new lazy.SessionData(this); + this.#navigationManager = new lazy.NavigationManager(); + this.#navigationManager.startMonitoring(); + + // Map with inner window ids as keys, and sets of realm ids, assosiated with + // this window as values. + this.#realms = new Map(); + // In the general case, we don't get notified that realms got destroyed, + // because there is no communication between content and parent process at this moment, + // so we have to listen to the this notification to clean up the internal + // map and trigger the events. + Services.obs.addObserver(this, "window-global-destroyed"); + } + + get navigationManager() { + return this.#navigationManager; + } + + get realms() { + return this.#realms; + } + + get sessionData() { + return this.#sessionData; + } + + destroy() { + this.#sessionData.destroy(); + this.#navigationManager.destroy(); + + Services.obs.removeObserver(this, "window-global-destroyed"); + this.#realms = null; + + super.destroy(); + } + + /** + * Add new session data items of a given module, category and + * contextDescriptor. + * + * Forwards the call to the SessionData instance owned by this + * RootMessageHandler and propagates the information via a command to existing + * MessageHandlers. + */ + addSessionDataItem(sessionData = {}) { + sessionData.method = lazy.SessionDataMethod.Add; + return this.updateSessionData([sessionData]); + } + + emitEvent(name, eventPayload, contextInfo) { + // Intercept realm created and destroyed events to update internal map. + if (name === "realm-created") { + this.#onRealmCreated(eventPayload); + } + // We receive this events in the case of moving the page to BFCache. + if (name === "windowglobal-pagehide") { + this.#cleanUpRealmsForWindow( + eventPayload.innerWindowId, + eventPayload.context + ); + } + + super.emitEvent(name, eventPayload, contextInfo); + } + + /** + * Emit a public protocol event. This event will be sent over to the client. + * + * @param {string} name + * Name of the event. Protocol level events should be of the + * form [module name].[event name]. + * @param {object} data + * The event's data. + */ + emitProtocolEvent(name, data) { + this.emit("message-handler-protocol-event", { + name, + data, + sessionId: this.sessionId, + }); + } + + /** + * Forward the provided command to WINDOW_GLOBAL MessageHandlers via the + * RootTransport. + * + * @param {Command} command + * The command to forward. See type definition in MessageHandler.js + * @returns {Promise} + * Returns a promise that resolves with the result of the command. + */ + forwardCommand(command) { + switch (command.destination.type) { + case lazy.WindowGlobalMessageHandler.type: + return this.#rootTransport.forwardCommand(command); + default: + throw new Error( + `Cannot forward command to "${command.destination.type}" from "${this.constructor.type}".` + ); + } + } + + matchesContext() { + return true; + } + + observe(subject, topic) { + if (topic !== "window-global-destroyed") { + return; + } + + this.#cleanUpRealmsForWindow( + subject.innerWindowId, + subject.browsingContext + ); + } + + /** + * Remove session data items of a given module, category and + * contextDescriptor. + * + * Forwards the call to the SessionData instance owned by this + * RootMessageHandler and propagates the information via a command to existing + * MessageHandlers. + */ + removeSessionDataItem(sessionData = {}) { + sessionData.method = lazy.SessionDataMethod.Remove; + return this.updateSessionData([sessionData]); + } + + /** + * Update session data items of a given module, category and + * contextDescriptor. + * + * Forwards the call to the SessionData instance owned by this + * RootMessageHandler. + */ + async updateSessionData(sessionData = []) { + await this.#sessionData.updateSessionData(sessionData); + } + + #cleanUpRealmsForWindow(innerWindowId, context) { + const realms = this.#realms.get(innerWindowId); + + if (!realms) { + return; + } + + realms.forEach(realm => { + this.#realms.get(innerWindowId).delete(realm); + + this.emitEvent("realm-destroyed", { + context, + realm, + }); + }); + + this.#realms.delete(innerWindowId); + } + + #onRealmCreated = data => { + const { innerWindowId, realmInfo } = data; + + if (!this.#realms.has(innerWindowId)) { + this.#realms.set(innerWindowId, new Set()); + } + + this.#realms.get(innerWindowId).add(realmInfo.realm); + }; +} diff --git a/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs b/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs new file mode 100644 index 0000000000..09ac489182 --- /dev/null +++ b/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs @@ -0,0 +1,17 @@ +/* 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 { MessageHandlerRegistry } from "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs"; + +import { RootMessageHandler } from "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"; + +/** + * In the parent process, only one Root MessageHandlerRegistry should ever be + * created. All consumers can safely use this singleton to retrieve the Root + * registry and from there either create or retrieve Root MessageHandler + * instances for a specific session. + */ +export var RootMessageHandlerRegistry = new MessageHandlerRegistry( + RootMessageHandler.type +); diff --git a/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs b/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs new file mode 100644 index 0000000000..584c73d72f --- /dev/null +++ b/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs @@ -0,0 +1,264 @@ +/* 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 { + ContextDescriptorType, + MessageHandler, +} from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + getMessageHandlerFrameChildActor: + "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs", + RootMessageHandler: + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", + WindowRealm: "chrome://remote/content/shared/Realm.sys.mjs", +}); + +/** + * A WindowGlobalMessageHandler is dedicated to debugging a single window + * global. It follows the lifecycle of the corresponding window global and will + * therefore not survive any navigation. This MessageHandler cannot forward + * commands further to other MessageHandlers and represents a leaf node in a + * MessageHandler network. + */ +export class WindowGlobalMessageHandler extends MessageHandler { + #innerWindowId; + #realms; + + constructor() { + super(...arguments); + + this.#innerWindowId = this.context.window.windowGlobalChild.innerWindowId; + + // Maps sandbox names to instances of window realms. + this.#realms = new Map(); + } + + initialize(sessionDataItems) { + // Create the default realm, it is mapped to an empty string sandbox name. + this.#realms.set("", this.#createRealm()); + + // This method, even though being async, is not awaited on purpose, + // since for now the sessionDataItems are passed in response to an event in a for loop. + this.#applyInitialSessionDataItems(sessionDataItems); + + // With the session data applied the handler is now ready to be used. + this.emitEvent("window-global-handler-created", { + contextId: this.contextId, + innerWindowId: this.#innerWindowId, + }); + } + + destroy() { + for (const realm of this.#realms.values()) { + realm.destroy(); + } + this.emitEvent("windowglobal-pagehide", { + context: this.context, + innerWindowId: this.innerWindowId, + }); + this.#realms = null; + + super.destroy(); + } + + /** + * Returns the WindowGlobalMessageHandler module path. + * + * @returns {string} + */ + static get modulePath() { + return "windowglobal"; + } + + /** + * Returns the WindowGlobalMessageHandler type. + * + * @returns {string} + */ + static get type() { + return "WINDOW_GLOBAL"; + } + + /** + * For WINDOW_GLOBAL MessageHandlers, `context` is a BrowsingContext, + * and BrowsingContext.id can be used as the context id. + * + * @param {BrowsingContext} context + * WindowGlobalMessageHandler contexts are expected to be + * BrowsingContexts. + * @returns {string} + * The browsing context id. + */ + static getIdFromContext(context) { + return context.id; + } + + get innerWindowId() { + return this.#innerWindowId; + } + + get realms() { + return this.#realms; + } + + get window() { + return this.context.window; + } + + #createRealm(sandboxName = null) { + const realm = new lazy.WindowRealm(this.context.window, { + sandboxName, + }); + + this.emitEvent("realm-created", { + realmInfo: realm.getInfo(), + innerWindowId: this.innerWindowId, + }); + + return realm; + } + + #getRealmFromSandboxName(sandboxName = null) { + if (sandboxName === null || sandboxName === "") { + return this.#realms.get(""); + } + + if (this.#realms.has(sandboxName)) { + return this.#realms.get(sandboxName); + } + + const realm = this.#createRealm(sandboxName); + + this.#realms.set(sandboxName, realm); + + return realm; + } + + async #applyInitialSessionDataItems(sessionDataItems) { + if (!Array.isArray(sessionDataItems)) { + return; + } + + const destination = { + type: WindowGlobalMessageHandler.type, + }; + + // Create a Map with the structure moduleName -> category -> relevant session data items. + const structuredUpdates = new Map(); + for (const sessionDataItem of sessionDataItems) { + const { category, contextDescriptor, moduleName } = sessionDataItem; + + if (!this.matchesContext(contextDescriptor)) { + continue; + } + if (!structuredUpdates.has(moduleName)) { + // Skip session data item if the module is not present + // for the destination. + if (!this.moduleCache.hasModuleClass(moduleName, destination)) { + continue; + } + structuredUpdates.set(moduleName, new Map()); + } + + if (!structuredUpdates.get(moduleName).has(category)) { + structuredUpdates.get(moduleName).set(category, new Set()); + } + + structuredUpdates.get(moduleName).get(category).add(sessionDataItem); + } + + const sessionDataPromises = []; + + for (const [moduleName, categories] of structuredUpdates.entries()) { + for (const [category, relevantSessionData] of categories.entries()) { + sessionDataPromises.push( + this.handleCommand({ + moduleName, + commandName: "_applySessionData", + params: { + category, + sessionData: Array.from(relevantSessionData), + }, + destination, + }) + ); + } + } + + await Promise.all(sessionDataPromises); + } + + forwardCommand(command) { + switch (command.destination.type) { + case lazy.RootMessageHandler.type: + return lazy + .getMessageHandlerFrameChildActor(this) + .sendCommand(command, this.sessionId); + default: + throw new Error( + `Cannot forward command to "${command.destination.type}" from "${this.constructor.type}".` + ); + } + } + + /** + * If <var>realmId</var> is null or not provided get the realm for + * a given <var>sandboxName</var>, otherwise find the realm + * in the cache with the realm id equal given <var>realmId</var>. + * + * @param {object} options + * @param {string|null=} options.realmId + * The realm id. + * @param {string=} options.sandboxName + * The name of sandbox + * + * @returns {Realm} + * The realm object. + */ + getRealm(options = {}) { + const { realmId = null, sandboxName } = options; + if (realmId === null) { + return this.#getRealmFromSandboxName(sandboxName); + } + + const realm = Array.from(this.#realms.values()).find( + realm => realm.id === realmId + ); + + if (realm) { + return realm; + } + + throw new lazy.error.NoSuchFrameError(`Realm with id ${realmId} not found`); + } + + matchesContext(contextDescriptor) { + return ( + contextDescriptor.type === ContextDescriptorType.All || + (contextDescriptor.type === ContextDescriptorType.TopBrowsingContext && + contextDescriptor.id === this.context.browserId) + ); + } + + /** + * Send a command to the root MessageHandler. + * + * @param {Command} command + * The command to send to the root MessageHandler. + * @returns {Promise} + * A promise which resolves with the return value of the command. + */ + sendRootCommand(command) { + return this.handleCommand({ + ...command, + destination: { + type: lazy.RootMessageHandler.type, + }, + }); + } +} diff --git a/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs b/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs new file mode 100644 index 0000000000..10da617f77 --- /dev/null +++ b/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs @@ -0,0 +1,392 @@ +/* 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, { + ContextDescriptorType: + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + RootMessageHandler: + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * @typedef {string} SessionDataCategory + */ + +/** + * Enum of session data categories. + * + * @readonly + * @enum {SessionDataCategory} + */ +export const SessionDataCategory = { + Event: "event", + PreloadScript: "preload-script", +}; + +/** + * @typedef {string} SessionDataMethod + */ + +/** + * Enum of session data methods. + * + * @readonly + * @enum {SessionDataMethod} + */ +export const SessionDataMethod = { + Add: "add", + Remove: "remove", +}; + +export const SESSION_DATA_SHARED_DATA_KEY = "MessageHandlerSessionData"; + +// This is a map from session id to session data, which will be persisted and +// propagated to all processes using Services' sharedData. +// We have to store this as a unique object under a unique shared data key +// because new MessageHandlers in other processes will need to access this data +// without any notion of a specific session. +// This is a singleton. +const sessionDataMap = new Map(); + +/** + * @typedef {object} SessionDataItem + * @property {string} moduleName + * The name of the module responsible for this data item. + * @property {SessionDataCategory} category + * The category of data. The supported categories depend on the module. + * @property {(string|number|boolean)} value + * Value of the session data item. + * @property {ContextDescriptor} contextDescriptor + * ContextDescriptor to which this session data applies. + */ + +/** + * @typedef SessionDataItemUpdate + * @property {SessionDataMethod} method + * The way sessionData is updated. + * @property {string} moduleName + * The name of the module responsible for this data item. + * @property {SessionDataCategory} category + * The category of data. The supported categories depend on the module. + * @property {Array<(string|number|boolean)>} values + * Values of the session data item update. + * @property {ContextDescriptor} contextDescriptor + * ContextDescriptor to which this session data applies. + */ + +/** + * SessionData provides APIs to read and write the session data for a specific + * ROOT message handler. It holds the session data as a property and acts as the + * source of truth for this session data. + * + * The session data of a given message handler network should contain all the + * information that might be needed to setup new contexts, for instance a list + * of subscribed events, a list of breakpoints etc. + * + * The actual session data is an array of SessionDataItems. Example below: + * ``` + * data: [ + * { + * moduleName: "log", + * category: "event", + * value: "log.entryAdded", + * contextDescriptor: { type: "all" } + * }, + * { + * moduleName: "browsingContext", + * category: "event", + * value: "browsingContext.contextCreated", + * contextDescriptor: { type: "browser-element", id: "7"} + * }, + * { + * moduleName: "browsingContext", + * category: "event", + * value: "browsingContext.contextCreated", + * contextDescriptor: { type: "browser-element", id: "12"} + * }, + * ] + * ``` + * + * The session data will be persisted using Services.ppmm.sharedData, so that + * new contexts living in different processes can also access the information + * during their startup. + * + * This class should only be used from a ROOT MessageHandler, or from modules + * owned by a ROOT MessageHandler. Other MessageHandlers should rely on + * SessionDataReader's readSessionData to get read-only access to session data. + * + */ +export class SessionData { + constructor(messageHandler) { + if (messageHandler.constructor.type != lazy.RootMessageHandler.type) { + throw new Error( + "SessionData should only be used from a ROOT MessageHandler" + ); + } + + this._messageHandler = messageHandler; + + /* + * The actual data for this session. This is an array of SessionDataItems. + */ + this._data = []; + } + + destroy() { + // Update the sessionDataMap singleton. + sessionDataMap.delete(this._messageHandler.sessionId); + + // Update sharedData and flush to force consistency. + Services.ppmm.sharedData.set(SESSION_DATA_SHARED_DATA_KEY, sessionDataMap); + Services.ppmm.sharedData.flush(); + } + + /** + * Update session data items of a given module, category and + * contextDescriptor. + * + * A SessionDataItem will be added or removed for each value of each update + * in the provided array. + * + * Attempting to add a duplicate SessionDataItem or to remove an unknown + * SessionDataItem will be silently skipped (no-op). + * + * The data will be persisted across processes at the end of this method. + * + * @param {Array<SessionDataItemUpdate>} sessionDataItemUpdates + * Array of session data item updates. + * + * @returns {Array<SessionDataItemUpdate>} + * The subset of session data item updates which want to be applied. + */ + applySessionData(sessionDataItemUpdates = []) { + // The subset of session data item updates, which are cleaned up from + // duplicates and unknown items. + let updates = []; + for (const sessionDataItemUpdate of sessionDataItemUpdates) { + const { category, contextDescriptor, method, moduleName, values } = + sessionDataItemUpdate; + const updatedValues = []; + for (const value of values) { + const item = { moduleName, category, contextDescriptor, value }; + + if (method === SessionDataMethod.Add) { + const hasItem = this._findIndex(item) != -1; + + if (!hasItem) { + this._data.push(item); + updatedValues.push(value); + } else { + lazy.logger.warn( + `Duplicated session data item was not added: ${JSON.stringify( + item + )}` + ); + } + } else { + const itemIndex = this._findIndex(item); + + if (itemIndex != -1) { + // The item was found in the session data, remove it. + this._data.splice(itemIndex, 1); + updatedValues.push(value); + } else { + lazy.logger.warn( + `Missing session data item was not removed: ${JSON.stringify( + item + )}` + ); + } + } + } + + if (updatedValues.length) { + updates.push({ + ...sessionDataItemUpdate, + values: updatedValues, + }); + } + } + // Persist the sessionDataMap. + this._persist(); + + return updates; + } + + /** + * Retrieve the SessionDataItems for a given module and type. + * + * @param {string} moduleName + * The name of the module responsible for this data item. + * @param {string} category + * The session data category. + * @param {ContextDescriptor=} contextDescriptor + * Optional context descriptor, to retrieve only session data items added + * for a specific context descriptor. + * @returns {Array<SessionDataItem>} + * Array of SessionDataItems for the provided module and type. + */ + getSessionData(moduleName, category, contextDescriptor) { + return this._data.filter( + item => + item.moduleName === moduleName && + item.category === category && + (!contextDescriptor || + this._isSameContextDescriptor( + item.contextDescriptor, + contextDescriptor + )) + ); + } + + /** + * Update session data items of a given module, category and + * contextDescriptor and propagate the information + * via a command to existing MessageHandlers. + * + * @param {Array<SessionDataItemUpdate>} sessionDataItemUpdates + * Array of session data item updates. + */ + async updateSessionData(sessionDataItemUpdates = []) { + const updates = this.applySessionData(sessionDataItemUpdates); + + if (!updates.length) { + // Avoid unnecessary broadcast if no items were updated. + return; + } + + // Create a Map with the structure moduleName -> category -> list of descriptors. + const structuredUpdates = new Map(); + for (const { moduleName, category, contextDescriptor } of updates) { + if (!structuredUpdates.has(moduleName)) { + structuredUpdates.set(moduleName, new Map()); + } + if (!structuredUpdates.get(moduleName).has(category)) { + structuredUpdates.get(moduleName).set(category, new Set()); + } + const descriptors = structuredUpdates.get(moduleName).get(category); + // If there is at least one update for all contexts, + // keep only this descriptor in the list of descriptors + if (contextDescriptor.type === lazy.ContextDescriptorType.All) { + structuredUpdates + .get(moduleName) + .set(category, new Set([contextDescriptor])); + } + // Add an individual descriptor if there is no descriptor for all contexts. + else if ( + descriptors.size !== 1 || + Array.from(descriptors)[0]?.type !== lazy.ContextDescriptorType.All + ) { + descriptors.add(contextDescriptor); + } + } + + const rootDestination = { + type: lazy.RootMessageHandler.type, + }; + const sessionDataPromises = []; + + for (const [moduleName, categories] of structuredUpdates.entries()) { + for (const [category, contextDescriptors] of categories.entries()) { + // Find sessionData for the category and the moduleName. + const relevantSessionData = this._data.filter( + item => item.category == category && item.moduleName === moduleName + ); + for (const contextDescriptor of contextDescriptors.values()) { + const windowGlobalDestination = { + type: lazy.WindowGlobalMessageHandler.type, + contextDescriptor, + }; + + for (const destination of [ + windowGlobalDestination, + rootDestination, + ]) { + // Only apply session data if the module is present for the destination. + if ( + this._messageHandler.supportsCommand( + moduleName, + "_applySessionData", + destination + ) + ) { + sessionDataPromises.push( + this._messageHandler + .handleCommand({ + moduleName, + commandName: "_applySessionData", + params: { + sessionData: relevantSessionData, + category, + contextDescriptor, + }, + destination, + }) + ?.catch(reason => + lazy.logger.error( + `_applySessionData for module: ${moduleName} failed, reason: ${reason}` + ) + ) + ); + } + } + } + } + } + + await Promise.allSettled(sessionDataPromises); + } + + _isSameItem(item1, item2) { + const descriptor1 = item1.contextDescriptor; + const descriptor2 = item2.contextDescriptor; + + return ( + item1.moduleName === item2.moduleName && + item1.category === item2.category && + this._isSameContextDescriptor(descriptor1, descriptor2) && + this._isSameValue(item1.category, item1.value, item2.value) + ); + } + + _isSameContextDescriptor(contextDescriptor1, contextDescriptor2) { + if (contextDescriptor1.type === lazy.ContextDescriptorType.All) { + // Ignore the id for type "all" since we made the id optional for this type. + return contextDescriptor1.type === contextDescriptor2.type; + } + + return ( + contextDescriptor1.type === contextDescriptor2.type && + contextDescriptor1.id === contextDescriptor2.id + ); + } + + _isSameValue(category, value1, value2) { + if (category === SessionDataCategory.PreloadScript) { + return value1.script === value2.script; + } + + return value1 === value2; + } + + _findIndex(item) { + return this._data.findIndex(_item => this._isSameItem(item, _item)); + } + + _persist() { + // Update the sessionDataMap singleton. + sessionDataMap.set(this._messageHandler.sessionId, this._data); + + // Update sharedData and flush to force consistency. + Services.ppmm.sharedData.set(SESSION_DATA_SHARED_DATA_KEY, sessionDataMap); + Services.ppmm.sharedData.flush(); + } +} diff --git a/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs b/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs new file mode 100644 index 0000000000..6d5ea08e59 --- /dev/null +++ b/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs @@ -0,0 +1,27 @@ +/* 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, { + SESSION_DATA_SHARED_DATA_KEY: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "sharedData", () => { + const isInParent = + Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + + return isInParent ? Services.ppmm.sharedData : Services.cpmm.sharedData; +}); + +/** + * Returns a snapshot of the session data map, which is cloned from the + * sessionDataMap singleton of SessionData.jsm. + * + * @returns {Map.<string, Array<SessionDataItem>>} + * Map of session id to arrays of SessionDataItems. + */ +export const readSessionData = () => + lazy.sharedData.get(lazy.SESSION_DATA_SHARED_DATA_KEY) || new Map(); diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser.toml b/remote/shared/messagehandler/test/browser/broadcast/browser.toml new file mode 100644 index 0000000000..f18bfdaab2 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser.toml @@ -0,0 +1,22 @@ +[DEFAULT] +tags = "remote" +subsuite = "remote" +support-files = [ + "doc_messagehandler_broadcasting_xul.xhtml", + "head.js", + "!/remote/shared/messagehandler/test/browser/head.js", + "!/remote/shared/messagehandler/test/browser/resources/*" +] +prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"] + +["browser_filter_top_browsing_context.js"] + +["browser_only_content_process.js"] + +["browser_two_tabs.js"] + +["browser_two_tabs_with_params.js"] + +["browser_two_windows.js"] + +["browser_with_frames.js"] diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js b/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js new file mode 100644 index 0000000000..c140c26fc6 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const COM_TEST_PAGE = "https://example.com/document-builder.sjs?html=COM"; +const FRAME_TEST_PAGE = createTestMarkupWithFrames(); + +add_task(async function test_broadcasting_filter_top_browsing_context() { + info("Navigate the initial tab to the COM test URL"); + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, COM_TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + info("Open a second tab on the frame test URL"); + const tab2 = await addTab(FRAME_TEST_PAGE); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + const contextsForTab2 = + tab2.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree(); + is( + contextsForTab2.length, + 4, + "Frame test tab has 3 children contexts (4 in total)" + ); + + const rootMessageHandler = createRootMessageHandler( + "session-id-broadcasting_filter_top_browsing_context" + ); + + const broadcastValue1 = await sendBroadcastForTopBrowsingContext( + browsingContext1, + rootMessageHandler + ); + + ok( + Array.isArray(broadcastValue1), + "The broadcast returned an array of values" + ); + + is(broadcastValue1.length, 1, "The broadcast returned one value as expected"); + + ok( + broadcastValue1.includes("broadcast-" + browsingContext1.id), + "The broadcast returned the expected value from tab1" + ); + + const broadcastValue2 = await sendBroadcastForTopBrowsingContext( + browsingContext2, + rootMessageHandler + ); + + ok( + Array.isArray(broadcastValue2), + "The broadcast returned an array of values" + ); + + is(broadcastValue2.length, 4, "The broadcast returned 4 values as expected"); + + for (const context of contextsForTab2) { + ok( + broadcastValue2.includes("broadcast-" + context.id), + "The broadcast contains the value for browsing context " + context.id + ); + } + + rootMessageHandler.destroy(); +}); + +function sendBroadcastForTopBrowsingContext( + topBrowsingContext, + rootMessageHandler +) { + return sendTestBroadcastCommand( + "commandwindowglobalonly", + "testBroadcast", + {}, + { + type: ContextDescriptorType.TopBrowsingContext, + id: topBrowsingContext.browserId, + }, + rootMessageHandler + ); +} diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js b/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js new file mode 100644 index 0000000000..d5090c701e --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_broadcasting_only_content_process() { + info("Navigate the initial tab to the test URL"); + const tab1 = gBrowser.selectedTab; + await loadURL( + tab1.linkedBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + info("Open a new tab on a parent process about: page"); + await addTab("about:robots"); + + info("Open a new tab on a XUL page"); + await addTab( + getRootDirectory(gTestPath) + "doc_messagehandler_broadcasting_xul.xhtml" + ); + + const rootMessageHandler = createRootMessageHandler( + "session-id-broadcasting_only_content_process" + ); + const broadcastValue = await sendTestBroadcastCommand( + "commandwindowglobalonly", + "testBroadcast", + {}, + contextDescriptorAll, + rootMessageHandler + ); + + ok( + Array.isArray(broadcastValue), + "The broadcast returned an array of values" + ); + + is(broadcastValue.length, 1, "The broadcast returned 1 value as expected"); + ok( + broadcastValue.includes("broadcast-" + browsingContext1.id), + "The broadcast returned the expected value from tab1" + ); + + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js new file mode 100644 index 0000000000..16b97e2a0a --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_broadcasting_two_tabs_command() { + info("Navigate the initial tab to the test URL"); + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + info("Open a new tab on the same test URL"); + const tab2 = await addTab(TEST_PAGE); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-broadcasting_two_tabs_command" + ); + + const broadcastValue = await sendTestBroadcastCommand( + "commandwindowglobalonly", + "testBroadcast", + {}, + contextDescriptorAll, + rootMessageHandler + ); + + ok( + Array.isArray(broadcastValue), + "The broadcast returned an array of values" + ); + + is(broadcastValue.length, 2, "The broadcast returned 2 values as expected"); + + ok( + broadcastValue.includes("broadcast-" + browsingContext1.id), + "The broadcast returned the expected value from tab1" + ); + ok( + broadcastValue.includes("broadcast-" + browsingContext2.id), + "The broadcast returned the expected value from tab2" + ); + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js new file mode 100644 index 0000000000..261b8c4cd6 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_broadcasting_two_tabs_with_params_command() { + info("Navigate the initial tab to the test URL"); + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + info("Open a new tab on the same test URL"); + const tab2 = await addTab(TEST_PAGE); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-broadcasting_two_tabs_command" + ); + + const broadcastValue = await sendTestBroadcastCommand( + "commandwindowglobalonly", + "testBroadcastWithParameter", + { + value: "some-value", + }, + contextDescriptorAll, + rootMessageHandler + ); + ok( + Array.isArray(broadcastValue), + "The broadcast returned an array of values" + ); + + is(broadcastValue.length, 2, "The broadcast returned 2 values as expected"); + + ok( + broadcastValue.includes("broadcast-" + browsingContext1.id + "-some-value"), + "The broadcast returned the expected value from tab1" + ); + ok( + broadcastValue.includes("broadcast-" + browsingContext2.id + "-some-value"), + "The broadcast returned the expected value from tab2" + ); + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js new file mode 100644 index 0000000000..f59bebba69 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_broadcasting_two_windows_command() { + const window1Browser = gBrowser.selectedTab.linkedBrowser; + await loadURL(window1Browser, TEST_PAGE); + const browsingContext1 = window1Browser.browsingContext; + + const window2 = await BrowserTestUtils.openNewBrowserWindow(); + registerCleanupFunction(() => BrowserTestUtils.closeWindow(window2)); + + const window2Browser = window2.gBrowser.selectedBrowser; + await loadURL(window2Browser, TEST_PAGE); + const browsingContext2 = window2Browser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-broadcasting_two_windows_command" + ); + const broadcastValue = await sendTestBroadcastCommand( + "commandwindowglobalonly", + "testBroadcast", + {}, + contextDescriptorAll, + rootMessageHandler + ); + + ok( + Array.isArray(broadcastValue), + "The broadcast returned an array of values" + ); + is(broadcastValue.length, 2, "The broadcast returned 2 values as expected"); + + ok( + broadcastValue.includes("broadcast-" + browsingContext1.id), + "The broadcast returned the expected value from tab1" + ); + ok( + broadcastValue.includes("broadcast-" + browsingContext2.id), + "The broadcast returned the expected value from tab2" + ); + + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js b/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js new file mode 100644 index 0000000000..50326d3885 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_broadcasting_with_frames() { + info("Navigate the initial tab to the test URL"); + const tab = gBrowser.selectedTab; + await loadURL(tab.linkedBrowser, createTestMarkupWithFrames()); + + const contexts = + tab.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree(); + is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)"); + + const rootMessageHandler = createRootMessageHandler( + "session-id-broadcasting_with_frames" + ); + const broadcastValue = await sendTestBroadcastCommand( + "commandwindowglobalonly", + "testBroadcast", + {}, + contextDescriptorAll, + rootMessageHandler + ); + + ok( + Array.isArray(broadcastValue), + "The broadcast returned an array of values" + ); + is(broadcastValue.length, 4, "The broadcast returned 4 values as expected"); + + for (const context of contexts) { + ok( + broadcastValue.includes("broadcast-" + context.id), + "The broadcast contains the value for browsing context " + context.id + ); + } + + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml b/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml new file mode 100644 index 0000000000..91f3503ac3 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml @@ -0,0 +1,3 @@ +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <box id="box" style="background-color: red;">Test chrome broadcasting</box> +</window> diff --git a/remote/shared/messagehandler/test/browser/broadcast/head.js b/remote/shared/messagehandler/test/browser/broadcast/head.js new file mode 100644 index 0000000000..eb97549c26 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/head.js @@ -0,0 +1,48 @@ +/* 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"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/head.js", + this +); + +/** + * Broadcast the provided method to WindowGlobal contexts on a MessageHandler + * network. + * Returns a promise which will resolve the result of the command broadcast. + * + * @param {string} module + * The name of the module implementing the command to broadcast. + * @param {string} command + * The name of the command to broadcast. + * @param {object} params + * The parameters for the command. + * @param {ContextDescriptor} contextDescriptor + * The context descriptor to use for this broadcast + * @param {RootMessageHandler} rootMessageHandler + * The root of the MessageHandler network. + * @returns {Promise.<Array>} + * Promise which resolves an array where each item is the result of the + * command handled by an individual context. + */ +function sendTestBroadcastCommand( + module, + command, + params, + contextDescriptor, + rootMessageHandler +) { + info("Send a test broadcast command"); + return rootMessageHandler.handleCommand({ + moduleName: module, + commandName: command, + params, + destination: { + contextDescriptor, + type: WindowGlobalMessageHandler.type, + }, + }); +} diff --git a/remote/shared/messagehandler/test/browser/browser.toml b/remote/shared/messagehandler/test/browser/browser.toml new file mode 100644 index 0000000000..ffbc880a0a --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser.toml @@ -0,0 +1,46 @@ +[DEFAULT] +tags = "remote" +subsuite = "remote" +support-files = [ + "head.js", + "resources/*" +] +prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"] + +["browser_bfcache.js"] + +["browser_events_dispatcher.js"] + +["browser_events_handler.js"] + +["browser_events_interception.js"] + +["browser_events_module.js"] + +["browser_frame_context_utils.js"] + +["browser_handle_command_errors.js"] + +["browser_handle_command_retry.js"] + +["browser_handle_simple_command.js"] + +["browser_navigation_manager.js"] + +["browser_realms.js"] + +["browser_registry.js"] + +["browser_session_data.js"] + +["browser_session_data_browser_element.js"] + +["browser_session_data_constructor_race.js"] + +["browser_session_data_update.js"] + +["browser_session_data_update_categories.js"] + +["browser_session_data_update_contexts.js"] + +["browser_windowglobal_to_root.js"] diff --git a/remote/shared/messagehandler/test/browser/browser_bfcache.js b/remote/shared/messagehandler/test/browser/browser_bfcache.js new file mode 100644 index 0000000000..f829d8b58d --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_bfcache.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +const TEST_PREF = "remote.messagehandler.test.pref"; + +// Check that pages in bfcache no longer have message handlers attached to them, +// and that they will not emit unexpected events. +add_task(async function test_bfcache_broadcast() { + const tab = await addTab("https://example.com/document-builder.sjs?html=tab"); + const rootMessageHandler = createRootMessageHandler("session-id-bfcache"); + + try { + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + + // Whenever a "preference-changed" event from the eventonprefchange module + // will be received on the root MessageHandler, increment a counter. + let preferenceChangeEventCount = 0; + const onEvent = (evtName, wrappedEvt) => { + if (wrappedEvt.name === "preference-changed") { + preferenceChangeEventCount++; + } + }; + rootMessageHandler.on("message-handler-event", onEvent); + + // Initialize the preference, no eventonprefchange module should be created + // yet so preferenceChangeEventCount is not expected to be updated. + Services.prefs.setIntPref(TEST_PREF, 0); + await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 0); + is(preferenceChangeEventCount, 0); + + // Broadcast a "ping" command to force the creation of the eventonprefchange + // module + let values = await sendPingCommand(rootMessageHandler, contextDescriptor); + is(values.length, 1, "Broadcast returned a single value"); + + Services.prefs.setIntPref(TEST_PREF, 1); + await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 1); + is(preferenceChangeEventCount, 1); + + info("Navigate to another page"); + await loadURL( + tab.linkedBrowser, + "https://example.com/document-builder.sjs?html=othertab" + ); + + values = await sendPingCommand(rootMessageHandler, contextDescriptor); + is(values.length, 1, "Broadcast returned a single value after navigation"); + + info("Update the preference and check we only receive 1 event"); + Services.prefs.setIntPref(TEST_PREF, 2); + await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 2); + is(preferenceChangeEventCount, 2); + + info("Navigate to another origin"); + await loadURL( + tab.linkedBrowser, + "https://example.org/document-builder.sjs?html=otherorigin" + ); + + values = await sendPingCommand(rootMessageHandler, contextDescriptor); + is( + values.length, + 1, + "Broadcast returned a single value after cross origin navigation" + ); + + info("Update the preference and check again that we only receive 1 event"); + Services.prefs.setIntPref(TEST_PREF, 3); + await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 3); + is(preferenceChangeEventCount, 3); + } finally { + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); + Services.prefs.clearUserPref(TEST_PREF); + } +}); + +function sendPingCommand(rootMessageHandler, contextDescriptor) { + return rootMessageHandler.handleCommand({ + moduleName: "eventonprefchange", + commandName: "ping", + params: {}, + destination: { + contextDescriptor, + type: WindowGlobalMessageHandler.type, + }, + }); +} diff --git a/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js b/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js new file mode 100644 index 0000000000..98d9fd2890 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js @@ -0,0 +1,532 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check the basic behavior of on/off. + */ +add_task(async function test_add_remove_event_listener() { + const tab = await addTab("https://example.com/document-builder.sjs?html=tab"); + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + + const root = createRootMessageHandler("session-id-event"); + const monitoringEvents = await setupEventMonitoring(root); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(await isSubscribed(root, browsingContext), false); + + info("Add an listener for eventemitter.testEvent"); + const events = []; + const onEvent = (event, data) => events.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 1); + + info( + "Remove a listener for a callback not added before and check that the first one is still registered" + ); + const anotherCallback = () => {}; + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + anotherCallback + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 2); + + info("Remove the listener for eventemitter.testEvent"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), false); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 2); + + info("Add the listener for eventemitter.testEvent again"); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 3); + + info("Remove the listener for eventemitter.testEvent"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), false); + + info("Remove the listener again to check the API will not throw"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + + root.destroy(); + gBrowser.removeTab(tab); +}); + +add_task(async function test_has_listener() { + const tab1 = await addTab("https://example.com/document-builder.sjs?html=1"); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + const tab2 = await addTab("https://example.com/document-builder.sjs?html=2"); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + const contextDescriptor1 = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }; + const contextDescriptor2 = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext2.browserId, + }; + + const root = createRootMessageHandler("session-id-event"); + + // Shortcut for the EventsDispatcher.hasListener API. + function hasListener(contextId) { + return root.eventsDispatcher.hasListener("eventemitter.testEvent", { + contextId, + }); + } + + const onEvent = () => {}; + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor1, + onEvent + ); + ok(hasListener(browsingContext1.id), "Has a listener for browsingContext1"); + ok(!hasListener(browsingContext2.id), "No listener for browsingContext2"); + + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor2, + onEvent + ); + ok(hasListener(browsingContext1.id), "Still a listener for browsingContext1"); + ok(hasListener(browsingContext2.id), "Has a listener for browsingContext2"); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor1, + onEvent + ); + ok(!hasListener(browsingContext1.id), "No listener for browsingContext1"); + ok(hasListener(browsingContext2.id), "Still a listener for browsingContext2"); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor2, + onEvent + ); + ok(!hasListener(browsingContext1.id), "No listener for browsingContext1"); + ok(!hasListener(browsingContext2.id), "No listener for browsingContext2"); + + await root.eventsDispatcher.on( + "eventemitter.testEvent", + { + type: ContextDescriptorType.All, + }, + onEvent + ); + ok(hasListener(browsingContext1.id), "Has a listener for browsingContext1"); + ok(hasListener(browsingContext2.id), "Has a listener for browsingContext2"); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + { + type: ContextDescriptorType.All, + }, + onEvent + ); + ok(!hasListener(browsingContext1.id), "No listener for browsingContext1"); + ok(!hasListener(browsingContext2.id), "No listener for browsingContext2"); + + root.destroy(); + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab1); +}); + +/** + * Check that two callbacks can subscribe to the same event in the same context + * in parallel. + */ +add_task(async function test_two_callbacks() { + const tab = await addTab("https://example.com/document-builder.sjs?html=tab"); + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + + const root = createRootMessageHandler("session-id-event"); + const monitoringEvents = await setupEventMonitoring(root); + + info("Add an listener for eventemitter.testEvent"); + const events = []; + const onEvent = (event, data) => events.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 1); + + info("Add another listener for eventemitter.testEvent"); + const otherevents = []; + const otherCallback = (event, data) => otherevents.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor, + otherCallback + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 2); + is(otherevents.length, 1); + + info("Remove the other listener for eventemitter.testEvent"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + otherCallback + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 3); + is(otherevents.length, 1); + + info("Remove the first listener for eventemitter.testEvent"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), false); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 3); + is(otherevents.length, 1); + + root.destroy(); + gBrowser.removeTab(tab); +}); + +/** + * Check that two callbacks can subscribe to the same event in the two contexts. + */ +add_task(async function test_two_contexts() { + const tab1 = await addTab("https://example.com/document-builder.sjs?html=1"); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + const tab2 = await addTab("https://example.com/document-builder.sjs?html=2"); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + const contextDescriptor1 = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }; + const contextDescriptor2 = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext2.browserId, + }; + + const root = createRootMessageHandler("session-id-event"); + + const monitoringEvents = await setupEventMonitoring(root); + + const events1 = []; + const onEvent1 = (event, data) => events1.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor1, + onEvent1 + ); + is(await isSubscribed(root, browsingContext1), true); + is(await isSubscribed(root, browsingContext2), false); + + const events2 = []; + const onEvent2 = (event, data) => events2.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor2, + onEvent2 + ); + is(await isSubscribed(root, browsingContext1), true); + is(await isSubscribed(root, browsingContext2), true); + + await emitTestEvent(root, browsingContext1, monitoringEvents); + is(events1.length, 1); + is(events2.length, 0); + + await emitTestEvent(root, browsingContext2, monitoringEvents); + is(events1.length, 1); + is(events2.length, 1); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor1, + onEvent1 + ); + is(await isSubscribed(root, browsingContext1), false); + is(await isSubscribed(root, browsingContext2), true); + + // No event expected here since the module for browsingContext1 is no longer + // subscribed + await emitTestEvent(root, browsingContext1, monitoringEvents); + is(events1.length, 1); + is(events2.length, 1); + + // Whereas the module for browsingContext2 is still subscribed + await emitTestEvent(root, browsingContext2, monitoringEvents); + is(events1.length, 1); + is(events2.length, 2); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor2, + onEvent2 + ); + is(await isSubscribed(root, browsingContext1), false); + is(await isSubscribed(root, browsingContext2), false); + + await emitTestEvent(root, browsingContext1, monitoringEvents); + await emitTestEvent(root, browsingContext2, monitoringEvents); + is(events1.length, 1); + is(events2.length, 2); + + root.destroy(); + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab1); +}); + +/** + * Check that adding and removing first listener for the specific context and then + * for the global context works as expected. + */ +add_task( + async function test_remove_context_event_listener_and_then_global_event_listener() { + const tab = await addTab( + "https://example.com/document-builder.sjs?html=tab" + ); + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + const contextDescriptorAll = { + type: ContextDescriptorType.All, + }; + + const root = createRootMessageHandler("session-id-event"); + const monitoringEvents = await setupEventMonitoring(root); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(await isSubscribed(root, browsingContext), false); + + info("Add an listener for eventemitter.testEvent"); + const events = []; + const onEvent = (event, data) => events.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 1); + + info( + "Add another listener for eventemitter.testEvent, using global context" + ); + const eventsAll = []; + const onEventAll = (event, data) => eventsAll.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptorAll, + onEventAll + ); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(eventsAll.length, 1); + is(events.length, 2); + + info("Remove the first listener for eventemitter.testEvent"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + + info("Check that we are still subscribed to eventemitter.testEvent"); + is(await isSubscribed(root, browsingContext), true); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(eventsAll.length, 2); + is(events.length, 2); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptorAll, + onEventAll + ); + is(await isSubscribed(root, browsingContext), false); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(eventsAll.length, 2); + is(events.length, 2); + + root.destroy(); + gBrowser.removeTab(tab); + } +); + +/** + * Check that adding and removing first listener for the global context and then + * for the specific context works as expected. + */ +add_task( + async function test_global_event_listener_and_then_remove_context_event_listener() { + const tab = await addTab( + "https://example.com/document-builder.sjs?html=tab" + ); + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + const contextDescriptorAll = { + type: ContextDescriptorType.All, + }; + + const root = createRootMessageHandler("session-id-event"); + const monitoringEvents = await setupEventMonitoring(root); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(await isSubscribed(root, browsingContext), false); + + info("Add an listener for eventemitter.testEvent"); + const events = []; + const onEvent = (event, data) => events.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + is(await isSubscribed(root, browsingContext), true); + + await emitTestEvent(root, browsingContext, monitoringEvents); + is(events.length, 1); + + info( + "Add another listener for eventemitter.testEvent, using global context" + ); + const eventsAll = []; + const onEventAll = (event, data) => eventsAll.push(data.text); + await root.eventsDispatcher.on( + "eventemitter.testEvent", + contextDescriptorAll, + onEventAll + ); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(eventsAll.length, 1); + is(events.length, 2); + + info("Remove the global listener for eventemitter.testEvent"); + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptorAll, + onEventAll + ); + + info( + "Check that we are still subscribed to eventemitter.testEvent for the specific context" + ); + is(await isSubscribed(root, browsingContext), true); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(eventsAll.length, 1); + is(events.length, 3); + + await root.eventsDispatcher.off( + "eventemitter.testEvent", + contextDescriptor, + onEvent + ); + + is(await isSubscribed(root, browsingContext), false); + await emitTestEvent(root, browsingContext, monitoringEvents); + is(eventsAll.length, 1); + is(events.length, 3); + + root.destroy(); + gBrowser.removeTab(tab); + } +); + +async function setupEventMonitoring(root) { + const monitoringEvents = []; + const onMonitoringEvent = (event, data) => monitoringEvents.push(data.text); + root.on("eventemitter.monitoringEvent", onMonitoringEvent); + + registerCleanupFunction(() => + root.off("eventemitter.monitoringEvent", onMonitoringEvent) + ); + + return monitoringEvents; +} + +async function emitTestEvent(root, browsingContext, monitoringEvents) { + const count = monitoringEvents.length; + info("Call eventemitter.emitTestEvent"); + await root.handleCommand({ + moduleName: "eventemitter", + commandName: "emitTestEvent", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); + + // The monitoring event is always emitted, regardless of the status of the + // module. Wait for catching this event before resuming the assertions. + info("Wait for the monitoring event"); + await BrowserTestUtils.waitForCondition( + () => monitoringEvents.length >= count + 1 + ); + is(monitoringEvents.length, count + 1); +} + +function isSubscribed(root, browsingContext) { + info("Call eventemitter.isSubscribed"); + return root.handleCommand({ + moduleName: "eventemitter", + commandName: "isSubscribed", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); +} diff --git a/remote/shared/messagehandler/test/browser/browser_events_handler.js b/remote/shared/messagehandler/test/browser/browser_events_handler.js new file mode 100644 index 0000000000..705c306de3 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_events_handler.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that the window-global-handler-created event gets emitted for each + * individual frame's browsing context. + */ +add_task(async function test_windowGlobalHandlerCreated() { + const events = []; + + const rootMessageHandler = createRootMessageHandler( + "session-id-event_with_frames" + ); + + info("Add a new session data item to get window global handlers created"); + await rootMessageHandler.addSessionDataItem({ + moduleName: "command", + category: "browser_session_data_browser_element", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + values: [true], + }); + + const onEvent = (evtName, wrappedEvt) => { + if (wrappedEvt.name === "window-global-handler-created") { + console.info(`Received event for context ${wrappedEvt.data.contextId}`); + events.push(wrappedEvt.data); + } + }; + rootMessageHandler.on("message-handler-event", onEvent); + + info("Navigate the initial tab to the test URL"); + const browser = gBrowser.selectedTab.linkedBrowser; + await loadURL(browser, createTestMarkupWithFrames()); + + const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)"); + + // Wait for all the events + await TestUtils.waitForCondition(() => events.length >= 4); + + for (const context of contexts) { + const contextEvents = events.filter(evt => { + return ( + evt.contextId === context.id && + evt.innerWindowId === context.currentWindowGlobal.innerWindowId + ); + }); + is(contextEvents.length, 1, `Found event for context ${context.id}`); + } + + rootMessageHandler.off("message-handler-event", onEvent); + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_events_interception.js b/remote/shared/messagehandler/test/browser/browser_events_interception.js new file mode 100644 index 0000000000..aaf39353a6 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_events_interception.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs" +); +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +/** + * Test that events can be intercepted in the windowglobal-in-root layer. + */ +add_task(async function test_intercepted_event() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-intercepted_event" + ); + + const onInterceptedEvent = rootMessageHandler.once( + "event.testEventWithInterception" + ); + rootMessageHandler.handleCommand({ + moduleName: "event", + commandName: "testEmitEventWithInterception", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); + + const interceptedEvent = await onInterceptedEvent; + is( + interceptedEvent.additionalInformation, + "information added through interception", + "Intercepted event contained additional information" + ); + + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); +}); + +/** + * Test that events can be canceled in the windowglobal-in-root layer. + */ +add_task(async function test_cancelable_event() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-cancelable_event" + ); + + const cancelableEvents = []; + const onCancelableEvent = (name, event) => cancelableEvents.push(event); + rootMessageHandler.on( + "event.testEventCancelableWithInterception", + onCancelableEvent + ); + + // Emit an event that should be canceled in the windowglobal-in-root layer. + // Note that `shouldCancel` is only something supported for this test event, + // and not a general message handler mechanism to cancel events. + await rootMessageHandler.handleCommand({ + moduleName: "event", + commandName: "testEmitEventCancelableWithInterception", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + params: { + shouldCancel: true, + }, + }); + + is(cancelableEvents.length, 0, "No event was received"); + + // Emit another event which should not be canceled (shouldCancel: false). + await rootMessageHandler.handleCommand({ + moduleName: "event", + commandName: "testEmitEventCancelableWithInterception", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + params: { + shouldCancel: false, + }, + }); + + await TestUtils.waitForCondition(() => cancelableEvents.length == 1); + is(cancelableEvents[0].shouldCancel, false, "Expected event was received"); + + rootMessageHandler.off( + "event.testEventCancelableWithInterception", + onCancelableEvent + ); + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_events_module.js b/remote/shared/messagehandler/test/browser/browser_events_module.js new file mode 100644 index 0000000000..32b60d34b1 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_events_module.js @@ -0,0 +1,296 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs" +); +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +/** + * Emit an event from a WindowGlobal module triggered by a specific command. + * Check that the event is emitted on the RootMessageHandler as well as on + * the parent process MessageHandlerRegistry. + */ +add_task(async function test_event() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler("session-id-event"); + let messageHandlerEvent; + let registryEvent; + + // Events are emitted both as generic message-handler-event events as well + // as under their own name. We expect to receive the event for both. + const _onMessageHandlerEvent = (eventName, eventData) => { + if (eventData.name === "event-from-window-global") { + messageHandlerEvent = eventData; + } + }; + rootMessageHandler.on("message-handler-event", _onMessageHandlerEvent); + const onNamedEvent = rootMessageHandler.once("event-from-window-global"); + // MessageHandlerRegistry should forward all the message-handler-events. + const _onMessageHandlerRegistryEvent = (eventName, eventData) => { + if (eventData.name === "event-from-window-global") { + registryEvent = eventData; + } + }; + RootMessageHandlerRegistry.on( + "message-handler-registry-event", + _onMessageHandlerRegistryEvent + ); + + callTestEmitEvent(rootMessageHandler, browsingContext.id); + + const namedEvent = await onNamedEvent; + is( + namedEvent.text, + `event from ${browsingContext.id}`, + "Received the expected payload" + ); + + is( + messageHandlerEvent.name, + "event-from-window-global", + "Received event on the ROOT MessageHandler" + ); + is( + messageHandlerEvent.data.text, + `event from ${browsingContext.id}`, + "Received the expected payload" + ); + + is( + registryEvent, + messageHandlerEvent, + "The event forwarded by the MessageHandlerRegistry is identical to the MessageHandler event" + ); + rootMessageHandler.off("message-handler-event", _onMessageHandlerEvent); + RootMessageHandlerRegistry.off( + "message-handler-registry-event", + _onMessageHandlerRegistryEvent + ); + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); +}); + +/** + * Emit an event from a Root module triggered by a specific command. + * Check that the event is emitted on the RootMessageHandler. + */ +add_task(async function test_root_event() { + const rootMessageHandler = createRootMessageHandler("session-id-root_event"); + + // events are emitted both as generic message-handler-event events as + // well as under their own name. We expect to receive the event for both. + const onHandlerEvent = rootMessageHandler.once("message-handler-event"); + const onNamedEvent = rootMessageHandler.once("event-from-root"); + + rootMessageHandler.handleCommand({ + moduleName: "event", + commandName: "testEmitRootEvent", + destination: { + type: RootMessageHandler.type, + }, + }); + + const { name, data } = await onHandlerEvent; + is(name, "event-from-root", "Received event on the ROOT MessageHandler"); + is(data.text, "event from root", "Received the expected payload"); + + const namedEvent = await onNamedEvent; + is(namedEvent.text, "event from root", "Received the expected payload"); + + rootMessageHandler.destroy(); +}); + +/** + * Emit an event from a windowglobal-in-root module triggered by a specific command. + * Check that the event is emitted on the RootMessageHandler. + */ +add_task(async function test_windowglobal_in_root_event() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-windowglobal_in_root_event" + ); + + // events are emitted both as generic message-handler-event events as + // well as under their own name. We expect to receive the event for both. + const onHandlerEvent = rootMessageHandler.once("message-handler-event"); + const onNamedEvent = rootMessageHandler.once( + "event-from-window-global-in-root" + ); + rootMessageHandler.handleCommand({ + moduleName: "event", + commandName: "testEmitWindowGlobalInRootEvent", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); + + const { name, data } = await onHandlerEvent; + is( + name, + "event-from-window-global-in-root", + "Received event on the ROOT MessageHandler" + ); + is( + data.text, + `windowglobal-in-root event for ${browsingContext.id}`, + "Received the expected payload" + ); + + const namedEvent = await onNamedEvent; + is( + namedEvent.text, + `windowglobal-in-root event for ${browsingContext.id}`, + "Received the expected payload" + ); + + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); +}); + +/** + * Emit an event from a windowglobal module, but from 2 different sessions. + * Check that the event is emitted by the corresponding RootMessageHandler as + * well as by the parent process MessageHandlerRegistry. + */ +add_task(async function test_event_multisession() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContextId = tab.linkedBrowser.browsingContext.id; + + const root1 = createRootMessageHandler("session-id-event_multisession-1"); + let root1Events = 0; + const onRoot1Event = function (evtName, wrappedEvt) { + if (wrappedEvt.name === "event-from-window-global") { + root1Events++; + } + }; + root1.on("message-handler-event", onRoot1Event); + + const root2 = createRootMessageHandler("session-id-event_multisession-2"); + let root2Events = 0; + const onRoot2Event = function (evtName, wrappedEvt) { + if (wrappedEvt.name === "event-from-window-global") { + root2Events++; + } + }; + root2.on("message-handler-event", onRoot2Event); + + let registryEvents = 0; + const onRegistryEvent = function (evtName, wrappedEvt) { + if (wrappedEvt.name === "event-from-window-global") { + registryEvents++; + } + }; + RootMessageHandlerRegistry.on( + "message-handler-registry-event", + onRegistryEvent + ); + + callTestEmitEvent(root1, browsingContextId); + callTestEmitEvent(root2, browsingContextId); + + info("Wait for root1 event to be received"); + await TestUtils.waitForCondition(() => root1Events === 1); + info("Wait for root2 event to be received"); + await TestUtils.waitForCondition(() => root2Events === 1); + + await TestUtils.waitForTick(); + is(root1Events, 1, "Session 1 only received 1 event"); + is(root2Events, 1, "Session 2 only received 1 event"); + is( + registryEvents, + 2, + "MessageHandlerRegistry forwarded events from both sessions" + ); + + root1.off("message-handler-event", onRoot1Event); + root2.off("message-handler-event", onRoot2Event); + RootMessageHandlerRegistry.off( + "message-handler-registry-event", + onRegistryEvent + ); + root1.destroy(); + root2.destroy(); + gBrowser.removeTab(tab); +}); + +/** + * Test that events can be emitted from individual frame contexts and that + * events going through a shared content process MessageHandlerRegistry are not + * duplicated. + */ +add_task(async function test_event_with_frames() { + info("Navigate the initial tab to the test URL"); + const tab = gBrowser.selectedTab; + await loadURL(tab.linkedBrowser, createTestMarkupWithFrames()); + + const contexts = + tab.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree(); + is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)"); + + const rootMessageHandler = createRootMessageHandler( + "session-id-event_with_frames" + ); + + const rootEvents = []; + const onRootEvent = function (evtName, wrappedEvt) { + if (wrappedEvt.name === "event-from-window-global") { + rootEvents.push(wrappedEvt.data.text); + } + }; + rootMessageHandler.on("message-handler-event", onRootEvent); + + const namedEvents = []; + const onNamedEvent = (name, event) => namedEvents.push(event.text); + rootMessageHandler.on("event-from-window-global", onNamedEvent); + + for (const context of contexts) { + callTestEmitEvent(rootMessageHandler, context.id); + info("Wait for root event to be received in both event arrays"); + await TestUtils.waitForCondition(() => + [namedEvents, rootEvents].every(events => + events.includes(`event from ${context.id}`) + ) + ); + } + + info("Wait for a bit and check that we did not receive duplicated events"); + await TestUtils.waitForTick(); + is(rootEvents.length, 4, "Only received 4 events"); + + rootMessageHandler.off("message-handler-event", onRootEvent); + rootMessageHandler.off("event-from-window-global", onNamedEvent); + rootMessageHandler.destroy(); +}); + +function callTestEmitEvent(rootMessageHandler, browsingContextId) { + rootMessageHandler.handleCommand({ + moduleName: "event", + commandName: "testEmitEvent", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); +} diff --git a/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js b/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js new file mode 100644 index 0000000000..cddcba3529 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { isBrowsingContextCompatible } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs" +); +const TEST_COM_PAGE = "https://example.com/document-builder.sjs?html=com"; +const TEST_NET_PAGE = "https://example.net/document-builder.sjs?html=net"; + +// Test helpers from BrowsingContextUtils in various processes. +add_task(async function () { + const tab1 = BrowserTestUtils.addTab(gBrowser, TEST_COM_PAGE); + const contentBrowser1 = tab1.linkedBrowser; + await BrowserTestUtils.browserLoaded(contentBrowser1); + const browserId1 = contentBrowser1.browsingContext.browserId; + + const tab2 = BrowserTestUtils.addTab(gBrowser, TEST_NET_PAGE); + const contentBrowser2 = tab2.linkedBrowser; + await BrowserTestUtils.browserLoaded(contentBrowser2); + const browserId2 = contentBrowser2.browsingContext.browserId; + + const { extension, sidebarBrowser } = await installSidebarExtension(); + + const tab3 = BrowserTestUtils.addTab( + gBrowser, + `moz-extension://${extension.uuid}/tab.html` + ); + const { bcId } = await extension.awaitMessage("tab-loaded"); + const tabExtensionBrowser = BrowsingContext.get(bcId).top.embedderElement; + + const parentBrowser1 = createParentBrowserElement(tab1, "content"); + const parentBrowser2 = createParentBrowserElement(tab1, "chrome"); + + info("Check browsing context compatibility for content browser 1"); + await checkBrowsingContextCompatible(contentBrowser1, undefined, true); + await checkBrowsingContextCompatible(contentBrowser1, browserId1, true); + await checkBrowsingContextCompatible(contentBrowser1, browserId2, false); + + info("Check browsing context compatibility for content browser 2"); + await checkBrowsingContextCompatible(contentBrowser2, undefined, true); + await checkBrowsingContextCompatible(contentBrowser2, browserId1, false); + await checkBrowsingContextCompatible(contentBrowser2, browserId2, true); + + info("Check browsing context compatibility for parent browser 1"); + await checkBrowsingContextCompatible(parentBrowser1, undefined, false); + await checkBrowsingContextCompatible(parentBrowser1, browserId1, false); + await checkBrowsingContextCompatible(parentBrowser1, browserId2, false); + + info("Check browsing context compatibility for parent browser 2"); + await checkBrowsingContextCompatible(parentBrowser2, undefined, false); + await checkBrowsingContextCompatible(parentBrowser2, browserId1, false); + await checkBrowsingContextCompatible(parentBrowser2, browserId2, false); + + info("Check browsing context compatibility for extension"); + await checkBrowsingContextCompatible(sidebarBrowser, undefined, false); + await checkBrowsingContextCompatible(sidebarBrowser, browserId1, false); + await checkBrowsingContextCompatible(sidebarBrowser, browserId2, false); + + info("Check browsing context compatibility for extension viewed in a tab"); + await checkBrowsingContextCompatible(tabExtensionBrowser, undefined, false); + await checkBrowsingContextCompatible(tabExtensionBrowser, browserId1, false); + await checkBrowsingContextCompatible(tabExtensionBrowser, browserId2, false); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab3); + await extension.unload(); +}); + +async function checkBrowsingContextCompatible(browser, browserId, expected) { + const options = { browserId }; + info("Check browsing context compatibility from the parent process"); + is(isBrowsingContextCompatible(browser.browsingContext, options), expected); + + info( + "Check browsing context compatibility from the browsing context's process" + ); + await SpecialPowers.spawn( + browser, + [browserId, expected], + (_browserId, _expected) => { + const BrowsingContextUtils = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs" + ); + is( + BrowsingContextUtils.isBrowsingContextCompatible( + content.browsingContext, + { + browserId: _browserId, + } + ), + _expected + ); + } + ); +} diff --git a/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js b/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js new file mode 100644 index 0000000000..c115517980 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js @@ -0,0 +1,218 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +// Check that errors from WindowGlobal modules can be caught by the consumer +// of the RootMessageHandler. +add_task(async function test_module_error() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + + const rootMessageHandler = createRootMessageHandler("session-id-error"); + + info("Call a module method which will throw"); + + await Assert.rejects( + rootMessageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "testError", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }), + err => err.message.includes("error-from-module"), + "Error from window global module caught" + ); + + rootMessageHandler.destroy(); +}); + +// Check that sending commands to incorrect destinations creates an error which +// can be caught by the consumer of the RootMessageHandler. +add_task(async function test_destination_error() { + const rootMessageHandler = createRootMessageHandler("session-id-error"); + + const fakeBrowsingContextId = -1; + ok( + !BrowsingContext.get(fakeBrowsingContextId), + "No browsing context matches fakeBrowsingContextId" + ); + + info("Call a valid module method, but on a non-existent browsing context id"); + Assert.throws( + () => + rootMessageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "testOnlyInWindowGlobal", + destination: { + type: WindowGlobalMessageHandler.type, + id: fakeBrowsingContextId, + }, + }), + err => err.message == `Unable to find a BrowsingContext for id -1` + ); + + rootMessageHandler.destroy(); +}); + +add_task(async function test_invalid_module_error() { + const rootMessageHandler = createRootMessageHandler( + "session-id-missing_module" + ); + + info("Attempt to call a Root module which has a syntax error"); + Assert.throws( + () => + rootMessageHandler.handleCommand({ + moduleName: "invalid", + commandName: "someMethod", + destination: { + type: RootMessageHandler.type, + }, + }), + err => + err.name === "SyntaxError" && + err.message == "expected expression, got ';'" + ); + + rootMessageHandler.destroy(); +}); + +add_task(async function test_missing_root_module_error() { + const rootMessageHandler = createRootMessageHandler( + "session-id-missing_module" + ); + + info("Attempt to call a Root module which doesn't exist"); + Assert.throws( + () => + rootMessageHandler.handleCommand({ + moduleName: "missingmodule", + commandName: "someMethod", + destination: { + type: RootMessageHandler.type, + }, + }), + err => + err.name == "UnsupportedCommandError" && + err.message == + `missingmodule.someMethod not supported for destination ROOT` + ); + + rootMessageHandler.destroy(); +}); + +add_task(async function test_missing_windowglobal_module_error() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + const rootMessageHandler = createRootMessageHandler( + "session-id-missing_windowglobal_module" + ); + + info("Attempt to call a WindowGlobal module which doesn't exist"); + Assert.throws( + () => + rootMessageHandler.handleCommand({ + moduleName: "missingmodule", + commandName: "someMethod", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }), + err => + err.name == "UnsupportedCommandError" && + err.message == + `missingmodule.someMethod not supported for destination WINDOW_GLOBAL` + ); + + rootMessageHandler.destroy(); +}); + +add_task(async function test_missing_root_method_error() { + const rootMessageHandler = createRootMessageHandler( + "session-id-missing_root_method" + ); + + info("Attempt to call an invalid method on a Root module"); + Assert.throws( + () => + rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "wrongMethod", + destination: { + type: RootMessageHandler.type, + }, + }), + err => + err.name == "UnsupportedCommandError" && + err.message == `command.wrongMethod not supported for destination ROOT` + ); + + rootMessageHandler.destroy(); +}); + +add_task(async function test_missing_windowglobal_method_error() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + const rootMessageHandler = createRootMessageHandler( + "session-id-missing_windowglobal_method" + ); + + info("Attempt to call an invalid method on a WindowGlobal module"); + Assert.throws( + () => + rootMessageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "wrongMethod", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }), + err => + err.name == "UnsupportedCommandError" && + err.message == + `commandwindowglobalonly.wrongMethod not supported for destination WINDOW_GLOBAL` + ); + + rootMessageHandler.destroy(); +}); + +/** + * This test checks that even if a command is rerouted to another command after + * the RootMessageHandler, we still check the new command and log a useful + * error message. + * + * This illustrates why it is important to perform the command check at each + * layer of the MessageHandler network. + */ +add_task(async function test_missing_intermediary_method_error() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + const rootMessageHandler = createRootMessageHandler( + "session-id-missing_intermediary_method" + ); + + info( + "Call a (valid) command that relies on another (missing) command on a WindowGlobal module" + ); + await Assert.rejects( + rootMessageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "testMissingIntermediaryMethod", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }), + err => + err.name == "UnsupportedCommandError" && + err.message == + `commandwindowglobalonly.missingMethod not supported for destination WINDOW_GLOBAL` + ); + + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js b/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js new file mode 100644 index 0000000000..1d020397e1 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js @@ -0,0 +1,229 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// We are forcing the actors to shutdown while queries are unresolved. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Actor 'MessageHandlerFrame' destroyed before query 'MessageHandlerFrameParent:sendCommand' was resolved/ +); + +// The tests in this file assert the retry behavior for MessageHandler commands. +// We call "blocked" commands from resources/modules/windowglobal/retry.jsm and +// then trigger reload and navigations to simulate AbortErrors and force the +// MessageHandler to retry the commands, when possible. + +// Test that without retry behavior, a pending command rejects when the +// underlying JSWindowActor pair is destroyed. +add_task(async function test_no_retry() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler("session-id-no-retry"); + + try { + info("Call a module method which will throw"); + const onBlockedOneTime = rootMessageHandler.handleCommand({ + moduleName: "retry", + commandName: "blockedOneTime", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); + + // Reloading the tab will reject the pending query with an AbortError. + await BrowserTestUtils.reloadTab(tab); + + await Assert.rejects( + onBlockedOneTime, + e => e.name == "AbortError", + "Caught the expected abort error when reloading" + ); + } finally { + await cleanup(rootMessageHandler, tab); + } +}); + +// Test various commands, which all need a different number of "retries" to +// succeed. Check that they only resolve when the expected number of "retries" +// was reached. For commands which require more "retries" than we allow, check +// that we still fail with an AbortError once all the attempts are consumed. +add_task(async function test_retry() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler("session-id-retry"); + + try { + // This command will return if called twice. + const onBlockedOneTime = rootMessageHandler.handleCommand({ + moduleName: "retry", + commandName: "blockedOneTime", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + params: { + foo: "bar", + }, + retryOnAbort: true, + }); + + // This command will return if called three times. + const onBlockedTenTimes = rootMessageHandler.handleCommand({ + moduleName: "retry", + commandName: "blockedTenTimes", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + params: { + foo: "baz", + }, + retryOnAbort: true, + }); + + // This command will return if called twelve times, which is greater than the + // maximum amount of retries allowed. + const onBlockedElevenTimes = rootMessageHandler.handleCommand({ + moduleName: "retry", + commandName: "blockedElevenTimes", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + retryOnAbort: true, + }); + + info("Reload one time"); + await BrowserTestUtils.reloadTab(tab); + + info("blockedOneTime should resolve on the first retry"); + let { callsToCommand, foo } = await onBlockedOneTime; + is( + callsToCommand, + 2, + "The command was called twice (initial call + 1 retry)" + ); + is(foo, "bar", "The parameter was sent when the command was retried"); + + // We already reloaded 1 time. Reload 9 more times to unblock blockedTenTimes. + for (let i = 2; i < 11; i++) { + info("blockedTenTimes/blockedElevenTimes should not have resolved yet"); + ok(!(await hasPromiseResolved(onBlockedTenTimes))); + ok(!(await hasPromiseResolved(onBlockedElevenTimes))); + + info(`Reload the tab (time: ${i})`); + await BrowserTestUtils.reloadTab(tab); + } + + info("blockedTenTimes should resolve on the 10th reload"); + ({ callsToCommand, foo } = await onBlockedTenTimes); + is( + callsToCommand, + 11, + "The command was called 11 times (initial call + 10 retry)" + ); + is(foo, "baz", "The parameter was sent when the command was retried"); + + info("Reload one more time"); + await BrowserTestUtils.reloadTab(tab); + + info( + "The call to blockedElevenTimes now exceeds the maximum attempts allowed" + ); + await Assert.rejects( + onBlockedElevenTimes, + e => e.name == "AbortError", + "Caught the expected abort error when reloading" + ); + } finally { + await cleanup(rootMessageHandler, tab); + } +}); + +// Test cross-group navigations to check that the retry mechanism will +// transparently switch to the new Browsing Context created by the cross-group +// navigation. +add_task(async function test_retry_cross_group() { + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=COM" + + // Attach an unload listener to prevent the page from going into bfcache, + // so that pending queries will be rejected with an AbortError. + "<script type='text/javascript'>window.onunload = function() {};</script>" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const rootMessageHandler = createRootMessageHandler( + "session-id-retry-cross-group" + ); + + try { + // This command hangs and only returns if the current domain is example.net. + // We send the command while on example.com, perform a series of reload and + // navigations, and the retry mechanism should allow onBlockedOnNetDomain to + // resolve. + const onBlockedOnNetDomain = rootMessageHandler.handleCommand({ + moduleName: "retry", + commandName: "blockedOnNetDomain", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + params: { + foo: "bar", + }, + retryOnAbort: true, + }); + + info("Reload one time"); + await BrowserTestUtils.reloadTab(tab); + + info("blockedOnNetDomain should not have resolved yet"); + ok(!(await hasPromiseResolved(onBlockedOnNetDomain))); + + info( + "Navigate to example.net with COOP headers to destroy browsing context" + ); + await loadURL( + tab.linkedBrowser, + "https://example.net/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=NET" + ); + + info("blockedOnNetDomain should resolve now"); + let { foo } = await onBlockedOnNetDomain; + is(foo, "bar", "The parameter was sent when the command was retried"); + } finally { + await cleanup(rootMessageHandler, tab); + } +}); + +async function cleanup(rootMessageHandler, tab) { + const browsingContext = tab.linkedBrowser.browsingContext; + // Cleanup global JSM state in the test module. + await rootMessageHandler.handleCommand({ + moduleName: "retry", + commandName: "cleanup", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); + + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); +} diff --git a/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js b/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js new file mode 100644 index 0000000000..0a086d6f09 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js @@ -0,0 +1,203 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +// Test calling methods only implemented in the root version of a module. +add_task(async function test_rootModule_command() { + const rootMessageHandler = createRootMessageHandler("session-id-rootModule"); + const rootValue = await rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "testRootModule", + destination: { + type: RootMessageHandler.type, + }, + }); + + is( + rootValue, + "root-value", + "Retrieved the expected value from testRootModule" + ); + + rootMessageHandler.destroy(); +}); + +// Test calling methods only implemented in the windowglobal-in-root version of +// a module. +add_task(async function test_windowglobalInRootModule_command() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + + const rootMessageHandler = createRootMessageHandler( + "session-id-windowglobalInRootModule" + ); + const interceptedValue = await rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "testInterceptModule", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + interceptedValue, + "intercepted-value", + "Retrieved the expected value from testInterceptModule" + ); + + rootMessageHandler.destroy(); +}); + +// Test calling methods only implemented in the windowglobal version of a +// module. +add_task(async function test_windowglobalModule_command() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + + const rootMessageHandler = createRootMessageHandler( + "session-id-windowglobalModule" + ); + const windowGlobalValue = await rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "testWindowGlobalModule", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + windowGlobalValue, + "windowglobal-value", + "Retrieved the expected value from testWindowGlobalModule" + ); + + rootMessageHandler.destroy(); +}); + +// Test calling a method on a module which is only available in the "windowglobal" +// folder. This will check that the MessageHandler/ModuleCache correctly moves +// on to the next layer when no implementation can be found in the root layer. +add_task(async function test_windowglobalOnlyModule_command() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + + const rootMessageHandler = createRootMessageHandler( + "session-id-windowglobalOnlyModule" + ); + const windowGlobalOnlyValue = await rootMessageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "testOnlyInWindowGlobal", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + windowGlobalOnlyValue, + "only-in-windowglobal", + "Retrieved the expected value from testOnlyInWindowGlobal" + ); + + rootMessageHandler.destroy(); +}); + +// Try to create 2 sessions which will both set values in individual modules +// via a command `testSetValue`, and then retrieve the values via another +// command `testGetValue`. +// This will ensure that different sessions use different module instances. +add_task(async function test_multisession() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + + const rootMessageHandler1 = createRootMessageHandler( + "session-id-multisession-1" + ); + const rootMessageHandler2 = createRootMessageHandler( + "session-id-multisession-2" + ); + + info("Set value for session 1"); + await rootMessageHandler1.handleCommand({ + moduleName: "command", + commandName: "testSetValue", + params: { value: "session1-value" }, + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + info("Set value for session 2"); + await rootMessageHandler2.handleCommand({ + moduleName: "command", + commandName: "testSetValue", + params: { value: "session2-value" }, + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + const session1Value = await rootMessageHandler1.handleCommand({ + moduleName: "command", + commandName: "testGetValue", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + session1Value, + "session1-value", + "Retrieved the expected value for session 1" + ); + + const session2Value = await rootMessageHandler2.handleCommand({ + moduleName: "command", + commandName: "testGetValue", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + session2Value, + "session2-value", + "Retrieved the expected value for session 2" + ); + + rootMessageHandler1.destroy(); + rootMessageHandler2.destroy(); +}); + +// Test calling a method from the windowglobal-in-root module which will +// internally forward to the windowglobal module and will return a composite +// result built both in parent and content process. +add_task(async function test_forwarding_command() { + const browsingContextId = gBrowser.selectedBrowser.browsingContext.id; + + const rootMessageHandler = createRootMessageHandler("session-id-forwarding"); + const interceptAndForwardValue = await rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "testInterceptAndForwardModule", + params: { id: "value" }, + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + interceptAndForwardValue, + "intercepted-and-forward+forward-to-windowglobal-value", + "Retrieved the expected value from testInterceptAndForwardModule" + ); + + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_navigation_manager.js b/remote/shared/messagehandler/test/browser/browser_navigation_manager.js new file mode 100644 index 0000000000..474605e90f --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_navigation_manager.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs" +); +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +// Check that a functional navigation manager is available on the +// RootMessageHandler. +add_task(async function test_navigationManager() { + const sessionId = "navigationManager-test"; + const type = RootMessageHandler.type; + + const rootMessageHandlerRegistry = new MessageHandlerRegistry(type); + + const rootMessageHandler = + rootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId); + + const navigationManager = rootMessageHandler.navigationManager; + ok(!!navigationManager, "ROOT MessageHandler provides a navigation manager"); + + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + info("Check the navigation manager monitors navigations"); + + const testUrl = "https://example.com/document-builder.sjs?html=test"; + const tab1 = BrowserTestUtils.addTab(gBrowser, testUrl); + const contentBrowser1 = tab1.linkedBrowser; + await BrowserTestUtils.browserLoaded(contentBrowser1); + + const navigation = navigationManager.getNavigationForBrowsingContext( + contentBrowser1.browsingContext + ); + is(navigation.url, testUrl, "Navigation has the expected URL"); + + is(events.length, 2, "Received 2 navigation events"); + is(events[0].name, "navigation-started"); + is(events[1].name, "navigation-stopped"); + + info( + "Check the navigation manager is destroyed after destroying the message handler" + ); + rootMessageHandler.destroy(); + const otherUrl = "https://example.com/document-builder.sjs?html=other"; + const tab2 = BrowserTestUtils.addTab(gBrowser, otherUrl); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser); + is(events.length, 2, "No new navigation event received"); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_realms.js b/remote/shared/messagehandler/test/browser/browser_realms.js new file mode 100644 index 0000000000..815bfbbe85 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_realms.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +add_task(async function test_tab_is_removed() { + const tab = await addTab("https://example.com/document-builder.sjs?html=tab"); + const sessionId = "realms"; + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + + const rootMessageHandler = createRootMessageHandler(sessionId); + + const onRealmCreated = rootMessageHandler.once("realm-created"); + + // Add a new session data item to get window global handlers created + await rootMessageHandler.addSessionDataItem({ + moduleName: "command", + category: "browser_realms", + contextDescriptor, + values: [true], + }); + + const realmCreatedEvent = await onRealmCreated; + const createdRealmId = realmCreatedEvent.realmInfo.realm; + + is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map"); + + const onRealmDestroyed = rootMessageHandler.once("realm-destroyed"); + + gBrowser.removeTab(tab); + + const realmDestroyedEvent = await onRealmDestroyed; + + is( + realmDestroyedEvent.realm, + createdRealmId, + "Received a correct realm id in realm-destroyed event" + ); + is(rootMessageHandler.realms.size, 0, "The realm map is cleaned up"); + + rootMessageHandler.destroy(); +}); + +add_task(async function test_same_origin_navigation() { + const tab = await addTab("https://example.com/document-builder.sjs?html=tab"); + const sessionId = "realms"; + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + + const rootMessageHandler = createRootMessageHandler(sessionId); + + const onRealmCreated = rootMessageHandler.once("realm-created"); + + // Add a new session data item to get window global handlers created + await rootMessageHandler.addSessionDataItem({ + moduleName: "command", + category: "browser_realms", + contextDescriptor, + values: [true], + }); + + const realmCreatedEvent = await onRealmCreated; + const createdRealmId = realmCreatedEvent.realmInfo.realm; + + is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map"); + + const onRealmDestroyed = rootMessageHandler.once("realm-destroyed"); + const onNewRealmCreated = rootMessageHandler.once("realm-created"); + + // Navigate to another page with the same origin + await loadURL( + tab.linkedBrowser, + "https://example.com/document-builder.sjs?html=othertab" + ); + + const realmDestroyedEvent = await onRealmDestroyed; + + is( + realmDestroyedEvent.realm, + createdRealmId, + "Received a correct realm id in realm-destroyed event" + ); + + await onNewRealmCreated; + + is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map"); + + gBrowser.removeTab(tab); + rootMessageHandler.destroy(); +}); + +add_task(async function test_cross_origin_navigation() { + const tab = await addTab("https://example.com/document-builder.sjs?html=tab"); + const sessionId = "realms"; + const browsingContext = tab.linkedBrowser.browsingContext; + const contextDescriptor = { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext.browserId, + }; + + const rootMessageHandler = createRootMessageHandler(sessionId); + + const onRealmCreated = rootMessageHandler.once("realm-created"); + + // Add a new session data item to get window global handlers created + await rootMessageHandler.addSessionDataItem({ + moduleName: "command", + category: "browser_realms", + contextDescriptor, + values: [true], + }); + + const realmCreatedEvent = await onRealmCreated; + const createdRealmId = realmCreatedEvent.realmInfo.realm; + + is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map"); + + const onRealmDestroyed = rootMessageHandler.once("realm-destroyed"); + const onNewRealmCreated = rootMessageHandler.once("realm-created"); + + // Navigate to another page with the different origin + await loadURL( + tab.linkedBrowser, + "https://example.com/document-builder.sjs?html=otherorigin" + ); + + const realmDestroyedEvent = await onRealmDestroyed; + + is( + realmDestroyedEvent.realm, + createdRealmId, + "Received a correct realm id in realm-destroyed event" + ); + + await onNewRealmCreated; + + is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map"); + + gBrowser.removeTab(tab); + rootMessageHandler.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_registry.js b/remote/shared/messagehandler/test/browser/browser_registry.js new file mode 100644 index 0000000000..945ac06c19 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_registry.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs" +); +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +add_task(async function test_messageHandlerRegistry_API() { + const sessionId = 1; + const type = RootMessageHandler.type; + + const rootMessageHandlerRegistry = new MessageHandlerRegistry(type); + + const rootMessageHandler = + rootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId); + ok(rootMessageHandler, "Valid ROOT MessageHandler created"); + + const contextId = rootMessageHandler.contextId; + ok(contextId, "ROOT MessageHandler has a valid contextId"); + + is( + rootMessageHandler, + rootMessageHandlerRegistry.getExistingMessageHandler(sessionId), + "ROOT MessageHandler can be retrieved from the registry" + ); + + rootMessageHandler.destroy(); + ok( + !rootMessageHandlerRegistry.getExistingMessageHandler(sessionId), + "Destroyed ROOT MessageHandler is no longer returned by the Registry" + ); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_session_data.js b/remote/shared/messagehandler/test/browser/browser_session_data.js new file mode 100644 index 0000000000..591073feb6 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data.js @@ -0,0 +1,273 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs" +); +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); +const { SessionData } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs" +); + +const TEST_PAGE = "http://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_sessionData() { + info("Navigate the initial tab to the test URL"); + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + + const sessionId = "sessionData-test"; + + const rootMessageHandlerRegistry = new MessageHandlerRegistry( + RootMessageHandler.type + ); + + const rootMessageHandler = + rootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId); + ok(rootMessageHandler, "Valid ROOT MessageHandler created"); + + const sessionData = rootMessageHandler.sessionData; + ok( + sessionData instanceof SessionData, + "ROOT MessageHandler has a valid sessionData" + ); + + let sessionDataSnapshot = await getSessionDataFromContent(); + is(sessionDataSnapshot.size, 0, "session data is empty"); + + info("Store a string value in session data"); + sessionData.updateSessionData([ + { + method: "add", + moduleName: "fakemodule", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: ["value-1"], + }, + ]); + + sessionDataSnapshot = await getSessionDataFromContent(); + is(sessionDataSnapshot.size, 1, "session data contains 1 session"); + ok(sessionDataSnapshot.has(sessionId)); + let snapshot = sessionDataSnapshot.get(sessionId); + ok(Array.isArray(snapshot)); + is(snapshot.length, 1); + + const stringDataItem = snapshot[0]; + checkSessionDataItem( + stringDataItem, + "fakemodule", + "testCategory", + ContextDescriptorType.All, + "value-1" + ); + + info("Store a number value in session data"); + sessionData.updateSessionData([ + { + method: "add", + moduleName: "fakemodule", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: [12], + }, + ]); + snapshot = (await getSessionDataFromContent()).get(sessionId); + is(snapshot.length, 2); + + const numberDataItem = snapshot[1]; + checkSessionDataItem( + numberDataItem, + "fakemodule", + "testCategory", + ContextDescriptorType.All, + 12 + ); + + info("Store a boolean value in session data"); + sessionData.updateSessionData([ + { + method: "add", + moduleName: "fakemodule", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: [true], + }, + ]); + snapshot = (await getSessionDataFromContent()).get(sessionId); + is(snapshot.length, 3); + + const boolDataItem = snapshot[2]; + checkSessionDataItem( + boolDataItem, + "fakemodule", + "testCategory", + ContextDescriptorType.All, + true + ); + + info("Remove one value"); + sessionData.updateSessionData([ + { + method: "remove", + moduleName: "fakemodule", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: [12], + }, + ]); + snapshot = (await getSessionDataFromContent()).get(sessionId); + is(snapshot.length, 2); + checkSessionDataItem( + snapshot[0], + "fakemodule", + "testCategory", + ContextDescriptorType.All, + "value-1" + ); + checkSessionDataItem( + snapshot[1], + "fakemodule", + "testCategory", + ContextDescriptorType.All, + true + ); + + info("Remove all values"); + sessionData.updateSessionData([ + { + method: "remove", + moduleName: "fakemodule", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: ["value-1", true], + }, + ]); + snapshot = (await getSessionDataFromContent()).get(sessionId); + is(snapshot.length, 0, "Session data is now empty"); + + info("Add another value before destroy"); + sessionData.updateSessionData([ + { + method: "add", + moduleName: "fakemodule", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: ["value-2"], + }, + ]); + snapshot = (await getSessionDataFromContent()).get(sessionId); + is(snapshot.length, 1); + checkSessionDataItem( + snapshot[0], + "fakemodule", + "testCategory", + ContextDescriptorType.All, + "value-2" + ); + + sessionData.destroy(); + sessionDataSnapshot = await getSessionDataFromContent(); + is(sessionDataSnapshot.size, 0, "session data should be empty again"); +}); + +add_task(async function test_sessionDataRootOnlyModule() { + const sessionId = "sessionData-test-rootOnly"; + + const rootMessageHandler = createRootMessageHandler(sessionId); + ok(rootMessageHandler, "Valid ROOT MessageHandler created"); + + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + + const windowGlobalCreated = rootMessageHandler.once( + "window-global-handler-created" + ); + + info("Test that adding SessionData items works the root module"); + // Updating the session data on the root message handler should not cause + // failures for other message handlers if the module only exists for root. + await rootMessageHandler.addSessionDataItem({ + moduleName: "rootOnly", + category: "session_data_root_only", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + values: [true], + }); + + await windowGlobalCreated; + ok(true, "Window global has been initialized"); + + let sessionDataReceivedByRoot = await rootMessageHandler.handleCommand({ + moduleName: "rootOnly", + commandName: "getSessionDataReceived", + destination: { + type: RootMessageHandler.type, + }, + }); + + is(sessionDataReceivedByRoot.length, 1); + is(sessionDataReceivedByRoot[0].category, "session_data_root_only"); + is(sessionDataReceivedByRoot[0].added.length, 1); + is(sessionDataReceivedByRoot[0].added[0], true); + is( + sessionDataReceivedByRoot[0].contextDescriptor.type, + ContextDescriptorType.All + ); + + info("Now test that removing items also works on the root module"); + await rootMessageHandler.removeSessionDataItem({ + moduleName: "rootOnly", + category: "session_data_root_only", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + values: [true], + }); + + sessionDataReceivedByRoot = await rootMessageHandler.handleCommand({ + moduleName: "rootOnly", + commandName: "getSessionDataReceived", + destination: { + type: RootMessageHandler.type, + }, + }); + + is(sessionDataReceivedByRoot.length, 2); + is(sessionDataReceivedByRoot[1].category, "session_data_root_only"); + is(sessionDataReceivedByRoot[1].removed.length, 1); + is(sessionDataReceivedByRoot[1].removed[0], true); + is( + sessionDataReceivedByRoot[1].contextDescriptor.type, + ContextDescriptorType.All + ); + + rootMessageHandler.destroy(); +}); + +function checkSessionDataItem(item, moduleName, category, contextType, value) { + is(item.moduleName, moduleName, "Data item has the expected module name"); + is(item.category, category, "Data item has the expected category"); + is( + item.contextDescriptor.type, + contextType, + "Data item has the expected context type" + ); + is(item.value, value, "Data item has the expected value"); +} + +function getSessionDataFromContent() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const { readSessionData } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs" + ); + return readSessionData(); + }); +} diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js b/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js new file mode 100644 index 0000000000..9c15974ae6 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +/** + * Check that message handlers are not created for parent process browser + * elements, even if they have the type="content" attribute (eg used for the + * DevTools toolbox), as well as for webextension contexts. + */ +add_task(async function test_session_data_broadcast() { + // Prepare: + // - one content tab + // - one browser type content + // - one browser type chrome + // - one sidebar webextension + // We only expect session data to be applied to the content tab + const tab1 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + const contentBrowser1 = tab1.linkedBrowser; + await BrowserTestUtils.browserLoaded(contentBrowser1); + const parentBrowser1 = createParentBrowserElement(tab1, "content"); + const parentBrowser2 = createParentBrowserElement(tab1, "chrome"); + const { extension: extension1, sidebarBrowser: extSidebarBrowser1 } = + await installSidebarExtension(); + + const root = createRootMessageHandler("session-id-event"); + + // When the windowglobal command.jsm module applies the session data + // browser_session_data_browser_element, it will emit an event. + // Collect the events to detect which MessageHandlers have been started. + info("Watch events emitted when session data is applied"); + const sessionDataEvents = []; + const onRootEvent = function (evtName, wrappedEvt) { + if (wrappedEvt.name === "received-session-data") { + sessionDataEvents.push(wrappedEvt.data.contextId); + } + }; + root.on("message-handler-event", onRootEvent); + + info("Add a new session data item, expect one return value"); + await root.addSessionDataItem({ + moduleName: "command", + category: "browser_session_data_browser_element", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + values: [true], + }); + + function hasSessionData(browsingContext) { + return sessionDataEvents.includes(browsingContext.id); + } + + info( + "Check that only the content tab window global received the session data" + ); + is(hasSessionData(contentBrowser1.browsingContext), true); + is(hasSessionData(parentBrowser1.browsingContext), false); + is(hasSessionData(parentBrowser2.browsingContext), false); + is(hasSessionData(extSidebarBrowser1.browsingContext), false); + + const tab2 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + const contentBrowser2 = tab2.linkedBrowser; + await BrowserTestUtils.browserLoaded(contentBrowser2); + const parentBrowser3 = createParentBrowserElement(contentBrowser2, "content"); + const parentBrowser4 = createParentBrowserElement(contentBrowser2, "chrome"); + + const { extension: extension2, sidebarBrowser: extSidebarBrowser2 } = + await installSidebarExtension(); + + info("Wait until the session data was applied to the new tab"); + await TestUtils.waitForCondition(() => + sessionDataEvents.includes(contentBrowser2.browsingContext.id) + ); + + info("Check that parent browser elements did not apply the session data"); + is(hasSessionData(parentBrowser3.browsingContext), false); + is(hasSessionData(parentBrowser4.browsingContext), false); + + info( + "Check that extension did not apply the session data, " + + extSidebarBrowser2.browsingContext.id + ); + is(hasSessionData(extSidebarBrowser2.browsingContext), false); + + root.destroy(); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + await extension1.unload(); + await extension2.unload(); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js b/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js new file mode 100644 index 0000000000..03ed59166f --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +/** + * Check that modules created early for session data are still created with a + * fully initialized MessageHandler. See Bug 1743083. + */ +add_task(async function () { + const tab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContext = tab.linkedBrowser.browsingContext; + + const root = createRootMessageHandler("session-id-event"); + + info("Add some session data for the command module"); + await root.addSessionDataItem({ + moduleName: "command", + category: "testCategory", + contextDescriptor: contextDescriptorAll, + values: ["some-value"], + }); + + info("Reload the current tab to create new message handlers and modules"); + await BrowserTestUtils.reloadTab(tab); + + info( + "Check if the command module was created by the MessageHandler constructor" + ); + const isCreatedByMessageHandlerConstructor = await root.handleCommand({ + moduleName: "command", + commandName: "testIsCreatedByMessageHandlerConstructor", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContext.id, + }, + }); + + is( + isCreatedByMessageHandlerConstructor, + false, + "The command module from session data should not be created by the MessageHandler constructor" + ); + root.destroy(); + + gBrowser.removeTab(tab); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_update.js b/remote/shared/messagehandler/test/browser/browser_session_data_update.js new file mode 100644 index 0000000000..342a4a6139 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_update.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +const { assertUpdate, createSessionDataUpdate, getUpdates } = + SessionDataUpdateHelpers; + +// Test various session data update scenarios against a single browsing context. +add_task(async function test_session_data_update() { + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + const root = createRootMessageHandler("session-data-update"); + + info("Add a new session data item, expect one return value"); + await root.updateSessionData([ + createSessionDataUpdate(["text-1"], "add", "category1"), + ]); + let processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 1); + assertUpdate(processedUpdates.at(-1), ["text-1"], "category1"); + + info("Add two session data items, expect one return value with both items"); + await root.updateSessionData([ + createSessionDataUpdate(["text-2"], "add", "category1"), + createSessionDataUpdate(["text-3"], "add", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 2); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-3"], + "category1" + ); + + info("Try to add an existing data item, expect no update broadcast"); + await root.updateSessionData([ + createSessionDataUpdate(["text-1"], "add", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 2); + + info("Add an existing and a new item"); + await root.updateSessionData([ + createSessionDataUpdate(["text-2", "text-4"], "add", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 3); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-3", "text-4"], + "category1" + ); + + info("Remove an item, expect only the new item to return"); + await root.updateSessionData([ + createSessionDataUpdate(["text-3"], "remove", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 4); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-4"], + "category1" + ); + + info("Remove a unknown item, expect no return value"); + await root.updateSessionData([ + createSessionDataUpdate(["text-unknown"], "remove", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 4); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-4"], + "category1" + ); + + info("Remove an existing and a unknown item"); + await root.updateSessionData([ + createSessionDataUpdate(["text-2"], "remove", "category1"), + createSessionDataUpdate(["text-unknown"], "remove", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 5); + assertUpdate(processedUpdates.at(-1), ["text-1", "text-4"], "category1"); + + info("Add and remove at once"); + await root.updateSessionData([ + createSessionDataUpdate(["text-5"], "add", "category1"), + createSessionDataUpdate(["text-4"], "remove", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 6); + assertUpdate(processedUpdates.at(-1), ["text-1", "text-5"], "category1"); + + info("Adding and removing an item does not trigger any update"); + await root.updateSessionData([ + createSessionDataUpdate(["text-6"], "add", "category1"), + createSessionDataUpdate(["text-6"], "remove", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + // TODO: We could detect transactions which can't have any impact and fully + // ignore them. See Bug 1810807. + todo_is(processedUpdates.length, 6); + assertUpdate(processedUpdates.at(-1), ["text-1", "text-5"], "category1"); + + root.destroy(); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js b/remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js new file mode 100644 index 0000000000..b1cadcf095 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +const { assertUpdate, createSessionDataUpdate, getUpdates } = + SessionDataUpdateHelpers; + +// Test session data update scenarios involving different session data item +// categories. +add_task(async function test_session_data_update_categories() { + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + const root = createRootMessageHandler("session-data-update-categories"); + await root.updateSessionData([ + createSessionDataUpdate(["value1-1"], "add", "category1"), + createSessionDataUpdate(["value1-2"], "add", "category1"), + ]); + + let processedUpdates = await getUpdates(root, browsingContext1); + + is(processedUpdates.length, 1); + assertUpdate(processedUpdates.at(-1), ["value1-1", "value1-2"], "category1"); + + info("Adding a new item in category1 broadcasts all category1 items"); + await root.updateSessionData([ + createSessionDataUpdate(["value1-3"], "add", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 2); + assertUpdate( + processedUpdates.at(-1), + ["value1-1", "value1-2", "value1-3"], + "category1" + ); + + info("Removing a new item in category1 broadcasts all category1 items"); + await root.updateSessionData([ + createSessionDataUpdate(["value1-1"], "remove", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 3); + assertUpdate(processedUpdates.at(-1), ["value1-2", "value1-3"], "category1"); + + info("Adding a new category does not broadcast category1 items"); + await root.updateSessionData([ + createSessionDataUpdate(["value2-1"], "add", "category2"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 4); + assertUpdate(processedUpdates.at(-1), ["value2-1"], "category2"); + + info("Adding an item in 2 categories triggers an update for each category"); + await root.updateSessionData([ + createSessionDataUpdate(["value1-4"], "add", "category1"), + createSessionDataUpdate(["value2-2"], "add", "category2"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 6); + assertUpdate( + processedUpdates.at(-2), + ["value1-2", "value1-3", "value1-4"], + "category1" + ); + assertUpdate(processedUpdates.at(-1), ["value2-1", "value2-2"], "category2"); + + info("Removing an item in 2 categories triggers an update for each category"); + await root.updateSessionData([ + createSessionDataUpdate(["value1-4"], "remove", "category1"), + createSessionDataUpdate(["value2-2"], "remove", "category2"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 8); + assertUpdate(processedUpdates.at(-2), ["value1-2", "value1-3"], "category1"); + assertUpdate(processedUpdates.at(-1), ["value2-1"], "category2"); + + info("Opening a new tab triggers an update for each category"); + const tab2 = await addTab(TEST_PAGE); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 2); + assertUpdate(processedUpdates.at(-2), ["value1-2", "value1-3"], "category1"); + assertUpdate(processedUpdates.at(-1), ["value2-1"], "category2"); + + root.destroy(); + gBrowser.removeTab(tab2); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js b/remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js new file mode 100644 index 0000000000..711df1fc56 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js @@ -0,0 +1,194 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +const { assertUpdate, createSessionDataUpdate, getUpdates } = + SessionDataUpdateHelpers; + +// Test session data update scenarios involving 2 browsing contexts, and using +// the TopBrowsingContext ContextDescriptor type. +add_task(async function test_session_data_update_contexts() { + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + const root = createRootMessageHandler("session-data-update-contexts"); + + info("Add several items over 2 separate updates for all contexts"); + await root.updateSessionData([ + createSessionDataUpdate(["text-1"], "add", "category1"), + ]); + await root.updateSessionData([ + createSessionDataUpdate(["text-2"], "add", "category1"), + createSessionDataUpdate(["text-3"], "add", "category1"), + ]); + + info("Check we processed two distinct updates in browsingContext 1"); + let processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 2); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-3"], + "category1" + ); + + info("Open a new tab on the same test URL"); + const tab2 = await addTab(TEST_PAGE); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 1); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-3"], + "category1" + ); + + info("Add two items: one globally and one in a single context"); + await root.updateSessionData([ + createSessionDataUpdate(["text-4"], "add", "category1"), + createSessionDataUpdate(["text-5"], "add", "category1", { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext2.browserId, + }), + ]); + + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 3); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-3", "text-4"], + "category1" + ); + + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 2); + assertUpdate( + processedUpdates.at(-1), + ["text-1", "text-2", "text-3", "text-4", "text-5"], + "category1" + ); + + info("Remove two items: one globally and one in a single context"); + await root.updateSessionData([ + createSessionDataUpdate(["text-1"], "remove", "category1"), + createSessionDataUpdate(["text-5"], "remove", "category1", { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext2.browserId, + }), + ]); + + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 4); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4"], + "category1" + ); + + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 3); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4"], + "category1" + ); + + info( + "Add session data item to all contexts and remove this event for one context (2 steps)" + ); + + info("First step: add an item to browsingContext1"); + await root.updateSessionData([ + createSessionDataUpdate(["text-6"], "add", "category1", { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }), + ]); + + info( + "Second step: remove the item from browsingContext1, and add it globally" + ); + await root.updateSessionData([ + createSessionDataUpdate(["text-6"], "remove", "category1", { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }), + createSessionDataUpdate(["text-6"], "add", "category1"), + ]); + + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 6); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4", "text-6"], + "category1" + ); + + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 4); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4", "text-6"], + "category1" + ); + + info( + "Remove the event, which has also an individual subscription, for all contexts (2 steps)" + ); + + info("First step: Add the same item for browsingContext1 and globally"); + await root.updateSessionData([ + createSessionDataUpdate(["text-7"], "add", "category1", { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }), + createSessionDataUpdate(["text-7"], "add", "category1"), + ]); + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 7); + // We will find text-7 twice here, the module is responsible for not applying + // the same session data item twice. Each item corresponds to a different + // descriptor which matched browsingContext1. + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4", "text-6", "text-7", "text-7"], + "category1" + ); + + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 5); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4", "text-6", "text-7"], + "category1" + ); + + info("Second step: Remove the item globally"); + await root.updateSessionData([ + createSessionDataUpdate(["text-7"], "remove", "category1"), + ]); + + processedUpdates = await getUpdates(root, browsingContext1); + is(processedUpdates.length, 8); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4", "text-6", "text-7"], + "category1" + ); + + processedUpdates = await getUpdates(root, browsingContext2); + is(processedUpdates.length, 6); + assertUpdate( + processedUpdates.at(-1), + ["text-2", "text-3", "text-4", "text-6"], + "category1" + ); + + root.destroy(); + + gBrowser.removeTab(tab2); +}); diff --git a/remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js b/remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js new file mode 100644 index 0000000000..57629e5485 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); + +add_task(async function test_windowGlobal_to_root_command() { + // Navigate to a page to make sure that the windowglobal modules run in a + // different process than the root module. + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const browsingContextId = tab.linkedBrowser.browsingContext.id; + + const rootMessageHandler = createRootMessageHandler( + "session-id-windowglobal-to-rootModule" + ); + + for (const commandName of [ + "testHandleCommandToRoot", + "testSendRootCommand", + ]) { + const valueFromRoot = await rootMessageHandler.handleCommand({ + moduleName: "windowglobaltoroot", + commandName, + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + + is( + valueFromRoot, + "root-value-called-from-windowglobal", + "Retrieved the expected value from windowglobaltoroot using " + + commandName + ); + } + + rootMessageHandler.destroy(); + gBrowser.removeTab(tab); +}); diff --git a/remote/shared/messagehandler/test/browser/head.js b/remote/shared/messagehandler/test/browser/head.js new file mode 100644 index 0000000000..81cf0942d3 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/head.js @@ -0,0 +1,236 @@ +/* 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"; + +var { ContextDescriptorType } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs" +); + +var { WindowGlobalMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs" +); + +var contextDescriptorAll = { + type: ContextDescriptorType.All, +}; + +function createRootMessageHandler(sessionId) { + const { RootMessageHandlerRegistry } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs" + ); + return RootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId); +} + +/** + * Load the provided url in an existing browser. + * Returns a promise which will resolve when the page is loaded. + * + * @param {Browser} browser + * The browser element where the URL should be loaded. + * @param {string} url + * The URL to load in the new tab + */ +async function loadURL(browser, url) { + const loaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, url); + return loaded; +} + +/** + * Create a new foreground tab loading the provided url. + * Returns a promise which will resolve when the page is loaded. + * + * @param {string} url + * The URL to load in the new tab + */ +async function addTab(url) { + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + registerCleanupFunction(() => { + gBrowser.removeTab(tab); + }); + return tab; +} + +/** + * Create inline markup for a simple iframe that can be used with + * document-builder.sjs. The iframe will be served under the provided domain. + * + * @param {string} domain + * A domain (eg "example.com"), compatible with build/pgo/server-locations.txt + */ +function createFrame(domain) { + return createFrameForUri( + `https://${domain}/document-builder.sjs?html=frame-${domain}` + ); +} + +function createFrameForUri(uri) { + return `<iframe src="${encodeURI(uri)}"></iframe>`; +} + +/** + * Create a XUL browser element in the provided XUL tab, with the provided type. + * + * @param {XULTab} tab + * The XUL tab in which the browser element should be inserted. + * @param {string} type + * The type attribute of the browser element, "chrome" or "content". + * @returns {XULBrowser} + * The created browser element. + */ +function createParentBrowserElement(tab, type) { + const parentBrowser = gBrowser.ownerDocument.createXULElement("browser"); + parentBrowser.setAttribute("type", type); + const container = gBrowser.getBrowserContainer(tab.linkedBrowser); + container.appendChild(parentBrowser); + + return parentBrowser; +} + +// Create a test page with 2 iframes: +// - one with a different eTLD+1 (example.com) +// - one with a nested iframe on a different eTLD+1 (example.net) +// +// Overall the document structure should look like: +// +// html (example.org) +// iframe (example.org) +// iframe (example.net) +// iframe(example.com) +// +// Which means we should have 4 browsing contexts in total. +function createTestMarkupWithFrames() { + // Create the markup for an example.net frame nested in an example.com frame. + const NESTED_FRAME_MARKUP = createFrameForUri( + `https://example.org/document-builder.sjs?html=${createFrame( + "example.net" + )}` + ); + + // Combine the nested frame markup created above with an example.com frame. + const TEST_URI_MARKUP = `${NESTED_FRAME_MARKUP}${createFrame("example.com")}`; + + // Create the test page URI on example.org. + return `https://example.org/document-builder.sjs?html=${encodeURI( + TEST_URI_MARKUP + )}`; +} + +const hasPromiseResolved = async function (promise) { + let resolved = false; + promise.finally(() => (resolved = true)); + // Make sure microtasks have time to run. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + return resolved; +}; + +/** + * Install a sidebar extension. + * + * @returns {object} + * Return value with two properties: + * - extension: test wrapper as returned by SpecialPowers.loadExtension. + * Make sure to explicitly call extension.unload() before the end of the test. + * - sidebarBrowser: the browser element containing the extension sidebar. + */ +async function installSidebarExtension() { + info("Load the test extension"); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + + files: { + "sidebar.html": ` + <!DOCTYPE html> + <html> + Test extension + <script src="sidebar.js"></script> + </html> + `, + "sidebar.js": function () { + const { browser } = this; + browser.test.sendMessage("sidebar-loaded", { + bcId: SpecialPowers.wrap(window).browsingContext.id, + }); + }, + "tab.html": ` + <!DOCTYPE html> + <html> + Test extension (tab) + <script src="tab.js"></script> + </html> + `, + "tab.js": function () { + const { browser } = this; + browser.test.sendMessage("tab-loaded", { + bcId: SpecialPowers.wrap(window).browsingContext.id, + }); + }, + }, + }); + + info("Wait for the extension to start"); + await extension.startup(); + + info("Wait for the extension browsing context"); + const { bcId } = await extension.awaitMessage("sidebar-loaded"); + const sidebarBrowser = BrowsingContext.get(bcId).top.embedderElement; + ok(sidebarBrowser, "Got a browser element for the extension sidebar"); + + return { + extension, + sidebarBrowser, + }; +} + +const SessionDataUpdateHelpers = { + getUpdates(rootMessageHandler, browsingContext) { + return rootMessageHandler.handleCommand({ + moduleName: "sessiondataupdate", + commandName: "getSessionDataUpdates", + destination: { + id: browsingContext.id, + type: WindowGlobalMessageHandler.type, + }, + }); + }, + + createSessionDataUpdate( + values, + method, + category, + descriptor = { type: ContextDescriptorType.All } + ) { + return { + method, + values, + moduleName: "sessiondataupdate", + category, + contextDescriptor: descriptor, + }; + }, + + assertUpdate(update, expectedValues, expectedCategory) { + is( + update.length, + expectedValues.length, + "Update has the expected number of values" + ); + + for (const item of update) { + info(`Check session data update item '${item.value}'`); + is(item.category, expectedCategory, "Item has the expected category"); + is( + expectedValues[update.indexOf(item)], + item.value, + "Item has the expected value" + ); + } + }, +}; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs new file mode 100644 index 0000000000..7d93f45b33 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs @@ -0,0 +1,40 @@ +/* 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/. */ + +export const modules = { + root: {}, + "windowglobal-in-root": {}, + windowglobal: {}, +}; + +const BASE_FOLDER = + "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/resources/modules"; + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(modules.root, { + command: `${BASE_FOLDER}/root/command.sys.mjs`, + event: `${BASE_FOLDER}/root/event.sys.mjs`, + invalid: `${BASE_FOLDER}/root/invalid.sys.mjs`, + rootOnly: `${BASE_FOLDER}/root/rootOnly.sys.mjs`, + windowglobaltoroot: `${BASE_FOLDER}/root/windowglobaltoroot.sys.mjs`, +}); + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(modules["windowglobal-in-root"], { + command: `${BASE_FOLDER}/windowglobal-in-root/command.sys.mjs`, + event: `${BASE_FOLDER}/windowglobal-in-root/event.sys.mjs`, +}); + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(modules.windowglobal, { + command: `${BASE_FOLDER}/windowglobal/command.sys.mjs`, + commandwindowglobalonly: `${BASE_FOLDER}/windowglobal/commandwindowglobalonly.sys.mjs`, + event: `${BASE_FOLDER}/windowglobal/event.sys.mjs`, + eventemitter: `${BASE_FOLDER}/windowglobal/eventemitter.sys.mjs`, + eventnointercept: `${BASE_FOLDER}/windowglobal/eventnointercept.sys.mjs`, + eventonprefchange: `${BASE_FOLDER}/windowglobal/eventonprefchange.sys.mjs`, + retry: `${BASE_FOLDER}/windowglobal/retry.sys.mjs`, + sessiondataupdate: `${BASE_FOLDER}/windowglobal/sessiondataupdate.sys.mjs`, + windowglobaltoroot: `${BASE_FOLDER}/windowglobal/windowglobaltoroot.sys.mjs`, +}); diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs new file mode 100644 index 0000000000..29e4a75828 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs @@ -0,0 +1,29 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class CommandModule extends Module { + destroy() {} + + /** + * Commands + */ + + testRootModule() { + return "root-value"; + } + + testMissingIntermediaryMethod(params, destination) { + // Spawn a new internal command, but with a commandName which doesn't match + // any method. + return this.messageHandler.handleCommand({ + moduleName: "command", + commandName: "missingMethod", + destination, + }); + } +} + +export const command = CommandModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs new file mode 100644 index 0000000000..e49437e80d --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs @@ -0,0 +1,21 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class EventModule extends Module { + destroy() {} + + /** + * Commands + */ + + testEmitRootEvent() { + this.emitEvent("event-from-root", { + text: "event from root", + }); + } +} + +export const event = EventModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs new file mode 100644 index 0000000000..3b74769d06 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs @@ -0,0 +1,4 @@ +// This module is meant to check error reporting when importing a module fails +// due to an actual issue (syntax error etc...). + +SyntaxError(; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs new file mode 100644 index 0000000000..0931a7ee8e --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs @@ -0,0 +1,70 @@ +/* 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 { ContextDescriptorType } from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs"; +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class RootOnlyModule extends Module { + #sessionDataReceived; + #subscribedEvents; + + constructor(messageHandler) { + super(messageHandler); + + this.#sessionDataReceived = []; + this.#subscribedEvents = new Set(); + } + + destroy() {} + + /** + * Commands + */ + + getSessionDataReceived() { + return this.#sessionDataReceived; + } + + testCommand(params = {}) { + return params; + } + + _applySessionData(params) { + const added = []; + const removed = []; + + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + for (const event of this.#subscribedEvents.values()) { + const hasSessionItem = filteredSessionData.some( + item => item.value === event + ); + // If there are no session items for this context, we should unsubscribe from the event. + if (!hasSessionItem) { + this.#subscribedEvents.delete(event); + removed.push(event); + } + } + + // Subscribe to all events, which have an item in SessionData + for (const { value } of filteredSessionData) { + if (!this.#subscribedEvents.has(value)) { + this.#subscribedEvents.add(value); + added.push(value); + } + } + + this.#sessionDataReceived.push({ + category: params.category, + added, + removed, + contextDescriptor: { + type: ContextDescriptorType.All, + }, + }); + } +} + +export const rootOnly = RootOnlyModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs new file mode 100644 index 0000000000..0975c4abd5 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs @@ -0,0 +1,29 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class WindowGlobalToRootModule extends Module { + destroy() {} + + /** + * Commands + */ + + getValueFromRoot() { + this.#assertParentProcess(); + return "root-value-called-from-windowglobal"; + } + + #assertParentProcess() { + const isParent = + Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT; + + if (!isParent) { + throw new Error("Can only run in the parent process"); + } + } +} + +export const windowglobaltoroot = WindowGlobalToRootModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs new file mode 100644 index 0000000000..f9a2e5d4eb --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs @@ -0,0 +1,28 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class CommandModule extends Module { + destroy() {} + + /** + * Commands + */ + + testInterceptModule() { + return "intercepted-value"; + } + + async testInterceptAndForwardModule(params, destination) { + const windowGlobalValue = await this.messageHandler.handleCommand({ + moduleName: "command", + commandName: "testForwardToWindowGlobal", + destination, + }); + return "intercepted-and-forward+" + windowGlobalValue; + } +} + +export const command = CommandModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs new file mode 100644 index 0000000000..be8b284e8d --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs @@ -0,0 +1,39 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class EventModule extends Module { + destroy() {} + + interceptEvent(name, payload) { + if (name === "event.testEventWithInterception") { + return { + ...payload, + additionalInformation: "information added through interception", + }; + } + + if (name === "event.testEventCancelableWithInterception") { + if (payload.shouldCancel) { + return null; + } + return payload; + } + + return payload; + } + + /** + * Commands + */ + + testEmitWindowGlobalInRootEvent(params, destination) { + this.emitEvent("event-from-window-global-in-root", { + text: `windowglobal-in-root event for ${destination.id}`, + }); + } +} + +export const event = EventModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs new file mode 100644 index 0000000000..99ee76a4b8 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs @@ -0,0 +1,85 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class CommandModule extends Module { + constructor(messageHandler) { + super(messageHandler); + this._subscribedEvents = new Set(); + + this._createdByMessageHandlerConstructor = + this._isCreatedByMessageHandlerConstructor(); + } + destroy() {} + + /** + * Commands + */ + + _applySessionData(params) { + if (params.category === "testCategory") { + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + for (const event of this._subscribedEvents.values()) { + const hasSessionItem = filteredSessionData.some( + item => item.value === event + ); + // If there are no session items for this context, we should unsubscribe from the event. + if (!hasSessionItem) { + this._subscribedEvents.delete(event); + } + } + + // Subscribe to all events, which have an item in SessionData + for (const { value } of filteredSessionData) { + if (!this._subscribedEvents.has(value)) { + this._subscribedEvents.add(value); + } + } + } + + if (params.category === "browser_session_data_browser_element") { + this.emitEvent("received-session-data", { + contextId: this.messageHandler.contextId, + }); + } + } + + testWindowGlobalModule() { + return "windowglobal-value"; + } + + testSetValue(params) { + const { value } = params; + + this._testValue = value; + } + + testGetValue() { + return this._testValue; + } + + testForwardToWindowGlobal() { + return "forward-to-windowglobal-value"; + } + + testIsCreatedByMessageHandlerConstructor() { + return this._createdByMessageHandlerConstructor; + } + + _isCreatedByMessageHandlerConstructor() { + let caller = Components.stack.caller; + while (caller) { + if (caller.name === this.messageHandler.constructor.name) { + return true; + } + caller = caller.caller; + } + return false; + } +} + +export const command = CommandModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs new file mode 100644 index 0000000000..1e4e6c1574 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs @@ -0,0 +1,41 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class CommandWindowGlobalOnlyModule extends Module { + destroy() {} + + /** + * Commands + */ + + testOnlyInWindowGlobal() { + return "only-in-windowglobal"; + } + + testBroadcast() { + return `broadcast-${this.messageHandler.contextId}`; + } + + testBroadcastWithParameter(params) { + return `broadcast-${this.messageHandler.contextId}-${params.value}`; + } + + testError() { + throw new Error("error-from-module"); + } + + testMissingIntermediaryMethod(params, destination) { + // Spawn a new internal command, but with a commandName which doesn't match + // any method. + return this.messageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "missingMethod", + destination, + }); + } +} + +export const commandwindowglobalonly = CommandWindowGlobalOnlyModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs new file mode 100644 index 0000000000..415f32032e --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs @@ -0,0 +1,32 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class EventModule extends Module { + destroy() {} + + /** + * Commands + */ + + testEmitEvent() { + // Emit a payload including the contextId to check which context emitted + // a specific event. + const text = `event from ${this.messageHandler.contextId}`; + this.emitEvent("event-from-window-global", { text }); + } + + testEmitEventCancelableWithInterception(params) { + this.emitEvent("event.testEventCancelableWithInterception", { + shouldCancel: params.shouldCancel, + }); + } + + testEmitEventWithInterception() { + this.emitEvent("event.testEventWithInterception", {}); + } +} + +export const event = EventModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs new file mode 100644 index 0000000000..c86954c5e0 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs @@ -0,0 +1,81 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class EventEmitterModule extends Module { + #isSubscribed; + #subscribedEvents; + + constructor(messageHandler) { + super(messageHandler); + this.#isSubscribed = false; + this.#subscribedEvents = new Set(); + } + + destroy() {} + + /** + * Commands + */ + + emitTestEvent() { + if (this.#isSubscribed) { + const text = `event from ${this.messageHandler.contextId}`; + this.emitEvent("eventemitter.testEvent", { text }); + } + + // Emit another event consistently for monitoring during the test. + this.emitEvent("eventemitter.monitoringEvent", {}); + } + + isSubscribed() { + return this.#isSubscribed; + } + + _applySessionData(params) { + const { category } = params; + if (category === "event") { + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + for (const event of this.#subscribedEvents.values()) { + const hasSessionItem = filteredSessionData.some( + item => item.value === event + ); + // If there are no session items for this context, we should unsubscribe from the event. + if (!hasSessionItem) { + this.#unsubscribeEvent(event); + } + } + + // Subscribe to all events, which have an item in SessionData + for (const { value } of filteredSessionData) { + this.#subscribeEvent(value); + } + } + } + + #subscribeEvent(event) { + if (event === "eventemitter.testEvent") { + if (this.#isSubscribed) { + throw new Error("Already subscribed to eventemitter.testEvent"); + } + this.#isSubscribed = true; + this.#subscribedEvents.add(event); + } + } + + #unsubscribeEvent(event) { + if (event === "eventemitter.testEvent") { + if (!this.#isSubscribed) { + throw new Error("Not subscribed to eventemitter.testEvent"); + } + this.#isSubscribed = false; + this.#subscribedEvents.delete(event); + } + } +} + +export const eventemitter = EventEmitterModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs new file mode 100644 index 0000000000..48bbfbf951 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs @@ -0,0 +1,16 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class EventNoInterceptModule extends Module { + destroy() {} + + testEvent() { + const text = `event no interception`; + this.emitEvent("eventnointercept.testEvent", { text }); + } +} + +export const eventnointercept = EventNoInterceptModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs new file mode 100644 index 0000000000..33cb25d10b --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs @@ -0,0 +1,33 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const TEST_PREF = "remote.messagehandler.test.pref"; + +class EventOnPrefChangeModule extends Module { + constructor(messageHandler) { + super(messageHandler); + Services.prefs.addObserver(TEST_PREF, this.#onPreferenceUpdated); + } + + destroy() { + Services.prefs.removeObserver(TEST_PREF, this.#onPreferenceUpdated); + } + + #onPreferenceUpdated = () => { + this.emitEvent("preference-changed"); + }; + + /** + * Commands + */ + + ping() { + // We only use this command to force creating the module. + return 1; + } +} + +export const eventonprefchange = EventOnPrefChangeModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs new file mode 100644 index 0000000000..f7b2279018 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs @@ -0,0 +1,84 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +// Store counters in the JSM scope to persist them across reloads. +let callsToBlockedOneTime = 0; +let callsToBlockedTenTimes = 0; +let callsToBlockedElevenTimes = 0; + +// This module provides various commands which all hang for various reasons. +// The test is supposed to trigger the command and then destroy the +// JSWindowActor pair by any mean (eg a navigation) in order to trigger an +// AbortError and a retry. +class RetryModule extends Module { + destroy() {} + + /** + * Commands + */ + + // Resolves only if called while on the example.net domain. + async blockedOnNetDomain(params) { + // Note: we do not store a call counter here, because this is used for a + // cross-group navigation test, and the JSM will be loaded in different + // processes. + const uri = this.messageHandler.window.document.baseURI; + if (!uri.includes("example.net")) { + await new Promise(r => {}); + } + + return { ...params }; + } + + // Resolves only if called more than once. + async blockedOneTime(params) { + callsToBlockedOneTime++; + if (callsToBlockedOneTime < 2) { + await new Promise(r => {}); + } + + // Return: + // - params sent to the command to check that retries have correct params + // - the call counter + return { ...params, callsToCommand: callsToBlockedOneTime }; + } + + // Resolves only if called more than ten times (which is exactly the maximum + // of retry attempts). + async blockedTenTimes(params) { + callsToBlockedTenTimes++; + if (callsToBlockedTenTimes < 11) { + await new Promise(r => {}); + } + + // Return: + // - params sent to the command to check that retries have correct params + // - the call counter + return { ...params, callsToCommand: callsToBlockedTenTimes }; + } + + // Resolves only if called more than eleven times (which is greater than the + // maximum of retry attempts). + async blockedElevenTimes(params) { + callsToBlockedElevenTimes++; + if (callsToBlockedElevenTimes < 12) { + await new Promise(r => {}); + } + + // Return: + // - params sent to the command to check that retries have correct params + // - the call counter + return { ...params, callsToCommand: callsToBlockedElevenTimes }; + } + + cleanup() { + callsToBlockedOneTime = 0; + callsToBlockedTenTimes = 0; + callsToBlockedElevenTimes = 0; + } +} + +export const retry = RetryModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs new file mode 100644 index 0000000000..5e9ce00b46 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs @@ -0,0 +1,33 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +class SessionDataUpdateModule extends Module { + #sessionDataUpdates; + + constructor(messageHandler) { + super(messageHandler); + this.#sessionDataUpdates = []; + } + + destroy() {} + + /** + * Commands + */ + + _applySessionData(params) { + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + this.#sessionDataUpdates.push(filteredSessionData); + } + + getSessionDataUpdates() { + return this.#sessionDataUpdates; + } +} + +export const sessiondataupdate = SessionDataUpdateModule; diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs new file mode 100644 index 0000000000..815a836d9c --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs @@ -0,0 +1,47 @@ +/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; +import { RootMessageHandler } from "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"; + +class WindowGlobalToRootModule extends Module { + constructor(messageHandler) { + super(messageHandler); + this.#assertContentProcess(); + } + + destroy() {} + + /** + * Commands + */ + + testHandleCommandToRoot(params, destination) { + return this.messageHandler.handleCommand({ + moduleName: "windowglobaltoroot", + commandName: "getValueFromRoot", + destination: { + type: RootMessageHandler.type, + }, + }); + } + + testSendRootCommand(params, destination) { + return this.messageHandler.sendRootCommand({ + moduleName: "windowglobaltoroot", + commandName: "getValueFromRoot", + }); + } + + #assertContentProcess() { + const isContent = + Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; + + if (!isContent) { + throw new Error("Can only run in a content process"); + } + } +} + +export const windowglobaltoroot = WindowGlobalToRootModule; diff --git a/remote/shared/messagehandler/test/browser/webdriver/browser.toml b/remote/shared/messagehandler/test/browser/webdriver/browser.toml new file mode 100644 index 0000000000..45ccca74ef --- /dev/null +++ b/remote/shared/messagehandler/test/browser/webdriver/browser.toml @@ -0,0 +1,7 @@ +[DEFAULT] +tags = "remote" +subsuite = "remote" +support-files = ["!/remote/shared/messagehandler/test/browser/resources/*"] +prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"] + +["browser_session_execute_command_errors.js"] diff --git a/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js b/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js new file mode 100644 index 0000000000..36a510bb29 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { WebDriverSession } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Session.sys.mjs" +); + +const { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" +); + +add_task(async function test_execute_missing_command_error() { + const session = new WebDriverSession(); + + info("Attempt to execute an unknown protocol command"); + await Assert.rejects( + session.execute("command", "missingCommand"), + err => + err.name == "UnknownCommandError" && + err.message == `command.missingCommand` + ); +}); + +add_task(async function test_execute_missing_internal_command_error() { + const session = new WebDriverSession(); + + info( + "Attempt to execute a protocol command which relies on an unknown internal method" + ); + await Assert.rejects( + session.execute("command", "testMissingIntermediaryMethod"), + err => + err.name == "UnsupportedCommandError" && + err.message == + `command.missingMethod not supported for destination ROOT` && + !error.isWebDriverError(err) + ); +}); diff --git a/remote/shared/messagehandler/test/xpcshell/test_Errors.js b/remote/shared/messagehandler/test/xpcshell/test_Errors.js new file mode 100644 index 0000000000..26187dac11 --- /dev/null +++ b/remote/shared/messagehandler/test/xpcshell/test_Errors.js @@ -0,0 +1,91 @@ +/* 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 { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/Errors.sys.mjs" +); + +// Note: this test file is similar to remote/shared/webdriver/test/xpcshell/test_Errors.js +// because shared/webdriver/Errors.jsm and shared/messagehandler/Errors.jsm share +// similar helpers. + +add_task(function test_toJSON() { + let e0 = new error.MessageHandlerError(); + let e0s = e0.toJSON(); + equal(e0s.error, "message handler error"); + equal(e0s.message, ""); + + let e1 = new error.MessageHandlerError("a"); + let e1s = e1.toJSON(); + equal(e1s.message, e1.message); + + let e2 = new error.UnsupportedCommandError("foo"); + let e2s = e2.toJSON(); + equal(e2.status, e2s.error); + equal(e2.message, e2s.message); +}); + +add_task(function test_fromJSON() { + Assert.throws( + () => error.MessageHandlerError.fromJSON({ error: "foo" }), + /Not of MessageHandlerError descent/ + ); + Assert.throws( + () => error.MessageHandlerError.fromJSON({ error: "Error" }), + /Not of MessageHandlerError descent/ + ); + Assert.throws( + () => error.MessageHandlerError.fromJSON({}), + /Undeserialisable error type/ + ); + Assert.throws( + () => error.MessageHandlerError.fromJSON(undefined), + /TypeError/ + ); + + let e1 = new error.MessageHandlerError("1"); + let e1r = error.MessageHandlerError.fromJSON({ + error: "message handler error", + message: "1", + }); + ok(e1r instanceof error.MessageHandlerError); + equal(e1r.name, e1.name); + equal(e1r.status, e1.status); + equal(e1r.message, e1.message); + + let e2 = new error.UnsupportedCommandError("foo"); + let e2r = error.MessageHandlerError.fromJSON({ + error: "unsupported message handler command", + message: "foo", + }); + ok(e2r instanceof error.MessageHandlerError); + ok(e2r instanceof error.UnsupportedCommandError); + equal(e2r.name, e2.name); + equal(e2r.status, e2.status); + equal(e2r.message, e2.message); + + // parity with toJSON + let e3 = new error.UnsupportedCommandError("foo"); + let e3toJSON = e3.toJSON(); + let e3fromJSON = error.MessageHandlerError.fromJSON(e3toJSON); + equal(e3toJSON.error, e3fromJSON.status); + equal(e3toJSON.message, e3fromJSON.message); + equal(e3toJSON.stacktrace, e3fromJSON.stack); +}); + +add_task(function test_MessageHandlerError() { + let err = new error.MessageHandlerError("foo"); + equal("MessageHandlerError", err.name); + equal("foo", err.message); + equal("message handler error", err.status); + ok(err instanceof error.MessageHandlerError); +}); + +add_task(function test_UnsupportedCommandError() { + let e = new error.UnsupportedCommandError("foo"); + equal("UnsupportedCommandError", e.name); + equal("foo", e.message); + equal("unsupported message handler command", e.status); + ok(e instanceof error.MessageHandlerError); +}); diff --git a/remote/shared/messagehandler/test/xpcshell/test_SessionData.js b/remote/shared/messagehandler/test/xpcshell/test_SessionData.js new file mode 100644 index 0000000000..ef61ce27d4 --- /dev/null +++ b/remote/shared/messagehandler/test/xpcshell/test_SessionData.js @@ -0,0 +1,296 @@ +/* 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 { ContextDescriptorType } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs" +); +const { RootMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs" +); +const { SessionData, SessionDataMethod } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs" +); + +add_task(async function test_sessionData() { + const sessionData = new SessionData(new RootMessageHandler("session-id-1")); + equal(sessionData.getSessionData("mod", "event").length, 0); + + const globalContext = { + type: ContextDescriptorType.All, + }; + const otherContext = { type: "other-type", id: "some-id" }; + + info("Add a first event for the global context"); + let updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, globalContext, ["first.event"]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Add, "One item added"); + let updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value added"); + equal(updatedValues[0], "first.event", "Expected value was added"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: globalContext, + }, + ]); + + info("Add the exact same data (same module, type, context, value)"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, globalContext, ["first.event"]), + ]); + equal(updatedItems.length, 0, "No new item updated"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: globalContext, + }, + ]); + + info("Add another context for the same event"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Add, "One item added"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value added"); + equal(updatedValues[0], "first.event", "Expected value was added"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: globalContext, + }, + { + value: "first.event", + contextDescriptor: otherContext, + }, + ]); + + info("Add a second event for the global context"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, globalContext, ["second.event"]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Add, "One item added"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value added"); + equal(updatedValues[0], "second.event", "Expected value was added"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: globalContext, + }, + { + value: "first.event", + contextDescriptor: otherContext, + }, + { + value: "second.event", + contextDescriptor: globalContext, + }, + ]); + + info("Add two events for the global context"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, globalContext, [ + "third.event", + "fourth.event", + ]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Add, "One item added"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 2, "Two values added"); + equal(updatedValues[0], "third.event", "Expected value was added"); + equal(updatedValues[1], "fourth.event", "Expected value was added"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: globalContext, + }, + { + value: "first.event", + contextDescriptor: otherContext, + }, + { + value: "second.event", + contextDescriptor: globalContext, + }, + { + value: "third.event", + contextDescriptor: globalContext, + }, + { + value: "fourth.event", + contextDescriptor: globalContext, + }, + ]); + + info("Remove the second, third and fourth events"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Remove, globalContext, [ + "second.event", + "third.event", + "fourth.event", + ]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 3, "Three values removed"); + equal(updatedValues[0], "second.event", "Expected value was removed"); + equal(updatedValues[1], "third.event", "Expected value was removed"); + equal(updatedValues[2], "fourth.event", "Expected value was removed"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: globalContext, + }, + { + value: "first.event", + contextDescriptor: otherContext, + }, + ]); + + info("Remove the global context from the first event"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Remove, globalContext, ["first.event"]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value removed"); + equal(updatedValues[0], "first.event", "Expected value was removed"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: otherContext, + }, + ]); + + info("Remove the other context from the first event"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value removed"); + equal(updatedValues[0], "first.event", "Expected value was removed"); + checkEvents(sessionData.getSessionData("mod", "event"), []); + + info("Add two events for different contexts"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]), + createUpdate(SessionDataMethod.Add, globalContext, ["second.event"]), + ]); + equal(updatedItems.length, 2, "Two items updated"); + equal(updatedItems[0].method, SessionDataMethod.Add, "First item added"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value for first item added"); + equal(updatedValues[0], "first.event", "Expected value first item was added"); + equal(updatedItems[1].method, SessionDataMethod.Add, "Second item added"); + updatedValues = updatedItems[1].values; + equal(updatedValues.length, 1, "One value for second item added"); + equal( + updatedValues[0], + "second.event", + "Expected value second item was added" + ); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: otherContext, + }, + { + value: "second.event", + contextDescriptor: globalContext, + }, + ]); + + info("Remove two events for different contexts"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]), + createUpdate(SessionDataMethod.Remove, globalContext, ["second.event"]), + ]); + equal(updatedItems.length, 2, "Two items updated"); + equal(updatedItems[0].method, SessionDataMethod.Remove, "First item removed"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value for first item removed"); + equal( + updatedValues[0], + "first.event", + "Expected value first item was removed" + ); + equal( + updatedItems[1].method, + SessionDataMethod.Remove, + "Second item removed" + ); + updatedValues = updatedItems[1].values; + equal(updatedValues.length, 1, "One value for second item removed"); + equal( + updatedValues[0], + "second.event", + "Expected value second item was removed" + ); + checkEvents(sessionData.getSessionData("mod", "event"), []); + + info("Add and remove event in different order"); + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]), + createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]), + ]); + equal(updatedItems.length, 1, "One item updated"); + equal(updatedItems[0].method, SessionDataMethod.Add, "One item added"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value added"); + equal(updatedValues[0], "first.event", "Expected value was added"); + checkEvents(sessionData.getSessionData("mod", "event"), [ + { + value: "first.event", + contextDescriptor: otherContext, + }, + ]); + + updatedItems = sessionData.applySessionData([ + createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]), + createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]), + ]); + equal(updatedItems.length, 1, "No item update"); + equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed"); + updatedValues = updatedItems[0].values; + equal(updatedValues.length, 1, "One value removed"); + equal(updatedValues[0], "first.event", "Expected value was removed"); + checkEvents(sessionData.getSessionData("mod", "event"), []); +}); + +function checkEvents(events, expectedEvents) { + // Check the arrays have the same size. + equal(events.length, expectedEvents.length); + + // Check all the expectedEvents can be found in the events array. + for (const expected of expectedEvents) { + ok( + events.some( + event => + expected.contextDescriptor.type === event.contextDescriptor.type && + expected.contextDescriptor.id === event.contextDescriptor.id && + expected.value == event.value + ) + ); + } +} + +function createUpdate(method, contextDescriptor, values) { + return { + method, + moduleName: "mod", + category: "event", + contextDescriptor, + values, + }; +} diff --git a/remote/shared/messagehandler/test/xpcshell/xpcshell.toml b/remote/shared/messagehandler/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..10f8b2f715 --- /dev/null +++ b/remote/shared/messagehandler/test/xpcshell/xpcshell.toml @@ -0,0 +1,5 @@ +[DEFAULT] + +["test_Errors.js"] + +["test_SessionData.js"] diff --git a/remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs b/remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs new file mode 100644 index 0000000000..482f90948a --- /dev/null +++ b/remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs @@ -0,0 +1,57 @@ +/* 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/. */ + +function isExtensionContext(browsingContext) { + let principal; + if (CanonicalBrowsingContext.isInstance(browsingContext)) { + principal = browsingContext.currentWindowGlobal.documentPrincipal; + } else { + principal = browsingContext.window.document.nodePrincipal; + } + + // In practice, note that the principal will never be an expanded principal. + // The are only used for content scripts executed in a Sandbox, and do not + // have a browsing context on their own. + // But we still use this flag because there is no isAddonPrincipal flag. + return principal.isAddonOrExpandedAddonPrincipal; +} + +function isParentProcess(browsingContext) { + if (CanonicalBrowsingContext.isInstance(browsingContext)) { + return browsingContext.currentWindowGlobal.osPid === -1; + } + + // If `browsingContext` is not a `CanonicalBrowsingContext`, then we are + // necessarily in a content process page. + return false; +} + +/** + * Check if the given browsing context is valid for the message handler + * to use. + * + * @param {BrowsingContext} browsingContext + * The browsing context to check. + * @param {object=} options + * @param {string=} options.browserId + * The id of the browser to filter the browsing contexts by (optional). + * @returns {boolean} + * True if the browsing context is valid, false otherwise. + */ +export function isBrowsingContextCompatible(browsingContext, options = {}) { + const { browserId } = options; + + // If a browserId was provided, skip browsing contexts which are not + // associated with this browserId. + if (browserId !== undefined && browsingContext.browserId !== browserId) { + return false; + } + + // Skip: + // - extension contexts until we support debugging webextensions, see Bug 1755014. + // - privileged contexts until we support debugging Chrome context, see Bug 1713440. + return ( + !isExtensionContext(browsingContext) && !isParentProcess(browsingContext) + ); +} diff --git a/remote/shared/messagehandler/transports/RootTransport.sys.mjs b/remote/shared/messagehandler/transports/RootTransport.sys.mjs new file mode 100644 index 0000000000..b60d3726ef --- /dev/null +++ b/remote/shared/messagehandler/transports/RootTransport.sys.mjs @@ -0,0 +1,188 @@ +/* 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, { + ContextDescriptorType: + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs", + isBrowsingContextCompatible: + "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + MessageHandlerFrameActor: + "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +const MAX_RETRY_ATTEMPTS = 10; + +/** + * RootTransport is intended to be used from a ROOT MessageHandler to communicate + * with WINDOW_GLOBAL MessageHandlers via the MessageHandlerFrame JSWindow + * actors. + */ +export class RootTransport { + /** + * @param {MessageHandler} messageHandler + * The MessageHandler instance which owns this RootTransport instance. + */ + constructor(messageHandler) { + this._messageHandler = messageHandler; + + // RootTransport will rely on the MessageHandlerFrame JSWindow actors. + // Make sure they are registered when instanciating a RootTransport. + lazy.MessageHandlerFrameActor.register(); + } + + /** + * Forward the provided command to WINDOW_GLOBAL MessageHandlers via the + * MessageHandlerFrame actors. + * + * @param {Command} command + * The command to forward. See type definition in MessageHandler.js + * @returns {Promise} + * Returns a promise that resolves with the result of the command after + * being processed by WINDOW_GLOBAL MessageHandlers. + */ + forwardCommand(command) { + if (command.destination.id && command.destination.contextDescriptor) { + throw new Error( + "Invalid command destination with both 'id' and 'contextDescriptor' properties" + ); + } + + // With an id given forward the command to only this specific destination. + if (command.destination.id) { + const browsingContext = BrowsingContext.get(command.destination.id); + if (!browsingContext) { + throw new Error( + "Unable to find a BrowsingContext for id " + command.destination.id + ); + } + return this._sendCommandToBrowsingContext(command, browsingContext); + } + + // ... otherwise broadcast to destinations matching the contextDescriptor. + if (command.destination.contextDescriptor) { + return this._broadcastCommand(command); + } + + throw new Error( + "Unrecognized command destination, missing 'id' or 'contextDescriptor' properties" + ); + } + + _broadcastCommand(command) { + const { contextDescriptor } = command.destination; + const browsingContexts = + this._getBrowsingContextsForDescriptor(contextDescriptor); + + return Promise.all( + browsingContexts.map(async browsingContext => { + try { + return await this._sendCommandToBrowsingContext( + command, + browsingContext + ); + } catch (e) { + console.error( + `Failed to broadcast a command to browsingContext ${browsingContext.id}`, + e + ); + return null; + } + }) + ); + } + + async _sendCommandToBrowsingContext(command, browsingContext) { + const name = `${command.moduleName}.${command.commandName}`; + + // The browsing context might be destroyed by a navigation. Keep a reference + // to the webProgress, which will persist, and always use it to retrieve the + // currently valid browsing context. + const webProgress = browsingContext.webProgress; + + const { retryOnAbort = false } = command; + + let attempts = 0; + while (true) { + try { + return await webProgress.browsingContext.currentWindowGlobal + .getActor("MessageHandlerFrame") + .sendCommand(command, this._messageHandler.sessionId); + } catch (e) { + if (!retryOnAbort || e.name != "AbortError") { + // Only retry if the command supports retryOnAbort and when the + // JSWindowActor pair gets destroyed. + throw e; + } + + if (++attempts > MAX_RETRY_ATTEMPTS) { + lazy.logger.trace( + `RootTransport reached the limit of retry attempts (${MAX_RETRY_ATTEMPTS})` + + ` for command ${name} and browsing context ${webProgress.browsingContext.id}.` + ); + throw e; + } + + lazy.logger.trace( + `RootTransport retrying command ${name} for ` + + `browsing context ${webProgress.browsingContext.id}, attempt: ${attempts}.` + ); + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + } + } + } + + toString() { + return `[object ${this.constructor.name} ${this._messageHandler.name}]`; + } + + _getBrowsingContextsForDescriptor(contextDescriptor) { + const { id, type } = contextDescriptor; + + if (type === lazy.ContextDescriptorType.All) { + return this._getBrowsingContexts(); + } + + if (type === lazy.ContextDescriptorType.TopBrowsingContext) { + return this._getBrowsingContexts({ browserId: id }); + } + + // TODO: Handle other types of context descriptors. + throw new Error( + `Unsupported contextDescriptor type for broadcasting: ${type}` + ); + } + + /** + * Get all browsing contexts, optionally matching the provided options. + * + * @param {object} options + * @param {string=} options.browserId + * The id of the browser to filter the browsing contexts by (optional). + * @returns {Array<BrowsingContext>} + * The browsing contexts matching the provided options or all browsing contexts + * if no options are provided. + */ + _getBrowsingContexts(options = {}) { + // extract browserId from options + const { browserId } = options; + let browsingContexts = []; + + // Fetch all tab related browsing contexts for top-level windows. + for (const { browsingContext } of lazy.TabManager.browsers) { + if (lazy.isBrowsingContextCompatible(browsingContext, { browserId })) { + browsingContexts = browsingContexts.concat( + browsingContext.getAllBrowsingContextsInSubtree() + ); + } + } + + return browsingContexts; + } +} diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs new file mode 100644 index 0000000000..c236cebac7 --- /dev/null +++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs @@ -0,0 +1,51 @@ +/* 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, { + ActorManagerParent: "resource://gre/modules/ActorManagerParent.sys.mjs", + + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +const FRAME_ACTOR_CONFIG = { + parent: { + esModuleURI: + "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs", + }, + child: { + esModuleURI: + "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs", + events: { + DOMWindowCreated: {}, + pagehide: {}, + pageshow: {}, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], +}; + +/** + * MessageHandlerFrameActor exposes a simple registration helper to lazily + * register MessageHandlerFrame JSWindow actors. + */ +export const MessageHandlerFrameActor = { + registered: false, + + register() { + if (this.registered) { + return; + } + + lazy.ActorManagerParent.addJSWindowActors({ + MessageHandlerFrame: FRAME_ACTOR_CONFIG, + }); + this.registered = true; + lazy.logger.trace("Registered MessageHandlerFrame actors"); + }, +}; diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs new file mode 100644 index 0000000000..52a8fdc4c9 --- /dev/null +++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs @@ -0,0 +1,111 @@ +/* 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, { + isBrowsingContextCompatible: + "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs", + MessageHandlerRegistry: + "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +/** + * Map from MessageHandlerRegistry to MessageHandlerFrameChild actor. This will + * allow a WindowGlobalMessageHandler to find the JSWindowActorChild instance to + * use to send commands. + */ +const registryToActor = new WeakMap(); + +/** + * Retrieve the MessageHandlerFrameChild which is linked to the provided + * WindowGlobalMessageHandler instance. + * + * @param {WindowGlobalMessageHandler} messageHandler + * The WindowGlobalMessageHandler for which to get the JSWindowActor. + * @returns {MessageHandlerFrameChild} + * The corresponding MessageHandlerFrameChild instance. + */ +export function getMessageHandlerFrameChildActor(messageHandler) { + return registryToActor.get(messageHandler.registry); +} + +/** + * Child actor for the MessageHandlerFrame JSWindowActor. The + * MessageHandlerFrame actor is used by RootTransport to communicate between + * ROOT MessageHandlers and WINDOW_GLOBAL MessageHandlers. + */ +export class MessageHandlerFrameChild extends JSWindowActorChild { + actorCreated() { + this.type = lazy.WindowGlobalMessageHandler.type; + this.context = this.manager.browsingContext; + + this._registry = new lazy.MessageHandlerRegistry(this.type, this.context); + registryToActor.set(this._registry, this); + + this._onRegistryEvent = this._onRegistryEvent.bind(this); + + // MessageHandlerFrameChild is responsible for forwarding events from + // WindowGlobalMessageHandler to the parent process. + // Such events are re-emitted on the MessageHandlerRegistry to avoid + // setting up listeners on individual MessageHandler instances. + this._registry.on("message-handler-registry-event", this._onRegistryEvent); + } + + handleEvent({ persisted, type }) { + if (type == "DOMWindowCreated" || (type == "pageshow" && persisted)) { + // When the window is created or is retrieved from BFCache, instantiate + // a MessageHandler for all sessions which might need it. + if (lazy.isBrowsingContextCompatible(this.manager.browsingContext)) { + this._registry.createAllMessageHandlers(); + } + } else if (type == "pagehide" && persisted) { + // When the page is moved to BFCache, all the currently created message + // handlers should be destroyed. + this._registry.destroy(); + } + } + + async receiveMessage(message) { + if (message.name === "MessageHandlerFrameParent:sendCommand") { + const { sessionId, command } = message.data; + const messageHandler = + this._registry.getOrCreateMessageHandler(sessionId); + try { + return await messageHandler.handleCommand(command); + } catch (e) { + if (e?.isRemoteError) { + return { + error: e.toJSON(), + isMessageHandlerError: e.isMessageHandlerError, + }; + } + throw e; + } + } + + return null; + } + + sendCommand(command, sessionId) { + return this.sendQuery("MessageHandlerFrameChild:sendCommand", { + command, + sessionId, + }); + } + + _onRegistryEvent(eventName, wrappedEvent) { + this.sendAsyncMessage( + "MessageHandlerFrameChild:messageHandlerEvent", + wrappedEvent + ); + } + + didDestroy() { + this._registry.off("message-handler-registry-event", this._onRegistryEvent); + this._registry.destroy(); + } +} diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs new file mode 100644 index 0000000000..a4901571d9 --- /dev/null +++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs @@ -0,0 +1,127 @@ +/* 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/messagehandler/Errors.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + RootMessageHandlerRegistry: + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +ChromeUtils.defineLazyGetter(lazy, "WebDriverError", () => { + return ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" + ).error.WebDriverError; +}); + +/** + * Parent actor for the MessageHandlerFrame JSWindowActor. The + * MessageHandlerFrame actor is used by RootTransport to communicate between + * ROOT MessageHandlers and WINDOW_GLOBAL MessageHandlers. + */ +export class MessageHandlerFrameParent extends JSWindowActorParent { + async receiveMessage(message) { + switch (message.name) { + case "MessageHandlerFrameChild:sendCommand": { + return this.#handleSendCommandMessage(message.data); + } + case "MessageHandlerFrameChild:messageHandlerEvent": { + return this.#handleMessageHandlerEventMessage(message.data); + } + default: + throw new Error("Unsupported message:" + message.name); + } + } + + /** + * Send a command to the corresponding MessageHandlerFrameChild actor via a + * JSWindowActor query. + * + * @param {Command} command + * The command to forward. See type definition in MessageHandler.js + * @param {string} sessionId + * ID of the session that sent the command. + * @returns {Promise} + * Promise that will resolve with the result of query sent to the + * MessageHandlerFrameChild actor. + */ + async sendCommand(command, sessionId) { + const result = await this.sendQuery( + "MessageHandlerFrameParent:sendCommand", + { + command, + sessionId, + } + ); + + if (result?.error) { + if (result.isMessageHandlerError) { + throw lazy.error.MessageHandlerError.fromJSON(result.error); + } + + // TODO: Do not assume WebDriver is the session protocol, see Bug 1779026. + throw lazy.WebDriverError.fromJSON(result.error); + } + + return result; + } + + async #handleMessageHandlerEventMessage(messageData) { + const { name, contextInfo, data, sessionId } = messageData; + const [moduleName] = name.split("."); + + // Re-emit the event on the RootMessageHandler. + const messageHandler = + lazy.RootMessageHandlerRegistry.getExistingMessageHandler(sessionId); + // TODO: getModuleInstance expects a CommandDestination in theory, + // but only uses the MessageHandler type in practice, see Bug 1776389. + const module = messageHandler.moduleCache.getModuleInstance(moduleName, { + type: lazy.WindowGlobalMessageHandler.type, + }); + let eventPayload = data; + + // Modify an event payload if there is a special method in the targeted module. + // If present it can be found in windowglobal-in-root module. + if (module?.interceptEvent) { + eventPayload = await module.interceptEvent(name, data); + + if (eventPayload === null) { + lazy.logger.trace( + `${moduleName}.interceptEvent returned null, skipping event: ${name}, data: ${data}` + ); + return; + } + // Make sure that an event payload is returned. + if (!eventPayload) { + throw new Error( + `${moduleName}.interceptEvent doesn't return the event payload` + ); + } + } + messageHandler.emitEvent(name, eventPayload, contextInfo); + } + + async #handleSendCommandMessage(messageData) { + const { sessionId, command } = messageData; + const messageHandler = + lazy.RootMessageHandlerRegistry.getExistingMessageHandler(sessionId); + try { + return await messageHandler.handleCommand(command); + } catch (e) { + if (e?.isRemoteError) { + return { + error: e.toJSON(), + isMessageHandlerError: e.isMessageHandlerError, + }; + } + throw e; + } + } +} diff --git a/remote/shared/moz.build b/remote/shared/moz.build new file mode 100644 index 0000000000..69b7d9e8a1 --- /dev/null +++ b/remote/shared/moz.build @@ -0,0 +1,17 @@ +# 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/. + +BROWSER_CHROME_MANIFESTS += [ + "listeners/test/browser/browser.toml", + "messagehandler/test/browser/broadcast/browser.toml", + "messagehandler/test/browser/browser.toml", + "messagehandler/test/browser/webdriver/browser.toml", + "test/browser/browser.toml", +] + +XPCSHELL_TESTS_MANIFESTS += [ + "messagehandler/test/xpcshell/xpcshell.toml", + "test/xpcshell/xpcshell.toml", + "webdriver/test/xpcshell/xpcshell.toml", +] diff --git a/remote/shared/test/browser/browser.toml b/remote/shared/test/browser/browser.toml new file mode 100644 index 0000000000..de336a1cb7 --- /dev/null +++ b/remote/shared/test/browser/browser.toml @@ -0,0 +1,16 @@ +[DEFAULT] +tags = "remote" +subsuite = "remote" +support-files = ["head.js"] + +["browser_NavigationManager.js"] + +["browser_NavigationManager_failed_navigation.js"] + +["browser_NavigationManager_no_navigation.js"] + +["browser_NavigationManager_notify.js"] + +["browser_TabManager.js"] + +["browser_UserContextManager.js"] diff --git a/remote/shared/test/browser/browser_NavigationManager.js b/remote/shared/test/browser/browser_NavigationManager.js new file mode 100644 index 0000000000..7e0464c2fa --- /dev/null +++ b/remote/shared/test/browser/browser_NavigationManager.js @@ -0,0 +1,372 @@ +/* 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 { NavigationManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/NavigationManager.sys.mjs" +); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +const FIRST_URL = "https://example.com/document-builder.sjs?html=first"; +const SECOND_URL = "https://example.com/document-builder.sjs?html=second"; +const THIRD_URL = "https://example.com/document-builder.sjs?html=third"; + +const FIRST_COOP_URL = + "https://example.com/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=first_coop"; +const SECOND_COOP_URL = + "https://example.net/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=second_coop"; + +add_task(async function test_simpleNavigation() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + const tab = addTab(gBrowser, FIRST_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + const navigableId = TabManager.getIdForBrowser(browser); + + navigationManager.startMonitoring(); + is( + navigationManager.getNavigationForBrowsingContext(browser.browsingContext), + null, + "No navigation recorded yet" + ); + is(events.length, 0, "No event recorded"); + + await loadURL(browser, SECOND_URL); + + const firstNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(firstNavigation, SECOND_URL); + + is(events.length, 2, "Two events recorded"); + assertNavigationEvents( + events, + SECOND_URL, + firstNavigation.navigationId, + navigableId + ); + + await loadURL(browser, THIRD_URL); + + const secondNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(secondNavigation, THIRD_URL); + assertUniqueNavigationIds(firstNavigation, secondNavigation); + + is(events.length, 4, "Two new events recorded"); + assertNavigationEvents( + events, + THIRD_URL, + secondNavigation.navigationId, + navigableId + ); + + navigationManager.stopMonitoring(); + + // Navigate again to the first URL + await loadURL(browser, FIRST_URL); + is(events.length, 4, "No new event recorded"); + is( + navigationManager.getNavigationForBrowsingContext(browser.browsingContext), + null, + "No navigation recorded" + ); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); +}); + +add_task(async function test_loadTwoTabsSimultaneously() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + navigationManager.startMonitoring(); + + info("Add two tabs simultaneously"); + const tab1 = addTab(gBrowser, FIRST_URL); + const browser1 = tab1.linkedBrowser; + const navigableId1 = TabManager.getIdForBrowser(browser1); + const onLoad1 = BrowserTestUtils.browserLoaded(browser1, false, FIRST_URL); + + const tab2 = addTab(gBrowser, SECOND_URL); + const browser2 = tab2.linkedBrowser; + const navigableId2 = TabManager.getIdForBrowser(browser2); + const onLoad2 = BrowserTestUtils.browserLoaded(browser2, false, SECOND_URL); + + info("Wait for the tabs to load"); + await Promise.all([onLoad1, onLoad2]); + + is(events.length, 4, "Recorded 4 navigation events"); + + info("Check navigation monitored for tab1"); + const nav1 = navigationManager.getNavigationForBrowsingContext( + browser1.browsingContext + ); + assertNavigation(nav1, FIRST_URL); + assertNavigationEvents(events, FIRST_URL, nav1.navigationId, navigableId1); + + info("Check navigation monitored for tab2"); + const nav2 = navigationManager.getNavigationForBrowsingContext( + browser2.browsingContext + ); + assertNavigation(nav2, SECOND_URL); + assertNavigationEvents(events, SECOND_URL, nav2.navigationId, navigableId2); + assertUniqueNavigationIds(nav1, nav2); + + info("Reload the two tabs simultaneously"); + await Promise.all([ + BrowserTestUtils.reloadTab(tab1), + BrowserTestUtils.reloadTab(tab2), + ]); + + is(events.length, 8, "Recorded 8 navigation events"); + + info("Check the second navigation for tab1"); + const nav3 = navigationManager.getNavigationForBrowsingContext( + browser1.browsingContext + ); + assertNavigation(nav3, FIRST_URL); + assertNavigationEvents(events, FIRST_URL, nav3.navigationId, navigableId1); + + info("Check the second navigation monitored for tab2"); + const nav4 = navigationManager.getNavigationForBrowsingContext( + browser2.browsingContext + ); + assertNavigation(nav4, SECOND_URL); + assertNavigationEvents(events, SECOND_URL, nav4.navigationId, navigableId2); + assertUniqueNavigationIds(nav1, nav2, nav3, nav4); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); + +add_task(async function test_loadPageWithIframes() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + navigationManager.startMonitoring(); + + info("Add a tab with iframes"); + const testUrl = createTestPageWithFrames(); + const tab = addTab(gBrowser, testUrl); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser, false, testUrl); + + is(events.length, 8, "Recorded 8 navigation events"); + const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + + const navigations = []; + for (const context of contexts) { + const navigation = + navigationManager.getNavigationForBrowsingContext(context); + const navigable = TabManager.getIdForBrowsingContext(context); + + const url = context.currentWindowGlobal.documentURI.spec; + assertNavigation(navigation, url); + assertNavigationEvents(events, url, navigation.navigationId, navigable); + navigations.push(navigation); + } + assertUniqueNavigationIds(...navigations); + + await BrowserTestUtils.reloadTab(tab); + + is(events.length, 16, "Recorded 8 additional navigation events"); + const newContexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + + for (const context of newContexts) { + const navigation = + navigationManager.getNavigationForBrowsingContext(context); + const navigable = TabManager.getIdForBrowsingContext(context); + + const url = context.currentWindowGlobal.documentURI.spec; + assertNavigation(navigation, url); + assertNavigationEvents(events, url, navigation.navigationId, navigable); + navigations.push(navigation); + } + assertUniqueNavigationIds(...navigations); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); + +add_task(async function test_loadPageWithCoop() { + const tab = addTab(gBrowser, FIRST_COOP_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser, false, FIRST_COOP_URL); + + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + navigationManager.startMonitoring(); + + const navigableId = TabManager.getIdForBrowser(browser); + await loadURL(browser, SECOND_COOP_URL); + + const coopNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(coopNavigation, SECOND_COOP_URL); + + is(events.length, 2, "Two events recorded"); + assertNavigationEvents( + events, + SECOND_COOP_URL, + coopNavigation.navigationId, + navigableId + ); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); + +add_task(async function test_sameDocumentNavigation() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("location-changed", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + const url = "https://example.com/document-builder.sjs?html=test"; + const tab = addTab(gBrowser, url); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + navigationManager.startMonitoring(); + const navigableId = TabManager.getIdForBrowser(browser); + + is(events.length, 0, "No event recorded"); + + info("Perform a same-document navigation"); + let onLocationChanged = navigationManager.once("location-changed"); + BrowserTestUtils.startLoadingURIString(browser, url + "#hash"); + await onLocationChanged; + + const hashNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + is(events.length, 1, "Recorded 1 navigation event"); + assertNavigationEvents( + events, + url + "#hash", + hashNavigation.navigationId, + navigableId, + true + ); + + // Navigate from `url + "#hash"` to `url`, this will trigger a regular + // navigation and we can use `loadURL` to properly wait for the navigation to + // complete. + info("Perform a regular navigation"); + await loadURL(browser, url); + + const regularNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + is(events.length, 3, "Recorded 2 additional navigation events"); + assertNavigationEvents( + events, + url, + regularNavigation.navigationId, + navigableId + ); + + info("Perform another same-document navigation"); + onLocationChanged = navigationManager.once("location-changed"); + BrowserTestUtils.startLoadingURIString(browser, url + "#foo"); + await onLocationChanged; + + const otherHashNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + + is(events.length, 4, "Recorded 1 additional navigation event"); + + info("Perform a same-hash navigation"); + onLocationChanged = navigationManager.once("location-changed"); + BrowserTestUtils.startLoadingURIString(browser, url + "#foo"); + await onLocationChanged; + + const sameHashNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + + is(events.length, 5, "Recorded 1 additional navigation event"); + assertNavigationEvents( + events, + url + "#foo", + sameHashNavigation.navigationId, + navigableId, + true + ); + + assertUniqueNavigationIds([ + hashNavigation, + regularNavigation, + otherHashNavigation, + sameHashNavigation, + ]); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("location-changed", onEvent); + navigationManager.off("navigation-stopped", onEvent); + + navigationManager.stopMonitoring(); +}); + +add_task(async function test_startNavigationAndCloseTab() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + const tab = addTab(gBrowser, FIRST_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + navigationManager.startMonitoring(); + loadURL(browser, SECOND_URL); + gBrowser.removeTab(tab); + + // On top of the assertions below, the test also validates that there is no + // unhandled promise rejection related to handling the navigation-started event + // for the destroyed browsing context. + is(events.length, 0, "No event was received"); + is( + navigationManager.getNavigationForBrowsingContext(browser.browsingContext), + null, + "No navigation was recorded for the destroyed tab" + ); + navigationManager.stopMonitoring(); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); +}); diff --git a/remote/shared/test/browser/browser_NavigationManager_failed_navigation.js b/remote/shared/test/browser/browser_NavigationManager_failed_navigation.js new file mode 100644 index 0000000000..70c695b7ac --- /dev/null +++ b/remote/shared/test/browser/browser_NavigationManager_failed_navigation.js @@ -0,0 +1,99 @@ +/* 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 { NavigationManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/NavigationManager.sys.mjs" +); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +const TEST_URL = "https://example.com/document-builder.sjs?html=test1"; +const TEST_URL_CLOSED_PORT = "http://127.0.0.1:36325/"; +const TEST_URL_WRONG_URI = "https://www.wronguri.wronguri/"; + +add_task(async function testClosedPort() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + const tab = addTab(gBrowser, TEST_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + const navigableId = TabManager.getIdForBrowser(browser); + + navigationManager.startMonitoring(); + is( + navigationManager.getNavigationForBrowsingContext(browser.browsingContext), + null, + "No navigation recorded yet" + ); + is(events.length, 0, "No event recorded"); + + await loadURL(browser, TEST_URL_CLOSED_PORT, { maybeErrorPage: true }); + + const firstNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(firstNavigation, TEST_URL_CLOSED_PORT); + + is(events.length, 2, "Two events recorded"); + assertNavigationEvents( + events, + TEST_URL_CLOSED_PORT, + firstNavigation.navigationId, + navigableId + ); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); + +add_task(async function testWrongURI() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + const tab = addTab(gBrowser, TEST_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + const navigableId = TabManager.getIdForBrowser(browser); + + navigationManager.startMonitoring(); + + is( + navigationManager.getNavigationForBrowsingContext(browser.browsingContext), + null, + "No navigation recorded yet" + ); + is(events.length, 0, "No event recorded"); + + await loadURL(browser, TEST_URL_WRONG_URI, { maybeErrorPage: true }); + + const firstNavigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(firstNavigation, TEST_URL_WRONG_URI); + + is(events.length, 2, "Two events recorded"); + assertNavigationEvents( + events, + TEST_URL_WRONG_URI, + firstNavigation.navigationId, + navigableId + ); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); diff --git a/remote/shared/test/browser/browser_NavigationManager_no_navigation.js b/remote/shared/test/browser/browser_NavigationManager_no_navigation.js new file mode 100644 index 0000000000..370c09d351 --- /dev/null +++ b/remote/shared/test/browser/browser_NavigationManager_no_navigation.js @@ -0,0 +1,60 @@ +/* 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 { NavigationManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/NavigationManager.sys.mjs" +); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +add_task(async function testDocumentOpenWriteClose() { + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("location-changed", onEvent); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + const url = "https://example.com/document-builder.sjs?html=test"; + + const tab = addTab(gBrowser, url); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + navigationManager.startMonitoring(); + is(events.length, 0, "No event recorded"); + + info("Replace the document"); + await SpecialPowers.spawn(browser, [], async () => { + // Note: we need to use eval here to have reduced permissions and avoid + // security errors. + content.eval(` + document.open(); + document.write("<h1 class='replaced'>Replaced</h1>"); + document.close(); + `); + + await ContentTaskUtils.waitForCondition(() => + content.document.querySelector(".replaced") + ); + }); + + // See Bug 1844517. + // document.open/write/close is identical to same-url + same-hash navigations. + todo_is(events.length, 0, "No event recorded after replacing the document"); + + info("Reload the page, which should trigger a navigation"); + await loadURL(browser, url); + + // See Bug 1844517. + // document.open/write/close is identical to same-url + same-hash navigations. + todo_is(events.length, 2, "Recorded navigation events"); + + navigationManager.off("location-changed", onEvent); + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); diff --git a/remote/shared/test/browser/browser_NavigationManager_notify.js b/remote/shared/test/browser/browser_NavigationManager_notify.js new file mode 100644 index 0000000000..4dca0f7b4e --- /dev/null +++ b/remote/shared/test/browser/browser_NavigationManager_notify.js @@ -0,0 +1,170 @@ +/* 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 { NavigationManager, notifyNavigationStarted, notifyNavigationStopped } = + ChromeUtils.importESModule( + "chrome://remote/content/shared/NavigationManager.sys.mjs" + ); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +const FIRST_URL = "https://example.com/document-builder.sjs?html=first"; +const SECOND_URL = "https://example.com/document-builder.sjs?html=second"; + +add_task(async function test_notifyNavigationStartedStopped() { + const tab = addTab(gBrowser, FIRST_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser, false, FIRST_URL); + + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + navigationManager.startMonitoring(); + + const navigableId = TabManager.getIdForBrowser(browser); + + info("Programmatically start a navigation"); + const startedNavigation = notifyNavigationStarted({ + contextDetails: { + context: browser.browsingContext, + }, + url: SECOND_URL, + }); + + const navigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(navigation, SECOND_URL); + + is( + startedNavigation, + navigation, + "notifyNavigationStarted returned the expected navigation" + ); + is(events.length, 1, "Only one event recorded"); + + info("Attempt to start a navigation while another one is in progress"); + const alreadyStartedNavigation = notifyNavigationStarted({ + contextDetails: { + context: browser.browsingContext, + }, + url: SECOND_URL, + }); + is( + alreadyStartedNavigation, + navigation, + "notifyNavigationStarted returned the ongoing navigation" + ); + is(events.length, 1, "Still only one event recorded"); + + info("Programmatically stop the navigation"); + const stoppedNavigation = notifyNavigationStopped({ + contextDetails: { + context: browser.browsingContext, + }, + url: SECOND_URL, + }); + is( + stoppedNavigation, + navigation, + "notifyNavigationStopped returned the expected navigation" + ); + + is(events.length, 2, "Two events recorded"); + assertNavigationEvents( + events, + SECOND_URL, + navigation.navigationId, + navigableId + ); + + info("Attempt to stop an already stopped navigation"); + const alreadyStoppedNavigation = notifyNavigationStopped({ + contextDetails: { + context: browser.browsingContext, + }, + url: SECOND_URL, + }); + is( + alreadyStoppedNavigation, + navigation, + "notifyNavigationStopped returned the already stopped navigation" + ); + is(events.length, 2, "Still only two events recorded"); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); + +add_task(async function test_notifyNavigationWithContextDetails() { + const tab = addTab(gBrowser, FIRST_URL); + const browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser, false, FIRST_URL); + + const events = []; + const onEvent = (name, data) => events.push({ name, data }); + + const navigationManager = new NavigationManager(); + navigationManager.on("navigation-started", onEvent); + navigationManager.on("navigation-stopped", onEvent); + + navigationManager.startMonitoring(); + + const navigableId = TabManager.getIdForBrowser(browser); + + info("Programmatically start a navigation using browsing context details"); + const startedNavigation = notifyNavigationStarted({ + contextDetails: { + browsingContextId: browser.browsingContext.id, + browserId: browser.browsingContext.browserId, + isTopBrowsingContext: browser.browsingContext.parent === null, + }, + url: SECOND_URL, + }); + + const navigation = navigationManager.getNavigationForBrowsingContext( + browser.browsingContext + ); + assertNavigation(navigation, SECOND_URL); + + is( + startedNavigation, + navigation, + "notifyNavigationStarted returned the expected navigation" + ); + is(events.length, 1, "Only one event recorded"); + + info("Programmatically stop the navigation using browsing context details"); + const stoppedNavigation = notifyNavigationStopped({ + contextDetails: { + browsingContextId: browser.browsingContext.id, + browserId: browser.browsingContext.browserId, + isTopBrowsingContext: browser.browsingContext.parent === null, + }, + url: SECOND_URL, + }); + is( + stoppedNavigation, + navigation, + "notifyNavigationStopped returned the expected navigation" + ); + + is(events.length, 2, "Two events recorded"); + assertNavigationEvents( + events, + SECOND_URL, + navigation.navigationId, + navigableId + ); + + navigationManager.off("navigation-started", onEvent); + navigationManager.off("navigation-stopped", onEvent); + navigationManager.stopMonitoring(); +}); diff --git a/remote/shared/test/browser/browser_TabManager.js b/remote/shared/test/browser/browser_TabManager.js new file mode 100644 index 0000000000..fdc0d5c8b1 --- /dev/null +++ b/remote/shared/test/browser/browser_TabManager.js @@ -0,0 +1,178 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +const FRAME_URL = "https://example.com/document-builder.sjs?html=frame"; +const FRAME_MARKUP = `<iframe src="${encodeURI(FRAME_URL)}"></iframe>`; +const TEST_URL = `https://example.com/document-builder.sjs?html=${encodeURI( + FRAME_MARKUP +)}`; + +add_task(async function test_getBrowsingContextById() { + const browser = gBrowser.selectedBrowser; + + is(TabManager.getBrowsingContextById(null), null); + is(TabManager.getBrowsingContextById(undefined), null); + is(TabManager.getBrowsingContextById("wrong-id"), null); + + info(`Navigate to ${TEST_URL}`); + await loadURL(browser, TEST_URL); + + const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + is(contexts.length, 2, "Top context has 1 child"); + + const topContextId = TabManager.getIdForBrowsingContext(contexts[0]); + is(TabManager.getBrowsingContextById(topContextId), contexts[0]); + const childContextId = TabManager.getIdForBrowsingContext(contexts[1]); + is(TabManager.getBrowsingContextById(childContextId), contexts[1]); +}); + +add_task(async function test_addTab_focus() { + let tabsCount = gBrowser.tabs.length; + + let newTab1, newTab2, newTab3; + try { + newTab1 = await TabManager.addTab({ focus: true }); + + ok(gBrowser.tabs.includes(newTab1), "A new tab was created"); + is(gBrowser.tabs.length, tabsCount + 1); + is(gBrowser.selectedTab, newTab1, "Tab added with focus: true is selected"); + + newTab2 = await TabManager.addTab({ focus: false }); + + ok(gBrowser.tabs.includes(newTab2), "A new tab was created"); + is(gBrowser.tabs.length, tabsCount + 2); + is( + gBrowser.selectedTab, + newTab1, + "Tab added with focus: false is not selected" + ); + + newTab3 = await TabManager.addTab(); + + ok(gBrowser.tabs.includes(newTab3), "A new tab was created"); + is(gBrowser.tabs.length, tabsCount + 3); + is( + gBrowser.selectedTab, + newTab1, + "Tab added with no focus parameter is not selected (defaults to false)" + ); + } finally { + gBrowser.removeTab(newTab1); + gBrowser.removeTab(newTab2); + gBrowser.removeTab(newTab3); + } +}); + +add_task(async function test_addTab_referenceTab() { + let tab1, tab2, tab3, tab4; + try { + tab1 = await TabManager.addTab(); + // Add a second tab with no referenceTab, should be added at the end. + tab2 = await TabManager.addTab(); + // Add a third tab with tab1 as referenceTab, should be added right after tab1. + tab3 = await TabManager.addTab({ referenceTab: tab1 }); + // Add a fourth tab with tab2 as referenceTab, should be added right after tab2. + tab4 = await TabManager.addTab({ referenceTab: tab2 }); + + // Check that the tab order is as expected: tab1 > tab3 > tab2 > tab4 + const tab1Index = gBrowser.tabs.indexOf(tab1); + is(gBrowser.tabs[tab1Index + 1], tab3); + is(gBrowser.tabs[tab1Index + 2], tab2); + is(gBrowser.tabs[tab1Index + 3], tab4); + } finally { + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab3); + gBrowser.removeTab(tab4); + } +}); + +add_task(async function test_addTab_window() { + const win1 = await BrowserTestUtils.openNewBrowserWindow(); + const win2 = await BrowserTestUtils.openNewBrowserWindow(); + try { + // openNewBrowserWindow should ensure the new window is focused. + is(Services.wm.getMostRecentBrowserWindow(null), win2); + + const newTab1 = await TabManager.addTab({ window: win1 }); + is( + newTab1.ownerGlobal, + win1, + "The new tab was opened in the specified window" + ); + + const newTab2 = await TabManager.addTab({ window: win2 }); + is( + newTab2.ownerGlobal, + win2, + "The new tab was opened in the specified window" + ); + + const newTab3 = await TabManager.addTab(); + is( + newTab3.ownerGlobal, + win2, + "The new tab was opened in the foreground window" + ); + } finally { + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + } +}); + +add_task(async function test_getNavigableForBrowsingContext() { + const browser = gBrowser.selectedBrowser; + + info(`Navigate to ${TEST_URL}`); + await loadURL(browser, TEST_URL); + + const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + is(contexts.length, 2, "Top context has 1 child"); + + // For a top-level browsing context the content browser is returned. + const topContext = contexts[0]; + is( + TabManager.getNavigableForBrowsingContext(topContext), + browser, + "Top-Level browsing context has the content browser as navigable" + ); + + // For child browsing contexts the browsing context itself is returned. + const childContext = contexts[1]; + is( + TabManager.getNavigableForBrowsingContext(childContext), + childContext, + "Child browsing context has itself as navigable" + ); + + const invalidValues = [undefined, null, 1, "test", {}, []]; + for (const invalidValue of invalidValues) { + Assert.throws( + () => TabManager.getNavigableForBrowsingContext(invalidValue), + /Expected browsingContext to be a CanonicalBrowsingContext/ + ); + } +}); + +add_task(async function test_getTabForBrowsingContext() { + const tab = await TabManager.addTab(); + try { + const browser = tab.linkedBrowser; + + info(`Navigate to ${TEST_URL}`); + await loadURL(browser, TEST_URL); + + const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree(); + is(TabManager.getTabForBrowsingContext(contexts[0]), tab); + is(TabManager.getTabForBrowsingContext(contexts[1]), tab); + is(TabManager.getTabForBrowsingContext(null), null); + } finally { + gBrowser.removeTab(tab); + } +}); diff --git a/remote/shared/test/browser/browser_UserContextManager.js b/remote/shared/test/browser/browser_UserContextManager.js new file mode 100644 index 0000000000..2060c2bacd --- /dev/null +++ b/remote/shared/test/browser/browser_UserContextManager.js @@ -0,0 +1,236 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { UserContextManagerClass } = ChromeUtils.importESModule( + "chrome://remote/content/shared/UserContextManager.sys.mjs" +); + +add_task(async function test_invalid() { + const userContextManager = new UserContextManagerClass(); + + // Check invalid types for hasUserContextId/getInternalIdById which expects + // a string. + for (const value of [null, undefined, 1, [], {}]) { + is(userContextManager.hasUserContextId(value), false); + is(userContextManager.getInternalIdById(value), null); + } + + // Check an invalid value for hasUserContextId/getInternalIdById which expects + // either "default" or a UUID from Services.uuid.generateUUID. + is(userContextManager.hasUserContextId("foo"), false); + is(userContextManager.getInternalIdById("foo"), null); + + // Check invalid types for getIdByInternalId which expects a number. + for (const value of [null, undefined, "foo", [], {}]) { + is(userContextManager.getIdByInternalId(value), null); + } + + userContextManager.destroy(); +}); + +add_task(async function test_default_context() { + const userContextManager = new UserContextManagerClass(); + ok( + userContextManager.hasUserContextId("default"), + `Context id default is known by the manager` + ); + ok( + userContextManager.getUserContextIds().includes("default"), + `Context id default is listed by the manager` + ); + is( + userContextManager.getInternalIdById("default"), + 0, + "Default user context has the expected internal id" + ); + + userContextManager.destroy(); +}); + +add_task(async function test_new_internal_contexts() { + info("Create a new user context with ContextualIdentityService"); + const beforeInternalId = + ContextualIdentityService.create("before").userContextId; + + info("Create the UserContextManager"); + const userContextManager = new UserContextManagerClass(); + + const beforeContextId = + userContextManager.getIdByInternalId(beforeInternalId); + assertContextAvailable(userContextManager, beforeContextId, beforeInternalId); + + info("Create another user context with ContextualIdentityService"); + const afterInternalId = + ContextualIdentityService.create("after").userContextId; + const afterContextId = userContextManager.getIdByInternalId(afterInternalId); + assertContextAvailable(userContextManager, afterContextId, afterInternalId); + + info("Delete both user contexts"); + ContextualIdentityService.remove(beforeInternalId); + ContextualIdentityService.remove(afterInternalId); + assertContextRemoved(userContextManager, afterContextId, afterInternalId); + assertContextRemoved(userContextManager, beforeContextId, beforeInternalId); + + userContextManager.destroy(); +}); + +add_task(async function test_create_remove_context() { + const userContextManager = new UserContextManagerClass(); + + for (const closeContextTabs of [true, false]) { + info("Create two contexts via createContext"); + const userContextId1 = userContextManager.createContext(); + const internalId1 = userContextManager.getInternalIdById(userContextId1); + assertContextAvailable(userContextManager, userContextId1); + + const userContextId2 = userContextManager.createContext(); + const internalId2 = userContextManager.getInternalIdById(userContextId2); + assertContextAvailable(userContextManager, userContextId2); + + info("Create tabs in various user contexts"); + const url = "https://example.com/document-builder.sjs?html=tab"; + const tabDefault = await addTab(gBrowser, url); + const tabContext1 = await addTab(gBrowser, url, { + userContextId: internalId1, + }); + const tabContext2 = await addTab(gBrowser, url, { + userContextId: internalId2, + }); + + info("Remove the user context 1 via removeUserContext"); + userContextManager.removeUserContext(userContextId1, { closeContextTabs }); + + assertContextRemoved(userContextManager, userContextId1, internalId1); + if (closeContextTabs) { + ok(!gBrowser.tabs.includes(tabContext1), "Tab context 1 is closed"); + } else { + ok(gBrowser.tabs.includes(tabContext1), "Tab context 1 is not closed"); + } + ok(gBrowser.tabs.includes(tabDefault), "Tab default is not closed"); + ok(gBrowser.tabs.includes(tabContext2), "Tab context 2 is not closed"); + + info("Remove the user context 2 via removeUserContext"); + userContextManager.removeUserContext(userContextId2, { closeContextTabs }); + assertContextRemoved(userContextManager, userContextId2, internalId2); + if (closeContextTabs) { + ok(!gBrowser.tabs.includes(tabContext2), "Tab context 2 is closed"); + } else { + ok(gBrowser.tabs.includes(tabContext2), "Tab context 2 is not closed"); + } + ok(gBrowser.tabs.includes(tabDefault), "Tab default is not closed"); + } + + userContextManager.destroy(); +}); + +add_task(async function test_create_context_prefix() { + const userContextManager = new UserContextManagerClass(); + + info("Create a context with a custom prefix via createContext"); + const userContextId = userContextManager.createContext("test_prefix"); + const internalId = userContextManager.getInternalIdById(userContextId); + const identity = + ContextualIdentityService.getPublicIdentityFromId(internalId); + ok( + identity.name.startsWith("test_prefix"), + "The new identity used the provided prefix" + ); + + userContextManager.removeUserContext(userContextId); + userContextManager.destroy(); +}); + +add_task(async function test_several_managers() { + const manager1 = new UserContextManagerClass(); + const manager2 = new UserContextManagerClass(); + + info("Create a context via manager1"); + const contextId1 = manager1.createContext(); + const internalId = manager1.getInternalIdById(contextId1); + assertContextUnknown(manager2, contextId1); + + info("Retrieve the corresponding user context id in manager2"); + const contextId2 = manager2.getIdByInternalId(internalId); + is( + typeof contextId2, + "string", + "manager2 has a valid id for the user context created by manager 1" + ); + + ok( + contextId1 != contextId2, + "manager1 and manager2 have different ids for the same internal context id" + ); + + info("Remove the user context via manager2"); + manager2.removeUserContext(contextId2); + + info("Check that the user context is removed from both managers"); + assertContextRemoved(manager1, contextId1, internalId); + assertContextRemoved(manager2, contextId2, internalId); + + manager1.destroy(); + manager2.destroy(); +}); + +function assertContextAvailable(manager, contextId, expectedInternalId = null) { + ok( + manager.getUserContextIds().includes(contextId), + `Context id ${contextId} is listed by the manager` + ); + ok( + manager.hasUserContextId(contextId), + `Context id ${contextId} is known by the manager` + ); + + const internalId = manager.getInternalIdById(contextId); + if (expectedInternalId != null) { + is(internalId, expectedInternalId, "Internal id has the expected value"); + } + + is( + typeof internalId, + "number", + `Context id ${contextId} corresponds to a valid internal id (${internalId})` + ); + is( + manager.getIdByInternalId(internalId), + contextId, + `Context id ${contextId} is returned for internal id ${internalId}` + ); + ok( + ContextualIdentityService.getPublicUserContextIds().includes(internalId), + `User context for context id ${contextId} is found by ContextualIdentityService` + ); +} + +function assertContextUnknown(manager, contextId) { + ok( + !manager.getUserContextIds().includes(contextId), + `Context id ${contextId} is not listed by the manager` + ); + ok( + !manager.hasUserContextId(contextId), + `Context id ${contextId} is not known by the manager` + ); + is( + manager.getInternalIdById(contextId), + null, + `Context id ${contextId} does not match any internal id` + ); +} + +function assertContextRemoved(manager, contextId, internalId) { + assertContextUnknown(manager, contextId); + is( + manager.getIdByInternalId(internalId), + null, + `Internal id ${internalId} cannot be converted to user context id` + ); + ok( + !ContextualIdentityService.getPublicUserContextIds().includes(internalId), + `Internal id ${internalId} is not found in ContextualIdentityService` + ); +} diff --git a/remote/shared/test/browser/head.js b/remote/shared/test/browser/head.js new file mode 100644 index 0000000000..7960d99c9c --- /dev/null +++ b/remote/shared/test/browser/head.js @@ -0,0 +1,205 @@ +/* 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"; + +/** + * Add a new tab in a given browser, pointing to a given URL and automatically + * register the cleanup function to remove it at the end of the test. + * + * @param {Browser} browser + * The browser element where the tab should be added. + * @param {string} url + * The URL for the tab. + * @param {object=} options + * Options object to forward to BrowserTestUtils.addTab. + * @returns {Tab} + * The created tab. + */ +function addTab(browser, url, options) { + const tab = BrowserTestUtils.addTab(browser, url, options); + registerCleanupFunction(() => browser.removeTab(tab)); + return tab; +} + +/** + * Check if a given navigation is valid and has the expected url. + * + * @param {object} navigation + * The navigation to validate. + * @param {string} expectedUrl + * The expected url for the navigation. + */ +function assertNavigation(navigation, expectedUrl) { + ok(!!navigation, "Retrieved a navigation"); + is(navigation.url, expectedUrl, "Navigation has the expected URL"); + is( + typeof navigation.navigationId, + "string", + "Navigation has a string navigationId" + ); +} + +/** + * Check a pair of navigation events have the expected URL, navigation id and + * navigable id. The pair is expected to be ordered as follows: navigation-started + * and then navigation-stopped. + * + * @param {Array<object>} events + * The pair of events to validate. + * @param {string} url + * The expected url for the navigation. + * @param {string} navigationId + * The expected navigation id. + * @param {string} navigableId + * The expected navigable id. + * @param {boolean} isSameDocument + * If the navigation should be a same document navigation. + */ +function assertNavigationEvents( + events, + url, + navigationId, + navigableId, + isSameDocument +) { + const expectedEvents = isSameDocument ? 1 : 2; + + const navigationEvents = events.filter( + e => e.data.navigationId == navigationId + ); + is( + navigationEvents.length, + expectedEvents, + `Found ${expectedEvents} events for navigationId ${navigationId}` + ); + + if (isSameDocument) { + // Check there are no navigation-started/stopped events. + ok(!navigationEvents.some(e => e.name === "navigation-started")); + ok(!navigationEvents.some(e => e.name === "navigation-stopped")); + + const locationChanged = navigationEvents.find( + e => e.name === "location-changed" + ); + is(locationChanged.name, "location-changed", "event has the expected name"); + is(locationChanged.data.url, url, "event has the expected url"); + is( + locationChanged.data.navigableId, + navigableId, + "event has the expected navigable" + ); + } else { + // Check there is no location-changed event. + ok(!navigationEvents.some(e => e.name === "location-changed")); + + const started = navigationEvents.find(e => e.name === "navigation-started"); + const stopped = navigationEvents.find(e => e.name === "navigation-stopped"); + + // Check navigation-started + is(started.name, "navigation-started", "event has the expected name"); + is(started.data.url, url, "event has the expected url"); + is( + started.data.navigableId, + navigableId, + "event has the expected navigable" + ); + + // Check navigation-stopped + is(stopped.name, "navigation-stopped", "event has the expected name"); + is(stopped.data.url, url, "event has the expected url"); + is( + stopped.data.navigableId, + navigableId, + "event has the expected navigable" + ); + } +} + +/** + * Assert that the given navigations all have unique/different ids. + * + * @param {Array<object>} navigations + * The navigations to validate. + */ +function assertUniqueNavigationIds(...navigations) { + const ids = navigations.map(navigation => navigation.navigationId); + is(new Set(ids).size, ids.length, "Navigation ids are all different"); +} + +/** + * Create a document-builder based page with an iframe served by a given domain. + * + * @param {string} domain + * The domain which should serve the page. + * @returns {string} + * The URI for the page. + */ +function createFrame(domain) { + return createFrameForUri( + `https://${domain}/document-builder.sjs?html=frame-${domain}` + ); +} + +/** + * Create the markup for an iframe pointing to a given URI. + * + * @param {string} uri + * The uri for the iframe. + * @returns {string} + * The iframe markup. + */ +function createFrameForUri(uri) { + return `<iframe src="${encodeURI(uri)}"></iframe>`; +} + +/** + * Create the URL for a test page containing nested iframes + * + * @returns {string} + * The test page url. + */ +function createTestPageWithFrames() { + // Create the markup for an example.net frame nested in an example.com frame. + const NESTED_FRAME_MARKUP = createFrameForUri( + `https://example.org/document-builder.sjs?html=${createFrame( + "example.net" + )}` + ); + + // Combine the nested frame markup created above with an example.com frame. + const TEST_URI_MARKUP = `${NESTED_FRAME_MARKUP}${createFrame("example.com")}`; + + // Create the test page URI on example.org. + return `https://example.org/document-builder.sjs?html=${encodeURI( + TEST_URI_MARKUP + )}`; +} + +/** + * Load the provided url in an existing browser. + * + * @param {Browser} browser + * The browser element where the URL should be loaded. + * @param {string} url + * The URL to load. + * @param {object=} options + * @param {boolean} options.includeSubFrames + * Whether we should monitor load of sub frames. Defaults to false. + * @param {boolean} options.maybeErrorPage + * Whether we might reach an error page or not. Defaults to false. + * @returns {Promise} + * Promise which will resolve when the page is loaded with the expected url. + */ +async function loadURL(browser, url, options = {}) { + const { includeSubFrames = false, maybeErrorPage = false } = options; + const loaded = BrowserTestUtils.browserLoaded( + browser, + includeSubFrames, + url, + maybeErrorPage + ); + BrowserTestUtils.startLoadingURIString(browser, url); + return loaded; +} diff --git a/remote/shared/test/xpcshell/head.js b/remote/shared/test/xpcshell/head.js new file mode 100644 index 0000000000..2e7cf578d3 --- /dev/null +++ b/remote/shared/test/xpcshell/head.js @@ -0,0 +1,3 @@ +const SVG_NS = "http://www.w3.org/2000/svg"; +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; diff --git a/remote/shared/test/xpcshell/test_AppInfo.js b/remote/shared/test/xpcshell/test_AppInfo.js new file mode 100644 index 0000000000..9149564aa1 --- /dev/null +++ b/remote/shared/test/xpcshell/test_AppInfo.js @@ -0,0 +1,53 @@ +/* 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 { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const { AppInfo, getTimeoutMultiplier } = ChromeUtils.importESModule( + "chrome://remote/content/shared/AppInfo.sys.mjs" +); + +// Minimal xpcshell tests for AppInfo; Services.appinfo.* is not available + +add_task(function test_custom_properties() { + const properties = [ + // platforms + "isAndroid", + "isLinux", + "isMac", + "isWindows", + // applications + "isFirefox", + "isThunderbird", + ]; + + for (const prop of properties) { + equal( + typeof AppInfo[prop], + "boolean", + `Custom property ${prop} has expected type` + ); + } +}); + +add_task(function test_getTimeoutMultiplier() { + const message = "Timeout multiplier has expected value"; + const timeoutMultiplier = getTimeoutMultiplier(); + + if ( + AppConstants.DEBUG || + AppConstants.MOZ_CODE_COVERAGE || + AppConstants.ASAN + ) { + equal(timeoutMultiplier, 4, message); + } else if (AppConstants.TSAN) { + equal(timeoutMultiplier, 8, message); + } else { + equal(timeoutMultiplier, 1, message); + } +}); diff --git a/remote/shared/test/xpcshell/test_ChallengeHeaderParser.js b/remote/shared/test/xpcshell/test_ChallengeHeaderParser.js new file mode 100644 index 0000000000..fa624e9c20 --- /dev/null +++ b/remote/shared/test/xpcshell/test_ChallengeHeaderParser.js @@ -0,0 +1,140 @@ +/* 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 { parseChallengeHeader } = ChromeUtils.importESModule( + "chrome://remote/content/shared/ChallengeHeaderParser.sys.mjs" +); + +add_task(async function test_single_scheme() { + const TEST_HEADERS = [ + { + // double quotes + header: 'Basic realm="test"', + params: [{ name: "realm", value: "test" }], + }, + { + // single quote + header: "Basic realm='test'", + params: [{ name: "realm", value: "test" }], + }, + { + // multiline + header: `Basic + realm='test'`, + params: [{ name: "realm", value: "test" }], + }, + { + // with additional parameter. + header: 'Basic realm="test", charset="UTF-8"', + params: [ + { name: "realm", value: "test" }, + { name: "charset", value: "UTF-8" }, + ], + }, + ]; + for (const { header, params } of TEST_HEADERS) { + const challenges = parseChallengeHeader(header); + equal(challenges.length, 1); + equal(challenges[0].scheme, "Basic"); + deepEqual(challenges[0].params, params); + } +}); + +add_task(async function test_realmless_scheme() { + const TEST_HEADERS = [ + { + // no parameter + header: "Custom", + params: [], + }, + { + // one non-realm parameter + header: "Custom charset='UTF-8'", + params: [{ name: "charset", value: "UTF-8" }], + }, + ]; + + for (const { header, params } of TEST_HEADERS) { + const challenges = parseChallengeHeader(header); + equal(challenges.length, 1); + equal(challenges[0].scheme, "Custom"); + deepEqual(challenges[0].params, params); + } +}); + +add_task(async function test_multiple_schemes() { + const TEST_HEADERS = [ + { + header: 'Scheme1 realm="foo", Scheme2 realm="bar"', + params: [ + [{ name: "realm", value: "foo" }], + [{ name: "realm", value: "bar" }], + ], + }, + { + header: 'Scheme1 realm="foo", charset="UTF-8", Scheme2 realm="bar"', + params: [ + [ + { name: "realm", value: "foo" }, + { name: "charset", value: "UTF-8" }, + ], + [{ name: "realm", value: "bar" }], + ], + }, + { + header: `Scheme1 realm="foo", + charset="UTF-8", + Scheme2 realm="bar"`, + params: [ + [ + { name: "realm", value: "foo" }, + { name: "charset", value: "UTF-8" }, + ], + [{ name: "realm", value: "bar" }], + ], + }, + ]; + for (const { header, params } of TEST_HEADERS) { + const challenges = parseChallengeHeader(header); + equal(challenges.length, 2); + equal(challenges[0].scheme, "Scheme1"); + deepEqual(challenges[0].params, params[0]); + equal(challenges[1].scheme, "Scheme2"); + deepEqual(challenges[1].params, params[1]); + } +}); + +add_task(async function test_digest_scheme() { + const header = `Digest + realm="http-auth@example.org", + qop="auth, auth-int", + algorithm=SHA-256, + nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", + opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"`; + + const challenges = parseChallengeHeader(header); + equal(challenges.length, 1); + equal(challenges[0].scheme, "Digest"); + + // Note: we are not doing a deepEqual check here, because one of the params + // actually contains a `,` inside quotes for its value, which will not be + // handled properly by the current ChallengeHeaderParser. See Bug 1857847. + const realmParam = challenges[0].params.find(param => param.name === "realm"); + ok(realmParam); + equal(realmParam.value, "http-auth@example.org"); + + // Once Bug 1857847 is addressed, this should start failing and can be + // switched to deepEqual. + notDeepEqual( + challenges[0].params, + [ + { name: "realm", value: "http-auth@example.org" }, + { name: "qop", value: "auth, auth-int" }, + { name: "algorithm", value: "SHA-256" }, + { name: "nonce", value: "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v" }, + { name: "opaque", value: "FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS" }, + ], + "notDeepEqual should be changed to deepEqual when Bug 1857847 is fixed" + ); +}); diff --git a/remote/shared/test/xpcshell/test_DOM.js b/remote/shared/test/xpcshell/test_DOM.js new file mode 100644 index 0000000000..19844659b9 --- /dev/null +++ b/remote/shared/test/xpcshell/test_DOM.js @@ -0,0 +1,479 @@ +/* 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 { dom } = ChromeUtils.importESModule( + "chrome://remote/content/shared/DOM.sys.mjs" +); +const { NodeCache } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs" +); + +class MockElement { + constructor(tagName, attrs = {}) { + this.tagName = tagName; + this.localName = tagName; + + this.isConnected = false; + this.ownerGlobal = { + document: { + isActive() { + return true; + }, + }, + }; + + for (let attr in attrs) { + this[attr] = attrs[attr]; + } + } + + get nodeType() { + return 1; + } + + get ELEMENT_NODE() { + return 1; + } + + // this is a severely limited CSS selector + // that only supports lists of tag names + matches(selector) { + let tags = selector.split(","); + return tags.includes(this.localName); + } +} + +class MockXULElement extends MockElement { + constructor(tagName, attrs = {}) { + super(tagName, attrs); + this.namespaceURI = XUL_NS; + + if (typeof this.ownerDocument == "undefined") { + this.ownerDocument = {}; + } + if (typeof this.ownerDocument.documentElement == "undefined") { + this.ownerDocument.documentElement = { namespaceURI: XUL_NS }; + } + } +} + +const xulEl = new MockXULElement("text"); + +const domElInPrivilegedDocument = new MockElement("input", { + nodePrincipal: { isSystemPrincipal: true }, +}); +const xulElInPrivilegedDocument = new MockXULElement("text", { + nodePrincipal: { isSystemPrincipal: true }, +}); + +function setupTest() { + const browser = Services.appShell.createWindowlessBrowser(false); + + browser.document.body.innerHTML = ` + <div id="foo" style="margin: 50px"> + <iframe></iframe> + <video></video> + <svg xmlns="http://www.w3.org/2000/svg"></svg> + <textarea></textarea> + </div> + `; + + const divEl = browser.document.querySelector("div"); + const svgEl = browser.document.querySelector("svg"); + const textareaEl = browser.document.querySelector("textarea"); + const videoEl = browser.document.querySelector("video"); + + const iframeEl = browser.document.querySelector("iframe"); + const childEl = iframeEl.contentDocument.createElement("div"); + iframeEl.contentDocument.body.appendChild(childEl); + + const shadowRoot = videoEl.openOrClosedShadowRoot; + + return { + browser, + nodeCache: new NodeCache(), + childEl, + divEl, + iframeEl, + shadowRoot, + svgEl, + textareaEl, + videoEl, + }; +} + +add_task(function test_findClosest() { + const { divEl, videoEl } = setupTest(); + + equal(dom.findClosest(divEl, "foo"), null); + equal(dom.findClosest(videoEl, "div"), divEl); +}); + +add_task(function test_isSelected() { + const { browser, divEl } = setupTest(); + + const checkbox = browser.document.createElement("input"); + checkbox.setAttribute("type", "checkbox"); + + ok(!dom.isSelected(checkbox)); + checkbox.checked = true; + ok(dom.isSelected(checkbox)); + + // selected is not a property of <input type=checkbox> + checkbox.selected = true; + checkbox.checked = false; + ok(!dom.isSelected(checkbox)); + + const option = browser.document.createElement("option"); + + ok(!dom.isSelected(option)); + option.selected = true; + ok(dom.isSelected(option)); + + // checked is not a property of <option> + option.checked = true; + option.selected = false; + ok(!dom.isSelected(option)); + + // anything else should not be selected + for (const type of [undefined, null, "foo", true, [], {}, divEl]) { + ok(!dom.isSelected(type)); + } +}); + +add_task(function test_isElement() { + const { divEl, iframeEl, shadowRoot, svgEl } = setupTest(); + + ok(dom.isElement(divEl)); + ok(dom.isElement(svgEl)); + ok(dom.isElement(xulEl)); + ok(dom.isElement(domElInPrivilegedDocument)); + ok(dom.isElement(xulElInPrivilegedDocument)); + + ok(!dom.isElement(shadowRoot)); + ok(!dom.isElement(divEl.ownerGlobal)); + ok(!dom.isElement(iframeEl.contentWindow)); + + for (const type of [true, 42, {}, [], undefined, null]) { + ok(!dom.isElement(type)); + } +}); + +add_task(function test_isDOMElement() { + const { divEl, iframeEl, shadowRoot, svgEl } = setupTest(); + + ok(dom.isDOMElement(divEl)); + ok(dom.isDOMElement(svgEl)); + ok(dom.isDOMElement(domElInPrivilegedDocument)); + + ok(!dom.isDOMElement(shadowRoot)); + ok(!dom.isDOMElement(divEl.ownerGlobal)); + ok(!dom.isDOMElement(iframeEl.contentWindow)); + ok(!dom.isDOMElement(xulEl)); + ok(!dom.isDOMElement(xulElInPrivilegedDocument)); + + for (const type of [true, 42, "foo", {}, [], undefined, null]) { + ok(!dom.isDOMElement(type)); + } +}); + +add_task(function test_isXULElement() { + const { divEl, iframeEl, shadowRoot, svgEl } = setupTest(); + + ok(dom.isXULElement(xulEl)); + ok(dom.isXULElement(xulElInPrivilegedDocument)); + + ok(!dom.isXULElement(divEl)); + ok(!dom.isXULElement(domElInPrivilegedDocument)); + ok(!dom.isXULElement(svgEl)); + ok(!dom.isXULElement(shadowRoot)); + ok(!dom.isXULElement(divEl.ownerGlobal)); + ok(!dom.isXULElement(iframeEl.contentWindow)); + + for (const type of [true, 42, "foo", {}, [], undefined, null]) { + ok(!dom.isXULElement(type)); + } +}); + +add_task(function test_isDOMWindow() { + const { divEl, iframeEl, shadowRoot, svgEl } = setupTest(); + + ok(dom.isDOMWindow(divEl.ownerGlobal)); + ok(dom.isDOMWindow(iframeEl.contentWindow)); + + ok(!dom.isDOMWindow(divEl)); + ok(!dom.isDOMWindow(svgEl)); + ok(!dom.isDOMWindow(shadowRoot)); + ok(!dom.isDOMWindow(domElInPrivilegedDocument)); + ok(!dom.isDOMWindow(xulEl)); + ok(!dom.isDOMWindow(xulElInPrivilegedDocument)); + + for (const type of [true, 42, {}, [], undefined, null]) { + ok(!dom.isDOMWindow(type)); + } +}); + +add_task(function test_isShadowRoot() { + const { browser, divEl, iframeEl, shadowRoot, svgEl } = setupTest(); + + ok(dom.isShadowRoot(shadowRoot)); + + ok(!dom.isShadowRoot(divEl)); + ok(!dom.isShadowRoot(svgEl)); + ok(!dom.isShadowRoot(divEl.ownerGlobal)); + ok(!dom.isShadowRoot(iframeEl.contentWindow)); + ok(!dom.isShadowRoot(xulEl)); + ok(!dom.isShadowRoot(domElInPrivilegedDocument)); + ok(!dom.isShadowRoot(xulElInPrivilegedDocument)); + + for (const type of [true, 42, "foo", {}, [], undefined, null]) { + ok(!dom.isShadowRoot(type)); + } + + const documentFragment = browser.document.createDocumentFragment(); + ok(!dom.isShadowRoot(documentFragment)); +}); + +add_task(function test_isReadOnly() { + const { browser, divEl, textareaEl } = setupTest(); + + const input = browser.document.createElement("input"); + input.readOnly = true; + ok(dom.isReadOnly(input)); + + textareaEl.readOnly = true; + ok(dom.isReadOnly(textareaEl)); + + ok(!dom.isReadOnly(divEl)); + divEl.readOnly = true; + ok(!dom.isReadOnly(divEl)); + + ok(!dom.isReadOnly(null)); +}); + +add_task(function test_isDisabled() { + const { browser, divEl, svgEl } = setupTest(); + + const select = browser.document.createElement("select"); + const option = browser.document.createElement("option"); + select.appendChild(option); + select.disabled = true; + ok(dom.isDisabled(option)); + + const optgroup = browser.document.createElement("optgroup"); + option.parentNode = optgroup; + ok(dom.isDisabled(option)); + + optgroup.parentNode = select; + ok(dom.isDisabled(option)); + + select.disabled = false; + ok(!dom.isDisabled(option)); + + for (const type of ["button", "input", "select", "textarea"]) { + const elem = browser.document.createElement(type); + ok(!dom.isDisabled(elem)); + elem.disabled = true; + ok(dom.isDisabled(elem)); + } + + ok(!dom.isDisabled(divEl)); + + svgEl.disabled = true; + ok(!dom.isDisabled(svgEl)); + + ok(!dom.isDisabled(new MockXULElement("browser", { disabled: true }))); +}); + +add_task(function test_isEditingHost() { + const { browser, divEl, svgEl } = setupTest(); + + ok(!dom.isEditingHost(null)); + + ok(!dom.isEditingHost(divEl)); + divEl.contentEditable = true; + ok(dom.isEditingHost(divEl)); + + ok(!dom.isEditingHost(svgEl)); + browser.document.designMode = "on"; + ok(dom.isEditingHost(svgEl)); +}); + +add_task(function test_isEditable() { + const { browser, divEl, svgEl, textareaEl } = setupTest(); + + ok(!dom.isEditable(null)); + + for (let type of [ + "checkbox", + "radio", + "hidden", + "submit", + "button", + "image", + ]) { + const input = browser.document.createElement("input"); + input.setAttribute("type", type); + + ok(!dom.isEditable(input)); + } + + const input = browser.document.createElement("input"); + ok(dom.isEditable(input)); + input.setAttribute("type", "text"); + ok(dom.isEditable(input)); + + ok(dom.isEditable(textareaEl)); + + const textareaDisabled = browser.document.createElement("textarea"); + textareaDisabled.disabled = true; + ok(!dom.isEditable(textareaDisabled)); + + const textareaReadOnly = browser.document.createElement("textarea"); + textareaReadOnly.readOnly = true; + ok(!dom.isEditable(textareaReadOnly)); + + ok(!dom.isEditable(divEl)); + divEl.contentEditable = true; + ok(dom.isEditable(divEl)); + + ok(!dom.isEditable(svgEl)); + browser.document.designMode = "on"; + ok(dom.isEditable(svgEl)); +}); + +add_task(function test_isMutableFormControlElement() { + const { browser, divEl, textareaEl } = setupTest(); + + ok(!dom.isMutableFormControl(null)); + + ok(dom.isMutableFormControl(textareaEl)); + + const textareaDisabled = browser.document.createElement("textarea"); + textareaDisabled.disabled = true; + ok(!dom.isMutableFormControl(textareaDisabled)); + + const textareaReadOnly = browser.document.createElement("textarea"); + textareaReadOnly.readOnly = true; + ok(!dom.isMutableFormControl(textareaReadOnly)); + + const mutableStates = new Set([ + "color", + "date", + "datetime-local", + "email", + "file", + "month", + "number", + "password", + "range", + "search", + "tel", + "text", + "url", + "week", + ]); + for (const type of mutableStates) { + const input = browser.document.createElement("input"); + input.setAttribute("type", type); + ok(dom.isMutableFormControl(input)); + } + + const inputHidden = browser.document.createElement("input"); + inputHidden.setAttribute("type", "hidden"); + ok(!dom.isMutableFormControl(inputHidden)); + + ok(!dom.isMutableFormControl(divEl)); + divEl.contentEditable = true; + ok(!dom.isMutableFormControl(divEl)); + browser.document.designMode = "on"; + ok(!dom.isMutableFormControl(divEl)); +}); + +add_task(function test_coordinates() { + const { divEl } = setupTest(); + + let coords = dom.coordinates(divEl); + ok(coords.hasOwnProperty("x")); + ok(coords.hasOwnProperty("y")); + equal(typeof coords.x, "number"); + equal(typeof coords.y, "number"); + + deepEqual(dom.coordinates(divEl), { x: 0, y: 0 }); + deepEqual(dom.coordinates(divEl, 10, 10), { x: 10, y: 10 }); + deepEqual(dom.coordinates(divEl, -5, -5), { x: -5, y: -5 }); + + Assert.throws(() => dom.coordinates(null), /node is null/); + + Assert.throws( + () => dom.coordinates(divEl, "string", undefined), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, undefined, "string"), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, "string", "string"), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, {}, undefined), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, undefined, {}), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, {}, {}), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, [], undefined), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, undefined, []), + /Offset must be a number/ + ); + Assert.throws( + () => dom.coordinates(divEl, [], []), + /Offset must be a number/ + ); +}); + +add_task(function test_isDetached() { + const { childEl, iframeEl } = setupTest(); + + let detachedShadowRoot = childEl.attachShadow({ mode: "open" }); + detachedShadowRoot.innerHTML = "<input></input>"; + + // Connected to the DOM + ok(!dom.isDetached(detachedShadowRoot)); + + // Node document (ownerDocument) is not the active document + iframeEl.remove(); + ok(dom.isDetached(detachedShadowRoot)); + + // host element is stale (eg. not connected) + detachedShadowRoot.host.remove(); + equal(childEl.isConnected, false); + ok(dom.isDetached(detachedShadowRoot)); +}); + +add_task(function test_isStale() { + const { childEl, iframeEl } = setupTest(); + + // Connected to the DOM + ok(!dom.isStale(childEl)); + + // Not part of the active document + iframeEl.remove(); + ok(dom.isStale(childEl)); + + // Not connected to the DOM + childEl.remove(); + ok(dom.isStale(childEl)); +}); diff --git a/remote/shared/test/xpcshell/test_Format.js b/remote/shared/test/xpcshell/test_Format.js new file mode 100644 index 0000000000..cfdd35be08 --- /dev/null +++ b/remote/shared/test/xpcshell/test_Format.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { truncate, pprint } = ChromeUtils.importESModule( + "chrome://remote/content/shared/Format.sys.mjs" +); + +const MAX_STRING_LENGTH = 250; +const HALF = "x".repeat(MAX_STRING_LENGTH / 2); + +add_task(function test_pprint() { + equal('[object Object] {"foo":"bar"}', pprint`${{ foo: "bar" }}`); + + equal("[object Number] 42", pprint`${42}`); + equal("[object Boolean] true", pprint`${true}`); + equal("[object Undefined] undefined", pprint`${undefined}`); + equal("[object Null] null", pprint`${null}`); + + let complexObj = { toJSON: () => "foo" }; + equal('[object Object] "foo"', pprint`${complexObj}`); + + let cyclic = {}; + cyclic.me = cyclic; + equal("[object Object] <cyclic object value>", pprint`${cyclic}`); + + let el = { + hasAttribute: attr => attr in el, + getAttribute: attr => (attr in el ? el[attr] : null), + nodeType: 1, + localName: "input", + id: "foo", + class: "a b", + href: "#", + name: "bar", + src: "s", + type: "t", + }; + equal( + '<input id="foo" class="a b" href="#" name="bar" src="s" type="t">', + pprint`${el}` + ); +}); + +add_task(function test_truncate_empty() { + equal(truncate``, ""); +}); + +add_task(function test_truncate_noFields() { + equal(truncate`foo bar`, "foo bar"); +}); + +add_task(function test_truncate_multipleFields() { + equal(truncate`${0}`, "0"); + equal(truncate`${1}${2}${3}`, "123"); + equal(truncate`a${1}b${2}c${3}`, "a1b2c3"); +}); + +add_task(function test_truncate_primitiveFields() { + equal(truncate`${123}`, "123"); + equal(truncate`${true}`, "true"); + equal(truncate`${null}`, ""); + equal(truncate`${undefined}`, ""); +}); + +add_task(function test_truncate_string() { + equal(truncate`${"foo"}`, "foo"); + equal(truncate`${"x".repeat(250)}`, "x".repeat(250)); + equal(truncate`${"x".repeat(260)}`, `${HALF} ... ${HALF}`); +}); + +add_task(function test_truncate_array() { + equal(truncate`${["foo"]}`, JSON.stringify(["foo"])); + equal(truncate`${"foo"} ${["bar"]}`, `foo ${JSON.stringify(["bar"])}`); + equal( + truncate`${["x".repeat(260)]}`, + JSON.stringify([`${HALF} ... ${HALF}`]) + ); +}); + +add_task(function test_truncate_object() { + equal(truncate`${{}}`, JSON.stringify({})); + equal(truncate`${{ foo: "bar" }}`, JSON.stringify({ foo: "bar" })); + equal( + truncate`${{ foo: "x".repeat(260) }}`, + JSON.stringify({ foo: `${HALF} ... ${HALF}` }) + ); + equal(truncate`${{ foo: ["bar"] }}`, JSON.stringify({ foo: ["bar"] })); + equal( + truncate`${{ foo: ["bar", { baz: 42 }] }}`, + JSON.stringify({ foo: ["bar", { baz: 42 }] }) + ); + + let complex = { + toString() { + return "hello world"; + }, + }; + equal(truncate`${complex}`, "hello world"); + + let longComplex = { + toString() { + return "x".repeat(260); + }, + }; + equal(truncate`${longComplex}`, `${HALF} ... ${HALF}`); +}); diff --git a/remote/shared/test/xpcshell/test_Navigate.js b/remote/shared/test/xpcshell/test_Navigate.js new file mode 100644 index 0000000000..e41508189a --- /dev/null +++ b/remote/shared/test/xpcshell/test_Navigate.js @@ -0,0 +1,879 @@ +/* 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 { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +const { + DEFAULT_UNLOAD_TIMEOUT, + getUnloadTimeoutMultiplier, + ProgressListener, + waitForInitialNavigationCompleted, +} = ChromeUtils.importESModule( + "chrome://remote/content/shared/Navigate.sys.mjs" +); + +const CURRENT_URI = Services.io.newURI("http://foo.bar/"); +const INITIAL_URI = Services.io.newURI("about:blank"); +const TARGET_URI = Services.io.newURI("http://foo.cheese/"); +const TARGET_URI_IS_ERROR_PAGE = Services.io.newURI("doesnotexist://"); +const TARGET_URI_WITH_HASH = Services.io.newURI("http://foo.cheese/#foo"); + +function wait(time) { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + return new Promise(resolve => setTimeout(resolve, time)); +} + +class MockRequest { + constructor(uri) { + this.originalURI = uri; + } + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIRequest", "nsIChannel"]); + } +} + +class MockWebProgress { + constructor(browsingContext) { + this.browsingContext = browsingContext; + + this.documentRequest = null; + this.isLoadingDocument = false; + this.listener = null; + this.progressListenerRemoved = false; + } + + addProgressListener(listener) { + if (this.listener) { + throw new Error("Cannot register listener twice"); + } + + this.listener = listener; + } + + removeProgressListener(listener) { + if (listener === this.listener) { + this.listener = null; + this.progressListenerRemoved = true; + } else { + throw new Error("Unknown listener"); + } + } + + sendLocationChange(options = {}) { + const { flag = 0 } = options; + + this.documentRequest = null; + + if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) { + this.browsingContext.currentURI = TARGET_URI_WITH_HASH; + } else if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) { + this.browsingContext.currentURI = TARGET_URI_IS_ERROR_PAGE; + } + + this.listener?.onLocationChange( + this, + this.documentRequest, + TARGET_URI_WITH_HASH, + flag + ); + + return new Promise(executeSoon); + } + + sendStartState(options = {}) { + const { coop = false, isInitial = false } = options; + + if (coop) { + this.browsingContext = new MockTopContext(this); + } + + if (!this.browsingContext.currentWindowGlobal) { + this.browsingContext.currentWindowGlobal = {}; + } + + this.browsingContext.currentWindowGlobal.isInitialDocument = isInitial; + + this.isLoadingDocument = true; + const uri = isInitial ? INITIAL_URI : TARGET_URI; + this.documentRequest = new MockRequest(uri); + + this.listener?.onStateChange( + this, + this.documentRequest, + Ci.nsIWebProgressListener.STATE_START, + null + ); + + return new Promise(executeSoon); + } + + sendStopState(options = {}) { + const { errorFlag = 0 } = options; + + this.browsingContext.currentURI = this.documentRequest.originalURI; + + this.isLoadingDocument = false; + this.documentRequest = null; + + this.listener?.onStateChange( + this, + this.documentRequest, + Ci.nsIWebProgressListener.STATE_STOP, + errorFlag + ); + + return new Promise(executeSoon); + } +} + +class MockTopContext { + constructor(webProgress = null) { + this.currentURI = CURRENT_URI; + this.currentWindowGlobal = { isInitialDocument: true }; + this.id = 7; + this.top = this; + this.webProgress = webProgress || new MockWebProgress(this); + } +} + +const hasPromiseResolved = async function (promise) { + let resolved = false; + promise.finally(() => (resolved = true)); + // Make sure microtasks have time to run. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + return resolved; +}; + +const hasPromiseRejected = async function (promise) { + let rejected = false; + promise.catch(() => (rejected = true)); + // Make sure microtasks have time to run. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + return rejected; +}; + +add_task( + async function test_waitForInitialNavigation_initialDocumentNoWindowGlobal() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + // In some cases there might be no window global yet. + delete browsingContext.currentWindowGlobal; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + await webProgress.sendStartState({ isInitial: true }); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is initial document" + ); + equal( + currentURI.spec, + INITIAL_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_initialDocumentNotLoaded() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + await webProgress.sendStartState({ isInitial: true }); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is initial document" + ); + equal( + currentURI.spec, + INITIAL_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_initialDocumentLoadingAndNoAdditionalLoad() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: true }); + ok(webProgress.isLoadingDocument, "Document is loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is initial document" + ); + equal( + currentURI.spec, + INITIAL_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_initialDocumentFinishedLoadingNoAdditionalLoad() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: true }); + await webProgress.sendStopState(); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is initial document" + ); + equal( + currentURI.spec, + INITIAL_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_initialDocumentLoadingAndAdditionalLoad() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: true }); + + ok(webProgress.isLoadingDocument, "Document is loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + + await wait(100); + + await webProgress.sendStartState({ isInitial: false }); + await webProgress.sendStopState(); + + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + !webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is not initial document" + ); + equal( + currentURI.spec, + TARGET_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_initialDocumentFinishedLoadingAndAdditionalLoad() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: true }); + await webProgress.sendStopState(); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await wait(100); + + await webProgress.sendStartState({ isInitial: false }); + await webProgress.sendStopState(); + + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + !webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is not initial document" + ); + equal( + currentURI.spec, + TARGET_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_notInitialDocumentNotLoading() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + await webProgress.sendStartState({ isInitial: false }); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + !browsingContext.currentWindowGlobal.isInitialDocument, + "Is not initial document" + ); + equal( + currentURI.spec, + TARGET_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_notInitialDocumentAlreadyLoading() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: false }); + ok(webProgress.isLoadingDocument, "Document is loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + const { currentURI, targetURI } = await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + !browsingContext.currentWindowGlobal.isInitialDocument, + "Is not initial document" + ); + equal( + currentURI.spec, + TARGET_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set"); + } +); + +add_task( + async function test_waitForInitialNavigation_notInitialDocumentFinishedLoading() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: false }); + await webProgress.sendStopState(); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const { currentURI, targetURI } = await waitForInitialNavigationCompleted( + webProgress + ); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + !webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is not initial document" + ); + equal( + currentURI.spec, + TARGET_URI.spec, + "Expected current URI has been set" + ); + equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set"); + } +); + +add_task(async function test_waitForInitialNavigation_resolveWhenStarted() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + await webProgress.sendStartState({ isInitial: true }); + ok(webProgress.isLoadingDocument, "Document is already loading"); + + const { currentURI, targetURI } = await waitForInitialNavigationCompleted( + webProgress, + { + resolveWhenStarted: true, + } + ); + + ok(webProgress.isLoadingDocument, "Document is still loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is initial document" + ); + equal(currentURI.spec, CURRENT_URI.spec, "Expected current URI has been set"); + equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set"); +}); + +add_task(async function test_waitForInitialNavigation_crossOrigin() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + await webProgress.sendStartState({ coop: true }); + + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + await webProgress.sendStopState(); + const { currentURI, targetURI } = await navigated; + + notEqual( + browsingContext, + webProgress.browsingContext, + "Got new browsing context" + ); + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + !webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Is not initial document" + ); + equal(currentURI.spec, TARGET_URI.spec, "Expected current URI has been set"); + equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set"); +}); + +add_task(async function test_waitForInitialNavigation_unloadTimeout_default() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + // Stop the navigation on an initial page which is not loading anymore. + // This situation happens with new tabs on Android, even though they are on + // the initial document, they will not start another navigation on their own. + await webProgress.sendStartState({ isInitial: true }); + await webProgress.sendStopState(); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress); + + // Start a timer longer than the timeout which will be used by + // waitForInitialNavigationCompleted, and check that navigated resolves first. + const waitForMoreThanDefaultTimeout = wait( + DEFAULT_UNLOAD_TIMEOUT * 1.5 * getUnloadTimeoutMultiplier() + ); + await Promise.race([navigated, waitForMoreThanDefaultTimeout]); + + ok( + await hasPromiseResolved(navigated), + "waitForInitialNavigationCompleted has resolved" + ); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Document is still on the initial document" + ); +}); + +add_task(async function test_waitForInitialNavigation_unloadTimeout_longer() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + // Stop the navigation on an initial page which is not loading anymore. + // This situation happens with new tabs on Android, even though they are on + // the initial document, they will not start another navigation on their own. + await webProgress.sendStartState({ isInitial: true }); + await webProgress.sendStopState(); + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + + const navigated = waitForInitialNavigationCompleted(webProgress, { + unloadTimeout: DEFAULT_UNLOAD_TIMEOUT * 3, + }); + + // Start a timer longer than the default timeout of the Navigate module. + // However here we used a custom timeout, so we expect that the navigation + // will not be done yet by the time this timer is done. + const waitForMoreThanDefaultTimeout = wait( + DEFAULT_UNLOAD_TIMEOUT * 1.5 * getUnloadTimeoutMultiplier() + ); + await Promise.race([navigated, waitForMoreThanDefaultTimeout]); + + // The promise should not have resolved because we didn't reached the custom + // timeout which is 3 times the default one. + ok( + !(await hasPromiseResolved(navigated)), + "waitForInitialNavigationCompleted has not resolved yet" + ); + + // The navigation should eventually resolve once we reach the custom timeout. + await navigated; + + ok(!webProgress.isLoadingDocument, "Document is not loading"); + ok( + webProgress.browsingContext.currentWindowGlobal.isInitialDocument, + "Document is still on the initial document" + ); +}); + +add_task(async function test_ProgressListener_expectNavigation() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress, { + expectNavigation: true, + unloadTimeout: 10, + }); + const navigated = progressListener.start(); + + // Wait for unloadTimeout to finish in case it started + await wait(30); + + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved yet"); + + await webProgress.sendStartState(); + await webProgress.sendStopState(); + + ok(await hasPromiseResolved(navigated), "Listener has resolved"); +}); + +add_task( + async function test_ProgressListener_expectNavigation_initialDocumentFinishedLoading() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress, { + expectNavigation: true, + unloadTimeout: 10, + }); + const navigated = progressListener.start(); + + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved yet"); + + await webProgress.sendStartState({ isInitial: true }); + await webProgress.sendStopState(); + + // Wait for unloadTimeout to finish in case it started + await wait(30); + + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved yet"); + + await webProgress.sendStartState(); + await webProgress.sendStopState(); + + ok(await hasPromiseResolved(navigated), "Listener has resolved"); + } +); + +add_task(async function test_ProgressListener_isStarted() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress); + ok(!progressListener.isStarted); + + progressListener.start(); + ok(progressListener.isStarted); + + progressListener.stop(); + ok(!progressListener.isStarted); +}); + +add_task(async function test_ProgressListener_notWaitForExplicitStart() { + // Create a webprogress and start it before creating the progress listener. + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + await webProgress.sendStartState(); + + // Create the progress listener for a webprogress already in a navigation. + const progressListener = new ProgressListener(webProgress, { + waitForExplicitStart: false, + }); + const navigated = progressListener.start(); + + // Send stop state to complete the initial navigation + await webProgress.sendStopState(); + ok( + await hasPromiseResolved(navigated), + "Listener has resolved after initial navigation" + ); +}); + +add_task(async function test_ProgressListener_waitForExplicitStart() { + // Create a webprogress and start it before creating the progress listener. + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + await webProgress.sendStartState(); + + // Create the progress listener for a webprogress already in a navigation. + const progressListener = new ProgressListener(webProgress, { + waitForExplicitStart: true, + }); + const navigated = progressListener.start(); + + // Send stop state to complete the initial navigation + await webProgress.sendStopState(); + ok( + !(await hasPromiseResolved(navigated)), + "Listener has not resolved after initial navigation" + ); + + // Start a new navigation + await webProgress.sendStartState(); + ok( + !(await hasPromiseResolved(navigated)), + "Listener has not resolved after starting new navigation" + ); + + // Finish the new navigation + await webProgress.sendStopState(); + ok( + await hasPromiseResolved(navigated), + "Listener resolved after finishing the new navigation" + ); +}); + +add_task( + async function test_ProgressListener_waitForExplicitStartAndResolveWhenStarted() { + // Create a webprogress and start it before creating the progress listener. + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + await webProgress.sendStartState(); + + // Create the progress listener for a webprogress already in a navigation. + const progressListener = new ProgressListener(webProgress, { + resolveWhenStarted: true, + waitForExplicitStart: true, + }); + const navigated = progressListener.start(); + + // Send stop state to complete the initial navigation + await webProgress.sendStopState(); + ok( + !(await hasPromiseResolved(navigated)), + "Listener has not resolved after initial navigation" + ); + + // Start a new navigation + await webProgress.sendStartState(); + ok( + await hasPromiseResolved(navigated), + "Listener resolved after starting the new navigation" + ); + } +); + +add_task( + async function test_ProgressListener_resolveWhenNavigatingInsideDocument() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress); + const navigated = progressListener.start(); + + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved"); + + // Send hash change location change notification to complete the navigation + await webProgress.sendLocationChange({ + flag: Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT, + }); + + ok(await hasPromiseResolved(navigated), "Listener has resolved"); + + const { currentURI, targetURI } = progressListener; + equal( + currentURI.spec, + TARGET_URI_WITH_HASH.spec, + "Expected current URI has been set" + ); + equal( + targetURI.spec, + TARGET_URI_WITH_HASH.spec, + "Expected target URI has been set" + ); + } +); + +add_task(async function test_ProgressListener_ignoreCacheError() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress); + const navigated = progressListener.start(); + + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved"); + + await webProgress.sendStartState(); + await webProgress.sendStopState({ + errorFlag: Cr.NS_ERROR_PARSED_DATA_CACHED, + }); + + ok(await hasPromiseResolved(navigated), "Listener has resolved"); +}); + +add_task(async function test_ProgressListener_navigationRejectedOnErrorPage() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress, { + waitForExplicitStart: false, + }); + const navigated = progressListener.start(); + + await webProgress.sendStartState(); + await webProgress.sendLocationChange({ + flag: + Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT | + Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE, + }); + + ok( + await hasPromiseRejected(navigated), + "Listener has rejected in location change for error page" + ); +}); + +add_task(async function test_ProgressListener_navigationRejectedOnStopState() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress, { + waitForExplicitStart: false, + }); + const navigated = progressListener.start(); + + await webProgress.sendStartState(); + await webProgress.sendStopState({ errorFlag: Cr.NS_BINDING_ABORTED }); + + ok( + await hasPromiseRejected(navigated), + "Listener has rejected in stop state for erroneous navigation" + ); +}); + +add_task(async function test_ProgressListener_stopIfStarted() { + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + + const progressListener = new ProgressListener(webProgress); + const navigated = progressListener.start(); + + progressListener.stopIfStarted(); + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved"); + + await webProgress.sendStartState(); + progressListener.stopIfStarted(); + ok(await hasPromiseResolved(navigated), "Listener has resolved"); +}); + +add_task(async function test_ProgressListener_stopIfStarted_alreadyStarted() { + // Create an already navigating browsing context. + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + await webProgress.sendStartState(); + + // Create a progress listener which accepts already ongoing navigations. + const progressListener = new ProgressListener(webProgress, { + waitForExplicitStart: false, + }); + const navigated = progressListener.start(); + + // stopIfStarted should stop the listener because of the ongoing navigation. + progressListener.stopIfStarted(); + ok(await hasPromiseResolved(navigated), "Listener has resolved"); +}); + +add_task( + async function test_ProgressListener_stopIfStarted_alreadyStarted_waitForExplicitStart() { + // Create an already navigating browsing context. + const browsingContext = new MockTopContext(); + const webProgress = browsingContext.webProgress; + await webProgress.sendStartState(); + + // Create a progress listener which rejects already ongoing navigations. + const progressListener = new ProgressListener(webProgress, { + waitForExplicitStart: true, + }); + const navigated = progressListener.start(); + + // stopIfStarted will not stop the listener for the existing navigation. + progressListener.stopIfStarted(); + ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved"); + + // stopIfStarted will stop the listener when called after starting a new + // navigation. + await webProgress.sendStartState(); + progressListener.stopIfStarted(); + ok(await hasPromiseResolved(navigated), "Listener has resolved"); + } +); diff --git a/remote/shared/test/xpcshell/test_Realm.js b/remote/shared/test/xpcshell/test_Realm.js new file mode 100644 index 0000000000..3990cce482 --- /dev/null +++ b/remote/shared/test/xpcshell/test_Realm.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Realm, WindowRealm } = ChromeUtils.importESModule( + "chrome://remote/content/shared/Realm.sys.mjs" +); + +add_task(function test_id() { + const realm1 = new Realm(); + const id1 = realm1.id; + Assert.equal(typeof id1, "string"); + + const realm2 = new Realm(); + const id2 = realm2.id; + Assert.equal(typeof id2, "string"); + + Assert.notEqual(id1, id2, "Ids for different realms are different"); +}); + +add_task(function test_handleObjectMap() { + const realm = new Realm(); + + // Test an unknown handle. + Assert.equal( + realm.getObjectForHandle("unknown"), + undefined, + "Unknown handles return undefined" + ); + + // Test creating a simple handle. + const object = {}; + const handle = realm.getHandleForObject(object); + Assert.equal(typeof handle, "string", "Created a valid handle"); + Assert.equal( + realm.getObjectForHandle(handle), + object, + "Using the handle returned the original object" + ); + + // Test another handle for the same object. + const secondHandle = realm.getHandleForObject(object); + Assert.equal(typeof secondHandle, "string", "Created a valid handle"); + Assert.notEqual(secondHandle, handle, "A different handle was generated"); + Assert.equal( + realm.getObjectForHandle(secondHandle), + object, + "Using the second handle also returned the original object" + ); + + // Test using the handles in another realm. + const otherRealm = new Realm(); + Assert.equal( + otherRealm.getObjectForHandle(handle), + undefined, + "A realm returns undefined for handles from another realm" + ); + + // Removing an unknown handle should not throw or have any side effect on + // existing handles. + realm.removeObjectHandle("unknown"); + Assert.equal(realm.getObjectForHandle(handle), object); + Assert.equal(realm.getObjectForHandle(secondHandle), object); + + // Remove the second handle + realm.removeObjectHandle(secondHandle); + Assert.equal( + realm.getObjectForHandle(handle), + object, + "The first handle is still resolving the object" + ); + Assert.equal( + realm.getObjectForHandle(secondHandle), + undefined, + "The second handle returns undefined after calling removeObjectHandle" + ); + + // Remove the original handle + realm.removeObjectHandle(handle); + Assert.equal( + realm.getObjectForHandle(handle), + undefined, + "The first handle returns undefined as well" + ); +}); + +add_task(async function test_windowRealm_isSandbox() { + const windowlessBrowser = Services.appShell.createWindowlessBrowser(false); + const contentWindow = windowlessBrowser.docShell.domWindow; + + const realm1 = new WindowRealm(contentWindow); + Assert.equal(realm1.isSandbox, false); + + const realm2 = new WindowRealm(contentWindow, { sandboxName: "test" }); + Assert.equal(realm2.isSandbox, true); +}); + +add_task(async function test_windowRealm_userActivationEnabled() { + const windowlessBrowser = Services.appShell.createWindowlessBrowser(false); + const contentWindow = windowlessBrowser.docShell.domWindow; + const userActivation = contentWindow.navigator.userActivation; + + const realm = new WindowRealm(contentWindow); + + Assert.equal(realm.userActivationEnabled, false); + Assert.equal(userActivation.isActive && userActivation.hasBeenActive, false); + + realm.userActivationEnabled = true; + Assert.equal(realm.userActivationEnabled, true); + Assert.equal(userActivation.isActive && userActivation.hasBeenActive, true); + + realm.userActivationEnabled = false; + Assert.equal(realm.userActivationEnabled, false); + Assert.equal(userActivation.isActive && userActivation.hasBeenActive, false); +}); diff --git a/remote/shared/test/xpcshell/test_RecommendedPreferences.js b/remote/shared/test/xpcshell/test_RecommendedPreferences.js new file mode 100644 index 0000000000..20de07a528 --- /dev/null +++ b/remote/shared/test/xpcshell/test_RecommendedPreferences.js @@ -0,0 +1,118 @@ +/* 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 { RecommendedPreferences } = ChromeUtils.importESModule( + "chrome://remote/content/shared/RecommendedPreferences.sys.mjs" +); + +const COMMON_PREF = "toolkit.startup.max_resumed_crashes"; + +const PROTOCOL_1_PREF = "dom.disable_beforeunload"; +const PROTOCOL_1_RECOMMENDED_PREFS = new Map([[PROTOCOL_1_PREF, true]]); + +const PROTOCOL_2_PREF = "browser.contentblocking.features.standard"; +const PROTOCOL_2_RECOMMENDED_PREFS = new Map([ + [PROTOCOL_2_PREF, "-tp,tpPrivate,cookieBehavior0,-cm,-fp"], +]); + +function cleanup() { + info("Restore recommended preferences and test preferences"); + Services.prefs.clearUserPref("remote.prefs.recommended"); + RecommendedPreferences.restoreAllPreferences(); +} + +// cleanup() should be called: +// - explicitly after each test to avoid side effects +// - via registerCleanupFunction in case a test crashes/times out +registerCleanupFunction(cleanup); + +add_task(async function test_multipleClients() { + info("Check initial values for the test preferences"); + checkPreferences({ common: false, protocol_1: false, protocol_2: false }); + + checkPreferences({ common: false, protocol_1: false, protocol_2: false }); + + info("Apply recommended preferences for a protocol_1 client"); + RecommendedPreferences.applyPreferences(PROTOCOL_1_RECOMMENDED_PREFS); + checkPreferences({ common: true, protocol_1: true, protocol_2: false }); + + info("Apply recommended preferences for a protocol_2 client"); + RecommendedPreferences.applyPreferences(PROTOCOL_2_RECOMMENDED_PREFS); + checkPreferences({ common: true, protocol_1: true, protocol_2: true }); + + info("Restore protocol_1 preferences"); + RecommendedPreferences.restorePreferences(PROTOCOL_1_RECOMMENDED_PREFS); + checkPreferences({ common: true, protocol_1: false, protocol_2: true }); + + info("Restore protocol_2 preferences"); + RecommendedPreferences.restorePreferences(PROTOCOL_2_RECOMMENDED_PREFS); + checkPreferences({ common: true, protocol_1: false, protocol_2: false }); + + info("Restore all the altered preferences"); + RecommendedPreferences.restoreAllPreferences(); + checkPreferences({ common: false, protocol_1: false, protocol_2: false }); + + info("Attemps to restore again"); + RecommendedPreferences.restoreAllPreferences(); + checkPreferences({ common: false, protocol_1: false, protocol_2: false }); + + cleanup(); +}); + +add_task(async function test_disabled() { + info("Disable RecommendedPreferences"); + Services.prefs.setBoolPref("remote.prefs.recommended", false); + + info("Check initial values for the test preferences"); + checkPreferences({ common: false, protocol_1: false, protocol_2: false }); + + info("Recommended preferences are not applied, applyPreferences is a no-op"); + RecommendedPreferences.applyPreferences(PROTOCOL_1_RECOMMENDED_PREFS); + checkPreferences({ common: false, protocol_1: false, protocol_2: false }); + + cleanup(); +}); + +add_task(async function test_noCustomPreferences() { + info("Applying preferences without any custom preference should not throw"); + RecommendedPreferences.applyPreferences(); + + cleanup(); +}); + +// Check that protocols can override common preferences. +add_task(async function test_override() { + info("Make sure the common preference has no user value"); + Services.prefs.clearUserPref(COMMON_PREF); + + const OVERRIDE_VALUE = 42; + const OVERRIDE_COMMON_PREF = new Map([[COMMON_PREF, OVERRIDE_VALUE]]); + + info("Apply a map of preferences overriding a common preference"); + RecommendedPreferences.applyPreferences(OVERRIDE_COMMON_PREF); + + equal( + Services.prefs.getIntPref(COMMON_PREF), + OVERRIDE_VALUE, + "The common preference was set to the expected value" + ); + + cleanup(); +}); + +function checkPreferences({ common, protocol_1, protocol_2 }) { + checkPreference(COMMON_PREF, { hasValue: common }); + checkPreference(PROTOCOL_1_PREF, { hasValue: protocol_1 }); + checkPreference(PROTOCOL_2_PREF, { hasValue: protocol_2 }); +} + +function checkPreference(pref, { hasValue }) { + equal( + Services.prefs.prefHasUserValue(pref), + hasValue, + hasValue + ? `The preference ${pref} has a user value` + : `The preference ${pref} has no user value` + ); +} diff --git a/remote/shared/test/xpcshell/test_Stack.js b/remote/shared/test/xpcshell/test_Stack.js new file mode 100644 index 0000000000..c41c5f0240 --- /dev/null +++ b/remote/shared/test/xpcshell/test_Stack.js @@ -0,0 +1,120 @@ +/* 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 { getFramesFromStack, isChromeFrame } = ChromeUtils.importESModule( + "chrome://remote/content/shared/Stack.sys.mjs" +); + +const sourceFrames = [ + { + column: 1, + functionDisplayName: "foo", + line: 2, + source: "cheese", + sourceId: 1, + }, + { + column: 3, + functionDisplayName: null, + line: 4, + source: "cake", + sourceId: 2, + }, + { + column: 5, + functionDisplayName: "chrome", + line: 6, + source: "chrome://foo", + sourceId: 3, + }, +]; + +const targetFrames = [ + { + columnNumber: 1, + functionName: "foo", + lineNumber: 2, + filename: "cheese", + sourceId: 1, + }, + { + columnNumber: 3, + functionName: "", + lineNumber: 4, + filename: "cake", + sourceId: 2, + }, + { + columnNumber: 5, + functionName: "chrome", + lineNumber: 6, + filename: "chrome://foo", + sourceId: 3, + }, +]; + +add_task(async function test_getFramesFromStack() { + const stack = buildStack(sourceFrames); + const frames = getFramesFromStack(stack, { includeChrome: false }); + + ok(Array.isArray(frames), "frames is of expected type Array"); + equal(frames.length, 3, "Got expected amount of frames"); + checkFrame(frames.at(0), targetFrames.at(0)); + checkFrame(frames.at(1), targetFrames.at(1)); + checkFrame(frames.at(2), targetFrames.at(2)); +}); + +add_task(async function test_getFramesFromStack_asyncStack() { + const stack = buildStack(sourceFrames, true); + const frames = getFramesFromStack(stack); + + ok(Array.isArray(frames), "frames is of expected type Array"); + equal(frames.length, 3, "Got expected amount of frames"); + checkFrame(frames.at(0), targetFrames.at(0)); + checkFrame(frames.at(1), targetFrames.at(1)); + checkFrame(frames.at(2), targetFrames.at(2)); +}); + +add_task(async function test_isChromeFrame() { + for (const filename of ["chrome://foo/bar", "resource://foo/bar"]) { + ok(isChromeFrame({ filename }), "Frame is of expected chrome scope"); + } + + for (const filename of ["http://foo.bar", "about:blank"]) { + ok(!isChromeFrame({ filename }), "Frame is of expected content scope"); + } +}); + +function buildStack(frames, async = false) { + const parent = async ? "asyncParent" : "parent"; + + let currentFrame, stack; + for (const frame of frames) { + if (currentFrame) { + currentFrame[parent] = Object.assign({}, frame); + currentFrame = currentFrame[parent]; + } else { + stack = Object.assign({}, frame); + currentFrame = stack; + } + } + + return stack; +} + +function checkFrame(frame, expectedFrame) { + equal( + frame.columnNumber, + expectedFrame.columnNumber, + "Got expected column number" + ); + equal( + frame.functionName, + expectedFrame.functionName, + "Got expected function name" + ); + equal(frame.lineNumber, expectedFrame.lineNumber, "Got expected line number"); + equal(frame.filename, expectedFrame.filename, "Got expected filename"); + equal(frame.sourceId, expectedFrame.sourceId, "Got expected source id"); +} diff --git a/remote/shared/test/xpcshell/test_Sync.js b/remote/shared/test/xpcshell/test_Sync.js new file mode 100644 index 0000000000..de4a4d30fe --- /dev/null +++ b/remote/shared/test/xpcshell/test_Sync.js @@ -0,0 +1,436 @@ +/* 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 { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +const { AnimationFramePromise, Deferred, EventPromise, PollPromise } = + ChromeUtils.importESModule("chrome://remote/content/shared/Sync.sys.mjs"); + +const { Log } = ChromeUtils.importESModule( + "resource://gre/modules/Log.sys.mjs" +); + +/** + * Mimic a DOM node for listening for events. + */ +class MockElement { + constructor() { + this.capture = false; + this.eventName = null; + this.func = null; + this.mozSystemGroup = false; + this.wantUntrusted = false; + this.untrusted = false; + } + + addEventListener(name, func, options = {}) { + const { capture, mozSystemGroup, wantUntrusted } = options; + + this.eventName = name; + this.func = func; + this.capture = capture ?? false; + this.mozSystemGroup = mozSystemGroup ?? false; + this.wantUntrusted = wantUntrusted ?? false; + } + + click() { + if (this.func) { + const event = { + capture: this.capture, + mozSystemGroup: this.mozSystemGroup, + target: this, + type: this.eventName, + untrusted: this.untrusted, + wantUntrusted: this.wantUntrusted, + }; + this.func(event); + } + } + + dispatchEvent(event) { + if (this.wantUntrusted) { + this.untrusted = true; + } + this.click(); + } + + removeEventListener(name, func) { + this.capture = false; + this.eventName = null; + this.func = null; + this.mozSystemGroup = false; + this.untrusted = false; + this.wantUntrusted = false; + } +} + +class MockAppender extends Log.Appender { + constructor(formatter) { + super(formatter); + this.messages = []; + } + + append(message) { + this.doAppend(message); + } + + doAppend(message) { + this.messages.push(message); + } +} + +add_task(async function test_AnimationFramePromise() { + let called = false; + let win = { + requestAnimationFrame(callback) { + called = true; + callback(); + }, + }; + await AnimationFramePromise(win); + ok(called); +}); + +add_task(async function test_AnimationFramePromiseAbortWhenWindowClosed() { + let win = { + closed: true, + requestAnimationFrame() {}, + }; + await AnimationFramePromise(win); +}); + +add_task(async function test_DeferredPending() { + const deferred = Deferred(); + ok(deferred.pending); + + deferred.resolve(); + await deferred.promise; + ok(!deferred.pending); +}); + +add_task(async function test_DeferredRejected() { + const deferred = Deferred(); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => deferred.reject(new Error("foo")), 100); + + try { + await deferred.promise; + ok(false); + } catch (e) { + ok(!deferred.pending); + + ok(!deferred.fulfilled); + ok(deferred.rejected); + equal(e.message, "foo"); + } +}); + +add_task(async function test_DeferredResolved() { + const deferred = Deferred(); + ok(deferred.pending); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => deferred.resolve("foo"), 100); + + const result = await deferred.promise; + ok(!deferred.pending); + + ok(deferred.fulfilled); + ok(!deferred.rejected); + equal(result, "foo"); +}); + +add_task(async function test_EventPromise_subjectTypes() { + for (const subject of ["foo", 42, null, undefined, true, [], {}]) { + Assert.throws(() => new EventPromise(subject, "click"), /TypeError/); + } +}); + +add_task(async function test_EventPromise_eventNameTypes() { + const element = new MockElement(); + + for (const eventName of [42, null, undefined, true, [], {}]) { + Assert.throws(() => new EventPromise(element, eventName), /TypeError/); + } +}); + +add_task(async function test_EventPromise_subjectAndEventNameEvent() { + const element = new MockElement(); + + const clicked = new EventPromise(element, "click"); + element.click(); + const event = await clicked; + + equal(element, event.target); +}); + +add_task(async function test_EventPromise_captureTypes() { + const element = new MockElement(); + + for (const capture of [null, "foo", 42, [], {}]) { + Assert.throws( + () => new EventPromise(element, "click", { capture }), + /TypeError/ + ); + } +}); + +add_task(async function test_EventPromise_captureEvent() { + const element = new MockElement(); + + for (const capture of [undefined, false, true]) { + const expectedCapture = capture ?? false; + + const clicked = new EventPromise(element, "click", { capture }); + element.click(); + const event = await clicked; + + equal(element, event.target); + equal(expectedCapture, event.capture); + } +}); + +add_task(async function test_EventPromise_checkFnTypes() { + const element = new MockElement(); + + for (const checkFn of ["foo", 42, true, [], {}]) { + Assert.throws( + () => new EventPromise(element, "click", { checkFn }), + /TypeError/ + ); + } +}); + +add_task(async function test_EventPromise_checkFnCallback() { + const element = new MockElement(); + + let count; + const data = [ + { checkFn: null, expected_count: 0 }, + { checkFn: undefined, expected_count: 0 }, + { + checkFn: event => { + throw new Error("foo"); + }, + expected_count: 0, + }, + { checkFn: event => count++ > 0, expected_count: 2 }, + ]; + + for (const { checkFn, expected_count } of data) { + count = 0; + + const clicked = new EventPromise(element, "click", { checkFn }); + element.click(); + element.click(); + const event = await clicked; + + equal(element, event.target); + equal(expected_count, count); + } +}); + +add_task(async function test_EventPromise_mozSystemGroupTypes() { + const element = new MockElement(); + + for (const mozSystemGroup of [null, "foo", 42, [], {}]) { + Assert.throws( + () => new EventPromise(element, "click", { mozSystemGroup }), + /TypeError/ + ); + } +}); + +add_task(async function test_EventPromise_mozSystemGroupEvent() { + const element = new MockElement(); + + for (const mozSystemGroup of [undefined, false, true]) { + const expectedMozSystemGroup = mozSystemGroup ?? false; + + const clicked = new EventPromise(element, "click", { mozSystemGroup }); + element.click(); + const event = await clicked; + + equal(element, event.target); + equal(expectedMozSystemGroup, event.mozSystemGroup); + } +}); + +add_task(async function test_EventPromise_wantUntrustedTypes() { + const element = new MockElement(); + + for (let wantUntrusted of [null, "foo", 42, [], {}]) { + Assert.throws( + () => new EventPromise(element, "click", { wantUntrusted }), + /TypeError/ + ); + } +}); + +add_task(async function test_EventPromise_wantUntrustedEvent() { + for (const wantUntrusted of [undefined, false, true]) { + let expected_untrusted = wantUntrusted ?? false; + + const element = new MockElement(); + + const clicked = new EventPromise(element, "click", { wantUntrusted }); + element.dispatchEvent(new CustomEvent("click", {})); + const event = await clicked; + + equal(element, event.target); + equal(expected_untrusted, event.untrusted); + } +}); + +add_task(function test_executeSoon_callback() { + // executeSoon() is already defined for xpcshell in head.js. As such import + // our implementation into a custom namespace. + let sync = ChromeUtils.importESModule( + "chrome://remote/content/shared/Sync.sys.mjs" + ); + + for (let func of ["foo", null, true, [], {}]) { + Assert.throws(() => sync.executeSoon(func), /TypeError/); + } + + let a; + sync.executeSoon(() => { + a = 1; + }); + executeSoon(() => equal(1, a)); +}); + +add_task(function test_PollPromise_funcTypes() { + for (let type of ["foo", 42, null, undefined, true, [], {}]) { + Assert.throws(() => new PollPromise(type), /TypeError/); + } + new PollPromise(() => {}); + new PollPromise(function () {}); +}); + +add_task(function test_PollPromise_timeoutTypes() { + for (let timeout of ["foo", true, [], {}]) { + Assert.throws(() => new PollPromise(() => {}, { timeout }), /TypeError/); + } + for (let timeout of [1.2, -1]) { + Assert.throws(() => new PollPromise(() => {}, { timeout }), /RangeError/); + } + for (let timeout of [null, undefined, 42]) { + new PollPromise(resolve => resolve(1), { timeout }); + } +}); + +add_task(function test_PollPromise_intervalTypes() { + for (let interval of ["foo", null, true, [], {}]) { + Assert.throws(() => new PollPromise(() => {}, { interval }), /TypeError/); + } + for (let interval of [1.2, -1]) { + Assert.throws(() => new PollPromise(() => {}, { interval }), /RangeError/); + } + new PollPromise(() => {}, { interval: 42 }); +}); + +add_task(async function test_PollPromise_retvalTypes() { + for (let typ of [true, false, "foo", 42, [], {}]) { + strictEqual(typ, await new PollPromise(resolve => resolve(typ))); + } +}); + +add_task(async function test_PollPromise_rethrowError() { + let nevals = 0; + let err; + try { + await PollPromise(() => { + ++nevals; + throw new Error(); + }); + } catch (e) { + err = e; + } + equal(1, nevals); + ok(err instanceof Error); +}); + +add_task(async function test_PollPromise_noTimeout() { + let nevals = 0; + await new PollPromise((resolve, reject) => { + ++nevals; + nevals < 100 ? reject() : resolve(); + }); + equal(100, nevals); +}); + +add_task(async function test_PollPromise_zeroTimeout() { + // run at least once when timeout is 0 + let nevals = 0; + let start = new Date().getTime(); + await new PollPromise( + (resolve, reject) => { + ++nevals; + reject(); + }, + { timeout: 0 } + ); + let end = new Date().getTime(); + equal(1, nevals); + less(end - start, 500); +}); + +add_task(async function test_PollPromise_timeoutElapse() { + let nevals = 0; + let start = new Date().getTime(); + await new PollPromise( + (resolve, reject) => { + ++nevals; + reject(); + }, + { timeout: 100 } + ); + let end = new Date().getTime(); + lessOrEqual(nevals, 11); + greaterOrEqual(end - start, 100); +}); + +add_task(async function test_PollPromise_interval() { + let nevals = 0; + await new PollPromise( + (resolve, reject) => { + ++nevals; + reject(); + }, + { timeout: 100, interval: 100 } + ); + equal(2, nevals); +}); + +add_task(async function test_PollPromise_resolve() { + const log = Log.repository.getLogger("RemoteAgent"); + const appender = new MockAppender(new Log.BasicFormatter()); + appender.level = Log.Level.Info; + log.addAppender(appender); + + const errorMessage = "PollingFailed"; + const timeout = 100; + + await new PollPromise( + (resolve, reject) => { + resolve(); + }, + { timeout, errorMessage } + ); + Assert.equal(appender.messages.length, 0); + + await new PollPromise( + (resolve, reject) => { + reject(); + }, + { timeout, errorMessage: "PollingFailed" } + ); + Assert.equal(appender.messages.length, 1); + Assert.equal(appender.messages[0].level, Log.Level.Warn); + Assert.equal(appender.messages[0].message, "PollingFailed after 100 ms"); +}); diff --git a/remote/shared/test/xpcshell/test_TabManager.js b/remote/shared/test/xpcshell/test_TabManager.js new file mode 100644 index 0000000000..e9da02c861 --- /dev/null +++ b/remote/shared/test/xpcshell/test_TabManager.js @@ -0,0 +1,56 @@ +/* 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 { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +class MockTopBrowsingContext { + constructor() { + this.embedderElement = { permanentKey: {} }; + this.id = 1; + this.top = this; + } +} + +class MockBrowsingContext { + constructor() { + this.id = 2; + + const topContext = new MockTopBrowsingContext(); + this.parent = topContext; + this.top = topContext; + } +} + +const mockTopBrowsingContext = new MockTopBrowsingContext(); +const mockBrowsingContext = new MockBrowsingContext(); + +add_task(async function test_getIdForBrowsingContext() { + // Browsing context not set. + equal(TabManager.getIdForBrowsingContext(null), null); + equal(TabManager.getIdForBrowsingContext(undefined), null); + + // Child browsing context. + equal( + TabManager.getIdForBrowsingContext(mockBrowsingContext), + mockBrowsingContext.id + ); + + const browser = mockTopBrowsingContext.embedderElement; + equal( + TabManager.getIdForBrowsingContext(mockTopBrowsingContext), + TabManager.getIdForBrowser(browser) + ); +}); + +add_task(async function test_removeTab() { + // Tab not defined. + await TabManager.removeTab(null); +}); + +add_task(async function test_selectTab() { + // Tab not defined. + await TabManager.selectTab(null); +}); diff --git a/remote/shared/test/xpcshell/test_UUID.js b/remote/shared/test/xpcshell/test_UUID.js new file mode 100644 index 0000000000..e929a9e0a8 --- /dev/null +++ b/remote/shared/test/xpcshell/test_UUID.js @@ -0,0 +1,21 @@ +/* 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 { generateUUID } = ChromeUtils.importESModule( + "chrome://remote/content/shared/UUID.sys.mjs" +); + +add_task(function test_UUID_valid() { + const uuid = generateUUID(); + const regExp = new RegExp( + /^[a-f|0-9]{8}-[a-f|0-9]{4}-[a-f|0-9]{4}-[a-f|0-9]{4}-[a-f|0-9]{12}$/g + ); + ok(regExp.test(uuid)); +}); + +add_task(function test_UUID_unique() { + const uuid1 = generateUUID(); + const uuid2 = generateUUID(); + notEqual(uuid1, uuid2); +}); diff --git a/remote/shared/test/xpcshell/xpcshell.toml b/remote/shared/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..ebb6c77950 --- /dev/null +++ b/remote/shared/test/xpcshell/xpcshell.toml @@ -0,0 +1,24 @@ +[DEFAULT] +head = "head.js" + +["test_AppInfo.js"] + +["test_ChallengeHeaderParser.js"] + +["test_DOM.js"] + +["test_Format.js"] + +["test_Navigate.js"] + +["test_Realm.js"] + +["test_RecommendedPreferences.js"] + +["test_Stack.js"] + +["test_Sync.js"] + +["test_TabManager.js"] + +["test_UUID.js"] diff --git a/remote/shared/webdriver/Actions.sys.mjs b/remote/shared/webdriver/Actions.sys.mjs new file mode 100644 index 0000000000..4f5a41a421 --- /dev/null +++ b/remote/shared/webdriver/Actions.sys.mjs @@ -0,0 +1,2376 @@ +/* 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/. */ + +/* eslint no-dupe-keys:off */ +/* eslint-disable no-restricted-globals */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + dom: "chrome://remote/content/shared/DOM.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + event: "chrome://remote/content/marionette/event.sys.mjs", + keyData: "chrome://remote/content/shared/webdriver/KeyData.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", + Sleep: "chrome://remote/content/marionette/sync.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +// TODO? With ES 2016 and Symbol you can make a safer approximation +// to an enum e.g. https://gist.github.com/xmlking/e86e4f15ec32b12c4689 +/** + * Implements WebDriver Actions API: a low-level interface for providing + * virtualised device input to the web browser. + * + * Typical usage is to construct an action chain and then dispatch it: + * const state = new action.State(); + * const chain = new action.Chain.fromJSON(state, protocolData); + * await chain.dispatch(state, window); + * + * @namespace + */ +export const action = {}; + +// Max interval between two clicks that should result in a dblclick or a tripleclick (in ms) +export const CLICK_INTERVAL = 640; + +/** Map from normalized key value to UI Events modifier key name */ +const MODIFIER_NAME_LOOKUP = { + Alt: "alt", + Shift: "shift", + Control: "ctrl", + Meta: "meta", +}; + +/** + * State associated with actions + * + * Typically each top-level browsing context in a session should have a single State object + */ +action.State = class { + constructor() { + this.clickTracker = new ClickTracker(); + /** + * A map between input ID and the device state for that input + * source, with one entry for each active input source. + * + * Maps string => InputSource + */ + this.inputStateMap = new Map(); + + /** + * List of {@link Action} associated with current session. Used to + * manage dispatching events when resetting the state of the input sources. + * Reset operations are assumed to be idempotent. + */ + this.inputsToCancel = new TickActions(); + + /** + * Map between string input id and numeric pointer id + */ + this.pointerIdMap = new Map(); + } + + toString() { + return `[object ${this.constructor.name} ${JSON.stringify(this)}]`; + } + + /** + * Reset state stored in this object. + * It is an error to use the State object after calling release(). + * + * @param {WindowProxy} win Current window global. + */ + async release(win) { + this.inputsToCancel.reverse(); + await this.inputsToCancel.dispatch(this, win); + } + + /** + * Get the state for a given input source. + * + * @param {string} id Input source id. + * @returns {InputSource} Input source state. + */ + getInputSource(id) { + return this.inputStateMap.get(id); + } + + /** + * Find or add state for an input source. The caller should verify + * that the returned state is the expected type. + * + * @param {string} id Input source id. + * @param {InputSource} newInputSource Input source state. + */ + getOrAddInputSource(id, newInputSource) { + let inputSource = this.getInputSource(id); + + if (inputSource === undefined) { + this.inputStateMap.set(id, newInputSource); + inputSource = newInputSource; + } + + return inputSource; + } + + /** + * Iterate over all input states of a given type + * + * @param {string} type Input source type name (e.g. "pointer"). + * @returns {Iterator} Iterator over [id, input source]. + */ + *inputSourcesByType(type) { + for (const [id, inputSource] of this.inputStateMap) { + if (inputSource.type === type) { + yield [id, inputSource]; + } + } + } + + /** + * Get a numerical pointer id for a given pointer + * + * Pointer ids are positive integers. Mouse pointers are typically + * ids 0 or 1. Non-mouse pointers never get assigned id < 2. Each + * pointer gets a unique id. + * + * @param {string} id Pointer id. + * @param {string} type Pointer type. + * @returns {number} Numerical pointer id. + */ + getPointerId(id, type) { + let pointerId = this.pointerIdMap.get(id); + + if (pointerId === undefined) { + // Reserve pointer ids 0 and 1 for mouse pointers + const idValues = Array.from(this.pointerIdMap.values()); + + if (type === "mouse") { + for (const mouseId of [0, 1]) { + if (!idValues.includes(mouseId)) { + pointerId = mouseId; + break; + } + } + } + + if (pointerId === undefined) { + pointerId = Math.max(1, ...idValues) + 1; + } + this.pointerIdMap.set(id, pointerId); + } + + return pointerId; + } +}; + +export class ClickTracker { + #count; + #lastButtonClicked; + #timer; + + constructor() { + this.#count = 0; + this.#lastButtonClicked = null; + } + + get count() { + return this.#count; + } + + #cancelTimer() { + lazy.clearTimeout(this.#timer); + } + + #startTimer() { + this.#timer = lazy.setTimeout(this.reset.bind(this), CLICK_INTERVAL); + } + + /** + * Reset tracking mouse click counter. + */ + reset() { + this.#cancelTimer(); + this.#count = 0; + this.#lastButtonClicked = null; + } + + /** + * Track |button| click to identify possible double or triple click. + * + * @param {number} button + * A positive integer that refers to a mouse button. + */ + setClick(button) { + this.#cancelTimer(); + + if ( + this.#lastButtonClicked === null || + this.#lastButtonClicked === button + ) { + this.#count++; + } else { + this.#count = 1; + } + + this.#lastButtonClicked = button; + this.#startTimer(); + } +} + +/** + * Device state for an input source. + */ +class InputSource { + #id; + static type = null; + + constructor(id) { + this.#id = id; + this.type = this.constructor.type; + } + + toString() { + return `[object ${this.constructor.name} id: ${this.#id} type: ${ + this.type + }]`; + } + + /** + * @param {State} state Actions state. + * @param {Sequence} actionSequence Actions for a specific input source. + * + * @returns {InputSource} + * An {@link InputSource} object for the type of the + * {@link actionSequence}. + * + * @throws {InvalidArgumentError} + * If {@link actionSequence.type} is not valid. + */ + static fromJSON(state, actionSequence) { + const { id, type } = actionSequence; + + lazy.assert.string( + id, + lazy.pprint`Expected "id" to be a string, got ${id}` + ); + + const cls = inputSourceTypes.get(type); + if (cls === undefined) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected known action type, got ${type}` + ); + } + + const sequenceInputSource = cls.fromJSON(state, actionSequence); + const inputSource = state.getOrAddInputSource(id, sequenceInputSource); + + if (inputSource.type !== type) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected input source ${id} to be ` + + `type ${inputSource.type}, got ${type}` + ); + } + } +} + +/** + * Input state not associated with a specific physical device. + */ +class NullInputSource extends InputSource { + static type = "none"; + + static fromJSON(state, actionSequence) { + const { id } = actionSequence; + + return new this(id); + } +} + +/** + * Input state associated with a keyboard-type device. + */ +class KeyInputSource extends InputSource { + static type = "key"; + + constructor(id) { + super(id); + + this.pressed = new Set(); + this.alt = false; + this.shift = false; + this.ctrl = false; + this.meta = false; + } + + static fromJSON(state, actionSequence) { + const { id } = actionSequence; + + return new this(id); + } + + /** + * Update modifier state according to |key|. + * + * @param {string} key + * Normalized key value of a modifier key. + * @param {boolean} value + * Value to set the modifier attribute to. + * + * @throws {InvalidArgumentError} + * If |key| is not a modifier. + */ + setModState(key, value) { + if (key in MODIFIER_NAME_LOOKUP) { + this[MODIFIER_NAME_LOOKUP[key]] = value; + } else { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected "key" to be one of ${Object.keys( + MODIFIER_NAME_LOOKUP + )}, got ${key}` + ); + } + } + + /** + * Check whether |key| is pressed. + * + * @param {string} key + * Normalized key value. + * + * @returns {boolean} + * True if |key| is in set of pressed keys. + */ + isPressed(key) { + return this.pressed.has(key); + } + + /** + * Add |key| to the set of pressed keys. + * + * @param {string} key + * Normalized key value. + * + * @returns {boolean} + * True if |key| is in list of pressed keys. + */ + press(key) { + return this.pressed.add(key); + } + + /** + * Remove |key| from the set of pressed keys. + * + * @param {string} key + * Normalized key value. + * + * @returns {boolean} + * True if |key| was present before removal, false otherwise. + */ + release(key) { + return this.pressed.delete(key); + } +} + +/** + * Input state associated with a pointer-type device. + */ +class PointerInputSource extends InputSource { + static type = "pointer"; + + /** + * @param {string} id InputSource id. + * @param {Pointer} pointer Object representing the specific pointer + * type associated with this input source. + */ + constructor(id, pointer) { + super(id); + + this.pointer = pointer; + this.x = 0; + this.y = 0; + this.pressed = new Set(); + } + + /** + * Check whether |button| is pressed. + * + * @param {number} button + * Positive integer that refers to a mouse button. + * + * @returns {boolean} + * True if |button| is in set of pressed buttons. + */ + isPressed(button) { + lazy.assert.positiveInteger(button); + return this.pressed.has(button); + } + + /** + * Add |button| to the set of pressed keys. + * + * @param {number} button + * Positive integer that refers to a mouse button. + * + * @returns {Set} + * Set of pressed buttons. + */ + press(button) { + lazy.assert.positiveInteger(button); + this.pressed.add(button); + } + + /** + * Remove |button| from the set of pressed buttons. + * + * @param {number} button + * A positive integer that refers to a mouse button. + * + * @returns {boolean} + * True if |button| was present before removals, false otherwise. + */ + release(button) { + lazy.assert.positiveInteger(button); + return this.pressed.delete(button); + } + + static fromJSON(state, actionSequence) { + const { id, parameters } = actionSequence; + let pointerType = "mouse"; + + if (parameters !== undefined) { + lazy.assert.object( + parameters, + lazy.pprint`Expected "parameters" to be an object, got ${parameters}` + ); + + if (parameters.pointerType !== undefined) { + pointerType = lazy.assert.string( + parameters.pointerType, + lazy.pprint( + `Expected "pointerType" to be a string, got ${parameters.pointerType}` + ) + ); + + if (!["mouse", "pen", "touch"].includes(pointerType)) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected "pointerType" to be one of "mouse", "pen", or "touch"` + ); + } + } + } + + const pointerId = state.getPointerId(id, pointerType); + const pointer = Pointer.fromJSON(pointerId, pointerType); + + return new this(id, pointer); + } +} + +/** + * Input state associated with a wheel-type device. + */ +class WheelInputSource extends InputSource { + static type = "wheel"; + + static fromJSON(state, actionSequence) { + const { id } = actionSequence; + + return new this(id); + } +} + +const inputSourceTypes = new Map(); +for (const cls of [ + NullInputSource, + KeyInputSource, + PointerInputSource, + WheelInputSource, +]) { + inputSourceTypes.set(cls.type, cls); +} + +/** + * Representation of a coordinate origin + */ +class Origin { + /** + * Viewport coordinates of the origin of this coordinate system. + * + * This is overridden in subclasses to provide a class-specific origin. + * + * @param {InputSource} inputSource - State of current input device. + * @param {WindowProxy} win - Current window global + */ + getOriginCoordinates(inputSource, win) { + throw new Error( + `originCoordinates not defined for ${this.constructor.name}` + ); + } + + /** + * Convert [x, y] coordinates to viewport coordinates + * + * @param {InputSource} inputSource - State of the current input device + * @param {Array<number>} coords - [x, y] coordinate of target relative to origin + * @param {WindowProxy} win - Current window global + */ + getTargetCoordinates(inputSource, coords, win) { + const [x, y] = coords; + const origin = this.getOriginCoordinates(inputSource, win); + + return [origin.x + x, origin.y + y]; + } + + /** + * @param {Element|string=} origin - Type of orgin, one of "viewport", "pointer", element or undefined. + * + * @returns {Origin} - An origin object representing the origin. + * + * @throws {InvalidArgumentError} + * If <code>origin</code> isn't a valid origin. + */ + static fromJSON(origin) { + if (origin === undefined || origin === "viewport") { + return new ViewportOrigin(); + } + if (origin === "pointer") { + return new PointerOrigin(); + } + if (lazy.dom.isElement(origin)) { + return new ElementOrigin(origin); + } + + throw new lazy.error.InvalidArgumentError( + `Expected "origin" to be undefined, "viewport", "pointer", ` + + lazy.pprint`or an element, got: ${origin}` + ); + } +} + +class ViewportOrigin extends Origin { + getOriginCoordinates(inputSource, win) { + return { x: 0, y: 0 }; + } +} + +class PointerOrigin extends Origin { + getOriginCoordinates(inputSource, win) { + return { x: inputSource.x, y: inputSource.y }; + } +} + +class ElementOrigin extends Origin { + /** + * @param {Element} element - The element providing the coordinate origin. + */ + constructor(element) { + super(); + + this.element = element; + } + + getOriginCoordinates(inputSource, win) { + const clientRects = this.element.getClientRects(); + + // The spec doesn't handle this case; https://github.com/w3c/webdriver/issues/1642 + if (!clientRects.length) { + throw new lazy.error.MoveTargetOutOfBoundsError( + lazy.pprint`Origin element ${this.element} is not displayed` + ); + } + + return lazy.dom.getInViewCentrePoint(clientRects[0], win); + } +} + +/** + * Repesents the behaviour of a single input source at a single + * point in time. + * + * @param {string} id - Input source ID. + */ +class Action { + /** Type of the input source associated with this action */ + static type = null; + /** Type of action specific to the input source */ + static subtype = null; + /** Whether this kind of action affects the overall duration of a tick */ + affectsWallClockTime = false; + + constructor(id) { + this.id = id; + this.type = this.constructor.type; + this.subtype = this.constructor.subtype; + } + + toString() { + return `[${this.constructor.name} ${this.type}:${this.subtype}]`; + } + + /** + * Dispatch the action to the relevant window. + * + * This is overridden by subclasses to implement the type-specific + * dispatch of the action. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {number} tickDuration - Length of the current tick, in ms. + * @param {WindowProxy} win - Current window global. + * @returns {Promise} - Promise that is resolved once the action is complete. + */ + dispatch(state, inputSource, tickDuration, win) { + throw new Error( + `Action subclass ${this.constructor.name} must override dispatch()` + ); + } + + /** + * @param {string} type - Input source type. + * @param {string} id - Input source id. + * @param {object} actionItem - Object representing a single action. + * + * @returns {Action} - An action that can be dispatched. + * + * @throws {InvalidArgumentError} + * If any <code>actionSequence</code> or <code>actionItem</code> + * attributes are invalid. + */ + static fromJSON(type, id, actionItem) { + lazy.assert.object( + actionItem, + lazy.pprint`Expected "action" to be an object, got ${actionItem}` + ); + + const subtype = actionItem.type; + const subtypeMap = actionTypes.get(type); + + if (subtypeMap === undefined) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected known action type, got ${type}` + ); + } + + let cls = subtypeMap.get(subtype); + // Non-device specific actions can happen for any action type + if (cls === undefined) { + cls = actionTypes.get("none").get(subtype); + } + if (cls === undefined) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected known subtype for type ${type}, got ${subtype}` + ); + } + + return cls.fromJSON(id, actionItem); + } +} + +/** + * Action not associated with a specific input device. + */ +class NullAction extends Action { + static type = "none"; +} + +/** + * Action that waits for a given duration. + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number} options.duration - Time to pause, in ms. + */ +class PauseAction extends NullAction { + static subtype = "pause"; + affectsWallClockTime = true; + + constructor(id, options) { + super(id); + + const { duration } = options; + this.duration = duration; + } + + /** + * Dispatch pause action + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {number} tickDuration - Length of the current tick, in ms. + * @param {WindowProxy} win - Current window global. + * @returns {Promise} - Promise that is resolved once the action is complete. + */ + dispatch(state, inputSource, tickDuration, win) { + const ms = this.duration ?? tickDuration; + + lazy.logger.trace( + ` Dispatch ${this.constructor.name} with ${this.id} ${ms}` + ); + + return lazy.Sleep(ms); + } + + static fromJSON(id, actionItem) { + const { duration } = actionItem; + + if (duration !== undefined) { + lazy.assert.positiveInteger( + duration, + lazy.pprint`Expected "duration" to be a positive integer, got ${duration}` + ); + } + + return new this(id, { duration }); + } +} + +/** + * Action associated with a keyboard input device + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {string} options.value - Key character. + */ +class KeyAction extends Action { + static type = "key"; + + constructor(id, options) { + super(id); + + const { value } = options; + this.value = value; + } + + getEventData(inputSource) { + let value = this.value; + + if (inputSource.shift) { + value = lazy.keyData.getShiftedKey(value); + } + + return new KeyEventData(value); + } + + static fromJSON(id, actionItem) { + const { value } = actionItem; + + // TODO countGraphemes + // TODO key.value could be a single code point like "\uE012" + // (see rawKey) or "grapheme cluster" + // https://bugzilla.mozilla.org/show_bug.cgi?id=1496323 + + lazy.assert.string( + value, + 'Expected "value" to be a string that represents single code point ' + + lazy.pprint`or grapheme cluster, got ${value}` + ); + + return new this(id, { value }); + } +} + +/** + * Action equivalent to pressing a key on a keyboard. + * + * @param {string} id - Input source ID. + * @param {string} value - Key character. + */ +class KeyDownAction extends KeyAction { + static subtype = "keyDown"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${this.id} ${this.value}` + ); + + return new Promise(resolve => { + const keyEvent = this.getEventData(inputSource); + keyEvent.repeat = inputSource.isPressed(keyEvent.key); + inputSource.press(keyEvent.key); + + if (keyEvent.key in MODIFIER_NAME_LOOKUP) { + inputSource.setModState(keyEvent.key, true); + } + + // Append a copy of |a| with keyUp subtype + state.inputsToCancel.push(new KeyUpAction(this.id, this)); + keyEvent.update(state, inputSource); + lazy.event.sendKeyDown(keyEvent, win); + + resolve(); + }); + } +} + +/** + * Action equivalent to releasing a key on a keyboard. + * + * @param {string} id - Input source ID. + * @param {string} value - Key character. + */ +class KeyUpAction extends KeyAction { + static subtype = "keyUp"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${this.id} ${this.value}` + ); + + return new Promise(resolve => { + const keyEvent = this.getEventData(inputSource); + + if (!inputSource.isPressed(keyEvent.key)) { + resolve(); + return; + } + + if (keyEvent.key in MODIFIER_NAME_LOOKUP) { + inputSource.setModState(keyEvent.key, false); + } + + inputSource.release(keyEvent.key); + keyEvent.update(state, inputSource); + + lazy.event.sendKeyUp(keyEvent, win); + resolve(); + }); + } +} + +/** + * Action associated with a pointer input device + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number=} options.width - Pointer width in pixels. + * @param {number=} options.height - Pointer height in pixels. + * @param {number=} options.pressure - Pointer pressure. + * @param {number=} options.tangentialPressure - Pointer tangential pressure. + * @param {number=} options.tiltX - Pointer X tilt angle. + * @param {number=} options.tiltX - Pointer Y tilt angle. + * @param {number=} options.twist - Pointer twist angle. + * @param {number=} options.altitudeAngle - Pointer altitude angle. + * @param {number=} options.azimuthAngle - Pointer azimuth angle. + */ +class PointerAction extends Action { + static type = "pointer"; + + constructor(id, options) { + super(id); + const { + width, + height, + pressure, + tangentialPressure, + tiltX, + tiltY, + twist, + altitudeAngle, + azimuthAngle, + } = options; + this.width = width; + this.height = height; + this.pressure = pressure; + this.tangentialPressure = tangentialPressure; + this.tiltX = tiltX; + this.tiltY = tiltY; + this.twist = twist; + this.altitudeAngle = altitudeAngle; + this.azimuthAngle = azimuthAngle; + } + + /** + * Validate properties common to all pointer types + * + * @param {object} actionItem - Object representing a single action. + */ + static validateCommon(actionItem) { + const { + width, + height, + pressure, + tangentialPressure, + tiltX, + tiltY, + twist, + altitudeAngle, + azimuthAngle, + } = actionItem; + if (width !== undefined) { + lazy.assert.positiveInteger( + width, + lazy.pprint`Expected "width" to be a positive integer, got ${width}` + ); + } + if (height !== undefined) { + lazy.assert.positiveInteger( + height, + lazy.pprint`Expected "height" to be a positive integer, got ${height}` + ); + } + if (pressure !== undefined) { + lazy.assert.numberInRange( + pressure, + [0, 1], + lazy.pprint`Expected "pressure" to be in range 0 to 1, got ${pressure}` + ); + } + if (tangentialPressure !== undefined) { + lazy.assert.numberInRange( + tangentialPressure, + [-1, 1], + 'Expected "tangentialPressure" to be in range -1 to 1, ' + + lazy.pprint`got ${tangentialPressure}` + ); + } + if (tiltX !== undefined) { + lazy.assert.integerInRange( + tiltX, + [-90, 90], + lazy.pprint`Expected "tiltX" to be in range -90 to 90, got ${tiltX}` + ); + } + if (tiltY !== undefined) { + lazy.assert.integerInRange( + tiltY, + [-90, 90], + lazy.pprint`Expected "tiltY" to be in range -90 to 90, got ${tiltY}` + ); + } + if (twist !== undefined) { + lazy.assert.integerInRange( + twist, + [0, 359], + lazy.pprint`Expected "twist" to be in range 0 to 359, got ${twist}` + ); + } + if (altitudeAngle !== undefined) { + lazy.assert.numberInRange( + altitudeAngle, + [0, Math.PI / 2], + 'Expected "altitudeAngle" to be in range 0 to ${Math.PI / 2}, ' + + lazy.pprint`got ${altitudeAngle}` + ); + } + if (azimuthAngle !== undefined) { + lazy.assert.numberInRange( + azimuthAngle, + [0, 2 * Math.PI], + 'Expected "azimuthAngle" to be in range 0 to ${2 * Math.PI}, ' + + lazy.pprint`got ${azimuthAngle}` + ); + } + + return { + width, + height, + pressure, + tangentialPressure, + tiltX, + tiltY, + twist, + altitudeAngle, + azimuthAngle, + }; + } +} + +/** + * Action associated with a pointer input device being depressed. + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number} options.button - Button being pressed. For devices without buttons (e.g. touch), this should be 0. + * @param {number=} options.width - Pointer width in pixels. + * @param {number=} options.height - Pointer height in pixels. + * @param {number=} options.pressure - Pointer pressure. + * @param {number=} options.tangentialPressure - Pointer tangential pressure. + * @param {number=} options.tiltX - Pointer X tilt angle. + * @param {number=} options.tiltX - Pointer Y tilt angle. + * @param {number=} options.twist - Pointer twist angle. + * @param {number=} options.altitudeAngle - Pointer altitude angle. + * @param {number=} options.azimuthAngle - Pointer azimuth angle. + */ +class PointerDownAction extends PointerAction { + static subtype = "pointerDown"; + + constructor(id, options) { + super(id, options); + + const { button } = options; + this.button = button; + } + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}` + ); + + return new Promise(resolve => { + if (inputSource.isPressed(this.button)) { + resolve(); + return; + } + + inputSource.press(this.button); + // Append a copy of |a| with pointerUp subtype + state.inputsToCancel.push(new PointerUpAction(this.id, this)); + inputSource.pointer.pointerDown(state, inputSource, this, win); + resolve(); + }); + } + + static fromJSON(id, actionItem) { + const { button } = actionItem; + const props = PointerAction.validateCommon(actionItem); + + lazy.assert.positiveInteger( + button, + lazy.pprint`Expected "button" to be a positive integer, got ${button}` + ); + + props.button = button; + + return new this(id, props); + } +} + +/** + * Action associated with a pointer input device being released. + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number} options.button - Button being released. For devices without buttons (e.g. touch), this should be 0. + * @param {number=} options.width - Pointer width in pixels. + * @param {number=} options.height - Pointer height in pixels. + * @param {number=} options.pressure - Pointer pressure. + * @param {number=} options.tangentialPressure - Pointer tangential pressure. + * @param {number=} options.tiltX - Pointer X tilt angle. + * @param {number=} options.tiltX - Pointer Y tilt angle. + * @param {number=} options.twist - Pointer twist angle. + * @param {number=} options.altitudeAngle - Pointer altitude angle. + * @param {number=} options.azimuthAngle - Pointer azimuth angle. + */ +class PointerUpAction extends PointerAction { + static subtype = "pointerUp"; + + constructor(id, options) { + super(id, options); + + const { button } = options; + this.button = button; + } + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}` + ); + + return new Promise(resolve => { + if (!inputSource.isPressed(this.button)) { + resolve(); + return; + } + + inputSource.release(this.button); + inputSource.pointer.pointerUp(state, inputSource, this, win); + + resolve(); + }); + } + + static fromJSON(id, actionItem) { + const { button } = actionItem; + const props = PointerAction.validateCommon(actionItem); + + lazy.assert.positiveInteger( + button, + lazy.pprint`Expected "button" to be a positive integer, got ${button}` + ); + + props.button = button; + + return new this(id, props); + } +} + +/** + * Action associated with a pointer input device being moved. + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number=} options.width - Pointer width in pixels. + * @param {number=} options.height - Pointer height in pixels. + * @param {number=} options.pressure - Pointer pressure. + * @param {number=} options.tangentialPressure - Pointer tangential pressure. + * @param {number=} options.tiltX - Pointer X tilt angle. + * @param {number=} options.tiltX - Pointer Y tilt angle. + * @param {number=} options.twist - Pointer twist angle. + * @param {number=} options.altitudeAngle - Pointer altitude angle. + * @param {number=} options.azimuthAngle - Pointer azimuth angle. + * @param {number=} options.duration - Duration of move in ms. + * @param {Origin} options.origin - Origin of target coordinates. + * @param {number} options.x - X value of target coordinates. + * @param {number} options.y - Y value of target coordinates. + */ +class PointerMoveAction extends PointerAction { + static subtype = "pointerMove"; + affectsWallClockTime = true; + + constructor(id, options) { + super(id, options); + + const { duration, origin, x, y } = options; + this.duration = duration; + this.origin = origin; + this.x = x; + this.y = y; + } + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} x: ${this.x} y: ${this.y}` + ); + + const target = this.origin.getTargetCoordinates( + inputSource, + [this.x, this.y], + win + ); + + assertInViewPort(target, win); + + return moveOverTime( + [[inputSource.x, inputSource.y]], + [target], + this.duration ?? tickDuration, + target => this.performPointerMoveStep(state, inputSource, target, win) + ); + } + + /** + * Perform one part of a pointer move corresponding to a specific emitted event. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {Array<Array<number>>} targets - Array of [x, y] arrays + * specifying the viewport coordinates to move to. + * @param {WindowProxy} win - Current window global. + */ + performPointerMoveStep(state, inputSource, targets, win) { + if (targets.length !== 1) { + throw new Error( + "PointerMoveAction.performPointerMoveStep requires a single target" + ); + } + + const target = targets[0]; + lazy.logger.trace( + `PointerMoveAction.performPointerMoveStep ${JSON.stringify(target)}` + ); + if (target[0] == inputSource.x && target[1] == inputSource.y) { + return; + } + + inputSource.pointer.pointerMove( + state, + inputSource, + this, + target[0], + target[1], + win + ); + + inputSource.x = target[0]; + inputSource.y = target[1]; + } + + static fromJSON(id, actionItem) { + const { duration, origin, x, y } = actionItem; + + if (duration !== undefined) { + lazy.assert.positiveInteger( + duration, + lazy.pprint`Expected "duration" to be a positive integer, got ${duration}` + ); + } + + const originObject = Origin.fromJSON(origin); + lazy.assert.integer( + x, + lazy.pprint`Expected "x" to be an integer, got ${x}` + ); + lazy.assert.integer( + y, + lazy.pprint`Expected "y" to be an integer, got ${y}` + ); + const props = PointerAction.validateCommon(actionItem); + + props.duration = duration; + props.origin = originObject; + props.x = x; + props.y = y; + + return new this(id, props); + } +} + +/** + * Action associated with a wheel input device + * + */ +class WheelAction extends Action { + static type = "wheel"; +} + +/** + * Action associated with scrolling a scroll wheel + * + * @param {number} duration - Duration of scroll in ms. + * @param {Origin} origin - Origin of target coordinates. + * @param {number} x - X value of scroll coordinates. + * @param {number} y - Y value of scroll coordinates. + * @param {number} deltaX - Number of CSS pixels to scroll in X direction. + * @param {number} deltaY - Number of CSS pixels to scroll in Y direction + */ +class WheelScrollAction extends WheelAction { + static subtype = "scroll"; + affectsWallClockTime = true; + + constructor(id, { duration, origin, x, y, deltaX, deltaY }) { + super(id); + + this.duration = duration; + this.origin = origin; + this.x = x; + this.y = y; + this.deltaX = deltaX; + this.deltaY = deltaY; + } + + static fromJSON(id, actionItem) { + const { duration, origin, x, y, deltaX, deltaY } = actionItem; + + if (duration !== undefined) { + lazy.assert.positiveInteger( + duration, + lazy.pprint`Expected "duration" to be a positive integer, got ${duration}` + ); + } + + const originObject = Origin.fromJSON(origin); + if (originObject instanceof PointerOrigin) { + throw new lazy.error.InvalidArgumentError( + `"pointer" origin not supported for "wheel" input source.` + ); + } + + lazy.assert.integer( + x, + lazy.pprint`Expected "x" to be an Integer, got ${x}` + ); + lazy.assert.integer( + y, + lazy.pprint`Expected "y" to be an Integer, got ${y}` + ); + lazy.assert.integer( + deltaX, + lazy.pprint`Expected "deltaX" to be an Integer, got ${deltaX}` + ); + lazy.assert.integer( + deltaY, + lazy.pprint`Expected "deltaY" to be an Integer, got ${deltaY}` + ); + + return new this(id, { + duration, + origin: originObject, + x, + y, + deltaX, + deltaY, + }); + } + + async dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with id: ${this.id} deltaX: ${this.deltaX} deltaY: ${this.deltaY}` + ); + + const scrollCoordinates = this.origin.getTargetCoordinates( + inputSource, + [this.x, this.y], + win + ); + assertInViewPort(scrollCoordinates, win); + + const startX = 0; + const startY = 0; + // This is an action-local state that holds the amount of scroll completed + const deltaPosition = [startX, startY]; + + await moveOverTime( + [[startX, startY]], + [[this.deltaX, this.deltaY]], + this.duration ?? tickDuration, + deltaTarget => + this.performOneWheelScroll( + scrollCoordinates, + deltaPosition, + deltaTarget, + win + ) + ); + } + + /** + * Perform one part of a wheel scroll corresponding to a specific emitted event. + * + * @param {Array<number>} scrollCoordinates - [x, y] viewport coordinates of the scroll. + * @param {Array<number>} deltaPosition - [deltaX, deltaY] coordinates of the scroll before this event. + * @param {Array<Array<number>>} deltaTargets - Array of [deltaX, deltaY] coordinates to scroll to. + * @param {WindowProxy} win - Current window global. + */ + performOneWheelScroll(scrollCoordinates, deltaPosition, deltaTargets, win) { + if (deltaTargets.length !== 1) { + throw new Error("Can only scroll one wheel at a time"); + } + if (deltaPosition[0] == this.deltaX && deltaPosition[1] == this.deltaY) { + return; + } + + const deltaTarget = deltaTargets[0]; + const deltaX = deltaTarget[0] - deltaPosition[0]; + const deltaY = deltaTarget[1] - deltaPosition[1]; + const eventData = new WheelEventData({ + deltaX, + deltaY, + deltaZ: 0, + }); + + lazy.event.synthesizeWheelAtPoint( + scrollCoordinates[0], + scrollCoordinates[1], + eventData, + win + ); + + // Update the current scroll position for the caller + deltaPosition[0] = deltaTarget[0]; + deltaPosition[1] = deltaTarget[1]; + } +} + +/** + * Group of actions representing behaviour of all touch pointers during a single tick. + * + * For touch pointers, we need to call into the platform once with all + * the actions so that they are regarded as simultaneous. This means + * we don't use the `dispatch()` method on the underlying actions, but + * instead use one on this group object. + */ +class TouchActionGroup { + static type = null; + + constructor() { + this.type = this.constructor.type; + this.actions = new Map(); + } + + static forType(type) { + const cls = touchActionGroupTypes.get(type); + + return new cls(); + } + + /** + * Add action corresponding to a specific pointer to the group. + * + * @param {InputSource} inputSource - State of the current input device. + * @param {Action} action - Action to add to the group + */ + addPointer(inputSource, action) { + if (action.subtype !== this.type) { + throw new Error( + `Added action of unexpected type, got ${action.subtype}, expected ${this.type}` + ); + } + + this.actions.set(action.id, [inputSource, action]); + } + + /** + * Dispatch the action group to the relevant window. + * + * This is overridden by subclasses to implement the type-specific + * dispatch of the action. + * + * @param {State} state - Actions state. + * @param {null} inputSource + * This is always null; the argument only exists for compatibility + * with {@link Action.dispatch}. + * @param {number} tickDuration - Length of the current tick, in ms. + * @param {WindowProxy} win - Current window global. + * @returns {Promise} - Promise that is resolved once the action is complete. + */ + dispatch(state, inputSource, tickDuration, win) { + throw new Error( + "TouchActionGroup subclass missing dispatch implementation" + ); + } +} + +/** + * Group of actions representing behaviour of all touch pointers + * depressed during a single tick. + */ +class PointerDownTouchActionGroup extends TouchActionGroup { + static type = "pointerDown"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${Array.from( + this.actions.values() + ).map(x => x[1].id)}` + ); + + return new Promise(resolve => { + if (inputSource !== null) { + throw new Error( + "Expected null inputSource for PointerDownTouchActionGroup.dispatch" + ); + } + + // Only include pointers that are not already depressed + const actions = Array.from(this.actions.values()).filter( + ([actionInputSource, action]) => + !actionInputSource.isPressed(action.button) + ); + + if (actions.length) { + const eventData = new MultiTouchEventData("touchstart"); + + for (const [actionInputSource, action] of actions) { + // Skip if already pressed + eventData.addPointerEventData(actionInputSource, action); + actionInputSource.press(action.button); + // Append a copy of |action| with pointerUp subtype + state.inputsToCancel.push(new PointerUpAction(action.id, action)); + eventData.update(state, actionInputSource); + } + + // Touch start events must include all depressed touch pointers + for (const [id, pointerInputSource] of state.inputSourcesByType( + "pointer" + )) { + if ( + pointerInputSource.pointer.type === "touch" && + !this.actions.has(id) && + pointerInputSource.isPressed(0) + ) { + eventData.addPointerEventData(pointerInputSource, {}); + eventData.update(state, pointerInputSource); + } + } + lazy.event.synthesizeMultiTouch(eventData, win); + } + + resolve(); + }); + } +} + +/** + * Group of actions representing behaviour of all touch pointers + * released during a single tick. + */ +class PointerUpTouchActionGroup extends TouchActionGroup { + static type = "pointerUp"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${Array.from( + this.actions.values() + ).map(x => x[1].id)}` + ); + + return new Promise(resolve => { + if (inputSource !== null) { + throw new Error( + "Expected null inputSource for PointerUpTouchActionGroup.dispatch" + ); + } + + // Only include pointers that are not already depressed + const actions = Array.from(this.actions.values()).filter( + ([actionInputSource, action]) => + actionInputSource.isPressed(action.button) + ); + + if (actions.length) { + const eventData = new MultiTouchEventData("touchend"); + for (const [actionInputSource, action] of actions) { + eventData.addPointerEventData(actionInputSource, action); + actionInputSource.release(action.button); + eventData.update(state, actionInputSource); + } + lazy.event.synthesizeMultiTouch(eventData, win); + } + + resolve(); + }); + } +} + +/** + * Group of actions representing behaviour of all touch pointers + * moved during a single tick. + */ +class PointerMoveTouchActionGroup extends TouchActionGroup { + static type = "pointerMove"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${Array.from(this.actions).map( + x => x[1].id + )}` + ); + if (inputSource !== null) { + throw new Error( + "Expected null inputSource for PointerMoveTouchActionGroup.dispatch" + ); + } + + let startCoords = []; + let targetCoords = []; + + for (const [actionInputSource, action] of this.actions.values()) { + const target = action.origin.getTargetCoordinates( + actionInputSource, + [action.x, action.y], + win + ); + + assertInViewPort(target, win); + startCoords.push([actionInputSource.x, actionInputSource.y]); + targetCoords.push(target); + } + + // Touch move events must include all depressed touch pointers, even if they are static + // This can end up generating pointermove events even for static pointers, but Gecko + // seems to generate a lot of pointermove events anyway, so this seems like the lesser + // problem. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1779206 + const staticTouchPointers = []; + for (const [id, pointerInputSource] of state.inputSourcesByType( + "pointer" + )) { + if ( + pointerInputSource.pointer.type === "touch" && + !this.actions.has(id) && + pointerInputSource.isPressed(0) + ) { + staticTouchPointers.push(pointerInputSource); + } + } + + return moveOverTime( + startCoords, + targetCoords, + this.duration ?? tickDuration, + currentTargetCoords => + this.performPointerMoveStep( + state, + staticTouchPointers, + currentTargetCoords, + win + ) + ); + } + + /** + * Perform one part of a pointer move corresponding to a specific emitted event. + * + * @param {State} state - Actions state. + * @param {Array.<PointerInputSource>} staticTouchPointers + * Array of PointerInputSource objects for pointers that aren't involved in + * the touch move. + * @param {Array.<Array>} targetCoords + * Array of [x, y] arrays specifying the viewport coordinates to move to. + * @param {WindowProxy} win - Current window global. + */ + performPointerMoveStep(state, staticTouchPointers, targetCoords, win) { + if (targetCoords.length !== this.actions.size) { + throw new Error("Expected one target per pointer"); + } + + const perPointerData = Array.from(this.actions.values()).map( + ([inputSource, action], i) => { + const target = targetCoords[i]; + return [inputSource, action, target]; + } + ); + const reachedTarget = perPointerData.every( + ([inputSource, action, target]) => + target[0] === inputSource.x && target[1] === inputSource.y + ); + + if (reachedTarget) { + return; + } + + const eventData = new MultiTouchEventData("touchmove"); + for (const [inputSource, action, target] of perPointerData) { + inputSource.x = target[0]; + inputSource.y = target[1]; + eventData.addPointerEventData(inputSource, action); + eventData.update(state, inputSource); + } + + for (const inputSource of staticTouchPointers) { + eventData.addPointerEventData(inputSource, {}); + eventData.update(state, inputSource); + } + + lazy.event.synthesizeMultiTouch(eventData, win); + } +} + +const touchActionGroupTypes = new Map(); +for (const cls of [ + PointerDownTouchActionGroup, + PointerUpTouchActionGroup, + PointerMoveTouchActionGroup, +]) { + touchActionGroupTypes.set(cls.type, cls); +} + +/** + * Split a transition from startCoord to targetCoord linearly over duration. + * + * startCoords and targetCoords are lists of [x,y] positions in some space + * (e.g. screen position or scroll delta). This function will linearly + * interpolate intermediate positions, sending out roughly one event + * per frame to simulate moving between startCoord and targetCoord in + * a time of tickDuration milliseconds. The callback function is + * responsible for actually emitting the event, given the current + * position in the coordinate space. + * + * @param {Array.<Array>} startCoords + * Array of initial [x, y] coordinates for each input source involved + * in the move. + * @param {Array.<Array>} targetCoords + * Array of target [x, y] coordinates for each input source involved + * in the move. + * @param {number} duration - Time in ms the move will take. + * @param {Function} callback + * Function that actually performs the move. This takes a single parameter + * which is an array of [x, y] coordinates corresponding to the move + * targets. + */ +async function moveOverTime(startCoords, targetCoords, duration, callback) { + lazy.logger.trace( + `moveOverTime start: ${startCoords} target: ${targetCoords} duration: ${duration}` + ); + + if (startCoords.length !== targetCoords.length) { + throw new Error( + "Expected equal number of start coordinates and target coordinates" + ); + } + + if ( + !startCoords.every(item => item.length == 2) || + !targetCoords.every(item => item.length == 2) + ) { + throw new Error( + "Expected start coordinates target coordinates to be Array of multiple [x,y] coordinates." + ); + } + + if (duration === 0) { + // transition to destination in one step + callback(targetCoords); + return; + } + + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + // interval between transitions in ms, based on common vsync + const fps60 = 17; + + const distances = targetCoords.map((targetCoord, i) => { + const startCoord = startCoords[i]; + return [targetCoord[0] - startCoord[0], targetCoord[1] - startCoord[1]]; + }); + const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT; + const startTime = Date.now(); + const transitions = (async () => { + // wait |fps60| ms before performing first incremental transition + await new Promise(resolveTimer => + timer.initWithCallback(resolveTimer, fps60, ONE_SHOT) + ); + + let durationRatio = Math.floor(Date.now() - startTime) / duration; + const epsilon = fps60 / duration / 10; + while (1 - durationRatio > epsilon) { + const intermediateTargets = startCoords.map((startCoord, i) => { + let distance = distances[i]; + return [ + Math.floor(durationRatio * distance[0] + startCoord[0]), + Math.floor(durationRatio * distance[1] + startCoord[1]), + ]; + }); + callback(intermediateTargets); + // wait |fps60| ms before performing next transition + await new Promise(resolveTimer => + timer.initWithCallback(resolveTimer, fps60, ONE_SHOT) + ); + + durationRatio = Math.floor(Date.now() - startTime) / duration; + } + })(); + + await transitions; + + // perform last transitionafter all incremental moves are resolved and + // durationRatio is close enough to 1 + callback(targetCoords); +} + +const actionTypes = new Map(); +for (const cls of [ + KeyDownAction, + KeyUpAction, + PauseAction, + PointerDownAction, + PointerUpAction, + PointerMoveAction, + WheelScrollAction, +]) { + if (!actionTypes.has(cls.type)) { + actionTypes.set(cls.type, new Map()); + } + actionTypes.get(cls.type).set(cls.subtype, cls); +} + +/** + * Implementation of the behaviour of a specific type of pointer + */ +class Pointer { + /** Type of pointer */ + static type = null; + + constructor(id) { + this.id = id; + this.type = this.constructor.type; + } + + /** + * Implementation of depressing the pointer. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {Action} action - The Action object invoking the pointer + * @param {WindowProxy} win - Current window global. + */ + pointerDown(state, inputSource, action, win) { + throw new Error(`Unimplemented pointerDown for pointerType ${this.type}`); + } + + /** + * Implementation of releasing the pointer. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {Action} action - The Action object invoking the pointer + * @param {WindowProxy} win - Current window global. + */ + pointerUp(state, inputSource, action, win) { + throw new Error(`Unimplemented pointerUp for pointerType ${this.type}`); + } + + /** + * Implementation of moving the pointer. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {number} targetX - Target X coordinate of the pointer move + * @param {number} targetY - Target Y coordinate of the pointer move + * @param {WindowProxy} win - Current window global. + */ + pointerMove(state, inputSource, targetX, targetY, win) { + throw new Error(`Unimplemented pointerMove for pointerType ${this.type}`); + } + + /** + * @param {number} pointerId - Numeric pointer id. + * @param {string} pointerType - Pointer type. + * @returns {Pointer} - The pointer class for {@link pointerType} + * + * @throws {InvalidArgumentError} - If {@link pointerType} is not a valid pointer type. + */ + static fromJSON(pointerId, pointerType) { + const cls = pointerTypes.get(pointerType); + + if (cls === undefined) { + throw new lazy.error.InvalidArgumentError( + 'Expected "pointerType" type to be one of ' + + lazy.pprint`${pointerTypes}, got ${pointerType}` + ); + } + + return new cls(pointerId); + } +} + +/** + * Implementation of mouse pointer behaviour + */ +class MousePointer extends Pointer { + static type = "mouse"; + + pointerDown(state, inputSource, action, win) { + const mouseEvent = new MouseEventData("mousedown", { + button: action.button, + }); + mouseEvent.update(state, inputSource); + + if (mouseEvent.ctrlKey) { + if (lazy.AppInfo.isMac) { + mouseEvent.button = 2; + state.clickTracker.reset(); + } + } else { + mouseEvent.clickCount = state.clickTracker.count + 1; + } + + lazy.event.synthesizeMouseAtPoint( + inputSource.x, + inputSource.y, + mouseEvent, + win + ); + + if ( + lazy.event.MouseButton.isSecondary(mouseEvent.button) || + (mouseEvent.ctrlKey && lazy.AppInfo.isMac) + ) { + const contextMenuEvent = { ...mouseEvent, type: "contextmenu" }; + lazy.event.synthesizeMouseAtPoint( + inputSource.x, + inputSource.y, + contextMenuEvent, + win + ); + } + } + + pointerUp(state, inputSource, action, win) { + const mouseEvent = new MouseEventData("mouseup", { + button: action.button, + }); + mouseEvent.update(state, inputSource); + + state.clickTracker.setClick(action.button); + mouseEvent.clickCount = state.clickTracker.count; + + lazy.event.synthesizeMouseAtPoint( + inputSource.x, + inputSource.y, + mouseEvent, + win + ); + } + + pointerMove(state, inputSource, action, targetX, targetY, win) { + const mouseEvent = new MouseEventData("mousemove"); + mouseEvent.update(state, inputSource); + + lazy.event.synthesizeMouseAtPoint(targetX, targetY, mouseEvent, win); + + state.clickTracker.reset(); + } +} + +/* + * The implementation here is empty because touch actions have to go via the + * TouchActionGroup. So if we end up calling these methods that's a bug in + * the code. + */ +class TouchPointer extends Pointer { + static type = "touch"; +} + +/* + * Placeholder for future pen type pointer support. + */ +class PenPointer extends Pointer { + static type = "pen"; +} + +const pointerTypes = new Map(); +for (const cls of [MousePointer, TouchPointer, PenPointer]) { + pointerTypes.set(cls.type, cls); +} + +/** + * Represents a series of ticks, specifying which actions to perform at + * each tick. + */ +action.Chain = class extends Array { + toString() { + return `[chain ${super.toString()}]`; + } + + /** + * Dispatch the action chain to the relevant window. + * + * @param {State} state - Actions state. + * @param {WindowProxy} win - Current window global. + * @returns {Promise} - Promise that is resolved once the action + * chain is complete. + */ + dispatch(state, win) { + let i = 1; + + const chainEvents = (async () => { + for (const tickActions of this) { + lazy.logger.trace(`Dispatching tick ${i++}/${this.length}`); + await tickActions.dispatch(state, win); + } + })(); + + // Reset the current click tracker counter. We shouldn't be able to simulate + // a double click with multiple action chains. + state.clickTracker.reset(); + + return chainEvents; + } + + /** + * @param {State} state - Actions state. + * @param {Array.<object>} actions - Array of objects that each + * represent an action sequence. + * @returns {action.Chain} - Object that allows dispatching a chain + * of actions. + * @throws {InvalidArgumentError} - If actions doesn't correspond to + * a valid action chain. + */ + static fromJSON(state, actions) { + lazy.assert.array( + actions, + lazy.pprint`Expected "actions" to be an array, got ${actions}` + ); + + const actionsByTick = new this(); + for (const actionSequence of actions) { + lazy.assert.object( + actionSequence, + 'Expected "actions" item to be an object, ' + + lazy.pprint`got ${actionSequence}` + ); + + const inputSourceActions = Sequence.fromJSON(state, actionSequence); + + for (let i = 0; i < inputSourceActions.length; i++) { + // new tick + if (actionsByTick.length < i + 1) { + actionsByTick.push(new TickActions()); + } + actionsByTick[i].push(inputSourceActions[i]); + } + } + + return actionsByTick; + } +}; + +/** + * Represents the action for each input device to perform in a single tick. + */ +class TickActions extends Array { + /** + * Tick duration in milliseconds. + * + * @returns {number} - Longest action duration in |tickActions| if any, or 0. + */ + getDuration() { + let max = 0; + + for (const action of this) { + if (action.affectsWallClockTime && action.duration) { + max = Math.max(action.duration, max); + } + } + + return max; + } + + /** + * Dispatch sequence of actions for this tick. + * + * This creates a Promise for one tick that resolves once the Promise + * for each tick-action is resolved, which takes at least |tickDuration| + * milliseconds. The resolved set of events for each tick is followed by + * firing of pending DOM events. + * + * Note that the tick-actions are dispatched in order, but they may have + * different durations and therefore may not end in the same order. + * + * @param {State} state - Actions state. + * @param {WindowProxy} win - Current window global. + * + * @returns {Promise} - Promise that resolves when tick is complete. + */ + dispatch(state, win) { + const tickDuration = this.getDuration(); + const tickActions = this.groupTickActions(state); + const pendingEvents = tickActions.map(([inputSource, action]) => + action.dispatch(state, inputSource, tickDuration, win) + ); + + return Promise.all(pendingEvents); + } + + /** + * Group together actions from input sources that have to be + * dispatched together. + * + * The actual transformation here is to group together touch pointer + * actions into {@link TouchActionGroup} instances. + * + * @param {State} state - Actions state. + * @returns {Array.<Array.<InputSource?,Action|TouchActionGroup>>} + * Array of pairs. For ungrouped actions each element is + * [InputSource, Action] For touch actions there are multiple + * pointers handled at once, so the first item of the array is + * null, meaning the group has to perform its own handling of the + * relevant state, and the second element is a TouuchActionGroup. + */ + groupTickActions(state) { + const touchActions = new Map(); + const actions = []; + + for (const action of this) { + const inputSource = state.getInputSource(action.id); + if (action.type == "pointer" && inputSource.pointer.type === "touch") { + lazy.logger.debug( + `Grouping action ${action.type} ${action.id} ${action.subtype}` + ); + let group = touchActions.get(action.subtype); + if (group === undefined) { + group = TouchActionGroup.forType(action.subtype); + touchActions.set(action.subtype, group); + actions.push([null, group]); + } + group.addPointer(inputSource, action); + } else { + actions.push([inputSource, action]); + } + } + + return actions; + } +} + +/** + * Represents one input source action sequence; this is essentially an + * |Array.<Action>|. + * + * This is a temporary object only used when constructing an {@link + * action.Chain}. + */ +class Sequence extends Array { + toString() { + return `[sequence ${super.toString()}]`; + } + + /** + * @param {State} state - Actions state. + * @param {object} actionSequence + * Protocol representation of the actions for a specific input source. + * @returns {Array.<Array>} - Array of [InputSource?,Action|TouchActionGroup] + */ + static fromJSON(state, actionSequence) { + // used here to validate 'type' in addition to InputSource type below + const { id, type, actions } = actionSequence; + + // type and id get validated in InputSource.fromJSON + lazy.assert.array( + actions, + 'Expected "actionSequence.actions" to be an array, ' + + lazy.pprint`got ${actionSequence.actions}` + ); + + // This sets the input state in the global state map, if it's new + InputSource.fromJSON(state, actionSequence); + + const sequence = new this(); + for (const actionItem of actions) { + sequence.push(Action.fromJSON(type, id, actionItem)); + } + + return sequence; + } +} + +/** + * Representation of an input event + */ +class InputEventData { + constructor() { + this.altKey = false; + this.shiftKey = false; + this.ctrlKey = false; + this.metaKey = false; + } + + /** + * Update the input data based on global and input state + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + */ + update(state, inputSource) {} + + toString() { + return `${this.constructor.name} ${JSON.stringify(this)}`; + } +} + +/** + * Representation of a key input event + * + * @param {string} rawKey - Key value. + */ +class KeyEventData extends InputEventData { + constructor(rawKey) { + super(); + const { key, code, location, printable } = lazy.keyData.getData(rawKey); + + this.key = key; + this.code = code; + this.location = location; + this.printable = printable; + this.repeat = false; + // keyCode will be computed by event.sendKeyDown + } + + update(state, inputSource) { + this.altKey = inputSource.alt; + this.shiftKey = inputSource.shift; + this.ctrlKey = inputSource.ctrl; + this.metaKey = inputSource.meta; + } +} + +/** + * Representation of a pointer input event + * + * @param {string} type - Event type. + */ +class PointerEventData extends InputEventData { + constructor(type) { + super(); + + this.type = type; + this.buttons = 0; + } + + update(state, inputSource) { + // set modifier properties based on whether any corresponding keys are + // pressed on any key input source + for (const [, otherInputSource] of state.inputSourcesByType("key")) { + this.altKey = otherInputSource.alt || this.altKey; + this.ctrlKey = otherInputSource.ctrl || this.ctrlKey; + this.metaKey = otherInputSource.meta || this.metaKey; + this.shiftKey = otherInputSource.shift || this.shiftKey; + } + const allButtons = Array.from(inputSource.pressed); + this.buttons = allButtons.reduce( + (a, i) => a + PointerEventData.getButtonFlag(i), + 0 + ); + } + + /** + * Return a flag for buttons which indicates a button is pressed. + * + * @param {integer} button - Mouse button number. + */ + static getButtonFlag(button) { + switch (button) { + case 1: + return 4; + case 2: + return 2; + default: + return Math.pow(2, button); + } + } +} + +/** + * Representation of a mouse input event + * + * @param {string} type - Event type. + * @param {object=} options + * @param {number} options.button - Mouse button number. + */ +class MouseEventData extends PointerEventData { + constructor(type, options = {}) { + super(type); + + const { button = 0 } = options; + + this.button = button; + this.buttons = 0; + + // Some WPTs try to synthesize DnD only with mouse events. However, + // Gecko waits DnD events directly and non-WPT-tests use Gecko specific + // test API to synthesize DnD. Therefore, we want new path only for + // synthesized events coming from the webdriver. + this.allowToHandleDragDrop = true; + } + + update(state, inputSource) { + super.update(state, inputSource); + + this.id = inputSource.pointer.id; + } +} + +/** + * Representation of a wheel scroll event + * + * @param {object} options + * @param {number} options.deltaX - Scroll delta X. + * @param {number} options.deltaY - Scroll delta Y. + * @param {number} options.deltaY - Scroll delta Z (current always 0). + * @param {number=} options.deltaMode - Scroll delta mode (current always 0). + */ +class WheelEventData extends InputEventData { + constructor(options) { + super(); + + const { deltaX, deltaY, deltaZ, deltaMode = 0 } = options; + + this.deltaX = deltaX; + this.deltaY = deltaY; + this.deltaZ = deltaZ; + this.deltaMode = deltaMode; + } +} + +/** + * Representation of a multitouch event + * + * @param {string} type - Event type. + */ +class MultiTouchEventData extends PointerEventData { + #setGlobalState; + + constructor(type) { + super(type); + + this.id = []; + this.x = []; + this.y = []; + this.rx = []; + this.ry = []; + this.angle = []; + this.force = []; + this.tiltx = []; + this.tilty = []; + this.twist = []; + this.#setGlobalState = false; + } + + /** + * Add the data from one pointer to the event. + * + * @param {InputSource} inputSource - State of the pointer. + * @param {PointerAction} action - Action for the pointer. + */ + addPointerEventData(inputSource, action) { + this.x.push(inputSource.x); + this.y.push(inputSource.y); + this.id.push(inputSource.pointer.id); + this.rx.push(action.width || 1); + this.ry.push(action.height || 1); + this.angle.push(0); + this.force.push(action.pressure || (this.type === "touchend" ? 0 : 1)); + this.tiltx.push(action.tiltX || 0); + this.tilty.push(action.tiltY || 0); + this.twist.push(action.twist || 0); + } + + update(state, inputSource) { + // We call update once per input source, but only want to update global state once. + // Instead of introducing a new lifecycle method, or changing the API to allow multiple + // input sources in a single call, use a small bit of state to avoid repeatedly setting + // global state. + if (!this.#setGlobalState) { + // set modifier properties based on whether any corresponding keys are + // pressed on any key input source + for (const [, otherInputSource] of state.inputSourcesByType("key")) { + this.altKey = otherInputSource.alt || this.altKey; + this.ctrlKey = otherInputSource.ctrl || this.ctrlKey; + this.metaKey = otherInputSource.meta || this.metaKey; + this.shiftKey = otherInputSource.shift || this.shiftKey; + } + this.#setGlobalState = true; + } + + // Note that we currently emit Touch events that don't have this property + // but pointer events should have a `buttons` property, so we'll compute it + // anyway. + const allButtons = Array.from(inputSource.pressed); + this.buttons = + this.buttons | + allButtons.reduce((a, i) => a + PointerEventData.getButtonFlag(i), 0); + } +} + +// helpers + +/** + * Assert that target is in the viewport of win. + * + * @param {Array.<number>} target - [x, y] coordinates of target + * relative to viewport. + * @param {WindowProxy} win - target window. + * @throws {MoveTargetOutOfBoundsError} - If target is outside the + * viewport. + */ +function assertInViewPort(target, win) { + const [x, y] = target; + + lazy.assert.number( + x, + lazy.pprint`Expected "x" to be finite number, got ${x}` + ); + lazy.assert.number( + y, + lazy.pprint`Expected "y" to be finite number, got ${y}` + ); + + // Viewport includes scrollbars if rendered. + if (x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight) { + throw new lazy.error.MoveTargetOutOfBoundsError( + `Move target (${x}, ${y}) is out of bounds of viewport dimensions ` + + `(${win.innerWidth}, ${win.innerHeight})` + ); + } +} diff --git a/remote/shared/webdriver/Assert.sys.mjs b/remote/shared/webdriver/Assert.sys.mjs new file mode 100644 index 0000000000..6c254173aa --- /dev/null +++ b/remote/shared/webdriver/Assert.sys.mjs @@ -0,0 +1,489 @@ +/* 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, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", +}); + +/** + * Shorthands for common assertions made in WebDriver. + * + * @namespace + */ +export const assert = {}; + +/** + * Asserts that WebDriver has an active session. + * + * @param {WebDriverSession} session + * WebDriver session instance. + * @param {string=} msg + * Custom error message. + * + * @throws {InvalidSessionIDError} + * If session does not exist, or has an invalid id. + */ +assert.session = function (session, msg = "") { + msg = msg || "WebDriver session does not exist, or is not active"; + assert.that( + session => session && typeof session.id == "string", + msg, + lazy.error.InvalidSessionIDError + )(session); +}; + +/** + * Asserts that the current browser is Firefox Desktop. + * + * @param {string=} msg + * Custom error message. + * + * @throws {UnsupportedOperationError} + * If current browser is not Firefox. + */ +assert.firefox = function (msg = "") { + msg = msg || "Only supported in Firefox"; + assert.that( + isFirefox => isFirefox, + msg, + lazy.error.UnsupportedOperationError + )(lazy.AppInfo.isFirefox); +}; + +/** + * Asserts that the current application is Firefox Desktop or Thunderbird. + * + * @param {string=} msg + * Custom error message. + * + * @throws {UnsupportedOperationError} + * If current application is not running on desktop. + */ +assert.desktop = function (msg = "") { + msg = msg || "Only supported in desktop applications"; + assert.that( + isDesktop => isDesktop, + msg, + lazy.error.UnsupportedOperationError + )(!lazy.AppInfo.isAndroid); +}; + +/** + * Asserts that the current application runs on Android. + * + * @param {string=} msg + * Custom error message. + * + * @throws {UnsupportedOperationError} + * If current application is not running on Android. + */ +assert.mobile = function (msg = "") { + msg = msg || "Only supported on Android"; + assert.that( + isAndroid => isAndroid, + msg, + lazy.error.UnsupportedOperationError + )(lazy.AppInfo.isAndroid); +}; + +/** + * Asserts that the current <var>context</var> is content. + * + * @param {string} context + * Context to test. + * @param {string=} msg + * Custom error message. + * + * @returns {string} + * <var>context</var> is returned unaltered. + * + * @throws {UnsupportedOperationError} + * If <var>context</var> is not content. + */ +assert.content = function (context, msg = "") { + msg = msg || "Only supported in content context"; + assert.that( + c => c.toString() == "content", + msg, + lazy.error.UnsupportedOperationError + )(context); +}; + +/** + * Asserts that the {@link CanonicalBrowsingContext} is open. + * + * @param {CanonicalBrowsingContext} browsingContext + * Canonical browsing context to check. + * @param {string=} msg + * Custom error message. + * + * @returns {CanonicalBrowsingContext} + * <var>browsingContext</var> is returned unaltered. + * + * @throws {NoSuchWindowError} + * If <var>browsingContext</var> is no longer open. + */ +assert.open = function (browsingContext, msg = "") { + msg = msg || "Browsing context has been discarded"; + return assert.that( + browsingContext => { + if (!browsingContext?.currentWindowGlobal) { + return false; + } + + if (browsingContext.isContent && !browsingContext.top.embedderElement) { + return false; + } + + return true; + }, + msg, + lazy.error.NoSuchWindowError + )(browsingContext); +}; + +/** + * Asserts that there is no current user prompt. + * + * @param {modal.Dialog} dialog + * Reference to current dialogue. + * @param {string=} msg + * Custom error message. + * + * @throws {UnexpectedAlertOpenError} + * If there is a user prompt. + */ +assert.noUserPrompt = function (dialog, msg = "") { + assert.that( + d => d === null || typeof d == "undefined", + msg, + lazy.error.UnexpectedAlertOpenError + )(dialog); +}; + +/** + * Asserts that <var>obj</var> is defined. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {?} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not defined. + */ +assert.defined = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be defined`; + return assert.that(o => typeof o != "undefined", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a finite number. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a number. + */ +assert.number = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be finite number`; + return assert.that(Number.isFinite, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a positive number. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a positive integer. + */ +assert.positiveNumber = function (obj, msg = "") { + assert.number(obj, msg); + msg = msg || lazy.pprint`Expected ${obj} to be >= 0`; + return assert.that(n => n >= 0, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a number in the inclusive range <var>lower</var> to <var>upper</var>. + * + * @param {?} obj + * Value to test. + * @param {Array<number>} range + * Array range [lower, upper] + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a number in the specified range. + */ +assert.numberInRange = function (obj, range, msg = "") { + const [lower, upper] = range; + assert.number(obj, msg); + msg = msg || lazy.pprint`Expected ${obj} to be >= ${lower} and <= ${upper}`; + return assert.that(n => n >= lower && n <= upper, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is callable. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {Function} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not callable. + */ +assert.callable = function (obj, msg = "") { + msg = msg || lazy.pprint`${obj} is not callable`; + return assert.that(o => typeof o == "function", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an unsigned short number. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an unsigned short. + */ +assert.unsignedShort = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be >= 0 and < 65536`; + return assert.that(n => n >= 0 && n < 65536, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an integer. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an integer. + */ +assert.integer = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be an integer`; + return assert.that(Number.isSafeInteger, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a positive integer. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a positive integer. + */ +assert.positiveInteger = function (obj, msg = "") { + assert.integer(obj, msg); + msg = msg || lazy.pprint`Expected ${obj} to be >= 0`; + return assert.that(n => n >= 0, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an integer in the inclusive range <var>lower</var> to <var>upper</var>. + * + * @param {?} obj + * Value to test. + * @param {Array<number>} range + * Array range [lower, upper] + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a number in the specified range. + */ +assert.integerInRange = function (obj, range, msg = "") { + const [lower, upper] = range; + assert.integer(obj, msg); + msg = msg || lazy.pprint`Expected ${obj} to be >= ${lower} and <= ${upper}`; + return assert.that(n => n >= lower && n <= upper, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a boolean. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {boolean} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a boolean. + */ +assert.boolean = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be boolean`; + return assert.that(b => typeof b == "boolean", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a string. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {string} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a string. + */ +assert.string = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be a string`; + return assert.that(s => typeof s == "string", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an object. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {object} + * obj| is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an object. + */ +assert.object = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be an object`; + return assert.that(o => { + // unable to use instanceof because LHS and RHS may come from + // different globals + let s = Object.prototype.toString.call(o); + return s == "[object Object]" || s == "[object nsJSIID]"; + }, msg)(obj); +}; + +/** + * Asserts that <var>prop</var> is in <var>obj</var>. + * + * @param {?} prop + * An array element or own property to test if is in <var>obj</var>. + * @param {?} obj + * An array or an Object that is being tested. + * @param {string=} msg + * Custom error message. + * + * @returns {?} + * The array element, or the value of <var>obj</var>'s own property + * <var>prop</var>. + * + * @throws {InvalidArgumentError} + * If the <var>obj</var> was an array and did not contain <var>prop</var>. + * Otherwise if <var>prop</var> is not in <var>obj</var>, or <var>obj</var> + * is not an object. + */ +assert.in = function (prop, obj, msg = "") { + if (Array.isArray(obj)) { + assert.that(p => obj.includes(p), msg)(prop); + return prop; + } + assert.object(obj, msg); + msg = msg || lazy.pprint`Expected ${prop} in ${obj}`; + assert.that(p => obj.hasOwnProperty(p), msg)(prop); + return obj[prop]; +}; + +/** + * Asserts that <var>obj</var> is an Array. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {object} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an Array. + */ +assert.array = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be an Array`; + return assert.that(Array.isArray, msg)(obj); +}; + +/** + * Returns a function that is used to assert the |predicate|. + * + * @param {function(?): boolean} predicate + * Evaluated on calling the return value of this function. If its + * return value of the inner function is false, <var>error</var> + * is thrown with <var>message</var>. + * @param {string=} message + * Custom error message. + * @param {Error=} err + * Custom error type by its class. + * + * @returns {function(?): ?} + * Function that takes and returns the passed in value unaltered, + * and which may throw <var>error</var> with <var>message</var> + * if <var>predicate</var> evaluates to false. + */ +assert.that = function ( + predicate, + message = "", + err = lazy.error.InvalidArgumentError +) { + return obj => { + if (!predicate(obj)) { + throw new err(message); + } + return obj; + }; +}; diff --git a/remote/shared/webdriver/Capabilities.sys.mjs b/remote/shared/webdriver/Capabilities.sys.mjs new file mode 100644 index 0000000000..e3761315f2 --- /dev/null +++ b/remote/shared/webdriver/Capabilities.sys.mjs @@ -0,0 +1,1061 @@ +/* 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, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", + RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "remoteAgent", () => { + return Cc["@mozilla.org/remote/agent;1"].createInstance(Ci.nsIRemoteAgent); +}); + +// List of capabilities which are only relevant for Webdriver Classic. +export const WEBDRIVER_CLASSIC_CAPABILITIES = [ + "pageLoadStrategy", + "timeouts", + "strictFileInteractability", + "unhandledPromptBehavior", + "webSocketUrl", + "moz:useNonSpecCompliantPointerOrigin", + "moz:webdriverClick", + "moz:debuggerAddress", + "moz:firefoxOptions", +]; + +/** Representation of WebDriver session timeouts. */ +export class Timeouts { + constructor() { + // disabled + this.implicit = 0; + // five minutes + this.pageLoad = 300000; + // 30 seconds + this.script = 30000; + } + + toString() { + return "[object Timeouts]"; + } + + /** Marshals timeout durations to a JSON Object. */ + toJSON() { + return { + implicit: this.implicit, + pageLoad: this.pageLoad, + script: this.script, + }; + } + + static fromJSON(json) { + lazy.assert.object( + json, + lazy.pprint`Expected "timeouts" to be an object, got ${json}` + ); + let t = new Timeouts(); + + for (let [type, ms] of Object.entries(json)) { + switch (type) { + case "implicit": + t.implicit = lazy.assert.positiveInteger( + ms, + lazy.pprint`Expected ${type} to be a positive integer, got ${ms}` + ); + break; + + case "script": + if (ms !== null) { + lazy.assert.positiveInteger( + ms, + lazy.pprint`Expected ${type} to be a positive integer, got ${ms}` + ); + } + t.script = ms; + break; + + case "pageLoad": + t.pageLoad = lazy.assert.positiveInteger( + ms, + lazy.pprint`Expected ${type} to be a positive integer, got ${ms}` + ); + break; + + default: + throw new lazy.error.InvalidArgumentError( + "Unrecognised timeout: " + type + ); + } + } + + return t; + } +} + +/** + * Enum of page loading strategies. + * + * @enum + */ +export const PageLoadStrategy = { + /** No page load strategy. Navigation will return immediately. */ + None: "none", + /** + * Eager, causing navigation to complete when the document reaches + * the <code>interactive</code> ready state. + */ + Eager: "eager", + /** + * Normal, causing navigation to return when the document reaches the + * <code>complete</code> ready state. + */ + Normal: "normal", +}; + +/** Proxy configuration object representation. */ +export class Proxy { + /** @class */ + constructor() { + this.proxyType = null; + this.httpProxy = null; + this.httpProxyPort = null; + this.noProxy = null; + this.sslProxy = null; + this.sslProxyPort = null; + this.socksProxy = null; + this.socksProxyPort = null; + this.socksVersion = null; + this.proxyAutoconfigUrl = null; + } + + /** + * Sets Firefox proxy settings. + * + * @returns {boolean} + * True if proxy settings were updated as a result of calling this + * function, or false indicating that this function acted as + * a no-op. + */ + init() { + switch (this.proxyType) { + case "autodetect": + Services.prefs.setIntPref("network.proxy.type", 4); + return true; + + case "direct": + Services.prefs.setIntPref("network.proxy.type", 0); + return true; + + case "manual": + Services.prefs.setIntPref("network.proxy.type", 1); + + if (this.httpProxy) { + Services.prefs.setStringPref("network.proxy.http", this.httpProxy); + if (Number.isInteger(this.httpProxyPort)) { + Services.prefs.setIntPref( + "network.proxy.http_port", + this.httpProxyPort + ); + } + } + + if (this.sslProxy) { + Services.prefs.setStringPref("network.proxy.ssl", this.sslProxy); + if (Number.isInteger(this.sslProxyPort)) { + Services.prefs.setIntPref( + "network.proxy.ssl_port", + this.sslProxyPort + ); + } + } + + if (this.socksProxy) { + Services.prefs.setStringPref("network.proxy.socks", this.socksProxy); + if (Number.isInteger(this.socksProxyPort)) { + Services.prefs.setIntPref( + "network.proxy.socks_port", + this.socksProxyPort + ); + } + if (this.socksVersion) { + Services.prefs.setIntPref( + "network.proxy.socks_version", + this.socksVersion + ); + } + } + + if (this.noProxy) { + Services.prefs.setStringPref( + "network.proxy.no_proxies_on", + this.noProxy.join(", ") + ); + } + return true; + + case "pac": + Services.prefs.setIntPref("network.proxy.type", 2); + Services.prefs.setStringPref( + "network.proxy.autoconfig_url", + this.proxyAutoconfigUrl + ); + return true; + + case "system": + Services.prefs.setIntPref("network.proxy.type", 5); + return true; + + default: + return false; + } + } + + /** + * @param {Object<string, ?>} json + * JSON Object to unmarshal. + * + * @throws {InvalidArgumentError} + * When proxy configuration is invalid. + */ + static fromJSON(json) { + function stripBracketsFromIpv6Hostname(hostname) { + return hostname.includes(":") + ? hostname.replace(/[\[\]]/g, "") + : hostname; + } + + // Parse hostname and optional port from host + function fromHost(scheme, host) { + lazy.assert.string( + host, + lazy.pprint`Expected proxy "host" to be a string, got ${host}` + ); + + if (host.includes("://")) { + throw new lazy.error.InvalidArgumentError(`${host} contains a scheme`); + } + + let url; + try { + // To parse the host a scheme has to be added temporarily. + // If the returned value for the port is an empty string it + // could mean no port or the default port for this scheme was + // specified. In such a case parse again with a different + // scheme to ensure we filter out the default port. + url = new URL("http://" + host); + if (url.port == "") { + url = new URL("https://" + host); + } + } catch (e) { + throw new lazy.error.InvalidArgumentError(e.message); + } + + let hostname = stripBracketsFromIpv6Hostname(url.hostname); + + // If the port hasn't been set, use the default port of + // the selected scheme (except for socks which doesn't have one). + let port = parseInt(url.port); + if (!Number.isInteger(port)) { + if (scheme === "socks") { + port = null; + } else { + port = Services.io.getDefaultPort(scheme); + } + } + + if ( + url.username != "" || + url.password != "" || + url.pathname != "/" || + url.search != "" || + url.hash != "" + ) { + throw new lazy.error.InvalidArgumentError( + `${host} was not of the form host[:port]` + ); + } + + return [hostname, port]; + } + + let p = new Proxy(); + if (typeof json == "undefined" || json === null) { + return p; + } + + lazy.assert.object( + json, + lazy.pprint`Expected "proxy" to be an object, got ${json}` + ); + + lazy.assert.in( + "proxyType", + json, + lazy.pprint`Expected "proxyType" in "proxy" object, got ${json}` + ); + p.proxyType = lazy.assert.string( + json.proxyType, + lazy.pprint`Expected "proxyType" to be a string, got ${json.proxyType}` + ); + + switch (p.proxyType) { + case "autodetect": + case "direct": + case "system": + break; + + case "pac": + p.proxyAutoconfigUrl = lazy.assert.string( + json.proxyAutoconfigUrl, + `Expected "proxyAutoconfigUrl" to be a string, ` + + lazy.pprint`got ${json.proxyAutoconfigUrl}` + ); + break; + + case "manual": + if (typeof json.ftpProxy != "undefined") { + throw new lazy.error.InvalidArgumentError( + "Since Firefox 90 'ftpProxy' is no longer supported" + ); + } + if (typeof json.httpProxy != "undefined") { + [p.httpProxy, p.httpProxyPort] = fromHost("http", json.httpProxy); + } + if (typeof json.sslProxy != "undefined") { + [p.sslProxy, p.sslProxyPort] = fromHost("https", json.sslProxy); + } + if (typeof json.socksProxy != "undefined") { + [p.socksProxy, p.socksProxyPort] = fromHost("socks", json.socksProxy); + p.socksVersion = lazy.assert.positiveInteger( + json.socksVersion, + lazy.pprint`Expected "socksVersion" to be a positive integer, got ${json.socksVersion}` + ); + } + if (typeof json.noProxy != "undefined") { + let entries = lazy.assert.array( + json.noProxy, + lazy.pprint`Expected "noProxy" to be an array, got ${json.noProxy}` + ); + p.noProxy = entries.map(entry => { + lazy.assert.string( + entry, + lazy.pprint`Expected "noProxy" entry to be a string, got ${entry}` + ); + return stripBracketsFromIpv6Hostname(entry); + }); + } + break; + + default: + throw new lazy.error.InvalidArgumentError( + `Invalid type of proxy: ${p.proxyType}` + ); + } + + return p; + } + + /** + * @returns {Object<string, (number | string)>} + * JSON serialisation of proxy object. + */ + toJSON() { + function addBracketsToIpv6Hostname(hostname) { + return hostname.includes(":") ? `[${hostname}]` : hostname; + } + + function toHost(hostname, port) { + if (!hostname) { + return null; + } + + // Add brackets around IPv6 addresses + hostname = addBracketsToIpv6Hostname(hostname); + + if (port != null) { + return `${hostname}:${port}`; + } + + return hostname; + } + + let excludes = this.noProxy; + if (excludes) { + excludes = excludes.map(addBracketsToIpv6Hostname); + } + + return marshal({ + proxyType: this.proxyType, + httpProxy: toHost(this.httpProxy, this.httpProxyPort), + noProxy: excludes, + sslProxy: toHost(this.sslProxy, this.sslProxyPort), + socksProxy: toHost(this.socksProxy, this.socksProxyPort), + socksVersion: this.socksVersion, + proxyAutoconfigUrl: this.proxyAutoconfigUrl, + }); + } + + toString() { + return "[object Proxy]"; + } +} + +/** + * Enum of unhandled prompt behavior. + * + * @enum + */ +export const UnhandledPromptBehavior = { + /** All simple dialogs encountered should be accepted. */ + Accept: "accept", + /** + * All simple dialogs encountered should be accepted, and an error + * returned that the dialog was handled. + */ + AcceptAndNotify: "accept and notify", + /** All simple dialogs encountered should be dismissed. */ + Dismiss: "dismiss", + /** + * All simple dialogs encountered should be dismissed, and an error + * returned that the dialog was handled. + */ + DismissAndNotify: "dismiss and notify", + /** All simple dialogs encountered should be left to the user to handle. */ + Ignore: "ignore", +}; + +/** WebDriver session capabilities representation. */ +export class Capabilities extends Map { + /** @class */ + constructor() { + super([ + // webdriver + ["browserName", getWebDriverBrowserName()], + ["browserVersion", lazy.AppInfo.version], + ["platformName", getWebDriverPlatformName()], + ["acceptInsecureCerts", false], + ["pageLoadStrategy", PageLoadStrategy.Normal], + ["proxy", new Proxy()], + ["setWindowRect", !lazy.AppInfo.isAndroid], + ["timeouts", new Timeouts()], + ["strictFileInteractability", false], + ["unhandledPromptBehavior", UnhandledPromptBehavior.DismissAndNotify], + ["webSocketUrl", null], + + // proprietary + ["moz:accessibilityChecks", false], + ["moz:buildID", lazy.AppInfo.appBuildID], + [ + "moz:debuggerAddress", + // With bug 1715481 fixed always use the Remote Agent instance + lazy.RemoteAgent.running && lazy.RemoteAgent.cdp + ? lazy.remoteAgent.debuggerAddress + : null, + ], + [ + "moz:headless", + Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless, + ], + ["moz:platformVersion", Services.sysinfo.getProperty("version")], + ["moz:processID", lazy.AppInfo.processID], + ["moz:profile", maybeProfile()], + [ + "moz:shutdownTimeout", + Services.prefs.getIntPref("toolkit.asyncshutdown.crash_timeout"), + ], + ["moz:webdriverClick", true], + ["moz:windowless", false], + ]); + } + + /** + * @param {string} key + * Capability key. + * @param {(string|number|boolean)} value + * JSON-safe capability value. + */ + set(key, value) { + if (key === "timeouts" && !(value instanceof Timeouts)) { + throw new TypeError(); + } else if (key === "proxy" && !(value instanceof Proxy)) { + throw new TypeError(); + } + + return super.set(key, value); + } + + toString() { + return "[object Capabilities]"; + } + + /** + * JSON serialisation of capabilities object. + * + * @returns {Object<string, ?>} + */ + toJSON() { + let marshalled = marshal(this); + + // Always return the proxy capability even if it's empty + if (!("proxy" in marshalled)) { + marshalled.proxy = {}; + } + + marshalled.timeouts = super.get("timeouts"); + + return marshalled; + } + + /** + * Unmarshal a JSON object representation of WebDriver capabilities. + * + * @param {Object<string, *>=} json + * WebDriver capabilities. + * + * @returns {Capabilities} + * Internal representation of WebDriver capabilities. + */ + static fromJSON(json) { + if (typeof json == "undefined" || json === null) { + json = {}; + } + lazy.assert.object( + json, + lazy.pprint`Expected "capabilities" to be an object, got ${json}"` + ); + + const capabilities = new Capabilities(); + // TODO: Bug 1823907. We can start using here spec compliant method `validate`, + // as soon as `desiredCapabilities` and `requiredCapabilities` are not supported. + for (let [k, v] of Object.entries(json)) { + switch (k) { + case "acceptInsecureCerts": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be a boolean, got ${v}` + ); + break; + + case "pageLoadStrategy": + lazy.assert.string( + v, + lazy.pprint`Expected ${k} to be a string, got ${v}` + ); + if (!Object.values(PageLoadStrategy).includes(v)) { + throw new lazy.error.InvalidArgumentError( + "Unknown page load strategy: " + v + ); + } + break; + + case "proxy": + v = Proxy.fromJSON(v); + break; + + case "setWindowRect": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be boolean, got ${v}` + ); + if (!lazy.AppInfo.isAndroid && !v) { + throw new lazy.error.InvalidArgumentError( + "setWindowRect cannot be disabled" + ); + } else if (lazy.AppInfo.isAndroid && v) { + throw new lazy.error.InvalidArgumentError( + "setWindowRect is only supported on desktop" + ); + } + break; + + case "timeouts": + v = Timeouts.fromJSON(v); + break; + + case "strictFileInteractability": + v = lazy.assert.boolean(v); + break; + + case "unhandledPromptBehavior": + lazy.assert.string( + v, + lazy.pprint`Expected ${k} to be a string, got ${v}` + ); + if (!Object.values(UnhandledPromptBehavior).includes(v)) { + throw new lazy.error.InvalidArgumentError( + `Unknown unhandled prompt behavior: ${v}` + ); + } + break; + + case "webSocketUrl": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be boolean, got ${v}` + ); + + if (!v) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected ${k} to be true, got ${v}` + ); + } + break; + + case "webauthn:virtualAuthenticators": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be a boolean, got ${v}` + ); + break; + + case "webauthn:extension:uvm": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be a boolean, got ${v}` + ); + break; + + case "webauthn:extension:prf": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be a boolean, got ${v}` + ); + break; + + case "webauthn:extension:largeBlob": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be a boolean, got ${v}` + ); + break; + + case "webauthn:extension:credBlob": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be a boolean, got ${v}` + ); + break; + + case "moz:accessibilityChecks": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be boolean, got ${v}` + ); + break; + + // Don't set the value because it's only used to return the address + // of the Remote Agent's debugger (HTTP server). + case "moz:debuggerAddress": + continue; + + case "moz:useNonSpecCompliantPointerOrigin": + if (v !== undefined) { + throw new lazy.error.InvalidArgumentError( + `Since Firefox 116 the capability ${k} is no longer supported` + ); + } + break; + + case "moz:webdriverClick": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be boolean, got ${v}` + ); + break; + + case "moz:windowless": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be boolean, got ${v}` + ); + + // Only supported on MacOS + if (v && !lazy.AppInfo.isMac) { + throw new lazy.error.InvalidArgumentError( + "moz:windowless only supported on MacOS" + ); + } + break; + } + capabilities.set(k, v); + } + + return capabilities; + } + + /** + * Validate WebDriver capability. + * + * @param {string} name + * The name of capability. + * @param {string} value + * The value of capability. + * + * @throws {InvalidArgumentError} + * If <var>value</var> doesn't pass validation, + * which depends on <var>name</var>. + * + * @returns {string} + * The validated capability value. + */ + static validate(name, value) { + if (value === null) { + return value; + } + switch (name) { + case "acceptInsecureCerts": + lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + return value; + + case "browserName": + case "browserVersion": + case "platformName": + return lazy.assert.string( + value, + lazy.pprint`Expected ${name} to be a string, got ${value}` + ); + + case "pageLoadStrategy": + lazy.assert.string( + value, + lazy.pprint`Expected ${name} to be a string, got ${value}` + ); + if (!Object.values(PageLoadStrategy).includes(value)) { + throw new lazy.error.InvalidArgumentError( + "Unknown page load strategy: " + value + ); + } + return value; + + case "proxy": + return Proxy.fromJSON(value); + + case "strictFileInteractability": + return lazy.assert.boolean(value); + + case "timeouts": + return Timeouts.fromJSON(value); + + case "unhandledPromptBehavior": + lazy.assert.string( + value, + lazy.pprint`Expected ${name} to be a string, got ${value}` + ); + if (!Object.values(UnhandledPromptBehavior).includes(value)) { + throw new lazy.error.InvalidArgumentError( + `Unknown unhandled prompt behavior: ${value}` + ); + } + return value; + + case "webSocketUrl": + lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + + if (!value) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected ${name} to be true, got ${value}` + ); + } + return value; + + case "webauthn:virtualAuthenticators": + lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + return value; + + case "webauthn:extension:uvm": + lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + return value; + + case "webauthn:extension:largeBlob": + lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + return value; + + case "moz:firefoxOptions": + return lazy.assert.object( + value, + lazy.pprint`Expected ${name} to be an object, got ${value}` + ); + + case "moz:accessibilityChecks": + return lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + + case "moz:webdriverClick": + return lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + + case "moz:windowless": + lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + + // Only supported on MacOS + if (value && !lazy.AppInfo.isMac) { + throw new lazy.error.InvalidArgumentError( + "moz:windowless only supported on MacOS" + ); + } + return value; + + case "moz:debuggerAddress": + return lazy.assert.boolean( + value, + lazy.pprint`Expected ${name} to be a boolean, got ${value}` + ); + + default: + lazy.assert.string( + name, + lazy.pprint`Expected capability name to be a string, got ${name}` + ); + if (name.includes(":")) { + const [prefix] = name.split(":"); + if (prefix !== "moz") { + return value; + } + } + throw new lazy.error.InvalidArgumentError( + `${name} is not the name of a known capability or extension capability` + ); + } + } +} + +function getWebDriverBrowserName() { + // Similar to chromedriver which reports "chrome" as browser name for all + // WebView apps, we will report "firefox" for all GeckoView apps. + if (lazy.AppInfo.isAndroid) { + return "firefox"; + } + + return lazy.AppInfo.name?.toLowerCase(); +} + +function getWebDriverPlatformName() { + let name = Services.sysinfo.getProperty("name"); + + if (lazy.AppInfo.isAndroid) { + return "android"; + } + + switch (name) { + case "Windows_NT": + return "windows"; + + case "Darwin": + return "mac"; + + default: + return name.toLowerCase(); + } +} + +// Specialisation of |JSON.stringify| that produces JSON-safe object +// literals, dropping empty objects and entries which values are undefined +// or null. Objects are allowed to produce their own JSON representations +// by implementing a |toJSON| function. +function marshal(obj) { + let rv = Object.create(null); + + function* iter(mapOrObject) { + if (mapOrObject instanceof Map) { + for (const [k, v] of mapOrObject) { + yield [k, v]; + } + } else { + for (const k of Object.keys(mapOrObject)) { + yield [k, mapOrObject[k]]; + } + } + } + + for (let [k, v] of iter(obj)) { + // Skip empty values when serialising to JSON. + if (typeof v == "undefined" || v === null) { + continue; + } + + // Recursively marshal objects that are able to produce their own + // JSON representation. + if (typeof v.toJSON == "function") { + v = marshal(v.toJSON()); + + // Or do the same for object literals. + } else if (isObject(v)) { + v = marshal(v); + } + + // And finally drop (possibly marshaled) objects which have no + // entries. + if (!isObjectEmpty(v)) { + rv[k] = v; + } + } + + return rv; +} + +function isObject(obj) { + return Object.prototype.toString.call(obj) == "[object Object]"; +} + +function isObjectEmpty(obj) { + return isObject(obj) && Object.keys(obj).length === 0; +} + +// Services.dirsvc is not accessible from JSWindowActor child, +// but we should not panic about that. +function maybeProfile() { + try { + return Services.dirsvc.get("ProfD", Ci.nsIFile).path; + } catch (e) { + return "<protected>"; + } +} + +/** + * Merge WebDriver capabilities. + * + * @see https://w3c.github.io/webdriver/#dfn-merging-capabilities + * + * @param {object} primary + * Required capabilities which need to be merged with <var>secondary</var>. + * @param {object=} secondary + * Secondary capabilities. + * + * @returns {object} Merged capabilities. + * + * @throws {InvalidArgumentError} + * If <var>primary</var> and <var>secondary</var> have the same keys. + */ +export function mergeCapabilities(primary, secondary) { + const result = { ...primary }; + + if (secondary === undefined) { + return result; + } + + Object.entries(secondary).forEach(([name, value]) => { + if (primary[name] !== undefined) { + // Since at the moment we always pass as `primary` `alwaysMatch` object + // and as `secondary` an item from `firstMatch` array from `capabilities`, + // we can make this error message more specific. + throw new lazy.error.InvalidArgumentError( + `firstMatch key ${name} shadowed a value in alwaysMatch` + ); + } + result[name] = value; + }); + + return result; +} + +/** + * Validate WebDriver capabilities. + * + * @see https://w3c.github.io/webdriver/#dfn-validate-capabilities + * + * @param {object} capabilities + * Capabilities which need to be validated. + * + * @returns {object} Validated capabilities. + * + * @throws {InvalidArgumentError} + * If <var>capabilities</var> is not an object. + */ +export function validateCapabilities(capabilities) { + lazy.assert.object(capabilities); + + const result = {}; + + Object.entries(capabilities).forEach(([name, value]) => { + const deserialized = Capabilities.validate(name, value); + if (deserialized !== null) { + if (name === "proxy" || name === "timeouts") { + // Return pure value, the Proxy and Timeouts objects will be setup + // during session creation. + result[name] = value; + } else { + result[name] = deserialized; + } + } + }); + + return result; +} + +/** + * Process WebDriver capabilities. + * + * @see https://w3c.github.io/webdriver/#processing-capabilities + * + * @param {object} params + * @param {object} params.capabilities + * Capabilities which need to be processed. + * + * @returns {object} Processed capabilities. + * + * @throws {InvalidArgumentError} + * If <var>capabilities</var> do not satisfy the criteria. + */ +export function processCapabilities(params) { + const { capabilities } = params; + lazy.assert.object(capabilities); + + let { + alwaysMatch: requiredCapabilities = {}, + firstMatch: allFirstMatchCapabilities = [{}], + } = capabilities; + + requiredCapabilities = validateCapabilities(requiredCapabilities); + + lazy.assert.array(allFirstMatchCapabilities); + lazy.assert.that( + firstMatch => firstMatch.length >= 1, + lazy.pprint`Expected firstMatch ${allFirstMatchCapabilities} to have at least 1 entry` + )(allFirstMatchCapabilities); + + const validatedFirstMatchCapabilities = + allFirstMatchCapabilities.map(validateCapabilities); + + const mergedCapabilities = []; + validatedFirstMatchCapabilities.forEach(firstMatchCapabilities => { + const merged = mergeCapabilities( + requiredCapabilities, + firstMatchCapabilities + ); + mergedCapabilities.push(merged); + }); + + // TODO: Bug 1836288. Implement the capability matching logic + // for "browserName", "browserVersion" and "platformName" features, + // for now we can just pick the first merged capability. + const matchedCapabilities = mergedCapabilities[0]; + + return matchedCapabilities; +} diff --git a/remote/shared/webdriver/Errors.sys.mjs b/remote/shared/webdriver/Errors.sys.mjs new file mode 100644 index 0000000000..53b9d4426b --- /dev/null +++ b/remote/shared/webdriver/Errors.sys.mjs @@ -0,0 +1,881 @@ +/* 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 { RemoteError } from "chrome://remote/content/shared/RemoteError.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + pprint: "chrome://remote/content/shared/Format.sys.mjs", +}); + +const ERRORS = new Set([ + "DetachedShadowRootError", + "ElementClickInterceptedError", + "ElementNotAccessibleError", + "ElementNotInteractableError", + "InsecureCertificateError", + "InvalidArgumentError", + "InvalidCookieDomainError", + "InvalidElementStateError", + "InvalidSelectorError", + "InvalidSessionIDError", + "JavaScriptError", + "MoveTargetOutOfBoundsError", + "NoSuchAlertError", + "NoSuchElementError", + "NoSuchFrameError", + "NoSuchHandleError", + "NoSuchHistoryEntryError", + "NoSuchInterceptError", + "NoSuchNodeError", + "NoSuchRequestError", + "NoSuchScriptError", + "NoSuchShadowRootError", + "NoSuchUserContextError", + "NoSuchWindowError", + "ScriptTimeoutError", + "SessionNotCreatedError", + "StaleElementReferenceError", + "TimeoutError", + "UnableToCaptureScreen", + "UnableToSetCookieError", + "UnexpectedAlertOpenError", + "UnknownCommandError", + "UnknownError", + "UnsupportedOperationError", + "WebDriverError", +]); + +const BUILTIN_ERRORS = new Set([ + "Error", + "EvalError", + "InternalError", + "RangeError", + "ReferenceError", + "SyntaxError", + "TypeError", + "URIError", +]); + +/** @namespace */ +export const error = { + /** + * Check if ``val`` is an instance of the ``Error`` prototype. + * + * Because error objects may originate from different globals, comparing + * the prototype of the left hand side with the prototype property from + * the right hand side, which is what ``instanceof`` does, will not work. + * If the LHS and RHS come from different globals, this check will always + * fail because the two objects will not have the same identity. + * + * Therefore it is not safe to use ``instanceof`` in any multi-global + * situation, e.g. in content across multiple ``Window`` objects or anywhere + * in chrome scope. + * + * This function also contains a special check if ``val`` is an XPCOM + * ``nsIException`` because they are special snowflakes and may indeed + * cause Firefox to crash if used with ``instanceof``. + * + * @param {*} val + * Any value that should be undergo the test for errorness. + * @returns {boolean} + * True if error, false otherwise. + */ + isError(val) { + if (val === null || typeof val != "object") { + return false; + } else if (val instanceof Ci.nsIException) { + return true; + } + + // DOMRectList errors on string comparison + try { + let proto = Object.getPrototypeOf(val); + return BUILTIN_ERRORS.has(proto.toString()); + } catch (e) { + return false; + } + }, + + /** + * Checks if ``obj`` is an object in the :js:class:`WebDriverError` + * prototypal chain. + * + * @param {*} obj + * Arbitrary object to test. + * + * @returns {boolean} + * True if ``obj`` is of the WebDriverError prototype chain, + * false otherwise. + */ + isWebDriverError(obj) { + // Don't use "instanceof" to compare error objects because of possible + // problems when the other instance was created in a different global and + // as such won't have the same prototype object. + return error.isError(obj) && "name" in obj && ERRORS.has(obj.name); + }, + + /** + * Ensures error instance is a :js:class:`WebDriverError`. + * + * If the given error is already in the WebDriverError prototype + * chain, ``err`` is returned unmodified. If it is not, it is wrapped + * in :js:class:`UnknownError`. + * + * @param {Error} err + * Error to conditionally turn into a WebDriverError. + * + * @returns {WebDriverError} + * If ``err`` is a WebDriverError, it is returned unmodified. + * Otherwise an UnknownError type is returned. + */ + wrap(err) { + if (error.isWebDriverError(err)) { + return err; + } + return new UnknownError(err); + }, + + /** + * Unhandled error reporter. Dumps the error and its stacktrace to console, + * and reports error to the Browser Console. + */ + report(err) { + let msg = "Marionette threw an error: " + error.stringify(err); + dump(msg + "\n"); + console.error(msg); + }, + + /** + * Prettifies an instance of Error and its stacktrace to a string. + */ + stringify(err) { + try { + let s = err.toString(); + if ("stack" in err) { + s += "\n" + err.stack; + } + return s; + } catch (e) { + return "<unprintable error>"; + } + }, + + /** Create a stacktrace to the current line in the program. */ + stack() { + let trace = new Error().stack; + let sa = trace.split("\n"); + sa = sa.slice(1); + let rv = "stacktrace:\n" + sa.join("\n"); + return rv.trimEnd(); + }, +}; + +/** + * WebDriverError is the prototypal parent of all WebDriver errors. + * It should not be used directly, as it does not correspond to a real + * error in the specification. + */ +class WebDriverError extends RemoteError { + /** + * Base error for WebDriver protocols. + * + * @param {(string|Error)=} obj + * Optional string describing error situation or Error instance + * to propagate. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ + constructor(obj, data = {}) { + super(obj); + + this.name = this.constructor.name; + this.status = "webdriver error"; + this.data = data; + + // Error's ctor does not preserve x' stack + if (error.isError(obj)) { + this.stack = obj.stack; + } + + if (error.isWebDriverError(obj)) { + this.message = obj.message; + this.data = obj.data; + } + } + + /** + * @returns {Object<string, string>} + * JSON serialisation of error prototype. + */ + toJSON() { + const result = { + error: this.status, + message: this.message || "", + stacktrace: this.stack || "", + }; + + // Only add the field if additional data has been specified. + if (Object.keys(this.data).length) { + result.data = this.data; + } + + return result; + } + + /** + * Unmarshals a JSON error representation to the appropriate Marionette + * error type. + * + * @param {Object<string, string>} json + * Error object. + * + * @returns {Error} + * Error prototype. + */ + static fromJSON(json) { + if (typeof json.error == "undefined") { + let s = JSON.stringify(json); + throw new TypeError("Undeserialisable error type: " + s); + } + if (!STATUSES.has(json.error)) { + throw new TypeError("Not of WebDriverError descent: " + json.error); + } + + let cls = STATUSES.get(json.error); + let err = new cls(); + if ("message" in json) { + err.message = json.message; + } + if ("stacktrace" in json) { + err.stack = json.stacktrace; + } + if ("data" in json) { + err.data = json.data; + } + + return err; + } +} + +/** + * The Gecko a11y API indicates that the element is not accessible. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class ElementNotAccessibleError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "element not accessible"; + } +} + +/** + * An element click could not be completed because the element receiving + * the events is obscuring the element that was requested clicked. + * + * @param {string=} message + * Optional string describing error situation. Will be replaced if both + * `data.obscuredEl` and `data.coords` are provided. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + * @param {Element=} obscuredEl + * Element obscuring the element receiving the click. Providing this + * is not required, but will produce a nicer error message. + * @param {Map.<string, number>=} coords + * Original click location. Providing this is not required, but + * will produce a nicer error message. + */ +class ElementClickInterceptedError extends WebDriverError { + constructor(message, data = {}, obscuredEl = undefined, coords = undefined) { + let obscuredElDetails = null; + let overlayingElDetails = null; + + if (obscuredEl && coords) { + const doc = obscuredEl.ownerDocument; + const overlayingEl = doc.elementFromPoint(coords.x, coords.y); + + obscuredElDetails = lazy.pprint`${obscuredEl}`; + overlayingElDetails = lazy.pprint`${overlayingEl}`; + + switch (obscuredEl.style.pointerEvents) { + case "none": + message = + `Element ${obscuredElDetails} is not clickable ` + + `at point (${coords.x},${coords.y}) ` + + `because it does not have pointer events enabled, ` + + `and element ${overlayingElDetails} ` + + `would receive the click instead`; + break; + + default: + message = + `Element ${obscuredElDetails} is not clickable ` + + `at point (${coords.x},${coords.y}) ` + + `because another element ${overlayingElDetails} ` + + `obscures it`; + break; + } + } + + if (coords) { + data.coords = coords; + } + if (obscuredElDetails) { + data.obscuredElement = obscuredElDetails; + } + if (overlayingElDetails) { + data.overlayingElement = overlayingElDetails; + } + + super(message, data); + this.status = "element click intercepted"; + } +} + +/** + * A command could not be completed because the element is not pointer- + * or keyboard interactable. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class ElementNotInteractableError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "element not interactable"; + } +} + +/** + * Navigation caused the user agent to hit a certificate warning, which + * is usually the result of an expired or invalid TLS certificate. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InsecureCertificateError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "insecure certificate"; + } +} + +/** + * The arguments passed to a command are either invalid or malformed. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidArgumentError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid argument"; + } +} + +/** + * An illegal attempt was made to set a cookie under a different + * domain than the current page. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidCookieDomainError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid cookie domain"; + } +} + +/** + * A command could not be completed because the element is in an + * invalid state, e.g. attempting to clear an element that isn't both + * editable and resettable. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidElementStateError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid element state"; + } +} + +/** + * Argument was an invalid selector. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidSelectorError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid selector"; + } +} + +/** + * Occurs if the given session ID is not in the list of active sessions, + * meaning the session either does not exist or that it's not active. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidSessionIDError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid session id"; + } +} + +/** + * An error occurred whilst executing JavaScript supplied by the user. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class JavaScriptError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "javascript error"; + } +} + +/** + * The target for mouse interaction is not in the browser's viewport + * and cannot be brought into that viewport. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class MoveTargetOutOfBoundsError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "move target out of bounds"; + } +} + +/** + * An attempt was made to operate on a modal dialog when one was + * not open. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchAlertError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such alert"; + } +} + +/** + * An element could not be located on the page using the given + * search parameters. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchElementError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such element"; + } +} + +/** + * A command tried to remove an unknown preload script. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchScriptError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such script"; + } +} + +/** + * A shadow root was not attached to the element. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchShadowRootError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such shadow root"; + } +} + +/** + * A shadow root is no longer attached to the document. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class DetachedShadowRootError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "detached shadow root"; + } +} + +/** + * A command to switch to a frame could not be satisfied because + * the frame could not be found. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchFrameError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such frame"; + } +} + +/** + * The handle of a strong object reference could not be found. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchHandleError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such handle"; + } +} + +/** + * The entry of the history could not be found. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchHistoryEntryError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such history entry"; + } +} + +/** + * Tried to remove an unknown network intercept. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchInterceptError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such intercept"; + } +} + +/** + * A node as given by its unique shared id could not be found within the cache + * of known nodes. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchNodeError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such node"; + } +} + +/** + * Tried to continue an unknown request. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchRequestError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such request"; + } +} + +/** + * A command tried to reference an unknown user context (containers in Firefox). + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchUserContextError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such user context"; + } +} + +/** + * A command to switch to a window could not be satisfied because + * the window could not be found. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchWindowError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such window"; + } +} + +/** + * A script did not complete before its timeout expired. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class ScriptTimeoutError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "script timeout"; + } +} + +/** + * A new session could not be created. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class SessionNotCreatedError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "session not created"; + } +} + +/** + * A command failed because the referenced element is no longer + * attached to the DOM. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class StaleElementReferenceError extends WebDriverError { + constructor(message, options = {}) { + super(message, options); + this.status = "stale element reference"; + } +} + +/** + * An operation did not complete before its timeout expired. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class TimeoutError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "timeout"; + } +} + +/** + * A command to set a cookie's value could not be satisfied. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnableToSetCookieError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unable to set cookie"; + } +} + +/** + * A command to capture a screenshot could not be satisfied. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnableToCaptureScreen extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unable to capture screen"; + } +} + +/** + * A modal dialog was open, blocking this operation. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnexpectedAlertOpenError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unexpected alert open"; + } +} + +/** + * A command could not be executed because the remote end is not + * aware of it. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnknownCommandError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unknown command"; + } +} + +/** + * An unknown error occurred in the remote end while processing + * the command. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnknownError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unknown error"; + } +} + +/** + * Indicates that a command that should have executed properly + * cannot be supported for some reason. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnsupportedOperationError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unsupported operation"; + } +} + +const STATUSES = new Map([ + ["detached shadow root", DetachedShadowRootError], + ["element click intercepted", ElementClickInterceptedError], + ["element not accessible", ElementNotAccessibleError], + ["element not interactable", ElementNotInteractableError], + ["insecure certificate", InsecureCertificateError], + ["invalid argument", InvalidArgumentError], + ["invalid cookie domain", InvalidCookieDomainError], + ["invalid element state", InvalidElementStateError], + ["invalid selector", InvalidSelectorError], + ["invalid session id", InvalidSessionIDError], + ["javascript error", JavaScriptError], + ["move target out of bounds", MoveTargetOutOfBoundsError], + ["no such alert", NoSuchAlertError], + ["no such element", NoSuchElementError], + ["no such frame", NoSuchFrameError], + ["no such handle", NoSuchHandleError], + ["no such history entry", NoSuchHistoryEntryError], + ["no such intercept", NoSuchInterceptError], + ["no such node", NoSuchNodeError], + ["no such request", NoSuchRequestError], + ["no such script", NoSuchScriptError], + ["no such shadow root", NoSuchShadowRootError], + ["no such user context", NoSuchUserContextError], + ["no such window", NoSuchWindowError], + ["script timeout", ScriptTimeoutError], + ["session not created", SessionNotCreatedError], + ["stale element reference", StaleElementReferenceError], + ["timeout", TimeoutError], + ["unable to capture screen", UnableToCaptureScreen], + ["unable to set cookie", UnableToSetCookieError], + ["unexpected alert open", UnexpectedAlertOpenError], + ["unknown command", UnknownCommandError], + ["unknown error", UnknownError], + ["unsupported operation", UnsupportedOperationError], + ["webdriver error", WebDriverError], +]); + +// Errors must be expored on the local this scope so that the +// EXPORTED_SYMBOLS and the ChromeUtils.import("foo") machinery sees them. +// We could assign each error definition directly to |this|, but +// because they are Error prototypes this would mess up their names. +for (let cls of STATUSES.values()) { + error[cls.name] = cls; +} diff --git a/remote/shared/webdriver/KeyData.sys.mjs b/remote/shared/webdriver/KeyData.sys.mjs new file mode 100644 index 0000000000..dc19d19f35 --- /dev/null +++ b/remote/shared/webdriver/KeyData.sys.mjs @@ -0,0 +1,338 @@ +/* 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 KEY_DATA = { + " ": { code: "Space" }, + "!": { code: "Digit1", shifted: true }, + "#": { code: "Digit3", shifted: true }, + $: { code: "Digit4", shifted: true }, + "%": { code: "Digit5", shifted: true }, + "&": { code: "Digit7", shifted: true }, + "'": { code: "Quote" }, + "(": { code: "Digit9", shifted: true }, + ")": { code: "Digit0", shifted: true }, + "*": { code: "Digit8", shifted: true }, + "+": { code: "Equal", shifted: true }, + ",": { code: "Comma" }, + "-": { code: "Minus" }, + ".": { code: "Period" }, + "/": { code: "Slash" }, + 0: { code: "Digit0" }, + 1: { code: "Digit1" }, + 2: { code: "Digit2" }, + 3: { code: "Digit3" }, + 4: { code: "Digit4" }, + 5: { code: "Digit5" }, + 6: { code: "Digit6" }, + 7: { code: "Digit7" }, + 8: { code: "Digit8" }, + 9: { code: "Digit9" }, + ":": { code: "Semicolon", shifted: true }, + ";": { code: "Semicolon" }, + "<": { code: "Comma", shifted: true }, + "=": { code: "Equal" }, + ">": { code: "Period", shifted: true }, + "?": { code: "Slash", shifted: true }, + "@": { code: "Digit2", shifted: true }, + A: { code: "KeyA", shifted: true }, + B: { code: "KeyB", shifted: true }, + C: { code: "KeyC", shifted: true }, + D: { code: "KeyD", shifted: true }, + E: { code: "KeyE", shifted: true }, + F: { code: "KeyF", shifted: true }, + G: { code: "KeyG", shifted: true }, + H: { code: "KeyH", shifted: true }, + I: { code: "KeyI", shifted: true }, + J: { code: "KeyJ", shifted: true }, + K: { code: "KeyK", shifted: true }, + L: { code: "KeyL", shifted: true }, + M: { code: "KeyM", shifted: true }, + N: { code: "KeyN", shifted: true }, + O: { code: "KeyO", shifted: true }, + P: { code: "KeyP", shifted: true }, + Q: { code: "KeyQ", shifted: true }, + R: { code: "KeyR", shifted: true }, + S: { code: "KeyS", shifted: true }, + T: { code: "KeyT", shifted: true }, + U: { code: "KeyU", shifted: true }, + V: { code: "KeyV", shifted: true }, + W: { code: "KeyW", shifted: true }, + X: { code: "KeyX", shifted: true }, + Y: { code: "KeyY", shifted: true }, + Z: { code: "KeyZ", shifted: true }, + "[": { code: "BracketLeft" }, + '"': { code: "Quote", shifted: true }, + "\\": { code: "Backslash" }, + "]": { code: "BracketRight" }, + "^": { code: "Digit6", shifted: true }, + _: { code: "Minus", shifted: true }, + "`": { code: "Backquote" }, + a: { code: "KeyA" }, + b: { code: "KeyB" }, + c: { code: "KeyC" }, + d: { code: "KeyD" }, + e: { code: "KeyE" }, + f: { code: "KeyF" }, + g: { code: "KeyG" }, + h: { code: "KeyH" }, + i: { code: "KeyI" }, + j: { code: "KeyJ" }, + k: { code: "KeyK" }, + l: { code: "KeyL" }, + m: { code: "KeyM" }, + n: { code: "KeyN" }, + o: { code: "KeyO" }, + p: { code: "KeyP" }, + q: { code: "KeyQ" }, + r: { code: "KeyR" }, + s: { code: "KeyS" }, + t: { code: "KeyT" }, + u: { code: "KeyU" }, + v: { code: "KeyV" }, + w: { code: "KeyW" }, + x: { code: "KeyX" }, + y: { code: "KeyY" }, + z: { code: "KeyZ" }, + "{": { code: "BracketLeft", shifted: true }, + "|": { code: "Backslash", shifted: true }, + "}": { code: "BracketRight", shifted: true }, + "~": { code: "Backquote", shifted: true }, + "\uE000": { key: "Unidentified", printable: false }, + "\uE001": { key: "Cancel", printable: false }, + "\uE002": { code: "Help", key: "Help", printable: false }, + "\uE003": { code: "Backspace", key: "Backspace", printable: false }, + "\uE004": { code: "Tab", key: "Tab", printable: false }, + "\uE005": { code: "", key: "Clear", printable: false }, + "\uE006": { code: "Enter", key: "Enter", printable: false }, + "\uE007": { + code: "NumpadEnter", + key: "Enter", + location: 1, + printable: false, + }, + "\uE008": { + code: "ShiftLeft", + key: "Shift", + location: 1, + modifier: "shiftKey", + printable: false, + }, + "\uE009": { + code: "ControlLeft", + key: "Control", + location: 1, + modifier: "ctrlKey", + printable: false, + }, + "\uE00A": { + code: "AltLeft", + key: "Alt", + location: 1, + modifier: "altKey", + printable: false, + }, + "\uE00B": { code: "Pause", key: "Pause", printable: false }, + "\uE00C": { code: "Escape", key: "Escape", printable: false }, + "\uE00D": { code: "Space", key: " ", shifted: true }, + "\uE00E": { code: "PageUp", key: "PageUp", printable: false }, + "\uE00F": { code: "PageDown", key: "PageDown", printable: false }, + "\uE010": { code: "End", key: "End", printable: false }, + "\uE011": { code: "Home", key: "Home", printable: false }, + "\uE012": { code: "ArrowLeft", key: "ArrowLeft", printable: false }, + "\uE013": { code: "ArrowUp", key: "ArrowUp", printable: false }, + "\uE014": { code: "ArrowRight", key: "ArrowRight", printable: false }, + "\uE015": { code: "ArrowDown", key: "ArrowDown", printable: false }, + "\uE016": { code: "Insert", key: "Insert", printable: false }, + "\uE017": { code: "Delete", key: "Delete", printable: false }, + "\uE018": { code: "", key: ";" }, + "\uE019": { code: "NumpadEqual", key: "=", location: 3 }, + "\uE01A": { code: "Numpad0", key: "0", location: 3 }, + "\uE01B": { code: "Numpad1", key: "1", location: 3 }, + "\uE01C": { code: "Numpad2", key: "2", location: 3 }, + "\uE01D": { code: "Numpad3", key: "3", location: 3 }, + "\uE01E": { code: "Numpad4", key: "4", location: 3 }, + "\uE01F": { code: "Numpad5", key: "5", location: 3 }, + "\uE020": { code: "Numpad6", key: "6", location: 3 }, + "\uE021": { code: "Numpad7", key: "7", location: 3 }, + "\uE022": { code: "Numpad8", key: "8", location: 3 }, + "\uE023": { code: "Numpad9", key: "9", location: 3 }, + "\uE024": { code: "NumpadMultiply", key: "*", location: 3 }, + "\uE025": { code: "NumpadAdd", key: "+", location: 3 }, + "\uE026": { code: "NumpadComma", key: ",", location: 3 }, + "\uE027": { code: "NumpadSubtract", key: "-", location: 3 }, + "\uE028": { code: "NumpadDecimal", key: ".", location: 3 }, + "\uE029": { code: "NumpadDivide", key: "/", location: 3 }, + "\uE031": { code: "F1", key: "F1", printable: false }, + "\uE032": { code: "F2", key: "F2", printable: false }, + "\uE033": { code: "F3", key: "F3", printable: false }, + "\uE034": { code: "F4", key: "F4", printable: false }, + "\uE035": { code: "F5", key: "F5", printable: false }, + "\uE036": { code: "F6", key: "F6", printable: false }, + "\uE037": { code: "F7", key: "F7", printable: false }, + "\uE038": { code: "F8", key: "F8", printable: false }, + "\uE039": { code: "F9", key: "F9", printable: false }, + "\uE03A": { code: "F10", key: "F10", printable: false }, + "\uE03B": { code: "F11", key: "F11", printable: false }, + "\uE03C": { code: "F12", key: "F12", printable: false }, + "\uE03D": { + code: "MetaLeft", + key: "Meta", + location: 1, + modifier: "metaKey", + printable: false, + }, + "\uE040": { code: "", key: "ZenkakuHankaku", printable: false }, + "\uE050": { + code: "ShiftRight", + key: "Shift", + location: 2, + modifier: "shiftKey", + printable: false, + }, + "\uE051": { + code: "ControlRight", + key: "Control", + location: 2, + modifier: "ctrlKey", + printable: false, + }, + "\uE052": { + code: "AltRight", + key: "Alt", + location: 2, + modifier: "altKey", + printable: false, + }, + "\uE053": { + code: "MetaRight", + key: "Meta", + location: 2, + modifier: "metaKey", + printable: false, + }, + "\uE054": { + code: "Numpad9", + key: "PageUp", + location: 3, + printable: false, + shifted: true, + }, + "\uE055": { + code: "Numpad3", + key: "PageDown", + location: 3, + printable: false, + shifted: true, + }, + "\uE056": { + code: "Numpad1", + key: "End", + location: 3, + printable: false, + shifted: true, + }, + "\uE057": { + code: "Numpad7", + key: "Home", + location: 3, + printable: false, + shifted: true, + }, + "\uE058": { + code: "Numpad4", + key: "ArrowLeft", + location: 3, + printable: false, + shifted: true, + }, + "\uE059": { + code: "Numpad8", + key: "ArrowUp", + location: 3, + printable: false, + shifted: true, + }, + "\uE05A": { + code: "Numpad6", + key: "ArrowRight", + location: 3, + printable: false, + shifted: true, + }, + "\uE05B": { + code: "Numpad2", + key: "ArrowDown", + location: 3, + printable: false, + shifted: true, + }, + "\uE05C": { + code: "Numpad0", + key: "Insert", + location: 3, + printable: false, + shifted: true, + }, + "\uE05D": { + code: "NumpadDecimal", + key: "Delete", + location: 3, + printable: false, + shifted: true, + }, +}; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "SHIFT_DATA", () => { + // Initalize the shift mapping + const shiftData = new Map(); + const byCode = new Map(); + for (let [key, props] of Object.entries(KEY_DATA)) { + if (props.code) { + if (!byCode.has(props.code)) { + byCode.set(props.code, [null, null]); + } + byCode.get(props.code)[props.shifted ? 1 : 0] = key; + } + } + for (let [unshifted, shifted] of byCode.values()) { + if (unshifted !== null && shifted !== null) { + shiftData.set(unshifted, shifted); + } + } + return shiftData; +}); + +export const keyData = { + /** + * Get key event data for a given key character. + * + * @param {string} rawKey + * Key for which to get data. This can either be the key codepoint + * itself or one of the codepoints in the range U+E000-U+E05D that + * WebDriver uses to represent keys not corresponding directly to + * a codepoint. + * @returns {object} Key event data object. + */ + getData(rawKey) { + let keyData = { key: rawKey, location: 0, printable: true, shifted: false }; + if (KEY_DATA.hasOwnProperty(rawKey)) { + keyData = { ...keyData, ...KEY_DATA[rawKey] }; + } + return keyData; + }, + + /** + * Get shifted key character for a given key character. + * + * For characters unaffected by the shift key, this returns the input. + * + * @param {string} rawKey Key for which to get shifted key. + * @returns {string} Key string to use when the shift modifier is set. + */ + getShiftedKey(rawKey) { + return lazy.SHIFT_DATA.get(rawKey) ?? rawKey; + }, +}; diff --git a/remote/shared/webdriver/NodeCache.sys.mjs b/remote/shared/webdriver/NodeCache.sys.mjs new file mode 100644 index 0000000000..032eae2543 --- /dev/null +++ b/remote/shared/webdriver/NodeCache.sys.mjs @@ -0,0 +1,179 @@ +/* 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, { + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", +}); + +/** + * @typedef {object} NodeReferenceDetails + * @property {number} browserId + * @property {number} browsingContextGroupId + * @property {number} browsingContextId + * @property {boolean} isTopBrowsingContext + * @property {WeakRef} nodeWeakRef + */ + +/** + * The class provides a mapping between DOM nodes and a unique node references. + * Supported types of nodes are Element and ShadowRoot. + */ +export class NodeCache { + #nodeIdMap; + #seenNodesMap; + + constructor() { + // node => node id + this.#nodeIdMap = new WeakMap(); + + // Reverse map for faster lookup requests of node references. Values do + // not only contain the resolved DOM node but also further details like + // browsing context information. + // + // node id => node details + this.#seenNodesMap = new Map(); + } + + /** + * Get the number of nodes in the cache. + */ + get size() { + return this.#seenNodesMap.size; + } + + /** + * Get or if not yet existent create a unique reference for an Element or + * ShadowRoot node. + * + * @param {Node} node + * The node to be added. + * @param {Map<BrowsingContext, Array<string>>} seenNodeIds + * Map of browsing contexts to their seen node ids during the current + * serialization. + * + * @returns {string} + * The unique node reference for the DOM node. + */ + getOrCreateNodeReference(node, seenNodeIds) { + if (!Node.isInstance(node)) { + throw new TypeError(`Failed to create node reference for ${node}`); + } + + let nodeId; + if (this.#nodeIdMap.has(node)) { + // For already known nodes return the cached node id. + nodeId = this.#nodeIdMap.get(node); + } else { + // Bug 1820734: For some Node types like `CDATA` no `ownerGlobal` + // property is available, and as such they cannot be deserialized + // right now. + const browsingContext = node.ownerGlobal?.browsingContext; + + // For not yet cached nodes generate a unique id without curly braces. + nodeId = lazy.generateUUID(); + + const details = { + browserId: browsingContext?.browserId, + browsingContextGroupId: browsingContext?.group.id, + browsingContextId: browsingContext?.id, + isTopBrowsingContext: browsingContext?.parent === null, + nodeWeakRef: Cu.getWeakReference(node), + }; + + this.#nodeIdMap.set(node, nodeId); + this.#seenNodesMap.set(nodeId, details); + + // Also add the information for the node id and its correlated browsing + // context to allow the parent process to update the seen nodes. + if (!seenNodeIds.has(browsingContext)) { + seenNodeIds.set(browsingContext, []); + } + seenNodeIds.get(browsingContext).push(nodeId); + } + + return nodeId; + } + + /** + * Clear known DOM nodes. + * + * @param {object=} options + * @param {boolean=} options.all + * Clear all references from any browsing context. Defaults to false. + * @param {BrowsingContext=} options.browsingContext + * Clear all references living in that browsing context. + */ + clear(options = {}) { + const { all = false, browsingContext } = options; + + if (all) { + this.#nodeIdMap = new WeakMap(); + this.#seenNodesMap.clear(); + return; + } + + if (browsingContext) { + for (const [nodeId, identifier] of this.#seenNodesMap.entries()) { + const { browsingContextId, nodeWeakRef } = identifier; + const node = nodeWeakRef.get(); + + if (browsingContextId === browsingContext.id) { + this.#nodeIdMap.delete(node); + this.#seenNodesMap.delete(nodeId); + } + } + + return; + } + + throw new Error(`Requires "browsingContext" or "all" to be set.`); + } + + /** + * Get a DOM node by its unique reference. + * + * @param {BrowsingContext} browsingContext + * The browsing context the node should be part of. + * @param {string} nodeId + * The unique node reference of the DOM node. + * + * @returns {Node|null} + * The DOM node that the unique identifier was generated for or + * `null` if the node does not exist anymore. + */ + getNode(browsingContext, nodeId) { + const nodeDetails = this.getReferenceDetails(nodeId); + + // Check that the node reference is known, and is associated with a + // browsing context that shares the same browsing context group. + if ( + nodeDetails === null || + nodeDetails.browsingContextGroupId !== browsingContext.group.id + ) { + return null; + } + + if (nodeDetails.nodeWeakRef) { + return nodeDetails.nodeWeakRef.get(); + } + + return null; + } + + /** + * Get detailed information for the node reference. + * + * @param {string} nodeId + * + * @returns {NodeReferenceDetails} + * Node details like: browsingContextId + */ + getReferenceDetails(nodeId) { + const details = this.#seenNodesMap.get(nodeId); + + return details !== undefined ? details : null; + } +} diff --git a/remote/shared/webdriver/Session.sys.mjs b/remote/shared/webdriver/Session.sys.mjs new file mode 100644 index 0000000000..edffeea7b6 --- /dev/null +++ b/remote/shared/webdriver/Session.sys.mjs @@ -0,0 +1,418 @@ +/* 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, { + accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs", + allowAllCerts: "chrome://remote/content/marionette/cert.sys.mjs", + Capabilities: "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + registerProcessDataActor: + "chrome://remote/content/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs", + RootMessageHandler: + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", + RootMessageHandlerRegistry: + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + unregisterProcessDataActor: + "chrome://remote/content/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs", + WebDriverBiDiConnection: + "chrome://remote/content/webdriver-bidi/WebDriverBiDiConnection.sys.mjs", + WebSocketHandshake: + "chrome://remote/content/server/WebSocketHandshake.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +// Global singleton that holds active WebDriver sessions +const webDriverSessions = new Map(); + +/** + * Representation of WebDriver session. + */ +export class WebDriverSession { + /** + * Construct a new WebDriver session. + * + * It is expected that the caller performs the necessary checks on + * the requested capabilities to be WebDriver conforming. The WebDriver + * service offered by Marionette does not match or negotiate capabilities + * beyond type- and bounds checks. + * + * <h3>Capabilities</h3> + * + * <dl> + * <dt><code>acceptInsecureCerts</code> (boolean) + * <dd>Indicates whether untrusted and self-signed TLS certificates + * are implicitly trusted on navigation for the duration of the session. + * + * <dt><code>pageLoadStrategy</code> (string) + * <dd>The page load strategy to use for the current session. Must be + * one of "<tt>none</tt>", "<tt>eager</tt>", and "<tt>normal</tt>". + * + * <dt><code>proxy</code> (Proxy object) + * <dd>Defines the proxy configuration. + * + * <dt><code>setWindowRect</code> (boolean) + * <dd>Indicates whether the remote end supports all of the resizing + * and repositioning commands. + * + * <dt><code>timeouts</code> (Timeouts object) + * <dd>Describes the timeouts imposed on certian session operations. + * + * <dt><code>strictFileInteractability</code> (boolean) + * <dd>Defines the current session’s strict file interactability. + * + * <dt><code>unhandledPromptBehavior</code> (string) + * <dd>Describes the current session’s user prompt handler. Must be one of + * "<tt>accept</tt>", "<tt>accept and notify</tt>", "<tt>dismiss</tt>", + * "<tt>dismiss and notify</tt>", and "<tt>ignore</tt>". Defaults to the + * "<tt>dismiss and notify</tt>" state. + * + * <dt><code>moz:accessibilityChecks</code> (boolean) + * <dd>Run a11y checks when clicking elements. + * + * <dt><code>moz:debuggerAddress</code> (boolean) + * <dd>Indicate that the Chrome DevTools Protocol (CDP) has to be enabled. + * + * <dt><code>moz:webdriverClick</code> (boolean) + * <dd>Use a WebDriver conforming <i>WebDriver::ElementClick</i>. + * </dl> + * + * <h4>WebAuthn</h4> + * + * <dl> + * <dt><code>webauthn:virtualAuthenticators</code> (boolean) + * <dd>Indicates whether the endpoint node supports all Virtual + * Authenticators commands. + * + * <dt><code>webauthn:extension:uvm</code> (boolean) + * <dd>Indicates whether the endpoint node WebAuthn WebDriver + * implementation supports the User Verification Method extension. + * + * <dt><code>webauthn:extension:prf</code> (boolean) + * <dd>Indicates whether the endpoint node WebAuthn WebDriver + * implementation supports the prf extension. + * + * <dt><code>webauthn:extension:largeBlob</code> (boolean) + * <dd>Indicates whether the endpoint node WebAuthn WebDriver implementation + * supports the largeBlob extension. + * + * <dt><code>webauthn:extension:credBlob</code> (boolean) + * <dd>Indicates whether the endpoint node WebAuthn WebDriver implementation + * supports the credBlob extension. + * </dl> + * + * <h4>Timeouts object</h4> + * + * <dl> + * <dt><code>script</code> (number) + * <dd>Determines when to interrupt a script that is being evaluates. + * + * <dt><code>pageLoad</code> (number) + * <dd>Provides the timeout limit used to interrupt navigation of the + * browsing context. + * + * <dt><code>implicit</code> (number) + * <dd>Gives the timeout of when to abort when locating an element. + * </dl> + * + * <h4>Proxy object</h4> + * + * <dl> + * <dt><code>proxyType</code> (string) + * <dd>Indicates the type of proxy configuration. Must be one + * of "<tt>pac</tt>", "<tt>direct</tt>", "<tt>autodetect</tt>", + * "<tt>system</tt>", or "<tt>manual</tt>". + * + * <dt><code>proxyAutoconfigUrl</code> (string) + * <dd>Defines the URL for a proxy auto-config file if + * <code>proxyType</code> is equal to "<tt>pac</tt>". + * + * <dt><code>httpProxy</code> (string) + * <dd>Defines the proxy host for HTTP traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>noProxy</code> (string) + * <dd>Lists the adress for which the proxy should be bypassed when + * the <code>proxyType</code> is "<tt>manual</tt>". Must be a JSON + * List containing any number of any of domains, IPv4 addresses, or IPv6 + * addresses. + * + * <dt><code>sslProxy</code> (string) + * <dd>Defines the proxy host for encrypted TLS traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>socksProxy</code> (string) + * <dd>Defines the proxy host for a SOCKS proxy traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>socksVersion</code> (string) + * <dd>Defines the SOCKS proxy version when the <code>proxyType</code> is + * "<tt>manual</tt>". It must be any integer between 0 and 255 + * inclusive. + * </dl> + * + * <h3>Example</h3> + * + * Input: + * + * <pre><code> + * {"capabilities": {"acceptInsecureCerts": true}} + * </code></pre> + * + * @param {Object<string, *>=} capabilities + * JSON Object containing any of the recognised capabilities listed + * above. + * + * @param {WebDriverBiDiConnection=} connection + * An optional existing WebDriver BiDi connection to associate with the + * new session. + * + * @throws {SessionNotCreatedError} + * If, for whatever reason, a session could not be created. + */ + constructor(capabilities, connection) { + // WebSocket connections that use this session. This also accounts for + // possible disconnects due to network outages, which require clients + // to reconnect. + this._connections = new Set(); + + this.id = lazy.generateUUID(); + + // Define the HTTP path to query this session via WebDriver BiDi + this.path = `/session/${this.id}`; + + try { + this.capabilities = lazy.Capabilities.fromJSON(capabilities, this.path); + } catch (e) { + throw new lazy.error.SessionNotCreatedError(e); + } + + if (this.capabilities.get("acceptInsecureCerts")) { + lazy.logger.warn( + "TLS certificate errors will be ignored for this session" + ); + lazy.allowAllCerts.enable(); + } + + if (this.proxy.init()) { + lazy.logger.info( + `Proxy settings initialised: ${JSON.stringify(this.proxy)}` + ); + } + + // If we are testing accessibility with marionette, start a11y service in + // chrome first. This will ensure that we do not have any content-only + // services hanging around. + if (this.a11yChecks && lazy.accessibility.service) { + lazy.logger.info("Preemptively starting accessibility service in Chrome"); + } + + // If a connection without an associated session has been specified + // immediately register the newly created session for it. + if (connection) { + connection.registerSession(this); + this._connections.add(connection); + } + + // Maps a Navigable (browsing context or content browser for top-level + // browsing contexts) to a Set of nodeId's. + this.navigableSeenNodes = new WeakMap(); + + lazy.registerProcessDataActor(); + + webDriverSessions.set(this.id, this); + } + + destroy() { + webDriverSessions.delete(this.id); + + lazy.unregisterProcessDataActor(); + + this.navigableSeenNodes = null; + + lazy.allowAllCerts.disable(); + + // Close all open connections which unregister themselves. + this._connections.forEach(connection => connection.close()); + if (this._connections.size > 0) { + lazy.logger.warn( + `Failed to close ${this._connections.size} WebSocket connections` + ); + } + + // Destroy the dedicated MessageHandler instance if we created one. + if (this._messageHandler) { + this._messageHandler.off( + "message-handler-protocol-event", + this._onMessageHandlerProtocolEvent + ); + this._messageHandler.destroy(); + } + } + + async execute(module, command, params) { + // XXX: At the moment, commands do not describe consistently their destination, + // so we will need a translation step based on a specific command and its params + // in order to extract a destination that can be understood by the MessageHandler. + // + // For now, an option is to send all commands to ROOT, and all BiDi MessageHandler + // modules will therefore need to implement this translation step in the root + // implementation of their module. + const destination = { + type: lazy.RootMessageHandler.type, + }; + if (!this.messageHandler.supportsCommand(module, command, destination)) { + throw new lazy.error.UnknownCommandError(`${module}.${command}`); + } + + return this.messageHandler.handleCommand({ + moduleName: module, + commandName: command, + params, + destination, + }); + } + + get a11yChecks() { + return this.capabilities.get("moz:accessibilityChecks"); + } + + get messageHandler() { + if (!this._messageHandler) { + this._messageHandler = + lazy.RootMessageHandlerRegistry.getOrCreateMessageHandler(this.id); + this._onMessageHandlerProtocolEvent = + this._onMessageHandlerProtocolEvent.bind(this); + this._messageHandler.on( + "message-handler-protocol-event", + this._onMessageHandlerProtocolEvent + ); + } + + return this._messageHandler; + } + + get pageLoadStrategy() { + return this.capabilities.get("pageLoadStrategy"); + } + + get proxy() { + return this.capabilities.get("proxy"); + } + + get strictFileInteractability() { + return this.capabilities.get("strictFileInteractability"); + } + + get timeouts() { + return this.capabilities.get("timeouts"); + } + + set timeouts(timeouts) { + this.capabilities.set("timeouts", timeouts); + } + + get unhandledPromptBehavior() { + return this.capabilities.get("unhandledPromptBehavior"); + } + + /** + * Remove the specified WebDriver BiDi connection. + * + * @param {WebDriverBiDiConnection} connection + */ + removeConnection(connection) { + if (this._connections.has(connection)) { + this._connections.delete(connection); + } else { + lazy.logger.warn("Trying to remove a connection that doesn't exist."); + } + } + + toString() { + return `[object ${this.constructor.name} ${this.id}]`; + } + + // nsIHttpRequestHandler + + /** + * Handle new WebSocket connection requests. + * + * WebSocket clients will attempt to connect to this session at + * `/session/:id`. Hereby a WebSocket upgrade will automatically + * be performed. + * + * @param {Request} request + * HTTP request (httpd.js) + * @param {Response} response + * Response to an HTTP request (httpd.js) + */ + async handle(request, response) { + const webSocket = await lazy.WebSocketHandshake.upgrade(request, response); + const conn = new lazy.WebDriverBiDiConnection( + webSocket, + response._connection + ); + conn.registerSession(this); + this._connections.add(conn); + } + + _onMessageHandlerProtocolEvent(eventName, messageHandlerEvent) { + const { name, data } = messageHandlerEvent; + this._connections.forEach(connection => connection.sendEvent(name, data)); + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIHttpRequestHandler"]); + } +} + +/** + * Get the list of seen nodes for the given browsing context unique to a + * WebDriver session. + * + * @param {string} sessionId + * The id of the WebDriver session to use. + * @param {BrowsingContext} browsingContext + * Browsing context the node is part of. + * + * @returns {Set} + * The list of seen nodes. + */ +export function getSeenNodesForBrowsingContext(sessionId, browsingContext) { + if (!lazy.TabManager.isValidCanonicalBrowsingContext(browsingContext)) { + // If browsingContext is not a valid Browsing Context, return an empty set. + return new Set(); + } + + const navigable = + lazy.TabManager.getNavigableForBrowsingContext(browsingContext); + const session = getWebDriverSessionById(sessionId); + + if (!session.navigableSeenNodes.has(navigable)) { + // The navigable hasn't been seen yet. + session.navigableSeenNodes.set(navigable, new Set()); + } + + return session.navigableSeenNodes.get(navigable); +} + +/** + * + * @param {string} sessionId + * The ID of the WebDriver session to retrieve. + * + * @returns {WebDriverSession|undefined} + * The WebDriver session or undefined if the id is not known. + */ +export function getWebDriverSessionById(sessionId) { + return webDriverSessions.get(sessionId); +} diff --git a/remote/shared/webdriver/URLPattern.sys.mjs b/remote/shared/webdriver/URLPattern.sys.mjs new file mode 100644 index 0000000000..0033cced66 --- /dev/null +++ b/remote/shared/webdriver/URLPattern.sys.mjs @@ -0,0 +1,521 @@ +/* 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, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", +}); + +/** + * Parsed pattern to use for URL matching. + * + * @typedef {object} ParsedURLPattern + * @property {string|null} protocol + * The protocol, for instance "https". + * @property {string|null} hostname + * The hostname, for instance "example.com". + * @property {string|null} port + * The serialized port. Empty string for default ports of special schemes. + * @property {string|null} path + * The path, starting with "/". + * @property {string|null} search + * The search query string, without the leading "?" + */ + +/** + * Subset of properties extracted from a parsed URL. + * + * @typedef {object} ParsedURL + * @property {string=} host + * @property {string|Array<string>} path + * Either a string if the path is an opaque path, or an array of strings + * (path segments). + * @property {number=} port + * @property {string=} query + * @property {string=} scheme + */ + +/** + * Enum of URLPattern types. + * + * @readonly + * @enum {URLPatternType} + */ +const URLPatternType = { + Pattern: "pattern", + String: "string", +}; + +const supportedURLPatternTypes = Object.values(URLPatternType); + +const SPECIAL_SCHEMES = ["file", "http", "https", "ws", "wss"]; +const DEFAULT_PORTS = { + file: null, + http: 80, + https: 443, + ws: 80, + wss: 443, +}; + +/** + * Check if a given URL pattern is compatible with the provided URL. + * + * Implements https://w3c.github.io/webdriver-bidi/#match-url-pattern + * + * @param {ParsedURLPattern} urlPattern + * The URL pattern to match. + * @param {string} url + * The string representation of a URL to test against the pattern. + * + * @returns {boolean} + * True if the pattern is compatible with the provided URL, false otherwise. + */ +export function matchURLPattern(urlPattern, url) { + const parsedURL = parseURL(url); + + if (urlPattern.protocol !== null && urlPattern.protocol != parsedURL.scheme) { + return false; + } + + if (urlPattern.hostname !== null && urlPattern.hostname != parsedURL.host) { + return false; + } + + if (urlPattern.port !== null && urlPattern.port != serializePort(parsedURL)) { + return false; + } + + if ( + urlPattern.pathname !== null && + urlPattern.pathname != serializePath(parsedURL) + ) { + return false; + } + + if (urlPattern.search !== null) { + const urlQuery = parsedURL.query === null ? "" : parsedURL.query; + if (urlPattern.search != urlQuery) { + return false; + } + } + + return true; +} + +/** + * Parse a URLPattern into a parsed pattern object which can be used to match + * URLs using `matchURLPattern`. + * + * Implements https://w3c.github.io/webdriver-bidi/#parse-url-pattern + * + * @param {URLPattern} pattern + * The pattern to parse. + * + * @returns {ParsedURLPattern} + * The parsed URL pattern. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {UnsupportedOperationError} + * Raised if the pattern uses a protocol not supported by Firefox. + */ +export function parseURLPattern(pattern) { + lazy.assert.object( + pattern, + `Expected url pattern to be an object, got ${pattern}` + ); + + let hasProtocol = true; + let hasHostname = true; + let hasPort = true; + let hasPathname = true; + let hasSearch = true; + + let patternUrl; + switch (pattern.type) { + case URLPatternType.Pattern: + patternUrl = ""; + if ("protocol" in pattern) { + patternUrl += parseProtocol(pattern.protocol); + } else { + hasProtocol = false; + patternUrl += "http"; + } + + const scheme = patternUrl.toLowerCase(); + patternUrl += ":"; + if (SPECIAL_SCHEMES.includes(scheme)) { + patternUrl += "//"; + } + + if ("hostname" in pattern) { + patternUrl += parseHostname(pattern.hostname, scheme); + } else { + if (scheme != "file") { + patternUrl += "placeholder"; + } + hasHostname = false; + } + + if ("port" in pattern) { + patternUrl += parsePort(pattern.port); + } else { + hasPort = false; + } + + if ("pathname" in pattern) { + patternUrl += parsePathname(pattern.pathname); + } else { + hasPathname = false; + } + + if ("search" in pattern) { + patternUrl += parseSearch(pattern.search); + } else { + hasSearch = false; + } + break; + case URLPatternType.String: + lazy.assert.string( + pattern.pattern, + `Expected "urlPattern" of type "string" to have a string "pattern" property, got ${pattern.pattern}` + ); + patternUrl = unescapeUrlPattern(pattern.pattern); + break; + default: + throw new lazy.error.InvalidArgumentError( + `Expected "urlPattern" type to be one of ${supportedURLPatternTypes}, got ${pattern.type}` + ); + } + + if (!URL.canParse(patternUrl)) { + throw new lazy.error.InvalidArgumentError( + `Unable to parse URL "${patternUrl}"` + ); + } + + let parsedURL; + try { + parsedURL = parseURL(patternUrl); + } catch (e) { + throw new lazy.error.InvalidArgumentError( + `Failed to parse URL "${patternUrl}"` + ); + } + + if (hasProtocol && !SPECIAL_SCHEMES.includes(parsedURL.scheme)) { + throw new lazy.error.UnsupportedOperationError( + `URL pattern did not specify a supported protocol (one of ${SPECIAL_SCHEMES}), got ${parsedURL.scheme}` + ); + } + + return { + protocol: hasProtocol ? parsedURL.scheme : null, + hostname: hasHostname ? parsedURL.host : null, + port: hasPort ? serializePort(parsedURL) : null, + pathname: + hasPathname && parsedURL.path.length ? serializePath(parsedURL) : null, + search: hasSearch ? parsedURL.query || "" : null, + }; +} + +/** + * Parse the hostname property of a URLPatternPattern. + * + * @param {string} hostname + * A hostname property. + * @param {string} scheme + * The scheme for the URLPatternPattern. + * + * @returns {string} + * The parsed property. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ +function parseHostname(hostname, scheme) { + if (typeof hostname != "string" || hostname == "") { + throw new lazy.error.InvalidArgumentError( + `Expected URLPattern "hostname" to be a non-empty string, got ${hostname}` + ); + } + + if (scheme == "file") { + throw new lazy.error.InvalidArgumentError( + `URLPattern with "file" scheme cannot specify a hostname, got ${hostname}` + ); + } + + hostname = unescapeUrlPattern(hostname); + + const forbiddenHostnameCharacters = ["/", "?", "#"]; + let insideBrackets = false; + for (const codepoint of hostname) { + if ( + forbiddenHostnameCharacters.includes(codepoint) || + (!insideBrackets && codepoint == ":") + ) { + throw new lazy.error.InvalidArgumentError( + `URL pattern "hostname" contained a forbidden character, got "${hostname}"` + ); + } + + if (codepoint == "[") { + insideBrackets = true; + } else if (codepoint == "]") { + insideBrackets = false; + } + } + + return hostname; +} + +/** + * Parse the pathname property of a URLPatternPattern. + * + * @param {string} pathname + * A pathname property. + * + * @returns {string} + * The parsed property. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ +function parsePathname(pathname) { + lazy.assert.string( + pathname, + `Expected URLPattern "pathname" to be a string, got ${pathname}` + ); + + pathname = unescapeUrlPattern(pathname); + if (!pathname.startsWith("/")) { + pathname = `/${pathname}`; + } + + if (pathname.includes("?") || pathname.includes("#")) { + throw new lazy.error.InvalidArgumentError( + `URL pattern "pathname" contained a forbidden character, got "${pathname}"` + ); + } + + return pathname; +} + +/** + * Parse the port property of a URLPatternPattern. + * + * @param {string} port + * A port property. + * + * @returns {string} + * The parsed property. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ +function parsePort(port) { + if (typeof port != "string" || port == "") { + throw new lazy.error.InvalidArgumentError( + `Expected URLPattern "port" to be a non-empty string, got ${port}` + ); + } + + port = unescapeUrlPattern(port); + + const isNumber = /^\d*$/.test(port); + if (!isNumber) { + throw new lazy.error.InvalidArgumentError( + `URL pattern "port" is not a valid number, got "${port}"` + ); + } + + return `:${port}`; +} + +/** + * Parse the protocol property of a URLPatternPattern. + * + * @param {string} protocol + * A protocol property. + * + * @returns {string} + * The parsed property. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ +function parseProtocol(protocol) { + if (typeof protocol != "string" || protocol == "") { + throw new lazy.error.InvalidArgumentError( + `Expected URLPattern "protocol" to be a non-empty string, got ${protocol}` + ); + } + + protocol = unescapeUrlPattern(protocol); + if (!/^[a-zA-Z0-9+-.]*$/.test(protocol)) { + throw new lazy.error.InvalidArgumentError( + `URL pattern "protocol" contained a forbidden character, got "${protocol}"` + ); + } + + return protocol; +} + +/** + * Parse the search property of a URLPatternPattern. + * + * @param {string} search + * A search property. + * + * @returns {string} + * The parsed property. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ +function parseSearch(search) { + lazy.assert.string( + search, + `Expected URLPattern "search" to be a string, got ${search}` + ); + + search = unescapeUrlPattern(search); + if (!search.startsWith("?")) { + search = `?${search}`; + } + + if (search.includes("#")) { + throw new lazy.error.InvalidArgumentError( + `Expected URLPattern "search" to never contain "#", got ${search}` + ); + } + + return search; +} + +/** + * Parse a string URL. This tries to be close to Basic URL Parser, however since + * this is not currently implemented in Firefox and URL parsing has many edge + * cases, it does not try to be a faithful implementation. + * + * Edge cases which are not supported are mostly about non-special URLs, which + * in practice should not be observable in automation. + * + * @param {string} url + * The string based URL to parse. + * @returns {ParsedURL} + * The parsed URL. + */ +function parseURL(url) { + const urlObj = new URL(url); + const uri = urlObj.URI; + + return { + scheme: uri.scheme, + // Note: Use urlObj instead of uri for hostname: + // nsIURI removes brackets from ipv6 hostnames (eg [::1] becomes ::1). + host: urlObj.hostname, + path: uri.filePath, + // Note: Use urlObj instead of uri for port: + // nsIURI throws on the port getter for non-special schemes. + port: urlObj.port != "" ? Number(uri.port) : null, + query: uri.hasQuery ? uri.query : null, + }; +} + +/** + * Serialize the path of a parsed URL. + * + * @see https://pr-preview.s3.amazonaws.com/w3c/webdriver-bidi/pull/429.html#parse-url-pattern + * + * @param {ParsedURL} url + * A parsed url. + * + * @returns {string} + * The serialized path + */ +function serializePath(url) { + // Check for opaque path + if (typeof url.path == "string") { + return url.path; + } + + let serialized = ""; + for (const segment of url.path) { + serialized += `/${segment}`; + } + + return serialized; +} + +/** + * Serialize the port of a parsed URL. + * + * @see https://pr-preview.s3.amazonaws.com/w3c/webdriver-bidi/pull/429.html#parse-url-pattern + * + * @param {ParsedURL} url + * A parsed url. + * + * @returns {string} + * The serialized port + */ +function serializePort(url) { + let port = null; + if ( + SPECIAL_SCHEMES.includes(url.scheme) && + DEFAULT_PORTS[url.scheme] !== null && + (url.port === null || url.port == DEFAULT_PORTS[url.scheme]) + ) { + port = ""; + } else if (url.port !== null) { + port = `${url.port}`; + } + + return port; +} + +/** + * Unescape and check a pattern string against common forbidden characters. + * + * @see https://pr-preview.s3.amazonaws.com/w3c/webdriver-bidi/pull/429.html#unescape-url-pattern + * + * @param {string} pattern + * Either a full URLPatternString pattern or a property of a URLPatternPattern. + * + * @returns {string} + * The unescaped pattern + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ +function unescapeUrlPattern(pattern) { + const forbiddenCharacters = ["(", ")", "*", "{", "}"]; + const escapeCharacter = "\\"; + + let isEscaped = false; + let result = ""; + + for (const codepoint of Array.from(pattern)) { + if (!isEscaped) { + if (forbiddenCharacters.includes(codepoint)) { + throw new lazy.error.InvalidArgumentError( + `URL pattern contained an unescaped forbidden character ${codepoint}` + ); + } + + if (codepoint == escapeCharacter) { + isEscaped = true; + continue; + } + } + + result += codepoint; + isEscaped = false; + } + + return result; +} diff --git a/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs b/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs new file mode 100644 index 0000000000..39db9d939e --- /dev/null +++ b/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs @@ -0,0 +1,93 @@ +/* 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, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + NodeCache: "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +// Observer to clean-up element references for closed browsing contexts. +class BrowsingContextObserver { + constructor(actor) { + this.actor = actor; + } + + async observe(subject, topic, data) { + if (topic === "browsing-context-discarded") { + this.actor.cleanUp({ browsingContext: subject }); + } + } +} + +export class WebDriverProcessDataChild extends JSProcessActorChild { + #browsingContextObserver; + #nodeCache; + + constructor() { + super(); + + // For now have a single reference store only. Once multiple WebDriver + // sessions are supported, it needs to be hashed by the session id. + this.#nodeCache = new lazy.NodeCache(); + + // Register observer to cleanup element references when a browsing context + // gets destroyed. + this.#browsingContextObserver = new BrowsingContextObserver(this); + Services.obs.addObserver( + this.#browsingContextObserver, + "browsing-context-discarded" + ); + } + + actorCreated() { + lazy.logger.trace( + `WebDriverProcessData actor created for PID ${Services.appinfo.processID}` + ); + } + + didDestroy() { + Services.obs.removeObserver( + this.#browsingContextObserver, + "browsing-context-discarded" + ); + } + + /** + * Clean up all the process specific data. + * + * @param {object=} options + * @param {BrowsingContext=} options.browsingContext + * If specified only clear data living in that browsing context. + */ + cleanUp(options = {}) { + const { browsingContext = null } = options; + + this.#nodeCache.clear({ browsingContext }); + } + + /** + * Get the node cache. + * + * @returns {NodeCache} + * The cache containing DOM node references. + */ + getNodeCache() { + return this.#nodeCache; + } + + async receiveMessage(msg) { + switch (msg.name) { + case "WebDriverProcessDataParent:CleanUp": + return this.cleanUp(msg.data); + default: + return Promise.reject( + new Error(`Unexpected message received: ${msg.name}`) + ); + } + } +} diff --git a/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs b/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs new file mode 100644 index 0000000000..a895106c4b --- /dev/null +++ b/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs @@ -0,0 +1,37 @@ +/* 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, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * Register the WebDriverProcessData actor that holds session data. + */ +export function registerProcessDataActor() { + try { + ChromeUtils.registerProcessActor("WebDriverProcessData", { + kind: "JSProcessActor", + child: { + esModuleURI: + "chrome://remote/content/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs", + }, + includeParent: true, + }); + } catch (e) { + if (e.name === "NotSupportedError") { + lazy.logger.warn(`WebDriverProcessData actor is already registered!`); + } else { + throw e; + } + } +} + +export function unregisterProcessDataActor() { + ChromeUtils.unregisterProcessActor("WebDriverProcessData"); +} diff --git a/remote/shared/webdriver/test/xpcshell/head.js b/remote/shared/webdriver/test/xpcshell/head.js new file mode 100644 index 0000000000..ddc5573d78 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/head.js @@ -0,0 +1,15 @@ +async function doGC() { + // Run GC and CC a few times to make sure that as much as possible is freed. + const numCycles = 3; + for (let i = 0; i < numCycles; i++) { + Cu.forceGC(); + Cu.forceCC(); + await new Promise(resolve => Cu.schedulePreciseShrinkingGC(resolve)); + } + + const MemoryReporter = Cc[ + "@mozilla.org/memory-reporter-manager;1" + ].getService(Ci.nsIMemoryReporterManager); + + await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve)); +} diff --git a/remote/shared/webdriver/test/xpcshell/test_Actions.js b/remote/shared/webdriver/test/xpcshell/test_Actions.js new file mode 100644 index 0000000000..24eac2e09d --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Actions.js @@ -0,0 +1,758 @@ +/* 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 { action, CLICK_INTERVAL, ClickTracker } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Actions.sys.mjs" +); + +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +const XHTMLNS = "http://www.w3.org/1999/xhtml"; + +const domEl = { + nodeType: 1, + ELEMENT_NODE: 1, + namespaceURI: XHTMLNS, +}; + +add_task(function test_createInputState() { + for (let type of ["none", "key", "pointer" /*"wheel"*/]) { + const state = new action.State(); + const id = "device"; + const actionSequence = { + type, + id, + actions: [], + }; + action.Chain.fromJSON(state, [actionSequence]); + equal(state.inputStateMap.size, 1); + equal(state.inputStateMap.get(id).constructor.type, type); + } +}); + +add_task(function test_defaultPointerParameters() { + let state = new action.State(); + const inputTickActions = [ + { type: "pointer", subtype: "pointerDown", button: 0 }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + const pointerAction = chain[0][0]; + equal( + state.getInputSource(pointerAction.id).pointer.constructor.type, + "mouse" + ); +}); + +add_task(function test_processPointerParameters() { + for (let subtype of ["pointerDown", "pointerUp"]) { + for (let pointerType of [2, true, {}, []]) { + const inputTickActions = [ + { + type: "pointer", + parameters: { pointerType }, + subtype, + button: 0, + }, + ]; + let message = `Action sequence with parameters: {pointerType: ${pointerType} subtype: ${subtype}}`; + checkFromJSONErrors( + inputTickActions, + /Expected "pointerType" to be a string/, + message + ); + } + + for (let pointerType of ["", "foo"]) { + const inputTickActions = [ + { + type: "pointer", + parameters: { pointerType }, + subtype, + button: 0, + }, + ]; + let message = `Action sequence with parameters: {pointerType: ${pointerType} subtype: ${subtype}}`; + checkFromJSONErrors( + inputTickActions, + /Expected "pointerType" to be one of/, + message + ); + } + } + + for (let pointerType of ["mouse" /*"touch"*/]) { + let state = new action.State(); + const inputTickActions = [ + { + type: "pointer", + parameters: { pointerType }, + subtype: "pointerDown", + button: 0, + }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + const pointerAction = chain[0][0]; + equal( + state.getInputSource(pointerAction.id).pointer.constructor.type, + pointerType + ); + } +}); + +add_task(function test_processPointerDownAction() { + for (let button of [-1, "a"]) { + const inputTickActions = [ + { type: "pointer", subtype: "pointerDown", button }, + ]; + checkFromJSONErrors( + inputTickActions, + /Expected "button" to be a positive integer/, + `pointerDown with {button: ${button}}` + ); + } + let state = new action.State(); + const inputTickActions = [ + { type: "pointer", subtype: "pointerDown", button: 5 }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + equal(chain[0][0].button, 5); +}); + +add_task(function test_validateActionDurationAndCoordinates() { + for (let [type, subtype] of [ + ["none", "pause"], + ["pointer", "pointerMove"], + ]) { + for (let duration of [-1, "a"]) { + const inputTickActions = [{ type, subtype, duration }]; + checkFromJSONErrors( + inputTickActions, + /Expected "duration" to be a positive integer/, + `{subtype} with {duration: ${duration}}` + ); + } + } + for (let name of ["x", "y"]) { + const actionItem = { + type: "pointer", + subtype: "pointerMove", + duration: 5000, + }; + actionItem[name] = "a"; + checkFromJSONErrors( + [actionItem], + /Expected ".*" to be an integer/, + `${name}: "a", subtype: pointerMove` + ); + } +}); + +add_task(function test_processPointerMoveActionOriginValidation() { + for (let origin of [-1, { a: "blah" }, []]) { + const inputTickActions = [ + { type: "pointer", duration: 5000, subtype: "pointerMove", origin }, + ]; + checkFromJSONErrors( + inputTickActions, + /Expected "origin" to be undefined, "viewport", "pointer", or an element/, + `actionItem.origin: (${getTypeString(origin)})` + ); + } +}); + +add_task(function test_processPointerMoveActionOriginStringValidation() { + for (let origin of ["", "viewports", "pointers"]) { + const inputTickActions = [ + { type: "pointer", duration: 5000, subtype: "pointerMove", origin }, + ]; + checkFromJSONErrors( + inputTickActions, + /Expected "origin" to be undefined, "viewport", "pointer", or an element/, + `actionItem.origin: ${origin}` + ); + } +}); + +add_task(function test_processPointerMoveActionElementOrigin() { + let state = new action.State(); + const inputTickActions = [ + { + type: "pointer", + duration: 5000, + subtype: "pointerMove", + origin: domEl, + x: 0, + y: 0, + }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + deepEqual(chain[0][0].origin.element, domEl); +}); + +add_task(function test_processPointerMoveActionDefaultOrigin() { + let state = new action.State(); + const inputTickActions = [ + { type: "pointer", duration: 5000, subtype: "pointerMove", x: 0, y: 0 }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + // The default is viewport coordinates which have an origin at [0,0] and don't depend on inputSource + deepEqual(chain[0][0].origin.getOriginCoordinates(null, null), { + x: 0, + y: 0, + }); +}); + +add_task(function test_processPointerMoveAction() { + let state = new action.State(); + const actionItems = [ + { + duration: 5000, + type: "pointerMove", + origin: undefined, + x: 0, + y: 0, + }, + { + duration: undefined, + type: "pointerMove", + origin: domEl, + x: 0, + y: 0, + }, + { + duration: 5000, + type: "pointerMove", + x: 1, + y: 2, + origin: undefined, + }, + ]; + const actionSequence = { + id: "some_id", + type: "pointer", + actions: actionItems, + }; + let chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, actionItems.length); + for (let i = 0; i < actionItems.length; i++) { + let actual = chain[i][0]; + let expected = actionItems[i]; + equal(actual.duration, expected.duration); + equal(actual.x, expected.x); + equal(actual.y, expected.y); + + let originClass; + if (expected.origin === undefined || expected.origin == "viewport") { + originClass = "ViewportOrigin"; + } else if (expected.origin === "pointer") { + originClass = "PointerOrigin"; + } else { + originClass = "ElementOrigin"; + } + deepEqual(actual.origin.constructor.name, originClass); + } +}); + +add_task(function test_computePointerDestinationViewport() { + const state = new action.State(); + const inputTickActions = [ + { + type: "pointer", + subtype: "pointerMove", + x: 100, + y: 200, + origin: "viewport", + }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + const actionItem = chain[0][0]; + const inputSource = state.getInputSource(actionItem.id); + // these values should not affect the outcome + inputSource.x = "99"; + inputSource.y = "10"; + const target = actionItem.origin.getTargetCoordinates( + inputSource, + [actionItem.x, actionItem.y], + null + ); + equal(actionItem.x, target[0]); + equal(actionItem.y, target[1]); +}); + +add_task(function test_computePointerDestinationPointer() { + const state = new action.State(); + const inputTickActions = [ + { + type: "pointer", + subtype: "pointerMove", + x: 100, + y: 200, + origin: "pointer", + }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + const actionItem = chain[0][0]; + const inputSource = state.getInputSource(actionItem.id); + inputSource.x = 10; + inputSource.y = 99; + const target = actionItem.origin.getTargetCoordinates( + inputSource, + [actionItem.x, actionItem.y], + null + ); + equal(actionItem.x + inputSource.x, target[0]); + equal(actionItem.y + inputSource.y, target[1]); +}); + +add_task(function test_processPointerAction() { + for (let pointerType of ["mouse", "touch"]) { + const actionItems = [ + { + duration: 2000, + type: "pause", + }, + { + type: "pointerMove", + duration: 2000, + x: 0, + y: 0, + }, + { + type: "pointerUp", + button: 1, + }, + ]; + let actionSequence = { + type: "pointer", + id: "some_id", + parameters: { + pointerType, + }, + actions: actionItems, + }; + const state = new action.State(); + const chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, actionItems.length); + for (let i = 0; i < actionItems.length; i++) { + const actual = chain[i][0]; + const expected = actionItems[i]; + equal(actual.type, expected.type === "pause" ? "none" : "pointer"); + equal(actual.subtype, expected.type); + equal(actual.id, actionSequence.id); + if (expected.type === "pointerUp") { + equal(actual.button, expected.button); + } else { + equal(actual.duration, expected.duration); + } + if (expected.type !== "pause") { + equal( + state.getInputSource(actual.id).pointer.constructor.type, + pointerType + ); + } + } + } +}); + +add_task(function test_processPauseAction() { + for (let type of ["none", "key", "pointer"]) { + const state = new action.State(); + const actionSequence = { + type, + id: "some_id", + actions: [{ type: "pause", duration: 5000 }], + }; + const actionItem = action.Chain.fromJSON(state, [actionSequence])[0][0]; + equal(actionItem.type, "none"); + equal(actionItem.subtype, "pause"); + equal(actionItem.id, "some_id"); + equal(actionItem.duration, 5000); + } + const state = new action.State(); + const actionSequence = { + type: "none", + id: "some_id", + actions: [{ type: "pause" }], + }; + const actionItem = action.Chain.fromJSON(state, [actionSequence])[0][0]; + equal(actionItem.duration, undefined); +}); + +add_task(function test_processActionSubtypeValidation() { + for (let type of ["none", "key", "pointer"]) { + const message = `type: ${type}, subtype: dancing`; + const inputTickActions = [{ type, subtype: "dancing" }]; + checkFromJSONErrors( + inputTickActions, + new RegExp(`Expected known subtype for type`), + message + ); + } +}); + +add_task(function test_processKeyActionDown() { + for (let value of [-1, undefined, [], ["a"], { length: 1 }, null]) { + const inputTickActions = [{ type: "key", subtype: "keyDown", value }]; + const message = `actionItem.value: (${getTypeString(value)})`; + checkFromJSONErrors( + inputTickActions, + /Expected "value" to be a string that represents single code point/, + message + ); + } + + const state = new action.State(); + const actionSequence = { + type: "key", + id: "keyboard", + actions: [{ type: "keyDown", value: "\uE004" }], + }; + const actionItem = action.Chain.fromJSON(state, [actionSequence])[0][0]; + + equal(actionItem.type, "key"); + equal(actionItem.id, "keyboard"); + equal(actionItem.subtype, "keyDown"); + equal(actionItem.value, "\ue004"); +}); + +add_task(function test_processInputSourceActionSequenceValidation() { + checkFromJSONErrors( + [{ type: "swim", subtype: "pause", id: "some id" }], + /Expected known action type/, + "actionSequence type: swim" + ); + + checkFromJSONErrors( + [{ type: "none", subtype: "pause", id: -1 }], + /Expected "id" to be a string/, + "actionSequence id: -1" + ); + + checkFromJSONErrors( + [{ type: "none", subtype: "pause", id: undefined }], + /Expected "id" to be a string/, + "actionSequence id: undefined" + ); + + const state = new action.State(); + const actionSequence = [ + { type: "none", subtype: "pause", id: "some_id", actions: -1 }, + ]; + const errorRegex = /Expected "actionSequence.actions" to be an array/; + const message = "actionSequence actions: -1"; + + Assert.throws( + () => action.Chain.fromJSON(state, actionSequence), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(state, actionSequence), + errorRegex, + message + ); +}); + +add_task(function test_processInputSourceActionSequence() { + const state = new action.State(); + const actionItem = { type: "pause", duration: 5 }; + const actionSequence = { + type: "none", + id: "some id", + actions: [actionItem], + }; + const chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, 1); + const tickActions = chain[0]; + equal(tickActions.length, 1); + equal(tickActions[0].type, "none"); + equal(tickActions[0].subtype, "pause"); + equal(tickActions[0].duration, 5); + equal(tickActions[0].id, "some id"); +}); + +add_task(function test_processInputSourceActionSequencePointer() { + const state = new action.State(); + const actionItem = { type: "pointerDown", button: 1 }; + const actionSequence = { + type: "pointer", + id: "9", + actions: [actionItem], + parameters: { + pointerType: "mouse", // TODO "pen" + }, + }; + const chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, 1); + const tickActions = chain[0]; + equal(tickActions.length, 1); + equal(tickActions[0].type, "pointer"); + equal(tickActions[0].subtype, "pointerDown"); + equal(tickActions[0].button, 1); + equal(tickActions[0].id, "9"); + const inputSource = state.getInputSource(tickActions[0].id); + equal(inputSource.constructor.type, "pointer"); + equal(inputSource.pointer.constructor.type, "mouse"); +}); + +add_task(function test_processInputSourceActionSequenceKey() { + const state = new action.State(); + const actionItem = { type: "keyUp", value: "a" }; + const actionSequence = { + type: "key", + id: "9", + actions: [actionItem], + }; + const chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, 1); + const tickActions = chain[0]; + equal(tickActions.length, 1); + equal(tickActions[0].type, "key"); + equal(tickActions[0].subtype, "keyUp"); + equal(tickActions[0].value, "a"); + equal(tickActions[0].id, "9"); +}); + +add_task(function test_processInputSourceActionSequenceInputStateMap() { + const state = new action.State(); + const id = "1"; + const actionItem = { type: "pause", duration: 5000 }; + const actionSequence = { + type: "key", + id, + actions: [actionItem], + }; + action.Chain.fromJSON(state, [actionSequence]); + equal(state.inputStateMap.size, 1); + equal(state.inputStateMap.get(id).constructor.type, "key"); + + // Construct a different state with the same input id + const state1 = new action.State(); + const actionItem1 = { type: "pointerDown", button: 0 }; + const actionSequence1 = { + type: "pointer", + id, + actions: [actionItem1], + }; + action.Chain.fromJSON(state1, [actionSequence1]); + equal(state1.inputStateMap.size, 1); + + // Overwrite the state in the initial map with one of a different type + state.inputStateMap.set(id, state1.inputStateMap.get(id)); + equal(state.inputStateMap.get(id).constructor.type, "pointer"); + + const message = "Wrong state for input id type"; + Assert.throws( + () => action.Chain.fromJSON(state, [actionSequence]), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(state, [actionSequence]), + /Expected input source \[object String\] "1" to be type pointer/, + message + ); +}); + +add_task(function test_extractActionChainValidation() { + for (let actions of [-1, "a", undefined, null]) { + const state = new action.State(); + let message = `actions: ${getTypeString(actions)}`; + Assert.throws( + () => action.Chain.fromJSON(state, actions), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(state, actions), + /Expected "actions" to be an array/, + message + ); + } +}); + +add_task(function test_extractActionChainEmpty() { + const state = new action.State(); + deepEqual(action.Chain.fromJSON(state, []), []); +}); + +add_task(function test_extractActionChain_oneTickOneInput() { + const state = new action.State(); + const actionItem = { type: "pause", duration: 5000 }; + const actionSequence = { + type: "none", + id: "some id", + actions: [actionItem], + }; + const actionsByTick = action.Chain.fromJSON(state, [actionSequence]); + equal(1, actionsByTick.length); + equal(1, actionsByTick[0].length); + equal(actionsByTick[0][0].id, actionSequence.id); + equal(actionsByTick[0][0].type, "none"); + equal(actionsByTick[0][0].subtype, "pause"); + equal(actionsByTick[0][0].duration, actionItem.duration); +}); + +add_task(function test_extractActionChain_twoAndThreeTicks() { + const state = new action.State(); + const mouseActionItems = [ + { + type: "pointerDown", + button: 2, + }, + { + type: "pointerUp", + button: 2, + }, + ]; + const mouseActionSequence = { + type: "pointer", + id: "7", + actions: mouseActionItems, + parameters: { + pointerType: "mouse", + }, + }; + const keyActionItems = [ + { + type: "keyDown", + value: "a", + }, + { + type: "pause", + duration: 4, + }, + { + type: "keyUp", + value: "a", + }, + ]; + let keyActionSequence = { + type: "key", + id: "1", + actions: keyActionItems, + }; + let actionsByTick = action.Chain.fromJSON(state, [ + keyActionSequence, + mouseActionSequence, + ]); + // number of ticks is same as longest action sequence + equal(keyActionItems.length, actionsByTick.length); + equal(2, actionsByTick[0].length); + equal(2, actionsByTick[1].length); + equal(1, actionsByTick[2].length); + + equal(actionsByTick[2][0].id, keyActionSequence.id); + equal(actionsByTick[2][0].type, "key"); + equal(actionsByTick[2][0].subtype, "keyUp"); +}); + +add_task(function test_computeTickDuration() { + const state = new action.State(); + const expected = 8000; + const inputTickActions = [ + { type: "none", subtype: "pause", duration: 5000 }, + { type: "key", subtype: "pause", duration: 1000 }, + { type: "pointer", subtype: "pointerMove", duration: 6000, x: 0, y: 0 }, + // invalid because keyDown should not have duration, so duration should be ignored. + { type: "key", subtype: "keyDown", duration: 100000, value: "a" }, + { type: "pointer", subtype: "pause", duration: expected }, + { type: "pointer", subtype: "pointerUp", button: 0 }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + equal(1, chain.length); + const tickActions = chain[0]; + equal(expected, tickActions.getDuration()); +}); + +add_task(function test_computeTickDuration_noDurations() { + const state = new action.State(); + const inputTickActions = [ + // invalid because keyDown should not have duration, so duration should be ignored. + { type: "key", subtype: "keyDown", duration: 100000, value: "a" }, + // undefined duration permitted + { type: "none", subtype: "pause" }, + { type: "pointer", subtype: "pointerMove", button: 0, x: 0, y: 0 }, + { type: "pointer", subtype: "pointerDown", button: 0 }, + { type: "key", subtype: "keyUp", value: "a" }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + equal(0, chain[0].getDuration()); +}); + +add_task(function test_ClickTracker_setClick() { + const clickTracker = new ClickTracker(); + const button1 = 1; + const button2 = 2; + + clickTracker.setClick(button1); + equal(1, clickTracker.count); + + // Make sure that clicking different mouse buttons doesn't increase the count. + clickTracker.setClick(button2); + equal(1, clickTracker.count); + + clickTracker.setClick(button2); + equal(2, clickTracker.count); + + clickTracker.reset(); + equal(0, clickTracker.count); +}); + +add_task(function test_ClickTracker_reset_after_timeout() { + const clickTracker = new ClickTracker(); + + clickTracker.setClick(1); + equal(1, clickTracker.count); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => equal(0, clickTracker.count), CLICK_INTERVAL + 10); +}); + +// helpers +function getTypeString(obj) { + return Object.prototype.toString.call(obj); +} + +function checkFromJSONErrors(inputTickActions, regex, message) { + const state = new action.State(); + + if (typeof message == "undefined") { + message = `fromJSON`; + } + Assert.throws( + () => action.Chain.fromJSON(state, chainForTick(inputTickActions)), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(state, chainForTick(inputTickActions)), + regex, + message + ); +} + +function chainForTick(tickActions) { + const actions = []; + let lastId = 0; + for (let { type, subtype, parameters, ...props } of tickActions) { + let id; + if (!props.hasOwnProperty("id")) { + id = `${type}_${lastId++}`; + } else { + id = props.id; + delete props.id; + } + const inputAction = { type, id, actions: [{ type: subtype, ...props }] }; + if (parameters !== undefined) { + inputAction.parameters = parameters; + } + actions.push(inputAction); + } + return actions; +} diff --git a/remote/shared/webdriver/test/xpcshell/test_Assert.js b/remote/shared/webdriver/test/xpcshell/test_Assert.js new file mode 100644 index 0000000000..cf474868b6 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Assert.js @@ -0,0 +1,183 @@ +/* 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"; +/* eslint-disable no-array-constructor, no-object-constructor */ + +const { assert } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Assert.sys.mjs" +); +const { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" +); + +add_task(function test_session() { + assert.session({ id: "foo" }); + + const invalidTypes = [ + null, + undefined, + [], + {}, + { id: undefined }, + { id: null }, + { id: true }, + { id: 1 }, + { id: [] }, + { id: {} }, + ]; + + for (const invalidType of invalidTypes) { + Assert.throws(() => assert.session(invalidType), /InvalidSessionIDError/); + } + + Assert.throws(() => assert.session({ id: null }, "custom"), /custom/); +}); + +add_task(function test_platforms() { + // at least one will fail + let raised; + for (let fn of [assert.desktop, assert.mobile]) { + try { + fn(); + } catch (e) { + raised = e; + } + } + ok(raised instanceof error.UnsupportedOperationError); +}); + +add_task(function test_noUserPrompt() { + assert.noUserPrompt(null); + assert.noUserPrompt(undefined); + Assert.throws(() => assert.noUserPrompt({}), /UnexpectedAlertOpenError/); + Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/); +}); + +add_task(function test_defined() { + assert.defined({}); + Assert.throws(() => assert.defined(undefined), /InvalidArgumentError/); + Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/); +}); + +add_task(function test_number() { + assert.number(1); + assert.number(0); + assert.number(-1); + assert.number(1.2); + for (let i of ["foo", "1", {}, [], NaN, Infinity, undefined]) { + Assert.throws(() => assert.number(i), /InvalidArgumentError/); + } + + Assert.throws(() => assert.number("foo", "custom"), /custom/); +}); + +add_task(function test_callable() { + assert.callable(function () {}); + assert.callable(() => {}); + + for (let typ of [undefined, "", true, {}, []]) { + Assert.throws(() => assert.callable(typ), /InvalidArgumentError/); + } + + Assert.throws(() => assert.callable("foo", "custom"), /custom/); +}); + +add_task(function test_integer() { + assert.integer(1); + assert.integer(0); + assert.integer(-1); + Assert.throws(() => assert.integer("foo"), /InvalidArgumentError/); + Assert.throws(() => assert.integer(1.2), /InvalidArgumentError/); + + Assert.throws(() => assert.integer("foo", "custom"), /custom/); +}); + +add_task(function test_positiveInteger() { + assert.positiveInteger(1); + assert.positiveInteger(0); + Assert.throws(() => assert.positiveInteger(-1), /InvalidArgumentError/); + Assert.throws(() => assert.positiveInteger("foo"), /InvalidArgumentError/); + Assert.throws(() => assert.positiveInteger("foo", "custom"), /custom/); +}); + +add_task(function test_positiveNumber() { + assert.positiveNumber(1); + assert.positiveNumber(0); + assert.positiveNumber(1.1); + assert.positiveNumber(Number.MAX_VALUE); + // eslint-disable-next-line no-loss-of-precision + Assert.throws(() => assert.positiveNumber(1.8e308), /InvalidArgumentError/); + Assert.throws(() => assert.positiveNumber(-1), /InvalidArgumentError/); + Assert.throws(() => assert.positiveNumber(Infinity), /InvalidArgumentError/); + Assert.throws(() => assert.positiveNumber("foo"), /InvalidArgumentError/); + Assert.throws(() => assert.positiveNumber("foo", "custom"), /custom/); +}); + +add_task(function test_boolean() { + assert.boolean(true); + assert.boolean(false); + Assert.throws(() => assert.boolean("false"), /InvalidArgumentError/); + Assert.throws(() => assert.boolean(undefined), /InvalidArgumentError/); + Assert.throws(() => assert.boolean(undefined, "custom"), /custom/); +}); + +add_task(function test_string() { + assert.string("foo"); + assert.string(`bar`); + Assert.throws(() => assert.string(42), /InvalidArgumentError/); + Assert.throws(() => assert.string(42, "custom"), /custom/); +}); + +add_task(function test_open() { + assert.open({ currentWindowGlobal: {} }); + + for (let typ of [null, undefined, { currentWindowGlobal: null }]) { + Assert.throws(() => assert.open(typ), /NoSuchWindowError/); + } + + Assert.throws(() => assert.open(null, "custom"), /custom/); +}); + +add_task(function test_object() { + assert.object({}); + assert.object(new Object()); + for (let typ of [42, "foo", true, null, undefined]) { + Assert.throws(() => assert.object(typ), /InvalidArgumentError/); + } + + Assert.throws(() => assert.object(null, "custom"), /custom/); +}); + +add_task(function test_in() { + assert.in("foo", { foo: 42 }); + for (let typ of [{}, 42, true, null, undefined]) { + Assert.throws(() => assert.in("foo", typ), /InvalidArgumentError/); + } + + Assert.throws(() => assert.in("foo", { bar: 42 }, "custom"), /custom/); +}); + +add_task(function test_array() { + assert.array([]); + assert.array(new Array()); + Assert.throws(() => assert.array(42), /InvalidArgumentError/); + Assert.throws(() => assert.array({}), /InvalidArgumentError/); + + Assert.throws(() => assert.array(42, "custom"), /custom/); +}); + +add_task(function test_that() { + equal(1, assert.that(n => n + 1)(1)); + Assert.throws(() => assert.that(() => false)(), /InvalidArgumentError/); + Assert.throws(() => assert.that(val => val)(false), /InvalidArgumentError/); + Assert.throws( + () => assert.that(val => val, "foo", error.SessionNotCreatedError)(false), + /SessionNotCreatedError/ + ); + + Assert.throws(() => assert.that(() => false, "custom")(), /custom/); +}); + +/* eslint-enable no-array-constructor, no-new-object */ diff --git a/remote/shared/webdriver/test/xpcshell/test_Capabilities.js b/remote/shared/webdriver/test/xpcshell/test_Capabilities.js new file mode 100644 index 0000000000..19401dd463 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Capabilities.js @@ -0,0 +1,700 @@ +/* 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 { AppInfo } = ChromeUtils.importESModule( + "chrome://remote/content/shared/AppInfo.sys.mjs" +); +const { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" +); +const { + Capabilities, + mergeCapabilities, + PageLoadStrategy, + processCapabilities, + Proxy, + Timeouts, + UnhandledPromptBehavior, + validateCapabilities, +} = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs" +); + +add_task(function test_Timeouts_ctor() { + let ts = new Timeouts(); + equal(ts.implicit, 0); + equal(ts.pageLoad, 300000); + equal(ts.script, 30000); +}); + +add_task(function test_Timeouts_toString() { + equal(new Timeouts().toString(), "[object Timeouts]"); +}); + +add_task(function test_Timeouts_toJSON() { + let ts = new Timeouts(); + deepEqual(ts.toJSON(), { implicit: 0, pageLoad: 300000, script: 30000 }); +}); + +add_task(function test_Timeouts_fromJSON() { + let json = { + implicit: 0, + pageLoad: 2.0, + script: Number.MAX_SAFE_INTEGER, + }; + let ts = Timeouts.fromJSON(json); + equal(ts.implicit, json.implicit); + equal(ts.pageLoad, json.pageLoad); + equal(ts.script, json.script); +}); + +add_task(function test_Timeouts_fromJSON_unrecognised_field() { + let json = { + sessionId: "foobar", + }; + try { + Timeouts.fromJSON(json); + } catch (e) { + equal(e.name, error.InvalidArgumentError.name); + equal(e.message, "Unrecognised timeout: sessionId"); + } +}); + +add_task(function test_Timeouts_fromJSON_invalid_types() { + for (let value of [null, [], {}, false, "10", 2.5]) { + Assert.throws( + () => Timeouts.fromJSON({ implicit: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(function test_Timeouts_fromJSON_bounds() { + for (let value of [-1, Number.MAX_SAFE_INTEGER + 1]) { + Assert.throws( + () => Timeouts.fromJSON({ script: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(function test_PageLoadStrategy() { + equal(PageLoadStrategy.None, "none"); + equal(PageLoadStrategy.Eager, "eager"); + equal(PageLoadStrategy.Normal, "normal"); +}); + +add_task(function test_Proxy_ctor() { + let p = new Proxy(); + let props = [ + "proxyType", + "httpProxy", + "sslProxy", + "socksProxy", + "socksVersion", + "proxyAutoconfigUrl", + ]; + for (let prop of props) { + ok(prop in p, `${prop} in ${JSON.stringify(props)}`); + equal(p[prop], null); + } +}); + +add_task(function test_Proxy_init() { + let p = new Proxy(); + + // no changed made, and 5 (system) is default + equal(p.init(), false); + equal(Services.prefs.getIntPref("network.proxy.type"), 5); + + // pac + p.proxyType = "pac"; + p.proxyAutoconfigUrl = "http://localhost:1234"; + ok(p.init()); + + equal(Services.prefs.getIntPref("network.proxy.type"), 2); + equal( + Services.prefs.getStringPref("network.proxy.autoconfig_url"), + "http://localhost:1234" + ); + + // direct + p = new Proxy(); + p.proxyType = "direct"; + ok(p.init()); + equal(Services.prefs.getIntPref("network.proxy.type"), 0); + + // autodetect + p = new Proxy(); + p.proxyType = "autodetect"; + ok(p.init()); + equal(Services.prefs.getIntPref("network.proxy.type"), 4); + + // system + p = new Proxy(); + p.proxyType = "system"; + ok(p.init()); + equal(Services.prefs.getIntPref("network.proxy.type"), 5); + + // manual + for (let proxy of ["http", "ssl", "socks"]) { + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = ["foo", "bar"]; + p[`${proxy}Proxy`] = "foo"; + p[`${proxy}ProxyPort`] = 42; + if (proxy === "socks") { + p[`${proxy}Version`] = 4; + } + + ok(p.init()); + equal(Services.prefs.getIntPref("network.proxy.type"), 1); + equal( + Services.prefs.getStringPref("network.proxy.no_proxies_on"), + "foo, bar" + ); + equal(Services.prefs.getStringPref(`network.proxy.${proxy}`), "foo"); + equal(Services.prefs.getIntPref(`network.proxy.${proxy}_port`), 42); + if (proxy === "socks") { + equal(Services.prefs.getIntPref(`network.proxy.${proxy}_version`), 4); + } + } + + // empty no proxy should reset default exclustions + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = []; + ok(p.init()); + equal(Services.prefs.getStringPref("network.proxy.no_proxies_on"), ""); +}); + +add_task(function test_Proxy_toString() { + equal(new Proxy().toString(), "[object Proxy]"); +}); + +add_task(function test_Proxy_toJSON() { + let p = new Proxy(); + deepEqual(p.toJSON(), {}); + + // autoconfig url + p = new Proxy(); + p.proxyType = "pac"; + p.proxyAutoconfigUrl = "foo"; + deepEqual(p.toJSON(), { proxyType: "pac", proxyAutoconfigUrl: "foo" }); + + // manual proxy + p = new Proxy(); + p.proxyType = "manual"; + deepEqual(p.toJSON(), { proxyType: "manual" }); + + for (let proxy of ["httpProxy", "sslProxy", "socksProxy"]) { + let expected = { proxyType: "manual" }; + + p = new Proxy(); + p.proxyType = "manual"; + + if (proxy == "socksProxy") { + p.socksVersion = 5; + expected.socksVersion = 5; + } + + // without port + p[proxy] = "foo"; + expected[proxy] = "foo"; + deepEqual(p.toJSON(), expected); + + // with port + p[proxy] = "foo"; + p[`${proxy}Port`] = 0; + expected[proxy] = "foo:0"; + deepEqual(p.toJSON(), expected); + + p[`${proxy}Port`] = 42; + expected[proxy] = "foo:42"; + deepEqual(p.toJSON(), expected); + + // add brackets for IPv6 address as proxy hostname + p[proxy] = "2001:db8::1"; + p[`${proxy}Port`] = 42; + expected[proxy] = "foo:42"; + expected[proxy] = "[2001:db8::1]:42"; + deepEqual(p.toJSON(), expected); + } + + // noProxy: add brackets for IPv6 address + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = ["2001:db8::1"]; + let expected = { proxyType: "manual", noProxy: "[2001:db8::1]" }; + deepEqual(p.toJSON(), expected); +}); + +add_task(function test_Proxy_fromJSON() { + let p = new Proxy(); + deepEqual(p, Proxy.fromJSON(undefined)); + deepEqual(p, Proxy.fromJSON(null)); + + for (let typ of [true, 42, "foo", []]) { + Assert.throws(() => Proxy.fromJSON(typ), /InvalidArgumentError/); + } + + // must contain a valid proxyType + Assert.throws(() => Proxy.fromJSON({}), /InvalidArgumentError/); + Assert.throws( + () => Proxy.fromJSON({ proxyType: "foo" }), + /InvalidArgumentError/ + ); + + // autoconfig url + for (let url of [true, 42, [], {}]) { + Assert.throws( + () => Proxy.fromJSON({ proxyType: "pac", proxyAutoconfigUrl: url }), + /InvalidArgumentError/ + ); + } + + p = new Proxy(); + p.proxyType = "pac"; + p.proxyAutoconfigUrl = "foo"; + deepEqual(p, Proxy.fromJSON({ proxyType: "pac", proxyAutoconfigUrl: "foo" })); + + // manual proxy + p = new Proxy(); + p.proxyType = "manual"; + deepEqual(p, Proxy.fromJSON({ proxyType: "manual" })); + + for (let proxy of ["httpProxy", "sslProxy", "socksProxy"]) { + let manual = { proxyType: "manual" }; + + // invalid hosts + for (let host of [ + true, + 42, + [], + {}, + null, + "http://foo", + "foo:-1", + "foo:65536", + "foo/test", + "foo#42", + "foo?foo=bar", + "2001:db8::1", + ]) { + manual[proxy] = host; + Assert.throws(() => Proxy.fromJSON(manual), /InvalidArgumentError/); + } + + p = new Proxy(); + p.proxyType = "manual"; + if (proxy == "socksProxy") { + manual.socksVersion = 5; + p.socksVersion = 5; + } + + let host_map = { + "foo:1": { hostname: "foo", port: 1 }, + "foo:21": { hostname: "foo", port: 21 }, + "foo:80": { hostname: "foo", port: 80 }, + "foo:443": { hostname: "foo", port: 443 }, + "foo:65535": { hostname: "foo", port: 65535 }, + "127.0.0.1:42": { hostname: "127.0.0.1", port: 42 }, + "[2001:db8::1]:42": { hostname: "2001:db8::1", port: "42" }, + }; + + // valid proxy hosts with port + for (let host in host_map) { + manual[proxy] = host; + + p[`${proxy}`] = host_map[host].hostname; + p[`${proxy}Port`] = host_map[host].port; + + deepEqual(p, Proxy.fromJSON(manual)); + } + + // Without a port the default port of the scheme is used + for (let host of ["foo", "foo:"]) { + manual[proxy] = host; + + // For socks no default port is available + p[proxy] = `foo`; + if (proxy === "socksProxy") { + p[`${proxy}Port`] = null; + } else { + let default_ports = { httpProxy: 80, sslProxy: 443 }; + + p[`${proxy}Port`] = default_ports[proxy]; + } + + deepEqual(p, Proxy.fromJSON(manual)); + } + } + + // missing required socks version + Assert.throws( + () => Proxy.fromJSON({ proxyType: "manual", socksProxy: "foo:1234" }), + /InvalidArgumentError/ + ); + + // Bug 1703805: Since Firefox 90 ftpProxy is no longer supported + Assert.throws( + () => Proxy.fromJSON({ proxyType: "manual", ftpProxy: "foo:21" }), + /InvalidArgumentError/ + ); + + // noProxy: invalid settings + for (let noProxy of [true, 42, {}, null, "foo", [true], [42], [{}], [null]]) { + Assert.throws( + () => Proxy.fromJSON({ proxyType: "manual", noProxy }), + /InvalidArgumentError/ + ); + } + + // noProxy: valid settings + p = new Proxy(); + p.proxyType = "manual"; + for (let noProxy of [[], ["foo"], ["foo", "bar"], ["127.0.0.1"]]) { + let manual = { proxyType: "manual", noProxy }; + p.noProxy = noProxy; + deepEqual(p, Proxy.fromJSON(manual)); + } + + // noProxy: IPv6 needs brackets removed + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = ["2001:db8::1"]; + let manual = { proxyType: "manual", noProxy: ["[2001:db8::1]"] }; + deepEqual(p, Proxy.fromJSON(manual)); +}); + +add_task(function test_UnhandledPromptBehavior() { + equal(UnhandledPromptBehavior.Accept, "accept"); + equal(UnhandledPromptBehavior.AcceptAndNotify, "accept and notify"); + equal(UnhandledPromptBehavior.Dismiss, "dismiss"); + equal(UnhandledPromptBehavior.DismissAndNotify, "dismiss and notify"); + equal(UnhandledPromptBehavior.Ignore, "ignore"); +}); + +add_task(function test_Capabilities_ctor() { + let caps = new Capabilities(); + ok(caps.has("browserName")); + ok(caps.has("browserVersion")); + ok(caps.has("platformName")); + ok(["linux", "mac", "windows", "android"].includes(caps.get("platformName"))); + equal(PageLoadStrategy.Normal, caps.get("pageLoadStrategy")); + equal(false, caps.get("acceptInsecureCerts")); + ok(caps.get("timeouts") instanceof Timeouts); + ok(caps.get("proxy") instanceof Proxy); + equal(caps.get("setWindowRect"), !AppInfo.isAndroid); + equal(caps.get("strictFileInteractability"), false); + equal(caps.get("webSocketUrl"), null); + + equal(false, caps.get("moz:accessibilityChecks")); + ok(caps.has("moz:buildID")); + ok(caps.has("moz:debuggerAddress")); + ok(caps.has("moz:platformVersion")); + ok(caps.has("moz:processID")); + ok(caps.has("moz:profile")); + equal(true, caps.get("moz:webdriverClick")); + + // No longer supported capabilities + ok(!caps.has("moz:useNonSpecCompliantPointerOrigin")); +}); + +add_task(function test_Capabilities_toString() { + equal("[object Capabilities]", new Capabilities().toString()); +}); + +add_task(function test_Capabilities_toJSON() { + let caps = new Capabilities(); + let json = caps.toJSON(); + + equal(caps.get("browserName"), json.browserName); + equal(caps.get("browserVersion"), json.browserVersion); + equal(caps.get("platformName"), json.platformName); + equal(caps.get("pageLoadStrategy"), json.pageLoadStrategy); + equal(caps.get("acceptInsecureCerts"), json.acceptInsecureCerts); + deepEqual(caps.get("proxy").toJSON(), json.proxy); + deepEqual(caps.get("timeouts").toJSON(), json.timeouts); + equal(caps.get("setWindowRect"), json.setWindowRect); + equal(caps.get("strictFileInteractability"), json.strictFileInteractability); + equal(caps.get("webSocketUrl"), json.webSocketUrl); + + equal(caps.get("moz:accessibilityChecks"), json["moz:accessibilityChecks"]); + equal(caps.get("moz:buildID"), json["moz:buildID"]); + equal(caps.get("moz:debuggerAddress"), json["moz:debuggerAddress"]); + equal(caps.get("moz:platformVersion"), json["moz:platformVersion"]); + equal(caps.get("moz:processID"), json["moz:processID"]); + equal(caps.get("moz:profile"), json["moz:profile"]); + equal(caps.get("moz:webdriverClick"), json["moz:webdriverClick"]); +}); + +add_task(function test_Capabilities_fromJSON() { + const { fromJSON } = Capabilities; + + // plain + for (let typ of [{}, null, undefined]) { + ok(fromJSON(typ).has("browserName")); + } + + // matching + let caps = new Capabilities(); + + caps = fromJSON({ acceptInsecureCerts: true }); + equal(true, caps.get("acceptInsecureCerts")); + caps = fromJSON({ acceptInsecureCerts: false }); + equal(false, caps.get("acceptInsecureCerts")); + + for (let strategy of Object.values(PageLoadStrategy)) { + caps = fromJSON({ pageLoadStrategy: strategy }); + equal(strategy, caps.get("pageLoadStrategy")); + } + + let proxyConfig = { proxyType: "manual" }; + caps = fromJSON({ proxy: proxyConfig }); + equal("manual", caps.get("proxy").proxyType); + + let timeoutsConfig = { implicit: 123 }; + caps = fromJSON({ timeouts: timeoutsConfig }); + equal(123, caps.get("timeouts").implicit); + + caps = fromJSON({ strictFileInteractability: false }); + equal(false, caps.get("strictFileInteractability")); + caps = fromJSON({ strictFileInteractability: true }); + equal(true, caps.get("strictFileInteractability")); + + caps = fromJSON({ webSocketUrl: true }); + equal(true, caps.get("webSocketUrl")); + + caps = fromJSON({ "webauthn:virtualAuthenticators": true }); + equal(true, caps.get("webauthn:virtualAuthenticators")); + caps = fromJSON({ "webauthn:virtualAuthenticators": false }); + equal(false, caps.get("webauthn:virtualAuthenticators")); + Assert.throws( + () => fromJSON({ "webauthn:virtualAuthenticators": "foo" }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "webauthn:extension:uvm": true }); + equal(true, caps.get("webauthn:extension:uvm")); + caps = fromJSON({ "webauthn:extension:uvm": false }); + equal(false, caps.get("webauthn:extension:uvm")); + Assert.throws( + () => fromJSON({ "webauthn:extension:uvm": "foo" }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "webauthn:extension:prf": true }); + equal(true, caps.get("webauthn:extension:prf")); + caps = fromJSON({ "webauthn:extension:prf": false }); + equal(false, caps.get("webauthn:extension:prf")); + Assert.throws( + () => fromJSON({ "webauthn:extension:prf": "foo" }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "webauthn:extension:largeBlob": true }); + equal(true, caps.get("webauthn:extension:largeBlob")); + caps = fromJSON({ "webauthn:extension:largeBlob": false }); + equal(false, caps.get("webauthn:extension:largeBlob")); + Assert.throws( + () => fromJSON({ "webauthn:extension:largeBlob": "foo" }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "webauthn:extension:credBlob": true }); + equal(true, caps.get("webauthn:extension:credBlob")); + caps = fromJSON({ "webauthn:extension:credBlob": false }); + equal(false, caps.get("webauthn:extension:credBlob")); + Assert.throws( + () => fromJSON({ "webauthn:extension:credBlob": "foo" }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "moz:accessibilityChecks": true }); + equal(true, caps.get("moz:accessibilityChecks")); + caps = fromJSON({ "moz:accessibilityChecks": false }); + equal(false, caps.get("moz:accessibilityChecks")); + + // capability is always populated with null if remote agent is not listening + caps = fromJSON({}); + equal(null, caps.get("moz:debuggerAddress")); + caps = fromJSON({ "moz:debuggerAddress": "foo" }); + equal(null, caps.get("moz:debuggerAddress")); + caps = fromJSON({ "moz:debuggerAddress": true }); + equal(null, caps.get("moz:debuggerAddress")); + + caps = fromJSON({ "moz:webdriverClick": true }); + equal(true, caps.get("moz:webdriverClick")); + caps = fromJSON({ "moz:webdriverClick": false }); + equal(false, caps.get("moz:webdriverClick")); + + // No longer supported capabilities + Assert.throws( + () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": false }), + /InvalidArgumentError/ + ); + Assert.throws( + () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": true }), + /InvalidArgumentError/ + ); +}); + +add_task(function test_mergeCapabilities() { + // Shadowed values. + Assert.throws( + () => + mergeCapabilities( + { acceptInsecureCerts: true }, + { acceptInsecureCerts: false } + ), + /InvalidArgumentError/ + ); + + deepEqual( + { acceptInsecureCerts: true }, + mergeCapabilities({ acceptInsecureCerts: true }, undefined) + ); + deepEqual( + { acceptInsecureCerts: true, browserName: "Firefox" }, + mergeCapabilities({ acceptInsecureCerts: true }, { browserName: "Firefox" }) + ); +}); + +add_task(function test_validateCapabilities_invalid() { + const invalidCapabilities = [ + true, + 42, + "foo", + [], + { acceptInsecureCerts: "foo" }, + { browserName: true }, + { browserVersion: true }, + { platformName: true }, + { pageLoadStrategy: "foo" }, + { proxy: false }, + { strictFileInteractability: "foo" }, + { timeouts: false }, + { unhandledPromptBehavior: false }, + { webSocketUrl: false }, + { webSocketUrl: "foo" }, + { "moz:firefoxOptions": "foo" }, + { "moz:accessibilityChecks": "foo" }, + { "moz:webdriverClick": "foo" }, + { "moz:webdriverClick": 1 }, + { "moz:useNonSpecCompliantPointerOrigin": false }, + { "moz:debuggerAddress": "foo" }, + { "moz:someRandomString": {} }, + ]; + for (const capabilities of invalidCapabilities) { + Assert.throws( + () => validateCapabilities(capabilities), + /InvalidArgumentError/ + ); + } +}); + +add_task(function test_validateCapabilities_valid() { + // Ignore null value. + deepEqual({}, validateCapabilities({ test: null })); + + const validCapabilities = [ + { acceptInsecureCerts: true }, + { browserName: "firefox" }, + { browserVersion: "12" }, + { platformName: "linux" }, + { pageLoadStrategy: "eager" }, + { proxy: { proxyType: "manual", httpProxy: "test.com" } }, + { strictFileInteractability: true }, + { timeouts: { pageLoad: 500 } }, + { unhandledPromptBehavior: "accept" }, + { webSocketUrl: true }, + { "moz:firefoxOptions": {} }, + { "moz:accessibilityChecks": true }, + { "moz:webdriverClick": true }, + { "moz:debuggerAddress": true }, + { "test:extension": "foo" }, + ]; + for (const validCapability of validCapabilities) { + deepEqual(validCapability, validateCapabilities(validCapability)); + } +}); + +add_task(function test_processCapabilities() { + for (const invalidValue of [ + { capabilities: null }, + { capabilities: undefined }, + { capabilities: "foo" }, + { capabilities: true }, + { capabilities: [] }, + { capabilities: { alwaysMatch: null } }, + { capabilities: { alwaysMatch: "foo" } }, + { capabilities: { alwaysMatch: true } }, + { capabilities: { alwaysMatch: [] } }, + { capabilities: { firstMatch: null } }, + { capabilities: { firstMatch: "foo" } }, + { capabilities: { firstMatch: true } }, + { capabilities: { firstMatch: {} } }, + { capabilities: { firstMatch: [] } }, + ]) { + Assert.throws( + () => processCapabilities(invalidValue), + /InvalidArgumentError/ + ); + } + + deepEqual( + { acceptInsecureCerts: true }, + processCapabilities({ + capabilities: { alwaysMatch: { acceptInsecureCerts: true } }, + }) + ); + deepEqual( + { browserName: "Firefox" }, + processCapabilities({ + capabilities: { firstMatch: [{ browserName: "Firefox" }] }, + }) + ); + deepEqual( + { acceptInsecureCerts: true, browserName: "Firefox" }, + processCapabilities({ + capabilities: { + alwaysMatch: { acceptInsecureCerts: true }, + firstMatch: [{ browserName: "Firefox" }], + }, + }) + ); +}); + +// use Proxy.toJSON to test marshal +add_task(function test_marshal() { + let proxy = new Proxy(); + + // drop empty fields + deepEqual({}, proxy.toJSON()); + proxy.proxyType = "manual"; + deepEqual({ proxyType: "manual" }, proxy.toJSON()); + proxy.proxyType = null; + deepEqual({}, proxy.toJSON()); + proxy.proxyType = undefined; + deepEqual({}, proxy.toJSON()); + + // iterate over object literals + proxy.proxyType = { foo: "bar" }; + deepEqual({ proxyType: { foo: "bar" } }, proxy.toJSON()); + + // iterate over complex object that implement toJSON + proxy.proxyType = new Proxy(); + deepEqual({}, proxy.toJSON()); + proxy.proxyType.proxyType = "manual"; + deepEqual({ proxyType: { proxyType: "manual" } }, proxy.toJSON()); + + // drop objects with no entries + proxy.proxyType = { foo: {} }; + deepEqual({}, proxy.toJSON()); + proxy.proxyType = { foo: new Proxy() }; + deepEqual({}, proxy.toJSON()); +}); diff --git a/remote/shared/webdriver/test/xpcshell/test_Errors.js b/remote/shared/webdriver/test/xpcshell/test_Errors.js new file mode 100644 index 0000000000..22e3526039 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Errors.js @@ -0,0 +1,543 @@ +/* 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 { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" +); + +const errors = [ + error.WebDriverError, + + error.DetachedShadowRootError, + error.ElementClickInterceptedError, + error.ElementNotAccessibleError, + error.ElementNotInteractableError, + error.InsecureCertificateError, + error.InvalidArgumentError, + error.InvalidCookieDomainError, + error.InvalidElementStateError, + error.InvalidSelectorError, + error.InvalidSessionIDError, + error.JavaScriptError, + error.MoveTargetOutOfBoundsError, + error.NoSuchAlertError, + error.NoSuchElementError, + error.NoSuchFrameError, + error.NoSuchHandleError, + error.NoSuchInterceptError, + error.NoSuchNodeError, + error.NoSuchRequestError, + error.NoSuchScriptError, + error.NoSuchShadowRootError, + error.NoSuchWindowError, + error.ScriptTimeoutError, + error.SessionNotCreatedError, + error.StaleElementReferenceError, + error.TimeoutError, + error.UnableToSetCookieError, + error.UnexpectedAlertOpenError, + error.UnknownCommandError, + error.UnknownError, + error.UnsupportedOperationError, +]; + +function notok(condition) { + ok(!condition); +} + +add_task(function test_isError() { + notok(error.isError(null)); + notok(error.isError([])); + notok(error.isError(new Date())); + + ok(error.isError(new Components.Exception())); + ok(error.isError(new Error())); + ok(error.isError(new EvalError())); + ok(error.isError(new InternalError())); + ok(error.isError(new RangeError())); + ok(error.isError(new ReferenceError())); + ok(error.isError(new SyntaxError())); + ok(error.isError(new TypeError())); + ok(error.isError(new URIError())); + + errors.forEach(err => ok(error.isError(new err()))); +}); + +add_task(function test_isWebDriverError() { + notok(error.isWebDriverError(new Components.Exception())); + notok(error.isWebDriverError(new Error())); + notok(error.isWebDriverError(new EvalError())); + notok(error.isWebDriverError(new InternalError())); + notok(error.isWebDriverError(new RangeError())); + notok(error.isWebDriverError(new ReferenceError())); + notok(error.isWebDriverError(new SyntaxError())); + notok(error.isWebDriverError(new TypeError())); + notok(error.isWebDriverError(new URIError())); + + errors.forEach(err => ok(error.isWebDriverError(new err()))); +}); + +add_task(function test_wrap() { + // webdriver-derived errors should not be wrapped + errors.forEach(err => { + const unwrappedError = new err("foo"); + const wrappedError = error.wrap(unwrappedError); + + ok(wrappedError instanceof error.WebDriverError); + ok(wrappedError instanceof err); + equal(wrappedError.name, unwrappedError.name); + equal(wrappedError.status, unwrappedError.status); + equal(wrappedError.message, "foo"); + }); + + // JS errors should be wrapped in UnknownError and retain their type + // as part of the message field. + const jsErrors = [ + Error, + EvalError, + InternalError, + RangeError, + ReferenceError, + SyntaxError, + TypeError, + URIError, + ]; + + jsErrors.forEach(err => { + const originalError = new err("foo"); + const wrappedError = error.wrap(originalError); + + ok(wrappedError instanceof error.UnknownError); + equal(wrappedError.name, "UnknownError"); + equal(wrappedError.status, "unknown error"); + equal(wrappedError.message, `${originalError.name}: foo`); + }); +}); + +add_task(function test_stringify() { + equal("<unprintable error>", error.stringify()); + equal("<unprintable error>", error.stringify("foo")); + equal("[object Object]", error.stringify({})); + equal("[object Object]\nfoo", error.stringify({ stack: "foo" })); + equal("Error: foo", error.stringify(new Error("foo")).split("\n")[0]); + + errors.forEach(err => { + const e = new err("foo"); + + equal(`${e.name}: foo`, error.stringify(e).split("\n")[0]); + }); +}); + +add_task(function test_constructor_from_error() { + const data = { a: 3, b: "bar" }; + const origError = new error.WebDriverError("foo", data); + + errors.forEach(err => { + const newError = new err(origError); + + ok(newError instanceof err); + equal(newError.message, origError.message); + equal(newError.stack, origError.stack); + equal(newError.data, origError.data); + }); +}); + +add_task(function test_stack() { + equal("string", typeof error.stack()); + ok(error.stack().includes("test_stack")); + ok(!error.stack().includes("add_task")); +}); + +add_task(function test_toJSON() { + errors.forEach(err => { + const e0 = new err(); + const e0_json = e0.toJSON(); + equal(e0_json.error, e0.status); + equal(e0_json.message, ""); + equal(e0_json.stacktrace, e0.stack); + equal(e0_json.data, undefined); + + // message property + const e1 = new err("a"); + const e1_json = e1.toJSON(); + + equal(e1_json.message, e1.message); + equal(e1_json.stacktrace, e1.stack); + equal(e1_json.data, undefined); + + // message and optional data property + const data = { a: 3, b: "bar" }; + const e2 = new err("foo", data); + const e2_json = e2.toJSON(); + + equal(e2.status, e2_json.error); + equal(e2.message, e2_json.message); + equal(e2_json.data, data); + }); +}); + +add_task(function test_fromJSON() { + errors.forEach(err => { + Assert.throws( + () => err.fromJSON({ error: "foo" }), + /Not of WebDriverError descent/ + ); + Assert.throws( + () => err.fromJSON({ error: "Error" }), + /Not of WebDriverError descent/ + ); + Assert.throws(() => err.fromJSON({}), /Undeserialisable error type/); + Assert.throws(() => err.fromJSON(undefined), /TypeError/); + + // message and stack + const e1 = new err("1"); + const e1_json = { error: e1.status, message: "3", stacktrace: "4" }; + const e1_fromJSON = error.WebDriverError.fromJSON(e1_json); + + ok(e1_fromJSON instanceof error.WebDriverError); + ok(e1_fromJSON instanceof err); + equal(e1_fromJSON.name, e1.name); + equal(e1_fromJSON.status, e1_json.error); + equal(e1_fromJSON.message, e1_json.message); + equal(e1_fromJSON.stack, e1_json.stacktrace); + + // message and optional data + const e2_data = { a: 3, b: "bar" }; + const e2 = new err("1", e2_data); + const e2_json = { error: e1.status, message: "3", data: e2_data }; + const e2_fromJSON = error.WebDriverError.fromJSON(e2_json); + + ok(e2_fromJSON instanceof error.WebDriverError); + ok(e2_fromJSON instanceof err); + equal(e2_fromJSON.name, e2.name); + equal(e2_fromJSON.status, e2_json.error); + equal(e2_fromJSON.message, e2_json.message); + equal(e2_fromJSON.data, e2_json.data); + + // parity with toJSON + const e3_data = { a: 3, b: "bar" }; + const e3 = new err("1", e3_data); + const e3_json = e3.toJSON(); + const e3_fromJSON = error.WebDriverError.fromJSON(e3_json); + + equal(e3_json.error, e3_fromJSON.status); + equal(e3_json.message, e3_fromJSON.message); + equal(e3_json.stacktrace, e3_fromJSON.stack); + }); +}); + +add_task(function test_WebDriverError() { + let err = new error.WebDriverError("foo"); + equal("WebDriverError", err.name); + equal("foo", err.message); + equal("webdriver error", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_DetachedShadowRootError() { + let err = new error.DetachedShadowRootError("foo"); + equal("DetachedShadowRootError", err.name); + equal("foo", err.message); + equal("detached shadow root", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_ElementClickInterceptedError() { + let otherEl = { + hasAttribute: attr => attr in otherEl, + getAttribute: attr => (attr in otherEl ? otherEl[attr] : null), + nodeType: 1, + localName: "a", + }; + let obscuredEl = { + hasAttribute: attr => attr in obscuredEl, + getAttribute: attr => (attr in obscuredEl ? obscuredEl[attr] : null), + nodeType: 1, + localName: "b", + ownerDocument: { + elementFromPoint() { + return otherEl; + }, + }, + style: { + pointerEvents: "auto", + }, + }; + + let err1 = new error.ElementClickInterceptedError( + undefined, + undefined, + obscuredEl, + { x: 1, y: 2 } + ); + equal("ElementClickInterceptedError", err1.name); + equal( + "Element <b> is not clickable at point (1,2) " + + "because another element <a> obscures it", + err1.message + ); + equal("element click intercepted", err1.status); + ok(err1 instanceof error.WebDriverError); + + obscuredEl.style.pointerEvents = "none"; + let err2 = new error.ElementClickInterceptedError( + undefined, + undefined, + obscuredEl, + { x: 1, y: 2 } + ); + equal( + "Element <b> is not clickable at point (1,2) " + + "because it does not have pointer events enabled, " + + "and element <a> would receive the click instead", + err2.message + ); +}); + +add_task(function test_ElementNotAccessibleError() { + let err = new error.ElementNotAccessibleError("foo"); + equal("ElementNotAccessibleError", err.name); + equal("foo", err.message); + equal("element not accessible", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_ElementNotInteractableError() { + let err = new error.ElementNotInteractableError("foo"); + equal("ElementNotInteractableError", err.name); + equal("foo", err.message); + equal("element not interactable", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InsecureCertificateError() { + let err = new error.InsecureCertificateError("foo"); + equal("InsecureCertificateError", err.name); + equal("foo", err.message); + equal("insecure certificate", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidArgumentError() { + let err = new error.InvalidArgumentError("foo"); + equal("InvalidArgumentError", err.name); + equal("foo", err.message); + equal("invalid argument", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidCookieDomainError() { + let err = new error.InvalidCookieDomainError("foo"); + equal("InvalidCookieDomainError", err.name); + equal("foo", err.message); + equal("invalid cookie domain", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidElementStateError() { + let err = new error.InvalidElementStateError("foo"); + equal("InvalidElementStateError", err.name); + equal("foo", err.message); + equal("invalid element state", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidSelectorError() { + let err = new error.InvalidSelectorError("foo"); + equal("InvalidSelectorError", err.name); + equal("foo", err.message); + equal("invalid selector", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidSessionIDError() { + let err = new error.InvalidSessionIDError("foo"); + equal("InvalidSessionIDError", err.name); + equal("foo", err.message); + equal("invalid session id", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_JavaScriptError() { + let err = new error.JavaScriptError("foo"); + equal("JavaScriptError", err.name); + equal("foo", err.message); + equal("javascript error", err.status); + ok(err instanceof error.WebDriverError); + + equal("", new error.JavaScriptError(undefined).message); + + let superErr = new RangeError("foo"); + let inheritedErr = new error.JavaScriptError(superErr); + equal("RangeError: foo", inheritedErr.message); + equal(superErr.stack, inheritedErr.stack); +}); + +add_task(function test_MoveTargetOutOfBoundsError() { + let err = new error.MoveTargetOutOfBoundsError("foo"); + equal("MoveTargetOutOfBoundsError", err.name); + equal("foo", err.message); + equal("move target out of bounds", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchAlertError() { + let err = new error.NoSuchAlertError("foo"); + equal("NoSuchAlertError", err.name); + equal("foo", err.message); + equal("no such alert", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchElementError() { + let err = new error.NoSuchElementError("foo"); + equal("NoSuchElementError", err.name); + equal("foo", err.message); + equal("no such element", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchFrameError() { + let err = new error.NoSuchFrameError("foo"); + equal("NoSuchFrameError", err.name); + equal("foo", err.message); + equal("no such frame", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchHandleError() { + let err = new error.NoSuchHandleError("foo"); + equal("NoSuchHandleError", err.name); + equal("foo", err.message); + equal("no such handle", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchInterceptError() { + let err = new error.NoSuchInterceptError("foo"); + equal("NoSuchInterceptError", err.name); + equal("foo", err.message); + equal("no such intercept", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchNodeError() { + let err = new error.NoSuchNodeError("foo"); + equal("NoSuchNodeError", err.name); + equal("foo", err.message); + equal("no such node", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchRequestError() { + let err = new error.NoSuchRequestError("foo"); + equal("NoSuchRequestError", err.name); + equal("foo", err.message); + equal("no such request", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchScriptError() { + let err = new error.NoSuchScriptError("foo"); + equal("NoSuchScriptError", err.name); + equal("foo", err.message); + equal("no such script", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchShadowRootError() { + let err = new error.NoSuchShadowRootError("foo"); + equal("NoSuchShadowRootError", err.name); + equal("foo", err.message); + equal("no such shadow root", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchUserContextError() { + let err = new error.NoSuchUserContextError("foo"); + equal("NoSuchUserContextError", err.name); + equal("foo", err.message); + equal("no such user context", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchWindowError() { + let err = new error.NoSuchWindowError("foo"); + equal("NoSuchWindowError", err.name); + equal("foo", err.message); + equal("no such window", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_ScriptTimeoutError() { + let err = new error.ScriptTimeoutError("foo"); + equal("ScriptTimeoutError", err.name); + equal("foo", err.message); + equal("script timeout", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_SessionNotCreatedError() { + let err = new error.SessionNotCreatedError("foo"); + equal("SessionNotCreatedError", err.name); + equal("foo", err.message); + equal("session not created", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_StaleElementReferenceError() { + let err = new error.StaleElementReferenceError("foo"); + equal("StaleElementReferenceError", err.name); + equal("foo", err.message); + equal("stale element reference", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_TimeoutError() { + let err = new error.TimeoutError("foo"); + equal("TimeoutError", err.name); + equal("foo", err.message); + equal("timeout", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnableToSetCookieError() { + let err = new error.UnableToSetCookieError("foo"); + equal("UnableToSetCookieError", err.name); + equal("foo", err.message); + equal("unable to set cookie", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnexpectedAlertOpenError() { + let err = new error.UnexpectedAlertOpenError("foo"); + equal("UnexpectedAlertOpenError", err.name); + equal("foo", err.message); + equal("unexpected alert open", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnknownCommandError() { + let err = new error.UnknownCommandError("foo"); + equal("UnknownCommandError", err.name); + equal("foo", err.message); + equal("unknown command", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnknownError() { + let err = new error.UnknownError("foo"); + equal("UnknownError", err.name); + equal("foo", err.message); + equal("unknown error", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnsupportedOperationError() { + let err = new error.UnsupportedOperationError("foo"); + equal("UnsupportedOperationError", err.name); + equal("foo", err.message); + equal("unsupported operation", err.status); + ok(err instanceof error.WebDriverError); +}); diff --git a/remote/shared/webdriver/test/xpcshell/test_NodeCache.js b/remote/shared/webdriver/test/xpcshell/test_NodeCache.js new file mode 100644 index 0000000000..4efe9fba3a --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_NodeCache.js @@ -0,0 +1,265 @@ +const { NodeCache } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs" +); + +function setupTest() { + const browser = Services.appShell.createWindowlessBrowser(false); + + browser.document.body.innerHTML = ` + <div id="foo" style="margin: 50px"> + <iframe></iframe> + <video></video> + <svg xmlns="http://www.w3.org/2000/svg"></svg> + <textarea></textarea> + </div> + <div id="with-comment"><!-- Comment --></div> + `; + + const divEl = browser.document.querySelector("div"); + const svgEl = browser.document.querySelector("svg"); + const textareaEl = browser.document.querySelector("textarea"); + const videoEl = browser.document.querySelector("video"); + + const iframeEl = browser.document.querySelector("iframe"); + const childEl = iframeEl.contentDocument.createElement("div"); + iframeEl.contentDocument.body.appendChild(childEl); + + const shadowRoot = videoEl.openOrClosedShadowRoot; + + return { + browser, + nodeCache: new NodeCache(), + childEl, + divEl, + iframeEl, + shadowRoot, + seenNodeIds: new Map(), + svgEl, + textareaEl, + videoEl, + }; +} + +add_task(function getOrCreateNodeReference_invalid() { + const { nodeCache, seenNodeIds } = setupTest(); + + const invalidValues = [null, undefined, "foo", 42, true, [], {}]; + + for (const value of invalidValues) { + info(`Testing value: ${value}`); + Assert.throws( + () => nodeCache.getOrCreateNodeReference(value, seenNodeIds), + /TypeError/ + ); + } +}); + +add_task(function getOrCreateNodeReference_supportedNodeTypes() { + const { browser, divEl, nodeCache, seenNodeIds } = setupTest(); + + // Bug 1820734: No ownerGlobal is available in XPCShell tests + // const xmlDocument = new DOMParser().parseFromString( + // "<xml></xml>", + // "application/xml" + // ); + + const values = [ + { node: divEl, type: Node.ELEMENT_NODE }, + { node: divEl.attributes[0], type: Node.ATTRIBUTE_NODE }, + { node: browser.document.createTextNode("foo"), type: Node.TEXT_NODE }, + // Bug 1820734: No ownerGlobal is available in XPCShell tests + // { + // node: xmlDocument.createCDATASection("foo"), + // type: Node.CDATA_SECTION_NODE, + // }, + { + node: browser.document.createProcessingInstruction( + "xml-stylesheet", + "href='foo.css'" + ), + type: Node.PROCESSING_INSTRUCTION_NODE_NODE, + }, + { node: browser.document.createComment("foo"), type: Node.COMMENT_NODE }, + { node: browser.document, type: Node.Document_NODE }, + { + node: browser.document.implementation.createDocumentType( + "foo", + "bar", + "dtd" + ), + type: Node.DOCUMENT_TYPE_NODE_NODE, + }, + { + node: browser.document.createDocumentFragment(), + type: Node.DOCUMENT_FRAGMENT_NODE, + }, + ]; + + values.forEach((value, index) => { + info(`Testing value: ${value.type}`); + const nodeRef = nodeCache.getOrCreateNodeReference(value.node, seenNodeIds); + equal(nodeCache.size, index + 1); + equal(typeof nodeRef, "string"); + ok(seenNodeIds.get(browser.browsingContext).includes(nodeRef)); + }); +}); + +add_task(function getOrCreateNodeReference_referenceAlreadyCreated() { + const { browser, divEl, nodeCache, seenNodeIds } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + const divElRefOther = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + + equal(divElRefOther, divElRef); + equal(nodeCache.size, 1); + equal(seenNodeIds.size, 1); + ok(seenNodeIds.get(browser.browsingContext).includes(divElRef)); +}); + +add_task(function getOrCreateNodeReference_differentReference() { + const { browser, divEl, nodeCache, seenNodeIds, shadowRoot } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + equal(nodeCache.size, 1); + equal(seenNodeIds.size, 1); + ok(seenNodeIds.get(browser.browsingContext).includes(divElRef)); + + const shadowRootRef = nodeCache.getOrCreateNodeReference( + shadowRoot, + seenNodeIds + ); + equal(nodeCache.size, 2); + equal(seenNodeIds.size, 1); + ok(seenNodeIds.get(browser.browsingContext).includes(divElRef)); + ok(seenNodeIds.get(browser.browsingContext).includes(shadowRootRef)); + + notEqual(divElRef, shadowRootRef); +}); + +add_task(function getOrCreateNodeReference_differentReferencePerNodeCache() { + const { browser, divEl, nodeCache, seenNodeIds } = setupTest(); + const nodeCache2 = new NodeCache(); + + const divElRef1 = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + const divElRef2 = nodeCache2.getOrCreateNodeReference(divEl, seenNodeIds); + + notEqual(divElRef1, divElRef2); + equal( + nodeCache.getNode(browser.browsingContext, divElRef1), + nodeCache2.getNode(browser.browsingContext, divElRef2) + ); + + equal(seenNodeIds.size, 1); + ok(seenNodeIds.get(browser.browsingContext).includes(divElRef1)); + ok(seenNodeIds.get(browser.browsingContext).includes(divElRef2)); + + equal(nodeCache.getNode(browser.browsingContext, divElRef2), null); +}); + +add_task(function clear() { + const { browser, divEl, nodeCache, seenNodeIds, svgEl } = setupTest(); + + nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + nodeCache.getOrCreateNodeReference(svgEl, seenNodeIds); + equal(nodeCache.size, 2); + equal(seenNodeIds.size, 1); + + // Clear requires explicit arguments. + Assert.throws(() => nodeCache.clear(), /Error/); + + // Clear references for a different browsing context + const browser2 = Services.appShell.createWindowlessBrowser(false); + const imgEl = browser2.document.createElement("img"); + const imgElRef = nodeCache.getOrCreateNodeReference(imgEl, seenNodeIds); + equal(nodeCache.size, 3); + equal(seenNodeIds.size, 2); + + nodeCache.clear({ browsingContext: browser.browsingContext }); + equal(nodeCache.size, 1); + equal(nodeCache.getNode(browser2.browsingContext, imgElRef), imgEl); + + // Clear all references + nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + equal(nodeCache.size, 2); + equal(seenNodeIds.size, 2); + + nodeCache.clear({ all: true }); + equal(nodeCache.size, 0); +}); + +add_task(function getNode_multiple_nodes() { + const { browser, divEl, nodeCache, seenNodeIds, svgEl } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + const svgElRef = nodeCache.getOrCreateNodeReference(svgEl, seenNodeIds); + + equal(nodeCache.getNode(browser.browsingContext, svgElRef), svgEl); + equal(nodeCache.getNode(browser.browsingContext, divElRef), divEl); +}); + +add_task(function getNode_differentBrowsingContextInSameGroup() { + const { iframeEl, divEl, nodeCache, seenNodeIds } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + equal(nodeCache.size, 1); + + equal( + nodeCache.getNode(iframeEl.contentWindow.browsingContext, divElRef), + divEl + ); +}); + +add_task(function getNode_differentBrowsingContextInOtherGroup() { + const { divEl, nodeCache, seenNodeIds } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + equal(nodeCache.size, 1); + + const browser2 = Services.appShell.createWindowlessBrowser(false); + equal(nodeCache.getNode(browser2.browsingContext, divElRef), null); +}); + +add_task(async function getNode_nodeDeleted() { + const { browser, nodeCache, seenNodeIds } = setupTest(); + let el = browser.document.createElement("div"); + + const elRef = nodeCache.getOrCreateNodeReference(el, seenNodeIds); + + // Delete element and force a garbage collection + el = null; + + await doGC(); + + equal(nodeCache.getNode(browser.browsingContext, elRef), null); +}); + +add_task(function getNodeDetails_forTopBrowsingContext() { + const { browser, divEl, nodeCache, seenNodeIds } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds); + + const nodeDetails = nodeCache.getReferenceDetails(divElRef); + equal(nodeDetails.browserId, browser.browsingContext.browserId); + equal(nodeDetails.browsingContextGroupId, browser.browsingContext.group.id); + equal(nodeDetails.browsingContextId, browser.browsingContext.id); + ok(nodeDetails.isTopBrowsingContext); + ok(nodeDetails.nodeWeakRef); + equal(nodeDetails.nodeWeakRef.get(), divEl); +}); + +add_task(async function getNodeDetails_forChildBrowsingContext() { + const { browser, iframeEl, childEl, nodeCache, seenNodeIds } = setupTest(); + + const childElRef = nodeCache.getOrCreateNodeReference(childEl, seenNodeIds); + + const nodeDetails = nodeCache.getReferenceDetails(childElRef); + equal(nodeDetails.browserId, browser.browsingContext.browserId); + equal(nodeDetails.browsingContextGroupId, browser.browsingContext.group.id); + equal( + nodeDetails.browsingContextId, + iframeEl.contentWindow.browsingContext.id + ); + ok(!nodeDetails.isTopBrowsingContext); + ok(nodeDetails.nodeWeakRef); + equal(nodeDetails.nodeWeakRef.get(), childEl); +}); diff --git a/remote/shared/webdriver/test/xpcshell/test_Session.js b/remote/shared/webdriver/test/xpcshell/test_Session.js new file mode 100644 index 0000000000..3b3d893319 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Session.js @@ -0,0 +1,72 @@ +/* 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 { Capabilities, Timeouts } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs" +); +const { getWebDriverSessionById, WebDriverSession } = + ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Session.sys.mjs" + ); + +add_task(function test_WebDriverSession_ctor() { + const session = new WebDriverSession(); + + equal(typeof session.id, "string"); + ok(session.capabilities instanceof Capabilities); +}); + +add_task(function test_WebDriverSession_destroy() { + const session = new WebDriverSession(); + + session.destroy(); +}); + +add_task(function test_WebDriverSession_getters() { + const session = new WebDriverSession(); + + equal( + session.a11yChecks, + session.capabilities.get("moz:accessibilityChecks") + ); + equal(session.pageLoadStrategy, session.capabilities.get("pageLoadStrategy")); + equal(session.proxy, session.capabilities.get("proxy")); + equal( + session.strictFileInteractability, + session.capabilities.get("strictFileInteractability") + ); + equal(session.timeouts, session.capabilities.get("timeouts")); + equal( + session.unhandledPromptBehavior, + session.capabilities.get("unhandledPromptBehavior") + ); +}); + +add_task(function test_WebDriverSession_setters() { + const session = new WebDriverSession(); + + const timeouts = new Timeouts(); + timeouts.pageLoad = 45; + + session.timeouts = timeouts; + equal(session.timeouts, session.capabilities.get("timeouts")); +}); + +add_task(function test_getWebDriverSessionById() { + const session1 = new WebDriverSession(); + const session2 = new WebDriverSession(); + + equal(getWebDriverSessionById(session1.id), session1); + equal(getWebDriverSessionById(session2.id), session2); + + session1.destroy(); + equal(getWebDriverSessionById(session1.id), undefined); + equal(getWebDriverSessionById(session2.id), session2); + + session2.destroy(); + equal(getWebDriverSessionById(session1.id), undefined); + equal(getWebDriverSessionById(session2.id), undefined); +}); diff --git a/remote/shared/webdriver/test/xpcshell/test_URLPattern_invalid.js b/remote/shared/webdriver/test/xpcshell/test_URLPattern_invalid.js new file mode 100644 index 0000000000..0e537a210f --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_URLPattern_invalid.js @@ -0,0 +1,129 @@ +/* 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 { parseURLPattern } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs" +); + +add_task( + async function test_parseURLPattern_patternPattern_unescapedCharacters() { + const properties = ["protocol", "hostname", "port", "pathname", "search"]; + const values = ["*", "(", ")", "{", "}"]; + for (const property of properties) { + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", [property]: value }), + /InvalidArgumentError/ + ); + } + } + } +); + +add_task(async function test_parseURLPattern_patternPattern_protocol() { + const values = [ + "", + "http/", + "http\\*", + "http\\(", + "http\\)", + "http\\{", + "http\\}", + "http#", + "http@", + "http%", + ]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", protocol: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task( + async function test_parseURLPattern_patternPattern_unsupported_protocol() { + const values = ["ftp", "abc", "webpack"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", protocol: value }), + /UnsupportedOperationError/ + ); + } + } +); + +add_task(async function test_parseURLPattern_patternPattern_hostname() { + const values = ["", "abc/com/", "abc?com", "abc#com", "abc:com"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", hostname: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(async function test_parseURLPattern_patternPattern_port() { + const values = ["", "abcd", "-1", "80 ", "1.3", ":80", "65536"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", port: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(async function test_parseURLPattern_patternPattern_pathname() { + const values = ["path?", "path#"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", pathname: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(async function test_parseURLPattern_patternPattern_search() { + const values = ["search#"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", search: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(async function test_parseURLPattern_stringPattern_invalid_url() { + const values = ["", "invalid", "http:invalid:url", "[1::", "127.0..1"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "string", pattern: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task( + async function test_parseURLPattern_stringPattern_unescaped_characters() { + const values = ["*", "(", ")", "{", "}"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "string", pattern: value }), + /InvalidArgumentError/ + ); + } + } +); + +add_task( + async function test_parseURLPattern_stringPattern_unsupported_protocol() { + const values = ["ftp://some/path", "abc:pathplaceholder", "webpack://test"]; + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "string", pattern: value }), + /UnsupportedOperationError/ + ); + } + } +); diff --git a/remote/shared/webdriver/test/xpcshell/test_URLPattern_matchURLPattern.js b/remote/shared/webdriver/test/xpcshell/test_URLPattern_matchURLPattern.js new file mode 100644 index 0000000000..f4831d583f --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_URLPattern_matchURLPattern.js @@ -0,0 +1,607 @@ +/* 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 { matchURLPattern, parseURLPattern } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs" +); + +// Test several variations which should match a string based http://example.com +// pattern. +add_task(async function test_matchURLPattern_url_variations() { + const pattern = parseURLPattern({ + type: "string", + pattern: "http://example.com", + }); + + const urls = [ + "http://example.com", + "http://EXAMPLE.com", + "http://user:password@example.com", + "http://example.com:80", + "http://example.com/", + "http://example.com/#some-hash", + "http:example.com", + "http:/example.com", + "http://example.com?", + "http://example.com/?", + ]; + for (const url of urls) { + ok( + matchURLPattern(pattern, url), + `url "${url}" should match pattern "http://example.com"` + ); + } + + // Test URLs close to http://example.com but which should not match. + const failingUrls = [ + "https://example.com", + "http://example.com:88", + "http://example.com/a", + "http://example.com/?abc", + ]; + for (const url of failingUrls) { + ok( + !matchURLPattern(pattern, url), + `url "${url}" should not match pattern "http://example.com"` + ); + } +}); + +add_task(async function test_matchURLPattern_stringPatterns() { + const tests = [ + { + pattern: "http://example.com", + url: "http://example.com", + match: true, + }, + { + pattern: "HTTP://example.com:80", + url: "http://example.com", + match: true, + }, + { + pattern: "http://example.com:80", + url: "http://example.com", + match: true, + }, + { + pattern: "http://example.com/path", + url: "http://example.com/path", + match: true, + }, + { + pattern: "http://example.com/PATH_CASE", + url: "http://example.com/path_case", + match: false, + }, + { + pattern: "http://example.com/path_single_segment", + url: "http://example.com/path_single_segment/", + match: false, + }, + { + pattern: "http://example.com/path", + url: "http://example.com/path_continued", + match: false, + }, + { + pattern: "http://example.com/path_two_segments/", + url: "http://example.com/path_two_segments/", + match: true, + }, + { + pattern: "http://example.com/path_two_segments/", + url: "http://example.com/path_two_segments", + match: false, + }, + { + pattern: "http://example.com/emptysearch?", + url: "http://example.com/emptysearch?", + match: true, + }, + { + pattern: "http://example.com/emptysearch?", + url: "http://example.com/emptysearch", + match: true, + }, + { + pattern: "http://example.com/emptysearch?", + url: "http://example.com/emptysearch??", + match: false, + }, + { + pattern: "http://example.com/emptysearch?", + url: "http://example.com/emptysearch?a", + match: false, + }, + { + pattern: "http://example.com/search?param", + url: "http://example.com/search?param", + match: true, + }, + { + pattern: "http://example.com/search?param", + url: "http://example.com/search?param=value", + match: false, + }, + { + pattern: "http://example.com/search?param=value", + url: "http://example.com/search?param=value", + match: true, + }, + { + pattern: "http://example.com/search?a=b&c=d", + url: "http://example.com/search?a=b&c=d", + match: true, + }, + { + pattern: "http://example.com/search?a=b&c=d", + url: "http://example.com/search?c=d&a=b", + match: false, + }, + { + pattern: "http://example.com/search?param", + url: "http://example.com/search?param#ref", + match: true, + }, + { + pattern: "http://example.com/search?param#ref", + url: "http://example.com/search?param#ref", + match: true, + }, + { + pattern: "http://example.com/search?param#ref", + url: "http://example.com/search?param", + match: true, + }, + { + pattern: "http://example.com/search?param", + url: "http://example.com/search?parameter", + match: false, + }, + { + pattern: "http://example.com/search?parameter", + url: "http://example.com/search?param", + match: false, + }, + { + pattern: "https://example.com:80", + url: "https://example.com", + match: false, + }, + { + pattern: "https://example.com:443", + url: "https://example.com", + match: true, + }, + { + pattern: "ws://example.com", + url: "ws://example.com:80", + match: true, + }, + ]; + + runMatchPatternTests(tests, "string"); +}); + +add_task(async function test_patternPatterns_no_property() { + const tests = [ + // Test protocol + { + pattern: {}, + url: "https://example.com", + match: true, + }, + { + pattern: {}, + url: "https://example.com", + match: true, + }, + { + pattern: {}, + url: "https://example.com:1234", + match: true, + }, + { + pattern: {}, + url: "https://example.com/a", + match: true, + }, + { + pattern: {}, + url: "https://example.com/a?test", + match: true, + }, + ]; + + runMatchPatternTests(tests, "pattern"); +}); + +add_task(async function test_patternPatterns_protocol() { + const tests = [ + // Test protocol + { + pattern: { + protocol: "http", + }, + url: "http://example.com", + match: true, + }, + { + pattern: { + protocol: "HTTP", + }, + url: "http://example.com", + match: true, + }, + { + pattern: { + protocol: "http", + }, + url: "http://example.com:80", + match: true, + }, + { + pattern: { + protocol: "http", + }, + url: "http://example.com:1234", + match: true, + }, + { + pattern: { + protocol: "http", + port: "80", + }, + url: "http://example.com:80", + match: true, + }, + { + pattern: { + protocol: "http", + port: "1234", + }, + url: "http://example.com:1234", + match: true, + }, + { + pattern: { + protocol: "http", + port: "1234", + }, + url: "http://example.com", + match: false, + }, + { + pattern: { + protocol: "http", + }, + url: "https://wrong-scheme.com", + match: false, + }, + { + pattern: { + protocol: "http", + }, + url: "http://whatever.com/?search#ref", + match: true, + }, + { + pattern: { + protocol: "http", + }, + url: "http://example.com/a", + match: true, + }, + { + pattern: { + protocol: "http", + }, + url: "http://whatever.com/path?search#ref", + match: true, + }, + ]; + + runMatchPatternTests(tests, "pattern"); +}); + +add_task(async function test_patternPatterns_port() { + const tests = [ + { + pattern: { + protocol: "http", + port: "80", + }, + url: "http://abc.com/", + match: true, + }, + { + pattern: { + port: "1234", + }, + url: "http://a.com:1234", + match: true, + }, + { + pattern: { + port: "1234", + }, + url: "https://a.com:1234", + match: true, + }, + ]; + + runMatchPatternTests(tests, "pattern"); +}); + +add_task(async function test_patternPatterns_hostname() { + const tests = [ + { + pattern: { + hostname: "example.com", + }, + url: "http://example.com", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "http://example.com:80", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "https://example.com", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "https://example.com:443", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "ws://example.com", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "ws://example.com:80", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "http://example.com/path", + match: true, + }, + { + pattern: { + hostname: "example.com", + }, + url: "http://example.com/?search", + match: true, + }, + { + pattern: { + hostname: "example\\{.com", + }, + url: "http://example{.com/", + match: true, + }, + { + pattern: { + hostname: "example\\{.com", + }, + url: "http://example\\{.com/", + match: false, + }, + { + pattern: { + hostname: "127.0.0.1", + }, + url: "http://127.0.0.1/", + match: true, + }, + { + pattern: { + hostname: "127.0.0.1", + }, + url: "http://127.0.0.2/", + match: false, + }, + { + pattern: { + hostname: "[2001:db8::1]", + }, + url: "http://[2001:db8::1]/", + match: true, + }, + { + pattern: { + hostname: "[::AB:1]", + }, + url: "http://[::ab:1]/", + match: true, + }, + ]; + + runMatchPatternTests(tests, "pattern"); +}); + +add_task(async function test_patternPatterns_pathname() { + const tests = [ + { + pattern: { + pathname: "/", + }, + url: "http://example.com", + match: true, + }, + { + pattern: { + pathname: "/", + }, + url: "http://example.com/", + match: true, + }, + { + pattern: { + pathname: "/", + }, + url: "http://example.com/?", + match: true, + }, + { + pattern: { + pathname: "path", + }, + url: "http://example.com/path", + match: true, + }, + { + pattern: { + pathname: "/path", + }, + url: "http://example.com/path", + match: true, + }, + { + pattern: { + pathname: "path", + }, + url: "http://example.com/path/", + match: false, + }, + { + pattern: { + pathname: "path", + }, + url: "http://example.com/path_continued", + match: false, + }, + { + pattern: { + pathname: "/", + }, + url: "http://example.com/path", + match: false, + }, + ]; + + runMatchPatternTests(tests, "pattern"); +}); + +add_task(async function test_patternPatterns_search() { + const tests = [ + { + pattern: { + search: "", + }, + url: "http://example.com/?", + match: true, + }, + { + pattern: { + search: "", + }, + url: "http://example.com/", + match: true, + }, + { + pattern: { + search: "", + }, + url: "http://example.com/?#", + match: true, + }, + { + pattern: { + search: "?", + }, + url: "http://example.com/?", + match: true, + }, + { + pattern: { + search: "?a", + }, + url: "http://example.com/?a", + match: true, + }, + { + pattern: { + search: "?", + }, + url: "http://example.com/??", + match: false, + }, + { + pattern: { + search: "query", + }, + url: "http://example.com/?query", + match: true, + }, + { + pattern: { + search: "?query", + }, + url: "http://example.com/?query", + match: true, + }, + { + pattern: { + search: "query=value", + }, + url: "http://example.com/?query=value", + match: true, + }, + { + pattern: { + search: "query", + }, + url: "http://example.com/?query=value", + match: false, + }, + { + pattern: { + search: "query", + }, + url: "http://example.com/?query#value", + match: true, + }, + ]; + + runMatchPatternTests(tests, "pattern"); +}); + +function runMatchPatternTests(tests, type) { + for (const test of tests) { + let pattern; + if (type == "pattern") { + pattern = parseURLPattern({ type: "pattern", ...test.pattern }); + } else { + pattern = parseURLPattern({ type: "string", pattern: test.pattern }); + } + + equal( + matchURLPattern(pattern, test.url), + test.match, + `url "${test.url}" ${ + test.match ? "should" : "should not" + } match pattern ${JSON.stringify(test.pattern)}` + ); + } +} diff --git a/remote/shared/webdriver/test/xpcshell/test_URLPattern_parseURLPattern.js b/remote/shared/webdriver/test/xpcshell/test_URLPattern_parseURLPattern.js new file mode 100644 index 0000000000..d4bf3c5fdf --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_URLPattern_parseURLPattern.js @@ -0,0 +1,369 @@ +/* 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 { parseURLPattern } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs" +); + +add_task(async function test_parseURLPattern_stringPatterns() { + const STRING_PATTERN_TESTS = [ + { + input: "http://example.com", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://example.com/", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://EXAMPLE.com", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://example%2Ecom", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "", + }, + + { + input: "http://example.com:80", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://example.com:8888", + protocol: "http", + hostname: "example.com", + port: "8888", + pathname: "/", + search: "", + }, + { + input: "http://example.com/a////b", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/a////b", + search: "", + }, + { + input: "http://example.com/?", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://example.com/??", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "?", + }, + { + input: "http://example.com/?/", + protocol: "http", + hostname: "example.com", + port: "", + pathname: "/", + search: "/", + }, + { + input: "file:///testfolder/test.zip", + protocol: "file", + hostname: "", + port: null, + pathname: "/testfolder/test.zip", + search: "", + }, + { + input: "http://example\\{.com/", + protocol: "http", + hostname: "example{.com", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://[2001:db8::1]/", + protocol: "http", + hostname: "[2001:db8::1]", + port: "", + pathname: "/", + search: "", + }, + { + input: "http://127.0.0.1/", + protocol: "http", + hostname: "127.0.0.1", + port: "", + pathname: "/", + search: "", + }, + ]; + + for (const test of STRING_PATTERN_TESTS) { + const pattern = parseURLPattern({ + type: "string", + pattern: test.input, + }); + + equal(pattern.protocol, "protocol" in test ? test.protocol : null); + equal(pattern.hostname, "hostname" in test ? test.hostname : null); + equal(pattern.port, "port" in test ? test.port : null); + equal(pattern.pathname, "pathname" in test ? test.pathname : null); + equal(pattern.search, "search" in test ? test.search : null); + } +}); + +add_task(async function test_parseURLPattern_patternPatterns() { + const PATTERN_PATTERN_TESTS = [ + { + pattern: { + protocol: "http", + }, + protocol: "http", + hostname: null, + port: null, + pathname: null, + search: null, + }, + { + pattern: { + protocol: "HTTP", + }, + protocol: "http", + hostname: null, + port: null, + pathname: null, + search: null, + }, + { + pattern: { + hostname: "example.com", + }, + protocol: null, + hostname: "example.com", + port: null, + pathname: null, + search: null, + }, + { + pattern: { + hostname: "EXAMPLE.com", + }, + protocol: null, + hostname: "example.com", + port: null, + pathname: null, + search: null, + }, + { + pattern: { + hostname: "127.0.0.1", + }, + protocol: null, + hostname: "127.0.0.1", + port: null, + pathname: null, + search: null, + }, + { + pattern: { + hostname: "[2001:db8::1]", + }, + protocol: null, + hostname: "[2001:db8::1]", + port: null, + pathname: null, + search: null, + }, + { + pattern: { + port: "80", + }, + protocol: null, + hostname: null, + port: "", + pathname: null, + search: null, + }, + { + pattern: { + port: "1234", + }, + protocol: null, + hostname: null, + port: "1234", + pathname: null, + search: null, + }, + { + pattern: { + pathname: "path/to", + }, + protocol: null, + hostname: null, + port: null, + pathname: "/path/to", + search: null, + }, + { + pattern: { + pathname: "/path/to", + }, + protocol: null, + hostname: null, + port: null, + pathname: "/path/to", + search: null, + }, + { + pattern: { + pathname: "/path/to/", + }, + protocol: null, + hostname: null, + port: null, + pathname: "/path/to/", + search: null, + }, + { + pattern: { + search: "?search", + }, + protocol: null, + hostname: null, + port: null, + pathname: null, + search: "search", + }, + { + pattern: { + search: "search", + }, + protocol: null, + hostname: null, + port: null, + pathname: null, + search: "search", + }, + { + pattern: { + search: "?search=something", + }, + protocol: null, + hostname: null, + port: null, + pathname: null, + search: "search=something", + }, + { + pattern: { + search: "search=something", + }, + protocol: null, + hostname: null, + port: null, + pathname: null, + search: "search=something", + }, + ]; + + for (const test of PATTERN_PATTERN_TESTS) { + const pattern = parseURLPattern({ + type: "pattern", + ...test.pattern, + }); + + equal(pattern.protocol, "protocol" in test ? test.protocol : null); + equal(pattern.hostname, "hostname" in test ? test.hostname : null); + equal(pattern.port, "port" in test ? test.port : null); + equal(pattern.pathname, "pathname" in test ? test.pathname : null); + equal(pattern.search, "search" in test ? test.search : null); + } +}); + +add_task(async function test_parseURLPattern_invalid_type() { + const values = [null, undefined, 1, [], "string"]; + for (const value of values) { + Assert.throws(() => parseURLPattern(value), /InvalidArgumentError/); + } +}); + +add_task(async function test_parseURLPattern_invalid_type_type() { + const values = [null, undefined, 1, {}, []]; + for (const type of values) { + Assert.throws(() => parseURLPattern({ type }), /InvalidArgumentError/); + } +}); + +add_task(async function test_parseURLPattern_invalid_type_value() { + const values = ["", "unknownType"]; + for (const type of values) { + Assert.throws(() => parseURLPattern({ type }), /InvalidArgumentError/); + } +}); + +add_task(async function test_parseURLPattern_invalid_stringPatternType() { + const values = [null, undefined, 1, {}, []]; + for (const pattern of values) { + Assert.throws( + () => parseURLPattern({ type: "string", pattern }), + /InvalidArgumentError/ + ); + } +}); + +add_task(async function test_parseURLPattern_invalid_stringPattern() { + const values = [ + "foo", + "*", + "(", + ")", + "{", + "}", + "http\\{s\\}://example.com", + "https://example.com:port/", + ]; + for (const pattern of values) { + Assert.throws( + () => parseURLPattern({ type: "string", pattern }), + /InvalidArgumentError/ + ); + } +}); + +add_task(async function test_parseURLPattern_invalid_patternPattern_type() { + const properties = ["protocol", "hostname", "port", "pathname", "search"]; + const values = [false, 42, [], {}]; + for (const property of properties) { + for (const value of values) { + Assert.throws( + () => parseURLPattern({ type: "pattern", [property]: value }), + /InvalidArgumentError/ + ); + } + } +}); diff --git a/remote/shared/webdriver/test/xpcshell/xpcshell.toml b/remote/shared/webdriver/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..1cdd1eb47c --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/xpcshell.toml @@ -0,0 +1,20 @@ +[DEFAULT] +head = "head.js" + +["test_Actions.js"] + +["test_Assert.js"] + +["test_Capabilities.js"] + +["test_Errors.js"] + +["test_NodeCache.js"] + +["test_Session.js"] + +["test_URLPattern_invalid.js"] + +["test_URLPattern_matchURLPattern.js"] + +["test_URLPattern_parseURLPattern.js"] |