diff options
Diffstat (limited to '')
6 files changed, 1468 insertions, 0 deletions
diff --git a/remote/marionette/actors/MarionetteCommandsChild.sys.mjs b/remote/marionette/actors/MarionetteCommandsChild.sys.mjs new file mode 100644 index 0000000000..e805d6f9ef --- /dev/null +++ b/remote/marionette/actors/MarionetteCommandsChild.sys.mjs @@ -0,0 +1,579 @@ +/* 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-disable no-restricted-globals */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + action: "chrome://remote/content/marionette/action.sys.mjs", + atom: "chrome://remote/content/marionette/atom.sys.mjs", + element: "chrome://remote/content/marionette/element.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + evaluate: "chrome://remote/content/marionette/evaluate.sys.mjs", + event: "chrome://remote/content/marionette/event.sys.mjs", + interaction: "chrome://remote/content/marionette/interaction.sys.mjs", + json: "chrome://remote/content/marionette/json.sys.mjs", + legacyaction: "chrome://remote/content/marionette/legacyaction.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + sandbox: "chrome://remote/content/marionette/evaluate.sys.mjs", + Sandboxes: "chrome://remote/content/marionette/evaluate.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +export class MarionetteCommandsChild extends JSWindowActorChild { + #processActor; + + constructor() { + super(); + + this.#processActor = ChromeUtils.domProcessChild.getActor( + "WebDriverProcessData" + ); + + // sandbox storage and name of the current sandbox + this.sandboxes = new lazy.Sandboxes(() => this.document.defaultView); + // State of the input actions. This is specific to contexts and sessions + this.actionState = null; + } + + get innerWindowId() { + return this.manager.innerWindowId; + } + + /** + * Lazy getter to create a legacyaction Chain instance for touch events. + */ + get legacyactions() { + if (!this._legacyactions) { + this._legacyactions = new lazy.legacyaction.Chain(); + } + + return this._legacyactions; + } + + actorCreated() { + lazy.logger.trace( + `[${this.browsingContext.id}] MarionetteCommands actor created ` + + `for window id ${this.innerWindowId}` + ); + } + + didDestroy() { + lazy.logger.trace( + `[${this.browsingContext.id}] MarionetteCommands actor destroyed ` + + `for window id ${this.innerWindowId}` + ); + } + + async receiveMessage(msg) { + if (!this.contentWindow) { + throw new DOMException("Actor is no longer active", "InactiveActor"); + } + + try { + let result; + let waitForNextTick = false; + + const { name, data: serializedData } = msg; + const data = lazy.json.deserialize( + serializedData, + this.#processActor.getNodeCache(), + this.contentWindow + ); + + switch (name) { + case "MarionetteCommandsParent:clearElement": + this.clearElement(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:clickElement": + result = await this.clickElement(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:executeScript": + result = await this.executeScript(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:findElement": + result = await this.findElement(data); + break; + case "MarionetteCommandsParent:findElements": + result = await this.findElements(data); + break; + case "MarionetteCommandsParent:getActiveElement": + result = await this.getActiveElement(); + break; + case "MarionetteCommandsParent:getElementAttribute": + result = await this.getElementAttribute(data); + break; + case "MarionetteCommandsParent:getElementProperty": + result = await this.getElementProperty(data); + break; + case "MarionetteCommandsParent:getElementRect": + result = await this.getElementRect(data); + break; + case "MarionetteCommandsParent:getElementTagName": + result = await this.getElementTagName(data); + break; + case "MarionetteCommandsParent:getElementText": + result = await this.getElementText(data); + break; + case "MarionetteCommandsParent:getElementValueOfCssProperty": + result = await this.getElementValueOfCssProperty(data); + break; + case "MarionetteCommandsParent:getPageSource": + result = await this.getPageSource(); + break; + case "MarionetteCommandsParent:getScreenshotRect": + result = await this.getScreenshotRect(data); + break; + case "MarionetteCommandsParent:getShadowRoot": + result = await this.getShadowRoot(data); + break; + case "MarionetteCommandsParent:isElementDisplayed": + result = await this.isElementDisplayed(data); + break; + case "MarionetteCommandsParent:isElementEnabled": + result = await this.isElementEnabled(data); + break; + case "MarionetteCommandsParent:isElementSelected": + result = await this.isElementSelected(data); + break; + case "MarionetteCommandsParent:performActions": + result = await this.performActions(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:releaseActions": + result = await this.releaseActions(); + break; + case "MarionetteCommandsParent:sendKeysToElement": + result = await this.sendKeysToElement(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:singleTap": + result = await this.singleTap(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:switchToFrame": + result = await this.switchToFrame(data); + waitForNextTick = true; + break; + case "MarionetteCommandsParent:switchToParentFrame": + result = await this.switchToParentFrame(); + waitForNextTick = true; + break; + } + + // Inform the content process that the command has completed. It allows + // it to process async follow-up tasks before the reply is sent. + if (waitForNextTick) { + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + } + + return { + data: lazy.json.clone(result, this.#processActor.getNodeCache()), + }; + } catch (e) { + // Always wrap errors as WebDriverError + return { error: lazy.error.wrap(e).toJSON() }; + } + } + + // Implementation of WebDriver commands + + /** Clear the text of an element. + * + * @param {Object} options + * @param {Element} options.elem + */ + clearElement(options = {}) { + const { elem } = options; + + lazy.interaction.clearElement(elem); + } + + /** + * Click an element. + */ + async clickElement(options = {}) { + const { capabilities, elem } = options; + + return lazy.interaction.clickElement( + elem, + capabilities["moz:accessibilityChecks"], + capabilities["moz:webdriverClick"] + ); + } + + /** + * Executes a JavaScript function. + */ + async executeScript(options = {}) { + const { args, opts = {}, script } = options; + + let sb; + if (opts.sandboxName) { + sb = this.sandboxes.get(opts.sandboxName, opts.newSandbox); + } else { + sb = lazy.sandbox.createMutable(this.document.defaultView); + } + + return lazy.evaluate.sandbox(sb, script, args, opts); + } + + /** + * Find an element in the current browsing context's document using the + * given search strategy. + * + * @param {Object} options + * @param {Object} options.opts + * @param {Element} opts.startNode + * @param {string} opts.strategy + * @param {string} opts.selector + * + */ + async findElement(options = {}) { + const { strategy, selector, opts } = options; + + opts.all = false; + + const container = { frame: this.document.defaultView }; + return lazy.element.find(container, strategy, selector, opts); + } + + /** + * Find elements in the current browsing context's document using the + * given search strategy. + * + * @param {Object} options + * @param {Object} options.opts + * @param {Element} opts.startNode + * @param {string} opts.strategy + * @param {string} opts.selector + * + */ + async findElements(options = {}) { + const { strategy, selector, opts } = options; + + opts.all = true; + + const container = { frame: this.document.defaultView }; + return lazy.element.find(container, strategy, selector, opts); + } + + /** + * Return the active element in the document. + */ + async getActiveElement() { + let elem = this.document.activeElement; + if (!elem) { + throw new lazy.error.NoSuchElementError(); + } + + return elem; + } + + /** + * Get the value of an attribute for the given element. + */ + async getElementAttribute(options = {}) { + const { name, elem } = options; + + if (lazy.element.isBooleanAttribute(elem, name)) { + if (elem.hasAttribute(name)) { + return "true"; + } + return null; + } + return elem.getAttribute(name); + } + + /** + * Get the value of a property for the given element. + */ + async getElementProperty(options = {}) { + const { name, elem } = options; + + // Waive Xrays to get unfiltered access to the untrusted element. + const el = Cu.waiveXrays(elem); + return typeof el[name] != "undefined" ? el[name] : null; + } + + /** + * Get the position and dimensions of the element. + */ + async getElementRect(options = {}) { + const { elem } = options; + + const rect = elem.getBoundingClientRect(); + return { + x: rect.x + this.document.defaultView.pageXOffset, + y: rect.y + this.document.defaultView.pageYOffset, + width: rect.width, + height: rect.height, + }; + } + + /** + * Get the tagName for the given element. + */ + async getElementTagName(options = {}) { + const { elem } = options; + + return elem.tagName.toLowerCase(); + } + + /** + * Get the text content for the given element. + */ + async getElementText(options = {}) { + const { elem } = options; + + try { + return lazy.atom.getElementText(elem, this.document.defaultView); + } catch (e) { + lazy.logger.warn(`Atom getElementText failed: "${e.message}"`); + + // Fallback in case the atom implementation is broken. + // As known so far this only happens for XML documents (bug 1794099). + return elem.textContent; + } + } + + /** + * Get the value of a css property for the given element. + */ + async getElementValueOfCssProperty(options = {}) { + const { name, elem } = options; + + const style = this.document.defaultView.getComputedStyle(elem); + return style.getPropertyValue(name); + } + + /** + * Get the source of the current browsing context's document. + */ + async getPageSource() { + return this.document.documentElement.outerHTML; + } + + /** + * Returns the rect of the element to screenshot. + * + * Because the screen capture takes place in the parent process the dimensions + * for the screenshot have to be determined in the appropriate child process. + * + * Also it takes care of scrolling an element into view if requested. + * + * @param {Object} options + * @param {Element} options.elem + * Optional element to take a screenshot of. + * @param {boolean=} options.full + * True to take a screenshot of the entire document element. + * Defaults to true. + * @param {boolean=} options.scroll + * When <var>elem</var> is given, scroll it into view. + * Defaults to true. + * + * @return {DOMRect} + * The area to take a snapshot from. + */ + async getScreenshotRect(options = {}) { + const { elem, full = true, scroll = true } = options; + const win = elem + ? this.document.defaultView + : this.browsingContext.top.window; + + let rect; + + if (elem) { + if (scroll) { + lazy.element.scrollIntoView(elem); + } + rect = this.getElementRect({ elem }); + } else if (full) { + const docEl = win.document.documentElement; + rect = new DOMRect(0, 0, docEl.scrollWidth, docEl.scrollHeight); + } else { + // viewport + rect = new DOMRect( + win.pageXOffset, + win.pageYOffset, + win.innerWidth, + win.innerHeight + ); + } + + return rect; + } + + /** + * Return the shadowRoot attached to an element + */ + async getShadowRoot(options = {}) { + const { elem } = options; + + return lazy.element.getShadowRoot(elem); + } + + /** + * Determine the element displayedness of the given web element. + */ + async isElementDisplayed(options = {}) { + const { capabilities, elem } = options; + + return lazy.interaction.isElementDisplayed( + elem, + capabilities["moz:accessibilityChecks"] + ); + } + + /** + * Check if element is enabled. + */ + async isElementEnabled(options = {}) { + const { capabilities, elem } = options; + + return lazy.interaction.isElementEnabled( + elem, + capabilities["moz:accessibilityChecks"] + ); + } + + /** + * Determine whether the referenced element is selected or not. + */ + async isElementSelected(options = {}) { + const { capabilities, elem } = options; + + return lazy.interaction.isElementSelected( + elem, + capabilities["moz:accessibilityChecks"] + ); + } + + /** + * Perform a series of grouped actions at the specified points in time. + * + * @param {Object} options + * @param {Object} options.actions + * Array of objects with each representing an action sequence. + * @param {Object} options.capabilities + * Object with a list of WebDriver session capabilities. + */ + async performActions(options = {}) { + const { actions, capabilities } = options; + if (this.actionState === null) { + this.actionState = new lazy.action.State({ + specCompatPointerOrigin: !capabilities[ + "moz:useNonSpecCompliantPointerOrigin" + ], + }); + } + let actionChain = lazy.action.Chain.fromJSON(this.actionState, actions); + + await actionChain.dispatch(this.actionState, this.document.defaultView); + } + + /** + * The release actions command is used to release all the keys and pointer + * buttons that are currently depressed. This causes events to be fired + * as if the state was released by an explicit series of actions. It also + * clears all the internal state of the virtual devices. + */ + async releaseActions() { + if (this.actionState === null) { + return; + } + this.actionState.inputsToCancel.reverse(); + await this.actionState.inputsToCancel.dispatch( + this.actionState, + this.document.defaultView + ); + this.actionState = null; + lazy.event.DoubleClickTracker.resetClick(); + } + + /* + * Send key presses to element after focusing on it. + */ + async sendKeysToElement(options = {}) { + const { capabilities, elem, text } = options; + + const opts = { + strictFileInteractability: capabilities.strictFileInteractability, + accessibilityChecks: capabilities["moz:accessibilityChecks"], + webdriverClick: capabilities["moz:webdriverClick"], + }; + + return lazy.interaction.sendKeysToElement(elem, text, opts); + } + + /** + * Perform a single tap. + */ + async singleTap(options = {}) { + const { capabilities, elem, x, y } = options; + return this.legacyactions.singleTap(elem, x, y, capabilities); + } + + /** + * Switch to the specified frame. + * + * @param {Object=} options + * @param {(number|Element)=} options.id + * If it's a number treat it as the index for all the existing frames. + * If it's an Element switch to this specific frame. + * If not specified or `null` switch to the top-level browsing context. + */ + async switchToFrame(options = {}) { + const { id } = options; + + const childContexts = this.browsingContext.children; + let browsingContext; + + if (id == null) { + browsingContext = this.browsingContext.top; + } else if (typeof id == "number") { + if (id < 0 || id >= childContexts.length) { + throw new lazy.error.NoSuchFrameError( + `Unable to locate frame with index: ${id}` + ); + } + browsingContext = childContexts[id]; + } else { + const context = childContexts.find(context => { + return context.embedderElement === id; + }); + if (!context) { + throw new lazy.error.NoSuchFrameError( + `Unable to locate frame for element: ${id}` + ); + } + browsingContext = context; + } + + // For in-process iframes the window global is lazy-loaded for optimization + // reasons. As such force the currentWindowGlobal to be created so we always + // have a window (bug 1691348). + browsingContext.window; + + return { browsingContextId: browsingContext.id }; + } + + /** + * Switch to the parent frame. + */ + async switchToParentFrame() { + const browsingContext = this.browsingContext.parent || this.browsingContext; + + return { browsingContextId: browsingContext.id }; + } +} diff --git a/remote/marionette/actors/MarionetteCommandsParent.sys.mjs b/remote/marionette/actors/MarionetteCommandsParent.sys.mjs new file mode 100644 index 0000000000..fed8a4e7e4 --- /dev/null +++ b/remote/marionette/actors/MarionetteCommandsParent.sys.mjs @@ -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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + capture: "chrome://remote/content/shared/Capture.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +export class MarionetteCommandsParent extends JSWindowActorParent { + actorCreated() { + this._resolveDialogOpened = null; + } + + dialogOpenedPromise() { + return new Promise(resolve => { + this._resolveDialogOpened = resolve; + }); + } + + async sendQuery(name, data) { + // return early if a dialog is opened + const result = await Promise.race([ + super.sendQuery(name, data), + this.dialogOpenedPromise(), + ]).finally(() => { + this._resolveDialogOpened = null; + }); + + if ("error" in result) { + throw lazy.error.WebDriverError.fromJSON(result.error); + } else { + return result.data; + } + } + + notifyDialogOpened() { + if (this._resolveDialogOpened) { + this._resolveDialogOpened({ 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, + 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 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, capabilities) { + return this.sendQuery("MarionetteCommandsParent:performActions", { + actions, + capabilities: capabilities.toJSON(), + }); + } + + async releaseActions() { + return this.sendQuery("MarionetteCommandsParent:releaseActions"); + } + + async singleTap(webEl, x, y, capabilities) { + return this.sendQuery("MarionetteCommandsParent:singleTap", { + capabilities: capabilities.toJSON(), + elem: webEl, + x, + y, + }); + } + + 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", + "singleTap", + ]; + + 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. + */ +export function registerCommandsActor() { + 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; + } + } +} + +export function unregisterCommandsActor() { + ChromeUtils.unregisterWindowActor("MarionetteCommands"); +} diff --git a/remote/marionette/actors/MarionetteEventsChild.sys.mjs b/remote/marionette/actors/MarionetteEventsChild.sys.mjs new file mode 100644 index 0000000000..2cf5afac65 --- /dev/null +++ b/remote/marionette/actors/MarionetteEventsChild.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/. */ + +/* eslint-disable no-restricted-globals */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + event: "chrome://remote/content/marionette/event.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +export class MarionetteEventsChild extends JSWindowActorChild { + get innerWindowId() { + return this.manager.innerWindowId; + } + + actorCreated() { + // Prevent the logger from being created if the current log level + // isn't set to 'trace'. This is important for a faster content process + // creation when Marionette is running. + if (lazy.Log.isTraceLevel) { + lazy.logger.trace( + `[${this.browsingContext.id}] MarionetteEvents actor created ` + + `for window id ${this.innerWindowId}` + ); + } + } + + handleEvent({ target, type }) { + if (!Services.cpmm.sharedData.get("MARIONETTE_EVENTS_ENABLED")) { + // The parent process will set MARIONETTE_EVENTS_ENABLED to false when + // the Marionette session ends to avoid unnecessary inter process + // communications + return; + } + + // Ignore invalid combinations of load events and document's readyState. + if ( + (type === "DOMContentLoaded" && target.readyState != "interactive") || + (type === "pageshow" && target.readyState != "complete") + ) { + lazy.logger.warn( + `Ignoring event '${type}' because document has an invalid ` + + `readyState of '${target.readyState}'.` + ); + return; + } + + switch (type) { + case "beforeunload": + case "DOMContentLoaded": + case "hashchange": + case "pagehide": + case "pageshow": + case "popstate": + this.sendAsyncMessage("MarionetteEventsChild:PageLoadEvent", { + browsingContext: this.browsingContext, + documentURI: target.documentURI, + readyState: target.readyState, + type, + windowId: this.innerWindowId, + }); + break; + + // Listen for click event to indicate one click has happened, so actions + // code can send dblclick event + case "click": + lazy.event.DoubleClickTracker.setClick(); + break; + case "dblclick": + case "unload": + lazy.event.DoubleClickTracker.resetClick(); + break; + } + } +} diff --git a/remote/marionette/actors/MarionetteEventsParent.sys.mjs b/remote/marionette/actors/MarionetteEventsParent.sys.mjs new file mode 100644 index 0000000000..4211f99e59 --- /dev/null +++ b/remote/marionette/actors/MarionetteEventsParent.sys.mjs @@ -0,0 +1,115 @@ +/* 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, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +// Singleton to allow forwarding events to registered listeners. +export const EventDispatcher = { + init() { + lazy.EventEmitter.decorate(this); + }, +}; + +EventDispatcher.init(); + +export class MarionetteEventsParent extends JSWindowActorParent { + async receiveMessage(msg) { + const { name, data } = msg; + + let rv; + switch (name) { + case "MarionetteEventsChild:PageLoadEvent": + EventDispatcher.emit("page-load", data); + break; + } + + return rv; + } +} + +// Flag to check if the MarionetteEvents actors have already been registed. +let eventsActorRegistered = false; + +/** + * Register Events actors to listen for page load events via EventDispatcher. + */ +function registerEventsActor() { + if (eventsActorRegistered) { + return; + } + + try { + // Register the JSWindowActor pair for events as used by Marionette + ChromeUtils.registerWindowActor("MarionetteEvents", { + kind: "JSWindowActor", + parent: { + esModuleURI: + "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs", + }, + child: { + esModuleURI: + "chrome://remote/content/marionette/actors/MarionetteEventsChild.sys.mjs", + events: { + beforeunload: { capture: true }, + DOMContentLoaded: { mozSystemGroup: true }, + hashchange: { mozSystemGroup: true }, + pagehide: { mozSystemGroup: true }, + pageshow: { mozSystemGroup: true }, + // popstate doesn't bubble, as such use capturing phase + popstate: { capture: true, mozSystemGroup: true }, + + click: {}, + dblclick: {}, + unload: { capture: true, createActor: false }, + }, + }, + + allFrames: true, + includeChrome: true, + }); + + eventsActorRegistered = true; + } catch (e) { + if (e.name === "NotSupportedError") { + lazy.logger.warn(`MarionetteEvents actor is already registered!`); + } else { + throw e; + } + } +} + +/** + * Enable MarionetteEvents actors to start forwarding page load events from the + * child actor to the parent actor. Register the MarionetteEvents actor if necessary. + */ +export function enableEventsActor() { + // sharedData is replicated across processes and will be checked by + // MarionetteEventsChild before forward events to the parent actor. + Services.ppmm.sharedData.set("MARIONETTE_EVENTS_ENABLED", true); + // Request to immediately flush the data to the content processes to avoid races. + Services.ppmm.sharedData.flush(); + + registerEventsActor(); +} + +/** + * Disable MarionetteEvents actors to stop forwarding page load events from the + * child actor to the parent actor. + */ +export function disableEventsActor() { + Services.ppmm.sharedData.set("MARIONETTE_EVENTS_ENABLED", false); + Services.ppmm.sharedData.flush(); +} diff --git a/remote/marionette/actors/MarionetteReftestChild.sys.mjs b/remote/marionette/actors/MarionetteReftestChild.sys.mjs new file mode 100644 index 0000000000..e1a9918af2 --- /dev/null +++ b/remote/marionette/actors/MarionetteReftestChild.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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +/** + * Child JSWindowActor to handle navigation for reftests relying on marionette. + */ +export class MarionetteReftestChild extends JSWindowActorChild { + constructor() { + super(); + + // This promise will resolve with the URL recorded in the "load" event + // handler. This URL will not be impacted by any hash modification that + // might be performed by the test script. + // The harness should be loaded before loading any test page, so the actors + // should be registered before the "load" event is received for a test page. + this._loadedURLPromise = new Promise( + r => (this._resolveLoadedURLPromise = r) + ); + } + + handleEvent(event) { + if (event.type == "load") { + const url = event.target.location.href; + lazy.logger.debug(`Handle load event with URL ${url}`); + this._resolveLoadedURLPromise(url); + } + } + + actorCreated() { + lazy.logger.trace( + `[${this.browsingContext.id}] Reftest actor created ` + + `for window id ${this.manager.innerWindowId}` + ); + } + + async receiveMessage(msg) { + const { name, data } = msg; + + let result; + switch (name) { + case "MarionetteReftestParent:flushRendering": + result = await this.flushRendering(data); + break; + case "MarionetteReftestParent:reftestWait": + result = await this.reftestWait(data); + break; + } + return result; + } + + /** + * Wait for a reftest page to be ready for screenshots: + * - wait for the loadedURL to be available (see handleEvent) + * - check if the URL matches the expected URL + * - if present, wait for the "reftest-wait" classname to be removed from the + * document element + * + * @param {Object} options + * @param {String} options.url + * The expected test page URL + * @param {Boolean} options.useRemote + * True when using e10s + * @return {Boolean} + * Returns true when the correct page is loaded and ready for + * screenshots. Returns false if the page loaded bug does not have the + * expected URL. + */ + async reftestWait(options = {}) { + const { url, useRemote } = options; + const loadedURL = await this._loadedURLPromise; + if (loadedURL !== url) { + lazy.logger.debug( + `Window URL does not match the expected URL "${loadedURL}" !== "${url}"` + ); + return false; + } + + const documentElement = this.document.documentElement; + const hasReftestWait = documentElement.classList.contains("reftest-wait"); + + lazy.logger.debug("Waiting for event loop to spin"); + await new Promise(resolve => + this.document.defaultView.setTimeout(resolve, 0) + ); + + await this.paintComplete({ useRemote, ignoreThrottledAnimations: true }); + + if (hasReftestWait) { + const event = new this.document.defaultView.Event("TestRendered", { + bubbles: true, + }); + documentElement.dispatchEvent(event); + lazy.logger.info("Emitted TestRendered event"); + await this.reftestWaitRemoved(); + await this.paintComplete({ useRemote, ignoreThrottledAnimations: false }); + } + if ( + this.document.defaultView.innerWidth < documentElement.scrollWidth || + this.document.defaultView.innerHeight < documentElement.scrollHeight + ) { + lazy.logger.warn( + `${url} overflows viewport (width: ${documentElement.scrollWidth}, height: ${documentElement.scrollHeight})` + ); + } + return true; + } + + paintComplete({ useRemote, ignoreThrottledAnimations }) { + lazy.logger.debug("Waiting for rendering"); + let windowUtils = this.document.defaultView.windowUtils; + return new Promise(resolve => { + let maybeResolve = () => { + this.flushRendering({ ignoreThrottledAnimations }); + if (useRemote) { + // Flush display (paint) + lazy.logger.debug("Force update of layer tree"); + windowUtils.updateLayerTree(); + } + + if (windowUtils.isMozAfterPaintPending) { + lazy.logger.debug("isMozAfterPaintPending: true"); + this.document.defaultView.addEventListener( + "MozAfterPaint", + maybeResolve, + { + once: true, + } + ); + } else { + // resolve at the start of the next frame in case of leftover paints + lazy.logger.debug("isMozAfterPaintPending: false"); + this.document.defaultView.requestAnimationFrame(() => { + this.document.defaultView.requestAnimationFrame(resolve); + }); + } + }; + maybeResolve(); + }); + } + + reftestWaitRemoved() { + lazy.logger.debug("Waiting for reftest-wait removal"); + return new Promise(resolve => { + const documentElement = this.document.documentElement; + let observer = new this.document.defaultView.MutationObserver(() => { + if (!documentElement.classList.contains("reftest-wait")) { + observer.disconnect(); + lazy.logger.debug("reftest-wait removed"); + this.document.defaultView.setTimeout(resolve, 0); + } + }); + if (documentElement.classList.contains("reftest-wait")) { + observer.observe(documentElement, { attributes: true }); + } else { + this.document.defaultView.setTimeout(resolve, 0); + } + }); + } + + /** + * Ensure layout is flushed in each frame + * + * @param {Object} options + * @param {Boolean} options.ignoreThrottledAnimations Don't flush + * the layout of throttled animations. We can end up in a + * situation where flushing a throttled animation causes + * mozAfterPaint events even when all rendering we care about + * should have ceased. See + * https://searchfox.org/mozilla-central/rev/d58860eb739af613774c942c3bb61754123e449b/layout/tools/reftest/reftest-content.js#723-729 + * for more detail. + */ + flushRendering(options = {}) { + let { ignoreThrottledAnimations } = options; + lazy.logger.debug( + `flushRendering ignoreThrottledAnimations:${ignoreThrottledAnimations}` + ); + let anyPendingPaintsGeneratedInDescendants = false; + + let windowUtils = this.document.defaultView.windowUtils; + + function flushWindow(win) { + let utils = win.windowUtils; + let afterPaintWasPending = utils.isMozAfterPaintPending; + + let root = win.document.documentElement; + if (root) { + try { + if (ignoreThrottledAnimations) { + utils.flushLayoutWithoutThrottledAnimations(); + } else { + root.getBoundingClientRect(); + } + } catch (e) { + lazy.logger.error("flushWindow failed", e); + } + } + + if (!afterPaintWasPending && utils.isMozAfterPaintPending) { + anyPendingPaintsGeneratedInDescendants = true; + } + + for (let i = 0; i < win.frames.length; ++i) { + // Skip remote frames, flushRendering will be called on their individual + // MarionetteReftest actor via _recursiveFlushRendering performed from + // the topmost MarionetteReftest actor. + if (!Cu.isRemoteProxy(win.frames[i])) { + flushWindow(win.frames[i]); + } + } + } + flushWindow(this.document.defaultView); + + if ( + anyPendingPaintsGeneratedInDescendants && + !windowUtils.isMozAfterPaintPending + ) { + lazy.logger.error( + "Descendant frame generated a MozAfterPaint event, " + + "but the root document doesn't have one!" + ); + } + } +} diff --git a/remote/marionette/actors/MarionetteReftestParent.sys.mjs b/remote/marionette/actors/MarionetteReftestParent.sys.mjs new file mode 100644 index 0000000000..f6d79f04d3 --- /dev/null +++ b/remote/marionette/actors/MarionetteReftestParent.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/. */ + +/** + * Parent JSWindowActor to handle navigation for reftests relying on marionette. + */ +export class MarionetteReftestParent extends JSWindowActorParent { + /** + * Wait for the expected URL to be loaded. + * + * @param {String} url + * The expected url. + * @param {Boolean} useRemote + * True if tests are running with e10s. + * @return {Boolean} true if the page is fully loaded with the expected url, + * false otherwise. + */ + async reftestWait(url, useRemote) { + try { + const isCorrectUrl = await this.sendQuery( + "MarionetteReftestParent:reftestWait", + { + url, + useRemote, + } + ); + + if (isCorrectUrl) { + // Trigger flush rendering for all remote frames. + await this._flushRenderingInSubtree({ + ignoreThrottledAnimations: false, + }); + } + + return isCorrectUrl; + } catch (e) { + if (e.name === "AbortError") { + // If the query is aborted, the window global is being destroyed, most + // likely because a navigation happened. + return false; + } + + // Other errors should not be swallowed. + throw e; + } + } + + /** + * Call flushRendering on all browsing contexts in the subtree. + * Each actor will flush rendering in all the same process frames. + */ + async _flushRenderingInSubtree({ ignoreThrottledAnimations }) { + const browsingContext = this.manager.browsingContext; + const contexts = browsingContext.getAllBrowsingContextsInSubtree(); + + await Promise.all( + contexts.map(async context => { + if (context === browsingContext) { + // Skip the top browsing context, for which flushRendering is + // already performed via the initial reftestWait call. + return; + } + + const windowGlobal = context.currentWindowGlobal; + if (!windowGlobal) { + // Bail out if there is no window attached to the current context. + return; + } + + if (!windowGlobal.isProcessRoot) { + // Bail out if this window global is not a process root. + // MarionetteReftestChild::flushRendering will flush all same process + // frames, so we only need to call flushRendering on process roots. + return; + } + + const reftestActor = windowGlobal.getActor("MarionetteReftest"); + await reftestActor.sendQuery("MarionetteReftestParent:flushRendering", { + ignoreThrottledAnimations, + }); + }) + ); + } +} |