Child actors generally deal with content nodes, and those can be adopted, in order to preserve behavior, switch to the new .documentGlobal. Fix + rename the use-ownerGlobal lint since now it's wrong. .ownerDocument.defaultView is .documentGlobal, not .ownerGlobal now. Differential Revision: https://phabricator.services.mozilla.com/D294805
645 lines
18 KiB
JavaScript
645 lines
18 KiB
JavaScript
/* 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",
|
|
generateUUID: "chrome://remote/content/shared/UUID.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 accessibility 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 `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.documentGlobal.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;
|
|
};
|
|
|
|
// Data structures to map between Accessibles and globally unique ids. These ids
|
|
// are only used by WebDriver.
|
|
const accessibleToId = new WeakMap();
|
|
const idToAccessible = new Map();
|
|
// Clean up the maps when Accessibles die.
|
|
const accFinalizationRegistry = new FinalizationRegistry(id => {
|
|
idToAccessible.delete(id);
|
|
});
|
|
|
|
/**
|
|
* Get the WebDriver id for an Accessible, generating the id if necessary.
|
|
*/
|
|
function getAccessibleId(accessible) {
|
|
let id = accessibleToId.get(accessible);
|
|
if (id) {
|
|
// We generated an id previously.
|
|
return id;
|
|
}
|
|
// We haven't seen this Accessible before. Generate a new id.
|
|
id = lazy.generateUUID();
|
|
accessibleToId.set(accessible, id);
|
|
idToAccessible.set(id, new WeakRef(accessible));
|
|
accFinalizationRegistry.register(accessible, id);
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Convert an Accessible state to "true", "false" or "mixed".
|
|
*/
|
|
function toMixedString(accState, testState) {
|
|
if (accState & Ci.nsIAccessibleStates.STATE_MIXED) {
|
|
return "mixed";
|
|
}
|
|
return String(!!(accState & testState));
|
|
}
|
|
|
|
/**
|
|
* Serialize core properties of an Accessible to a JSON compatible object as
|
|
* specified by WebDriver.
|
|
*/
|
|
function serializeAccessible(accessible) {
|
|
const role = accessible.computedARIARole;
|
|
// Properties that are very easy to retrieve.
|
|
let result = {
|
|
description: accessible.description,
|
|
accessibilityId: getAccessibleId(accessible),
|
|
label: accessible.name,
|
|
role,
|
|
};
|
|
|
|
// Accessibility tree structure.
|
|
const parent = accessible.parent;
|
|
result.parent = parent ? getAccessibleId(parent) : null;
|
|
result.children = [];
|
|
const children = accessible.children;
|
|
for (let c = 0; c < children.length; ++c) {
|
|
result.children.push(
|
|
getAccessibleId(children.queryElementAt(c, Ci.nsIAccessible))
|
|
);
|
|
}
|
|
|
|
// States.
|
|
let state = {};
|
|
accessible.getState(state, {});
|
|
state = state.value;
|
|
if (state & Ci.nsIAccessibleStates.STATE_CHECKABLE) {
|
|
result.checked = toMixedString(state, Ci.nsIAccessibleStates.STATE_CHECKED);
|
|
}
|
|
if (role == "button") {
|
|
if (accessible.role == Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON) {
|
|
result.pressed = toMixedString(
|
|
state,
|
|
Ci.nsIAccessibleStates.STATE_PRESSED
|
|
);
|
|
} else {
|
|
result.pressed = "undefined";
|
|
}
|
|
}
|
|
result.required = String(!!(state & Ci.nsIAccessibleStates.STATE_REQUIRED));
|
|
if (state & Ci.nsIAccessibleStates.STATE_SELECTABLE) {
|
|
result.selected = String(!!(state & Ci.nsIAccessibleStates.STATE_SELECTED));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Retrieve serialized accessibility properties for the provided element.
|
|
*
|
|
* @param {Element} element
|
|
* The element for which to retrieve accessibility properties.
|
|
*
|
|
* @returns {object | null}
|
|
* The accessibility properties or null if the element isn't in the
|
|
* accessibility tree.
|
|
*/
|
|
accessibility.getAccessibilityPropertiesForElement = async function (element) {
|
|
const accessible = await accessibility.getAccessible(element);
|
|
if (!accessible) {
|
|
// If it's not in the a11y tree, it's probably presentational.
|
|
return null;
|
|
}
|
|
return serializeAccessible(accessible);
|
|
};
|
|
|
|
/**
|
|
* Retrieve serialized properties for the provided accessibility node.
|
|
*
|
|
* @param {string} id
|
|
* The id of the accessibility node for which to retrieve properties.
|
|
*
|
|
* @returns {object | null}
|
|
* The accessibility properties or null if the id is invalid.
|
|
*/
|
|
accessibility.getAccessibilityPropertiesForAccessibilityNode = async function (
|
|
id
|
|
) {
|
|
const accessible = idToAccessible.get(id);
|
|
if (!accessible) {
|
|
return null;
|
|
}
|
|
return serializeAccessible(accessible.deref());
|
|
};
|
|
|
|
/**
|
|
* 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 `hidden` attribute.
|
|
*
|
|
* @param {nsIAccessible} accessible
|
|
* Accessible object.
|
|
*
|
|
* @returns {boolean}
|
|
* True if the accessible object has a `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.documentGlobal;
|
|
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);
|
|
}
|
|
};
|