summaryrefslogtreecommitdiffstats
path: root/remote/shared/webdriver
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--remote/shared/webdriver/Accessibility.sys.mjs519
-rw-r--r--remote/shared/webdriver/Actions.sys.mjs27
-rw-r--r--remote/shared/webdriver/Assert.sys.mjs30
-rw-r--r--remote/shared/webdriver/Session.sys.mjs3
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Assert.js14
5 files changed, 591 insertions, 2 deletions
diff --git a/remote/shared/webdriver/Accessibility.sys.mjs b/remote/shared/webdriver/Accessibility.sys.mjs
new file mode 100644
index 0000000000..4c7b2a6c69
--- /dev/null
+++ b/remote/shared/webdriver/Accessibility.sys.mjs
@@ -0,0 +1,519 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+ChromeUtils.defineLazyGetter(lazy, "service", () => {
+ try {
+ return Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ } catch (e) {
+ lazy.logger.warn("Accessibility module is not present");
+ return undefined;
+ }
+});
+
+/** @namespace */
+export const accessibility = {
+ get service() {
+ return lazy.service;
+ },
+};
+
+/**
+ * Accessible states used to check element"s state from the accessiblity API
+ * perspective.
+ *
+ * Note: if gecko is built with --disable-accessibility, the interfaces
+ * are not defined. This is why we use getters instead to be able to use
+ * these statically.
+ */
+accessibility.State = {
+ get Unavailable() {
+ return Ci.nsIAccessibleStates.STATE_UNAVAILABLE;
+ },
+ get Focusable() {
+ return Ci.nsIAccessibleStates.STATE_FOCUSABLE;
+ },
+ get Selectable() {
+ return Ci.nsIAccessibleStates.STATE_SELECTABLE;
+ },
+ get Selected() {
+ return Ci.nsIAccessibleStates.STATE_SELECTED;
+ },
+};
+
+/**
+ * Accessible object roles that support some action.
+ */
+accessibility.ActionableRoles = new Set([
+ "checkbutton",
+ "check menu item",
+ "check rich option",
+ "combobox",
+ "combobox option",
+ "entry",
+ "key",
+ "link",
+ "listbox option",
+ "listbox rich option",
+ "menuitem",
+ "option",
+ "outlineitem",
+ "pagetab",
+ "pushbutton",
+ "radiobutton",
+ "radio menu item",
+ "rowheader",
+ "slider",
+ "spinbutton",
+ "switch",
+]);
+
+/**
+ * Factory function that constructs a new {@code accessibility.Checks}
+ * object with enforced strictness or not.
+ */
+accessibility.get = function (strict = false) {
+ return new accessibility.Checks(!!strict);
+};
+
+/**
+ * Wait for the document accessibility state to be different from STATE_BUSY.
+ *
+ * @param {Document} doc
+ * The document to wait for.
+ * @returns {Promise}
+ * A promise which resolves when the document's accessibility state is no
+ * longer busy.
+ */
+function waitForDocumentAccessibility(doc) {
+ const documentAccessible = accessibility.service.getAccessibleFor(doc);
+ const state = {};
+ documentAccessible.getState(state, {});
+ if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) {
+ return Promise.resolve();
+ }
+
+ // Accessibility for the doc is busy, so wait for the state to change.
+ return lazy.waitForObserverTopic("accessible-event", {
+ checkFn: subject => {
+ // If event type does not match expected type, skip the event.
+ // If event's accessible does not match expected accessible,
+ // skip the event.
+ const event = subject.QueryInterface(Ci.nsIAccessibleEvent);
+ return (
+ event.eventType === Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE &&
+ event.accessible === documentAccessible
+ );
+ },
+ });
+}
+
+/**
+ * Retrieve the Accessible for the provided element.
+ *
+ * @param {Element} element
+ * The element for which we need to retrieve the accessible.
+ *
+ * @returns {nsIAccessible|null}
+ * The Accessible object corresponding to the provided element or null if
+ * the accessibility service is not available.
+ */
+accessibility.getAccessible = async function (element) {
+ if (!accessibility.service) {
+ return null;
+ }
+
+ // First, wait for accessibility to be ready for the element's document.
+ await waitForDocumentAccessibility(element.ownerDocument);
+
+ const acc = accessibility.service.getAccessibleFor(element);
+ if (acc) {
+ return acc;
+ }
+
+ // The Accessible doesn't exist yet. This can happen because a11y tree
+ // mutations happen during refresh driver ticks. Stop the refresh driver from
+ // doing its regular ticks and force two refresh driver ticks: the first to
+ // let layout update and notify a11y, and the second to let a11y process
+ // updates.
+ const windowUtils = element.ownerGlobal.windowUtils;
+ windowUtils.advanceTimeAndRefresh(0);
+ windowUtils.advanceTimeAndRefresh(0);
+ // Go back to normal refresh driver ticks.
+ windowUtils.restoreNormalRefresh();
+ return accessibility.service.getAccessibleFor(element);
+};
+
+/**
+ * Retrieve the accessible name for the provided element.
+ *
+ * @param {Element} element
+ * The element for which we need to retrieve the accessible name.
+ *
+ * @returns {string}
+ * The accessible name.
+ */
+accessibility.getAccessibleName = async function (element) {
+ const accessible = await accessibility.getAccessible(element);
+ if (!accessible) {
+ return "";
+ }
+
+ // If name is null (absent), expose the empty string.
+ if (accessible.name === null) {
+ return "";
+ }
+
+ return accessible.name;
+};
+
+/**
+ * Compute the role for the provided element.
+ *
+ * @param {Element} element
+ * The element for which we need to compute the role.
+ *
+ * @returns {string}
+ * The computed role.
+ */
+accessibility.getComputedRole = async function (element) {
+ const accessible = await accessibility.getAccessible(element);
+ if (!accessible) {
+ // If it's not in the a11y tree, it's probably presentational.
+ return "none";
+ }
+
+ return accessible.computedARIARole;
+};
+
+/**
+ * Component responsible for interacting with platform accessibility
+ * API.
+ *
+ * Its methods serve as wrappers for testing content and chrome
+ * accessibility as well as accessibility of user interactions.
+ */
+accessibility.Checks = class {
+ /**
+ * @param {boolean} strict
+ * Flag indicating whether the accessibility issue should be logged
+ * or cause an error to be thrown. Default is to log to stdout.
+ */
+ constructor(strict) {
+ this.strict = strict;
+ }
+
+ /**
+ * Assert that the element has a corresponding accessible object, and retrieve
+ * this accessible. Note that if the accessibility.Checks component was
+ * created in non-strict mode, this helper will not attempt to resolve the
+ * accessible at all and will simply return null.
+ *
+ * @param {DOMElement|XULElement} element
+ * Element to get the accessible object for.
+ * @param {boolean=} mustHaveAccessible
+ * Flag indicating that the element must have an accessible object.
+ * Defaults to not require this.
+ *
+ * @returns {Promise.<nsIAccessible>}
+ * Promise with an accessibility object for the given element.
+ */
+ async assertAccessible(element, mustHaveAccessible = false) {
+ if (!this.strict) {
+ return null;
+ }
+
+ const accessible = await accessibility.getAccessible(element);
+ if (!accessible && mustHaveAccessible) {
+ this.error("Element does not have an accessible object", element);
+ }
+
+ return accessible;
+ }
+
+ /**
+ * Test if the accessible has a role that supports some arbitrary
+ * action.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @returns {boolean}
+ * True if an actionable role is found on the accessible, false
+ * otherwise.
+ */
+ isActionableRole(accessible) {
+ return accessibility.ActionableRoles.has(
+ accessibility.service.getStringRole(accessible.role)
+ );
+ }
+
+ /**
+ * Test if an accessible has at least one action that it supports.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @returns {boolean}
+ * True if the accessible has at least one supported action,
+ * false otherwise.
+ */
+ hasActionCount(accessible) {
+ return accessible.actionCount > 0;
+ }
+
+ /**
+ * Test if an accessible has a valid name.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @returns {boolean}
+ * True if the accessible has a non-empty valid name, or false if
+ * this is not the case.
+ */
+ hasValidName(accessible) {
+ return accessible.name && accessible.name.trim();
+ }
+
+ /**
+ * Test if an accessible has a {@code hidden} attribute.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @returns {boolean}
+ * True if the accessible object has a {@code hidden} attribute,
+ * false otherwise.
+ */
+ hasHiddenAttribute(accessible) {
+ let hidden = false;
+ try {
+ hidden = accessible.attributes.getStringProperty("hidden");
+ } catch (e) {}
+ // if the property is missing, error will be thrown
+ return hidden && hidden === "true";
+ }
+
+ /**
+ * Verify if an accessible has a given state.
+ * Test if an accessible has a given state.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object to test.
+ * @param {number} stateToMatch
+ * State to match.
+ *
+ * @returns {boolean}
+ * True if |accessible| has |stateToMatch|, false otherwise.
+ */
+ matchState(accessible, stateToMatch) {
+ let state = {};
+ accessible.getState(state, {});
+ return !!(state.value & stateToMatch);
+ }
+
+ /**
+ * Test if an accessible is hidden from the user.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @returns {boolean}
+ * True if element is hidden from user, false otherwise.
+ */
+ isHidden(accessible) {
+ if (!accessible) {
+ return true;
+ }
+
+ while (accessible) {
+ if (this.hasHiddenAttribute(accessible)) {
+ return true;
+ }
+ accessible = accessible.parent;
+ }
+ return false;
+ }
+
+ /**
+ * Test if the element's visible state corresponds to its accessibility
+ * API visibility.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ * @param {boolean} visible
+ * Visibility state of |element|.
+ *
+ * @throws ElementNotAccessibleError
+ * If |element|'s visibility state does not correspond to
+ * |accessible|'s.
+ */
+ assertVisible(accessible, element, visible) {
+ let hiddenAccessibility = this.isHidden(accessible);
+
+ let message;
+ if (visible && hiddenAccessibility) {
+ message =
+ "Element is not currently visible via the accessibility API " +
+ "and may not be manipulated by it";
+ } else if (!visible && !hiddenAccessibility) {
+ message =
+ "Element is currently only visible via the accessibility API " +
+ "and can be manipulated by it";
+ }
+ this.error(message, element);
+ }
+
+ /**
+ * Test if the element's unavailable accessibility state matches the
+ * enabled state.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ * @param {boolean} enabled
+ * Enabled state of |element|.
+ *
+ * @throws ElementNotAccessibleError
+ * If |element|'s enabled state does not match |accessible|'s.
+ */
+ assertEnabled(accessible, element, enabled) {
+ if (!accessible) {
+ return;
+ }
+
+ let win = element.ownerGlobal;
+ let disabledAccessibility = this.matchState(
+ accessible,
+ accessibility.State.Unavailable
+ );
+ let explorable =
+ win.getComputedStyle(element).getPropertyValue("pointer-events") !==
+ "none";
+
+ let message;
+ if (!explorable && !disabledAccessibility) {
+ message =
+ "Element is enabled but is not explorable via the " +
+ "accessibility API";
+ } else if (enabled && disabledAccessibility) {
+ message = "Element is enabled but disabled via the accessibility API";
+ } else if (!enabled && !disabledAccessibility) {
+ message = "Element is disabled but enabled via the accessibility API";
+ }
+ this.error(message, element);
+ }
+
+ /**
+ * Test if it is possible to activate an element with the accessibility
+ * API.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ *
+ * @throws ElementNotAccessibleError
+ * If it is impossible to activate |element| with |accessible|.
+ */
+ assertActionable(accessible, element) {
+ if (!accessible) {
+ return;
+ }
+
+ let message;
+ if (!this.hasActionCount(accessible)) {
+ message = "Element does not support any accessible actions";
+ } else if (!this.isActionableRole(accessible)) {
+ message =
+ "Element does not have a correct accessibility role " +
+ "and may not be manipulated via the accessibility API";
+ } else if (!this.hasValidName(accessible)) {
+ message = "Element is missing an accessible name";
+ } else if (!this.matchState(accessible, accessibility.State.Focusable)) {
+ message = "Element is not focusable via the accessibility API";
+ }
+
+ this.error(message, element);
+ }
+
+ /**
+ * Test that an element's selected state corresponds to its
+ * accessibility API selected state.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ * @param {boolean} selected
+ * The |element|s selected state.
+ *
+ * @throws ElementNotAccessibleError
+ * If |element|'s selected state does not correspond to
+ * |accessible|'s.
+ */
+ assertSelected(accessible, element, selected) {
+ if (!accessible) {
+ return;
+ }
+
+ // element is not selectable via the accessibility API
+ if (!this.matchState(accessible, accessibility.State.Selectable)) {
+ return;
+ }
+
+ let selectedAccessibility = this.matchState(
+ accessible,
+ accessibility.State.Selected
+ );
+
+ let message;
+ if (selected && !selectedAccessibility) {
+ message =
+ "Element is selected but not selected via the accessibility API";
+ } else if (!selected && selectedAccessibility) {
+ message =
+ "Element is not selected but selected via the accessibility API";
+ }
+ this.error(message, element);
+ }
+
+ /**
+ * Throw an error if strict accessibility checks are enforced and log
+ * the error to the log.
+ *
+ * @param {string} message
+ * @param {DOMElement|XULElement} element
+ * Element that caused an error.
+ *
+ * @throws ElementNotAccessibleError
+ * If |strict| is true.
+ */
+ error(message, element) {
+ if (!message || !this.strict) {
+ return;
+ }
+ if (element) {
+ let { id, tagName, className } = element;
+ message += `: id: ${id}, tagName: ${tagName}, className: ${className}`;
+ }
+
+ throw new lazy.error.ElementNotAccessibleError(message);
+ }
+};
diff --git a/remote/shared/webdriver/Actions.sys.mjs b/remote/shared/webdriver/Actions.sys.mjs
index 2639c4dc9f..2c21e989dd 100644
--- a/remote/shared/webdriver/Actions.sys.mjs
+++ b/remote/shared/webdriver/Actions.sys.mjs
@@ -1318,6 +1318,7 @@ class WheelScrollAction extends WheelAction {
this.duration ?? tickDuration,
deltaTarget =>
this.performOneWheelScroll(
+ state,
scrollCoordinates,
deltaPosition,
deltaTarget,
@@ -1329,12 +1330,19 @@ class WheelScrollAction extends WheelAction {
/**
* Perform one part of a wheel scroll corresponding to a specific emitted event.
*
+ * @param {State} state - Actions state.
* @param {Array<number>} scrollCoordinates - [x, y] viewport coordinates of the scroll.
* @param {Array<number>} deltaPosition - [deltaX, deltaY] coordinates of the scroll before this event.
* @param {Array<Array<number>>} deltaTargets - Array of [deltaX, deltaY] coordinates to scroll to.
* @param {WindowProxy} win - Current window global.
*/
- performOneWheelScroll(scrollCoordinates, deltaPosition, deltaTargets, win) {
+ performOneWheelScroll(
+ state,
+ scrollCoordinates,
+ deltaPosition,
+ deltaTargets,
+ win
+ ) {
if (deltaTargets.length !== 1) {
throw new Error("Can only scroll one wheel at a time");
}
@@ -1350,6 +1358,7 @@ class WheelScrollAction extends WheelAction {
deltaY,
deltaZ: 0,
});
+ eventData.update(state);
lazy.event.synthesizeWheelAtPoint(
scrollCoordinates[0],
@@ -2237,6 +2246,22 @@ class WheelEventData extends InputEventData {
this.deltaY = deltaY;
this.deltaZ = deltaZ;
this.deltaMode = deltaMode;
+
+ this.altKey = false;
+ this.ctrlKey = false;
+ this.metaKey = false;
+ this.shiftKey = false;
+ }
+
+ update(state) {
+ // set modifier properties based on whether any corresponding keys are
+ // pressed on any key input source
+ for (const [, otherInputSource] of state.inputSourcesByType("key")) {
+ this.altKey = otherInputSource.alt || this.altKey;
+ this.ctrlKey = otherInputSource.ctrl || this.ctrlKey;
+ this.metaKey = otherInputSource.meta || this.metaKey;
+ this.shiftKey = otherInputSource.shift || this.shiftKey;
+ }
}
}
diff --git a/remote/shared/webdriver/Assert.sys.mjs b/remote/shared/webdriver/Assert.sys.mjs
index 6c254173aa..fe83bc9181 100644
--- a/remote/shared/webdriver/Assert.sys.mjs
+++ b/remote/shared/webdriver/Assert.sys.mjs
@@ -410,6 +410,36 @@ assert.object = function (obj, msg = "") {
};
/**
+ * Asserts that <var>obj</var> is an instance of a specified class.
+ * <var>constructor</var> should have a static isInstance method implemented.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {?} constructor
+ * Class constructor.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {object}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not an instance of a specified class.
+ */
+assert.isInstance = function (obj, constructor, msg = "") {
+ assert.object(obj, msg);
+ assert.object(constructor.prototype, msg);
+
+ msg =
+ msg ||
+ lazy.pprint`Expected ${obj} to be an instance of ${constructor.name}`;
+ return assert.that(
+ o => Object.hasOwn(constructor, "isInstance") && constructor.isInstance(o),
+ msg
+ )(obj);
+};
+
+/**
* Asserts that <var>prop</var> is in <var>obj</var>.
*
* @param {?} prop
diff --git a/remote/shared/webdriver/Session.sys.mjs b/remote/shared/webdriver/Session.sys.mjs
index edffeea7b6..3d7b074ac9 100644
--- a/remote/shared/webdriver/Session.sys.mjs
+++ b/remote/shared/webdriver/Session.sys.mjs
@@ -5,7 +5,8 @@
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
- accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs",
+ accessibility:
+ "chrome://remote/content/shared/webdriver/Accessibility.sys.mjs",
allowAllCerts: "chrome://remote/content/marionette/cert.sys.mjs",
Capabilities: "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
diff --git a/remote/shared/webdriver/test/xpcshell/test_Assert.js b/remote/shared/webdriver/test/xpcshell/test_Assert.js
index cf474868b6..aabd8656dd 100644
--- a/remote/shared/webdriver/test/xpcshell/test_Assert.js
+++ b/remote/shared/webdriver/test/xpcshell/test_Assert.js
@@ -150,6 +150,20 @@ add_task(function test_object() {
Assert.throws(() => assert.object(null, "custom"), /custom/);
});
+add_task(function test_isInstance() {
+ class Foo {
+ static isInstance(obj) {
+ return obj instanceof Foo;
+ }
+ }
+ assert.isInstance(new Foo(), Foo);
+ for (let typ of [{}, 42, "foo", true, null, undefined]) {
+ Assert.throws(() => assert.isInstance(typ, Foo), /InvalidArgumentError/);
+ }
+
+ Assert.throws(() => assert.isInstance(null, null, "custom"), /custom/);
+});
+
add_task(function test_in() {
assert.in("foo", { foo: 42 });
for (let typ of [{}, 42, true, null, undefined]) {