gecko-dev/remote/marionette/accessibility.js

465 строки
13 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/. */
"use strict";
const EXPORTED_SYMBOLS = ["accessibility"];
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
const lazy = {};
XPCOMUtils.defineLazyModuleGetters(lazy, {
error: "chrome://remote/content/shared/webdriver/Errors.jsm",
Log: "chrome://remote/content/shared/Log.jsm",
});
XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
);
XPCOMUtils.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 */
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);
};
/**
* 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;
}
/**
* Get an accessible object for an element.
*
* @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.
*
* @return {Promise.<nsIAccessible>}
* Promise with an accessibility object for the given element.
*/
getAccessible(element, mustHaveAccessible = false) {
if (!this.strict) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
if (!accessibility.service) {
reject();
return;
}
// First, check if accessibility is ready.
let docAcc = accessibility.service.getAccessibleFor(
element.ownerDocument
);
let state = {};
docAcc.getState(state, {});
if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) {
// Accessibility is ready, resolve immediately.
let acc = accessibility.service.getAccessibleFor(element);
if (mustHaveAccessible && !acc) {
reject();
} else {
resolve(acc);
}
return;
}
// Accessibility for the doc is busy, so wait for the state to change.
let eventObserver = {
observe(subject, topic) {
if (topic !== "accessible-event") {
return;
}
// If event type does not match expected type, skip the event.
let event = subject.QueryInterface(Ci.nsIAccessibleEvent);
if (event.eventType !== Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE) {
return;
}
// If event's accessible does not match expected accessible,
// skip the event.
if (event.accessible !== docAcc) {
return;
}
Services.obs.removeObserver(this, "accessible-event");
let acc = accessibility.service.getAccessibleFor(element);
if (mustHaveAccessible && !acc) {
reject();
} else {
resolve(acc);
}
},
};
Services.obs.addObserver(eventObserver, "accessible-event");
}).catch(() =>
this.error("Element does not have an accessible object", element)
);
}
/**
* Test if the accessible has a role that supports some arbitrary
* action.
*
* @param {nsIAccessible} accessible
* Accessible object.
*
* @return {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.
*
* @return {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.
*
* @return {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.
*
* @return {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.
*
* @return {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.
*
* @return {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 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);
}
};