summaryrefslogtreecommitdiffstats
path: root/testing/marionette/actors
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /testing/marionette/actors
parentInitial commit. (diff)
downloadfirefox-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.jsm546
-rw-r--r--testing/marionette/actors/MarionetteCommandsParent.jsm396
-rw-r--r--testing/marionette/actors/MarionetteEventsChild.jsm74
-rw-r--r--testing/marionette/actors/MarionetteEventsParent.jsm92
-rw-r--r--testing/marionette/actors/MarionetteReftestChild.jsm209
-rw-r--r--testing/marionette/actors/MarionetteReftestParent.jsm44
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;
+ }
+ }
+}