/* 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, { capture: "chrome://remote/content/shared/Capture.sys.mjs", error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", getSeenNodesForBrowsingContext: "chrome://remote/content/shared/webdriver/Session.sys.mjs", json: "chrome://remote/content/marionette/json.sys.mjs", Log: "chrome://remote/content/shared/Log.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get(lazy.Log.TYPES.MARIONETTE) ); // Because Marionette supports a single session only we store its id // globally so that the parent actor can access it. let webDriverSessionId = null; export class MarionetteCommandsParent extends JSWindowActorParent { #deferredDialogOpened; actorCreated() { this.#deferredDialogOpened = null; } async sendQuery(name, serializedValue) { const seenNodes = lazy.getSeenNodesForBrowsingContext( webDriverSessionId, this.manager.browsingContext ); // return early if a dialog is opened this.#deferredDialogOpened = Promise.withResolvers(); let { error, seenNodeIds, serializedValue: serializedResult, hasSerializedWindows, } = await Promise.race([ super.sendQuery(name, serializedValue), this.#deferredDialogOpened.promise, ]).finally(() => { this.#deferredDialogOpened = null; }); if (error) { const err = lazy.error.WebDriverError.fromJSON(error); this.#handleError(err, seenNodes); } // Update seen nodes for serialized element and shadow root nodes. seenNodeIds?.forEach(nodeId => seenNodes.add(nodeId)); if (hasSerializedWindows) { // The serialized data contains WebWindow references that need to be // converted to unique identifiers. serializedResult = lazy.json.mapToNavigableIds(serializedResult); } return serializedResult; } /** * Handle WebDriver error and replace error type if necessary. * * @param {WebDriverError} error * The WebDriver error to handle. * @param {Set} seenNodes * List of node ids already seen in this navigable. * * @throws {WebDriverError} * The original or replaced WebDriver error. */ #handleError(error, seenNodes) { // If an element hasn't been found during deserialization check if it // may be a stale reference. if ( error instanceof lazy.error.NoSuchElementError && error.data.elementId !== undefined && seenNodes.has(error.data.elementId) ) { throw new lazy.error.StaleElementReferenceError(error); } // If a shadow root hasn't been found during deserialization check if it // may be a detached reference. if ( error instanceof lazy.error.NoSuchShadowRootError && error.data.shadowId !== undefined && seenNodes.has(error.data.shadowId) ) { throw new lazy.error.DetachedShadowRootError(error); } throw error; } notifyDialogOpened() { if (this.#deferredDialogOpened) { this.#deferredDialogOpened.resolve({ data: null }); } } // Proxying methods for WebDriver commands clearElement(webEl) { return this.sendQuery("MarionetteCommandsParent:clearElement", { elem: webEl, }); } clickElement(webEl, capabilities) { return this.sendQuery("MarionetteCommandsParent:clickElement", { elem: webEl, capabilities: capabilities.toJSON(), }); } async executeScript(script, args, opts) { return this.sendQuery("MarionetteCommandsParent:executeScript", { script, args: lazy.json.mapFromNavigableIds(args), opts, }); } findElement(strategy, selector, opts) { return this.sendQuery("MarionetteCommandsParent:findElement", { strategy, selector, opts, }); } findElements(strategy, selector, opts) { return this.sendQuery("MarionetteCommandsParent:findElements", { strategy, selector, opts, }); } async getShadowRoot(webEl) { return this.sendQuery("MarionetteCommandsParent:getShadowRoot", { elem: webEl, }); } async getActiveElement() { return this.sendQuery("MarionetteCommandsParent:getActiveElement"); } async getComputedLabel(webEl) { return this.sendQuery("MarionetteCommandsParent:getComputedLabel", { elem: webEl, }); } async getComputedRole(webEl) { return this.sendQuery("MarionetteCommandsParent:getComputedRole", { elem: webEl, }); } async getElementAttribute(webEl, name) { return this.sendQuery("MarionetteCommandsParent:getElementAttribute", { elem: webEl, name, }); } async getElementProperty(webEl, name) { return this.sendQuery("MarionetteCommandsParent:getElementProperty", { elem: webEl, name, }); } async getElementRect(webEl) { return this.sendQuery("MarionetteCommandsParent:getElementRect", { elem: webEl, }); } async getElementTagName(webEl) { return this.sendQuery("MarionetteCommandsParent:getElementTagName", { elem: webEl, }); } async getElementText(webEl) { return this.sendQuery("MarionetteCommandsParent:getElementText", { elem: webEl, }); } async getElementValueOfCssProperty(webEl, name) { return this.sendQuery( "MarionetteCommandsParent:getElementValueOfCssProperty", { elem: webEl, name, } ); } async getPageSource() { return this.sendQuery("MarionetteCommandsParent:getPageSource"); } async isElementDisplayed(webEl, capabilities) { return this.sendQuery("MarionetteCommandsParent:isElementDisplayed", { capabilities: capabilities.toJSON(), elem: webEl, }); } async isElementEnabled(webEl, capabilities) { return this.sendQuery("MarionetteCommandsParent:isElementEnabled", { capabilities: capabilities.toJSON(), elem: webEl, }); } async isElementSelected(webEl, capabilities) { return this.sendQuery("MarionetteCommandsParent:isElementSelected", { capabilities: capabilities.toJSON(), elem: webEl, }); } async sendKeysToElement(webEl, text, capabilities) { return this.sendQuery("MarionetteCommandsParent:sendKeysToElement", { capabilities: capabilities.toJSON(), elem: webEl, text, }); } async performActions(actions) { return this.sendQuery("MarionetteCommandsParent:performActions", { actions, }); } async releaseActions() { return this.sendQuery("MarionetteCommandsParent:releaseActions"); } async switchToFrame(id) { const { browsingContextId } = await this.sendQuery( "MarionetteCommandsParent:switchToFrame", { id } ); return { browsingContext: BrowsingContext.get(browsingContextId), }; } async switchToParentFrame() { const { browsingContextId } = await this.sendQuery( "MarionetteCommandsParent:switchToParentFrame" ); return { browsingContext: BrowsingContext.get(browsingContextId), }; } async takeScreenshot(webEl, format, full, scroll) { const rect = await this.sendQuery( "MarionetteCommandsParent:getScreenshotRect", { elem: webEl, full, scroll, } ); // If no element has been specified use the top-level browsing context. // Otherwise use the browsing context from the currently selected frame. const browsingContext = webEl ? this.browsingContext : this.browsingContext.top; let canvas = await lazy.capture.canvas( browsingContext.topChromeWindow, browsingContext, rect.x, rect.y, rect.width, rect.height ); switch (format) { case lazy.capture.Format.Hash: return lazy.capture.toHash(canvas); case lazy.capture.Format.Base64: return lazy.capture.toBase64(canvas); default: throw new TypeError(`Invalid capture format: ${format}`); } } } /** * Proxy that will dynamically create MarionetteCommands actors for a dynamically * provided browsing context until the method can be fully executed by the * JSWindowActor pair. * * @param {function(): BrowsingContext} browsingContextFn * A function that returns the reference to the browsing context for which * the query should run. */ export function getMarionetteCommandsActorProxy(browsingContextFn) { const MAX_ATTEMPTS = 10; /** * Methods which modify the content page cannot be retried safely. * See Bug 1673345. */ const NO_RETRY_METHODS = [ "clickElement", "executeScript", "performActions", "releaseActions", "sendKeysToElement", ]; return new Proxy( {}, { get(target, methodName) { return async (...args) => { let attempts = 0; while (true) { try { const browsingContext = browsingContextFn(); if (!browsingContext) { throw new DOMException( "No BrowsingContext found", "NoBrowsingContext" ); } // TODO: Scenarios where the window/tab got closed and // currentWindowGlobal is null will be handled in Bug 1662808. const actor = browsingContext.currentWindowGlobal.getActor( "MarionetteCommands" ); const result = await actor[methodName](...args); return result; } catch (e) { if (!["AbortError", "InactiveActor"].includes(e.name)) { // Only retry when the JSWindowActor pair gets destroyed, or // gets inactive eg. when the page is moved into bfcache. throw e; } if (NO_RETRY_METHODS.includes(methodName)) { const browsingContextId = browsingContextFn()?.id; lazy.logger.trace( `[${browsingContextId}] Querying "${methodName}" failed with` + ` ${e.name}, returning "null" as fallback` ); return null; } if (++attempts > MAX_ATTEMPTS) { const browsingContextId = browsingContextFn()?.id; lazy.logger.trace( `[${browsingContextId}] Querying "${methodName} "` + `reached the limit of retry attempts (${MAX_ATTEMPTS})` ); throw e; } lazy.logger.trace( `Retrying "${methodName}", attempt: ${attempts}` ); } } }; }, } ); } /** * Register the MarionetteCommands actor that holds all the commands. * * @param {string} sessionId * The id of the current WebDriver session. */ export function registerCommandsActor(sessionId) { try { ChromeUtils.registerWindowActor("MarionetteCommands", { kind: "JSWindowActor", parent: { esModuleURI: "chrome://remote/content/marionette/actors/MarionetteCommandsParent.sys.mjs", }, child: { esModuleURI: "chrome://remote/content/marionette/actors/MarionetteCommandsChild.sys.mjs", }, allFrames: true, includeChrome: true, }); } catch (e) { if (e.name === "NotSupportedError") { lazy.logger.warn(`MarionetteCommands actor is already registered!`); } else { throw e; } } webDriverSessionId = sessionId; } export function unregisterCommandsActor() { webDriverSessionId = null; ChromeUtils.unregisterWindowActor("MarionetteCommands"); }