From a90a5cba08fdf6c0ceb95101c275108a152a3aed Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 12 Jun 2024 07:35:37 +0200 Subject: Merging upstream version 127.0. Signed-off-by: Daniel Baumann --- remote/shared/webdriver/Accessibility.sys.mjs | 519 +++++++++++++++++++++ remote/shared/webdriver/Actions.sys.mjs | 27 +- remote/shared/webdriver/Assert.sys.mjs | 30 ++ remote/shared/webdriver/Session.sys.mjs | 3 +- .../shared/webdriver/test/xpcshell/test_Assert.js | 14 + 5 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 remote/shared/webdriver/Accessibility.sys.mjs (limited to 'remote/shared/webdriver') 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.} + * 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} scrollCoordinates - [x, y] viewport coordinates of the scroll. * @param {Array} deltaPosition - [deltaX, deltaY] coordinates of the scroll before this event. * @param {Array>} 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 @@ -409,6 +409,36 @@ assert.object = function (obj, msg = "") { }, msg)(obj); }; +/** + * Asserts that obj is an instance of a specified class. + * constructor should have a static isInstance method implemented. + * + * @param {?} obj + * Value to test. + * @param {?} constructor + * Class constructor. + * @param {string=} msg + * Custom error message. + * + * @returns {object} + * obj is returned unaltered. + * + * @throws {InvalidArgumentError} + * If obj 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 prop is in obj. * 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]) { -- cgit v1.2.3