diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /testing/marionette/actors | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/marionette/actors')
-rw-r--r-- | testing/marionette/actors/MarionetteCommandsChild.jsm | 546 | ||||
-rw-r--r-- | testing/marionette/actors/MarionetteCommandsParent.jsm | 396 | ||||
-rw-r--r-- | testing/marionette/actors/MarionetteEventsChild.jsm | 74 | ||||
-rw-r--r-- | testing/marionette/actors/MarionetteEventsParent.jsm | 92 | ||||
-rw-r--r-- | testing/marionette/actors/MarionetteReftestChild.jsm | 209 | ||||
-rw-r--r-- | testing/marionette/actors/MarionetteReftestParent.jsm | 44 |
6 files changed, 1361 insertions, 0 deletions
diff --git a/testing/marionette/actors/MarionetteCommandsChild.jsm b/testing/marionette/actors/MarionetteCommandsChild.jsm new file mode 100644 index 0000000000..9f04fec837 --- /dev/null +++ b/testing/marionette/actors/MarionetteCommandsChild.jsm @@ -0,0 +1,546 @@ +/* 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 */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["MarionetteCommandsChild", "clearActionInputState"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + action: "chrome://marionette/content/action.js", + atom: "chrome://marionette/content/atom.js", + element: "chrome://marionette/content/element.js", + error: "chrome://marionette/content/error.js", + evaluate: "chrome://marionette/content/evaluate.js", + event: "chrome://marionette/content/event.js", + interaction: "chrome://marionette/content/interaction.js", + legacyaction: "chrome://marionette/content/legacyaction.js", + Log: "chrome://marionette/content/log.js", + sandbox: "chrome://marionette/content/evaluate.js", + Sandboxes: "chrome://marionette/content/evaluate.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +let inputStateIsDirty = false; + +class MarionetteCommandsChild extends JSWindowActorChild { + constructor() { + super(); + + // sandbox storage and name of the current sandbox + this.sandboxes = new Sandboxes(() => this.document.defaultView); + } + + 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 legacyaction.Chain(); + } + + return this._legacyactions; + } + + actorCreated() { + logger.trace( + `[${this.browsingContext.id}] MarionetteCommands actor created ` + + `for window id ${this.innerWindowId}` + ); + + clearActionInputState(); + } + + async receiveMessage(msg) { + if (!this.contentWindow) { + throw new DOMException("Actor is no longer active", "InactiveActor"); + } + + try { + let result; + + const { name, data: serializedData } = msg; + const data = evaluate.fromJSON( + serializedData, + null, + this.document.defaultView + ); + + switch (name) { + case "MarionetteCommandsParent:clearElement": + this.clearElement(data); + break; + case "MarionetteCommandsParent:clickElement": + result = await this.clickElement(data); + break; + case "MarionetteCommandsParent:executeScript": + result = await this.executeScript(data); + break; + case "MarionetteCommandsParent:findElement": + result = await this.findElement(data); + break; + case "MarionetteCommandsParent:findElements": + result = await this.findElements(data); + break; + case "MarionetteCommandsParent:getCurrentUrl": + result = await this.getCurrentUrl(); + 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: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); + break; + case "MarionetteCommandsParent:releaseActions": + result = await this.releaseActions(); + break; + case "MarionetteCommandsParent:sendKeysToElement": + result = await this.sendKeysToElement(data); + break; + case "MarionetteCommandsParent:singleTap": + result = await this.singleTap(data); + break; + case "MarionetteCommandsParent:switchToFrame": + result = await this.switchToFrame(data); + break; + case "MarionetteCommandsParent:switchToParentFrame": + result = await this.switchToParentFrame(); + break; + } + + // The element reference store lives in the parent process. Calling + // toJSON() without a second argument here passes element reference ids + // of DOM nodes to the parent frame. + return { data: evaluate.toJSON(result) }; + } catch (e) { + // Always wrap errors as WebDriverError + return { error: 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; + + interaction.clearElement(elem); + } + + /** + * Click an element. + */ + async clickElement(options = {}) { + const { capabilities, elem } = options; + + return 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 = sandbox.createMutable(this.document.defaultView); + } + + return 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 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 element.find(container, strategy, selector, opts); + } + + /** + * Return the active element in the document. + */ + async getActiveElement() { + let elem = this.document.activeElement; + if (!elem) { + throw new error.NoSuchElementError(); + } + + return elem; + } + + /** + * Get the current URL. + */ + async getCurrentUrl() { + return this.document.defaultView.location.href; + } + + /** + * Get the value of an attribute for the given element. + */ + async getElementAttribute(options = {}) { + const { name, elem } = options; + + if (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; + + return typeof elem[name] != "undefined" ? elem[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; + + return atom.getElementText(elem, this.document.defaultView); + } + + /** + * 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) { + 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; + } + + /** + * Determine the element displayedness of the given web element. + */ + async isElementDisplayed(options = {}) { + const { capabilities, elem } = options; + + return interaction.isElementDisplayed( + elem, + capabilities["moz:accessibilityChecks"] + ); + } + + /** + * Check if element is enabled. + */ + async isElementEnabled(options = {}) { + const { capabilities, elem } = options; + + return interaction.isElementEnabled( + elem, + capabilities["moz:accessibilityChecks"] + ); + } + + /** + * Determine whether the referenced element is selected or not. + */ + async isElementSelected(options = {}) { + const { capabilities, elem } = options; + + return 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; + + await action.dispatch( + action.Chain.fromJSON(actions), + this.document.defaultView, + !capabilities["moz:useNonSpecCompliantPointerOrigin"] + ); + inputStateIsDirty = + action.inputsToCancel.length || action.inputStateMap.size; + } + + /** + * 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() { + await action.dispatchTickActions( + action.inputsToCancel.reverse(), + 0, + this.document.defaultView + ); + clearActionInputState(); + + 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 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 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 error.NoSuchFrameError( + `Unable to locate frame for element: ${id}` + ); + } + browsingContext = context; + } + + return { browsingContextId: browsingContext.id }; + } + + /** + * Switch to the parent frame. + */ + async switchToParentFrame() { + const browsingContext = this.browsingContext.parent || this.browsingContext; + + return { browsingContextId: browsingContext.id }; + } +} + +/** + * Reset Action API input state + */ +function clearActionInputState() { + // Avoid loading the action module before it is needed by a command + if (inputStateIsDirty) { + action.inputStateMap.clear(); + action.inputsToCancel.length = 0; + inputStateIsDirty = false; + } +} diff --git a/testing/marionette/actors/MarionetteCommandsParent.jsm b/testing/marionette/actors/MarionetteCommandsParent.jsm new file mode 100644 index 0000000000..f7c1990ef5 --- /dev/null +++ b/testing/marionette/actors/MarionetteCommandsParent.jsm @@ -0,0 +1,396 @@ +/* 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 EXPORTED_SYMBOLS = [ + "clearElementIdCache", + "getMarionetteCommandsActorProxy", + "MarionetteCommandsParent", + "registerCommandsActor", + "unregisterCommandsActor", +]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + capture: "chrome://marionette/content/capture.js", + element: "chrome://marionette/content/element.js", + error: "chrome://marionette/content/error.js", + evaluate: "chrome://marionette/content/evaluate.js", + Log: "chrome://marionette/content/log.js", + modal: "chrome://marionette/content/modal.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); +XPCOMUtils.defineLazyGetter(this, "elementIdCache", () => { + return new element.ReferenceStore(); +}); + +class MarionetteCommandsParent extends JSWindowActorParent { + actorCreated() { + this._resolveDialogOpened = null; + + this.dialogObserver = new modal.DialogObserver(); + this.dialogObserver.add(this.onDialog.bind(this)); + + this.topWindow = this.browsingContext.top.embedderElement?.ownerGlobal; + this.topWindow?.addEventListener("TabClose", _onTabClose); + } + + dialogOpenedPromise() { + return new Promise(resolve => { + this._resolveDialogOpened = resolve; + }); + } + + async sendQuery(name, data) { + const serializedData = evaluate.toJSON(data, elementIdCache); + + // return early if a dialog is opened + const result = await Promise.race([ + super.sendQuery(name, serializedData), + this.dialogOpenedPromise(), + ]).finally(() => { + this._resolveDialogOpened = null; + }); + + if ("error" in result) { + throw error.WebDriverError.fromJSON(result.error); + } else { + return evaluate.fromJSON(result.data, elementIdCache); + } + } + + didDestroy() { + this.dialogObserver.remove(this.onDialog); + this.dialogObserver.unregister(); + + this.topWindow?.removeEventListener("TabClose", _onTabClose); + } + + onDialog(action, dialogRef, win) { + if ( + this._resolveDialogOpened && + action == "opened" && + win == this.browsingContext.topChromeWindow + ) { + this._resolveDialogOpened({ data: null }); + } + } + + // Proxying methods for WebDriver commands + // TODO: Maybe using a proxy class instead similar to proxy.js + + clearElement(webEl) { + return this.sendQuery("MarionetteCommandsParent:clearElement", { + elem: webEl, + }); + } + + clickElement(webEl, capabilities) { + return this.sendQuery("MarionetteCommandsParent:clickElement", { + elem: webEl, + capabilities, + }); + } + + 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 getActiveElement() { + return this.sendQuery("MarionetteCommandsParent:getActiveElement"); + } + + async getCurrentUrl() { + return this.sendQuery("MarionetteCommandsParent:getCurrentUrl"); + } + + 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, + elem: webEl, + }); + } + + async isElementEnabled(webEl, capabilities) { + return this.sendQuery("MarionetteCommandsParent:isElementEnabled", { + capabilities, + elem: webEl, + }); + } + + async isElementSelected(webEl, capabilities) { + return this.sendQuery("MarionetteCommandsParent:isElementSelected", { + capabilities, + elem: webEl, + }); + } + + async sendKeysToElement(webEl, text, capabilities) { + return this.sendQuery("MarionetteCommandsParent:sendKeysToElement", { + capabilities, + elem: webEl, + text, + }); + } + + async performActions(actions, capabilities) { + return this.sendQuery("MarionetteCommandsParent:performActions", { + actions, + capabilities, + }); + } + + async releaseActions() { + return this.sendQuery("MarionetteCommandsParent:releaseActions"); + } + + async singleTap(webEl, x, y, capabilities) { + return this.sendQuery("MarionetteCommandsParent:singleTap", { + capabilities, + 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 capture.canvas( + browsingContext.topChromeWindow, + browsingContext, + rect.x, + rect.y, + rect.width, + rect.height + ); + + switch (format) { + case capture.Format.Hash: + return capture.toHash(canvas); + + case capture.Format.Base64: + return capture.toBase64(canvas); + + default: + throw new TypeError(`Invalid capture format: ${format}`); + } + } +} + +/** + * Clear all the entries from the element id cache. + */ +function clearElementIdCache() { + elementIdCache.clear(); +} + +function _onTabClose(event) { + elementIdCache.clear(event.target.linkedBrowser.browsingContext); +} + +/** + * 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. + */ +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 { + // TODO: Scenarios where the window/tab got closed and + // currentWindowGlobal is null will be handled in Bug 1662808. + const actor = browsingContextFn().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)) { + return null; + } + + if (++attempts > MAX_ATTEMPTS) { + const browsingContextId = browsingContextFn()?.id; + logger.trace( + `[${browsingContextId}] Querying "${methodName} "` + + `reached the limit of retry attempts (${MAX_ATTEMPTS})` + ); + throw e; + } + + logger.trace(`Retrying "${methodName}", attempt: ${attempts}`); + } + } + }; + }, + } + ); +} + +/** + * Register the MarionetteCommands actor that holds all the commands. + */ +function registerCommandsActor() { + try { + ChromeUtils.registerWindowActor("MarionetteCommands", { + kind: "JSWindowActor", + parent: { + moduleURI: + "chrome://marionette/content/actors/MarionetteCommandsParent.jsm", + }, + child: { + moduleURI: + "chrome://marionette/content/actors/MarionetteCommandsChild.jsm", + }, + + allFrames: true, + includeChrome: true, + }); + } catch (e) { + if (e.name === "NotSupportedError") { + logger.warn(`MarionetteCommands actor is already registered!`); + } else { + throw e; + } + } +} + +function unregisterCommandsActor() { + ChromeUtils.unregisterWindowActor("MarionetteCommands"); +} diff --git a/testing/marionette/actors/MarionetteEventsChild.jsm b/testing/marionette/actors/MarionetteEventsChild.jsm new file mode 100644 index 0000000000..e890c513c9 --- /dev/null +++ b/testing/marionette/actors/MarionetteEventsChild.jsm @@ -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/. */ + +/* eslint-disable no-restricted-globals */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["MarionetteEventsChild"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + event: "chrome://marionette/content/event.js", + Log: "chrome://marionette/content/log.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +class MarionetteEventsChild extends JSWindowActorChild { + get innerWindowId() { + return this.manager.innerWindowId; + } + + actorCreated() { + logger.trace( + `[${this.browsingContext.id}] MarionetteEvents actor created ` + + `for window id ${this.innerWindowId}` + ); + } + + handleEvent({ target, type }) { + // Ignore invalid combinations of load events and document's readyState. + if ( + (type === "DOMContentLoaded" && target.readyState != "interactive") || + (type === "pageshow" && target.readyState != "complete") + ) { + 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": + event.DoubleClickTracker.setClick(); + break; + case "dblclick": + case "unload": + event.DoubleClickTracker.resetClick(); + break; + } + } +} diff --git a/testing/marionette/actors/MarionetteEventsParent.jsm b/testing/marionette/actors/MarionetteEventsParent.jsm new file mode 100644 index 0000000000..4db861a8b0 --- /dev/null +++ b/testing/marionette/actors/MarionetteEventsParent.jsm @@ -0,0 +1,92 @@ +/* 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 EXPORTED_SYMBOLS = [ + "EventDispatcher", + "MarionetteEventsParent", + "registerEventsActor", + "unregisterEventsActor", +]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + EventEmitter: "resource://gre/modules/EventEmitter.jsm", + Log: "chrome://marionette/content/log.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +// Singleton to allow forwarding events to registered listeners. +const EventDispatcher = { + init() { + EventEmitter.decorate(this); + }, +}; +EventDispatcher.init(); + +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; + } +} + +/** + * Register Events actors to listen for page load events via EventDispatcher. + */ +function registerEventsActor() { + try { + // Register the JSWindowActor pair for events as used by Marionette + ChromeUtils.registerWindowActor("MarionetteEvents", { + kind: "JSWindowActor", + parent: { + moduleURI: + "chrome://marionette/content/actors/MarionetteEventsParent.jsm", + }, + child: { + moduleURI: + "chrome://marionette/content/actors/MarionetteEventsChild.jsm", + 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 }, + }, + }, + + allFrames: true, + includeChrome: true, + }); + } catch (e) { + if (e.name === "NotSupportedError") { + logger.warn(`MarionetteEvents actor is already registered!`); + } else { + throw e; + } + } +} + +function unregisterEventsActor() { + ChromeUtils.unregisterWindowActor("MarionetteEvents"); +} diff --git a/testing/marionette/actors/MarionetteReftestChild.jsm b/testing/marionette/actors/MarionetteReftestChild.jsm new file mode 100644 index 0000000000..dd1743d62c --- /dev/null +++ b/testing/marionette/actors/MarionetteReftestChild.jsm @@ -0,0 +1,209 @@ +/* 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 EXPORTED_SYMBOLS = ["MarionetteReftestChild"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Log: "chrome://marionette/content/log.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +/** + * Child JSWindowActor to handle navigation for reftests relying on marionette. + */ +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; + logger.debug(`Handle load event with URL ${url}`); + this._resolveLoadedURLPromise(url); + } + } + + actorCreated() { + 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: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) { + 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"); + + logger.debug("Waiting for event loop to spin"); + await new Promise(resolve => + this.document.defaultView.setTimeout(resolve, 0) + ); + + await this.paintComplete(useRemote); + + if (hasReftestWait) { + const event = new Event("TestRendered", { bubbles: true }); + documentElement.dispatchEvent(event); + logger.info("Emitted TestRendered event"); + await this.reftestWaitRemoved(); + await this.paintComplete(useRemote); + } + if ( + this.document.defaultView.innerWidth < documentElement.scrollWidth || + this.document.defaultView.innerHeight < documentElement.scrollHeight + ) { + logger.warn( + `${url} overflows viewport (width: ${documentElement.scrollWidth}, height: ${documentElement.scrollHeight})` + ); + } + return true; + } + + paintComplete(useRemote) { + logger.debug("Waiting for rendering"); + let windowUtils = this.document.defaultView.windowUtils; + return new Promise(resolve => { + let maybeResolve = () => { + this.flushRendering(); + if (useRemote) { + // Flush display (paint) + logger.debug("Force update of layer tree"); + windowUtils.updateLayerTree(); + } + + if (windowUtils.isMozAfterPaintPending) { + 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 + logger.debug("isMozAfterPaintPending: false"); + this.document.defaultView.requestAnimationFrame(() => { + this.document.defaultView.requestAnimationFrame(resolve); + }); + } + }; + maybeResolve(); + }); + } + + reftestWaitRemoved() { + 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(); + 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); + } + }); + } + + flushRendering() { + 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 { + // Flush pending restyles and reflows for this window (layout) + root.getBoundingClientRect(); + } catch (e) { + logger.error("flushWindow failed", e); + } + } + + if (!afterPaintWasPending && utils.isMozAfterPaintPending) { + anyPendingPaintsGeneratedInDescendants = true; + } + + for (let i = 0; i < win.frames.length; ++i) { + flushWindow(win.frames[i]); + } + } + flushWindow(this.document.defaultView); + + if ( + anyPendingPaintsGeneratedInDescendants && + !windowUtils.isMozAfterPaintPending + ) { + logger.error( + "Descendant frame generated a MozAfterPaint event, " + + "but the root document doesn't have one!" + ); + } + } +} diff --git a/testing/marionette/actors/MarionetteReftestParent.jsm b/testing/marionette/actors/MarionetteReftestParent.jsm new file mode 100644 index 0000000000..6a1a2187d8 --- /dev/null +++ b/testing/marionette/actors/MarionetteReftestParent.jsm @@ -0,0 +1,44 @@ +/* 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 EXPORTED_SYMBOLS = ["MarionetteReftestParent"]; + +/** + * Parent JSWindowActor to handle navigation for reftests relying on marionette. + */ +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, + } + ); + 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; + } + } +} |