diff options
Diffstat (limited to 'devtools/server/actors/accessibility/accessible.js')
-rw-r--r-- | devtools/server/actors/accessibility/accessible.js | 675 |
1 files changed, 675 insertions, 0 deletions
diff --git a/devtools/server/actors/accessibility/accessible.js b/devtools/server/actors/accessibility/accessible.js new file mode 100644 index 0000000000..1866d0a91b --- /dev/null +++ b/devtools/server/actors/accessibility/accessible.js @@ -0,0 +1,675 @@ +/* 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 { Actor } = require("resource://devtools/shared/protocol.js"); +const { + accessibleSpec, +} = require("resource://devtools/shared/specs/accessibility.js"); + +const { + accessibility: { AUDIT_TYPE }, +} = require("resource://devtools/shared/constants.js"); + +loader.lazyRequireGetter( + this, + "getContrastRatioFor", + "resource://devtools/server/actors/accessibility/audit/contrast.js", + true +); +loader.lazyRequireGetter( + this, + "auditKeyboard", + "resource://devtools/server/actors/accessibility/audit/keyboard.js", + true +); +loader.lazyRequireGetter( + this, + "auditTextLabel", + "resource://devtools/server/actors/accessibility/audit/text-label.js", + true +); +loader.lazyRequireGetter( + this, + "isDefunct", + "resource://devtools/server/actors/utils/accessibility.js", + true +); +loader.lazyRequireGetter( + this, + "findCssSelector", + "resource://devtools/shared/inspector/css-logic.js", + true +); +loader.lazyRequireGetter( + this, + "events", + "resource://devtools/shared/event-emitter.js" +); +loader.lazyRequireGetter( + this, + "getBounds", + "resource://devtools/server/actors/highlighters/utils/accessibility.js", + true +); +loader.lazyRequireGetter( + this, + "isFrameWithChildTarget", + "resource://devtools/shared/layout/utils.js", + true +); +const lazy = {}; +loader.lazyGetter( + lazy, + "ContentDOMReference", + () => + ChromeUtils.importESModule( + "resource://gre/modules/ContentDOMReference.sys.mjs", + { + // ContentDOMReference needs to be retrieved from the shared global + // since it is a shared singleton. + loadInDevToolsLoader: false, + } + ).ContentDOMReference +); + +const RELATIONS_TO_IGNORE = new Set([ + Ci.nsIAccessibleRelation.RELATION_CONTAINING_APPLICATION, + Ci.nsIAccessibleRelation.RELATION_CONTAINING_TAB_PANE, + Ci.nsIAccessibleRelation.RELATION_CONTAINING_WINDOW, + Ci.nsIAccessibleRelation.RELATION_PARENT_WINDOW_OF, + Ci.nsIAccessibleRelation.RELATION_SUBWINDOW_OF, +]); + +const nsIAccessibleRole = Ci.nsIAccessibleRole; +const TEXT_ROLES = new Set([ + nsIAccessibleRole.ROLE_TEXT_LEAF, + nsIAccessibleRole.ROLE_STATICTEXT, +]); + +const STATE_DEFUNCT = Ci.nsIAccessibleStates.EXT_STATE_DEFUNCT; +const CSS_TEXT_SELECTOR = "#text"; + +/** + * Get node inforamtion such as nodeType and the unique CSS selector for the node. + * @param {DOMNode} node + * Node for which to get the information. + * @return {Object} + * Information about the type of the node and how to locate it. + */ +function getNodeDescription(node) { + if (!node || Cu.isDeadWrapper(node)) { + return { nodeType: undefined, nodeCssSelector: "" }; + } + + const { nodeType } = node; + return { + nodeType, + // If node is a text node, we find a unique CSS selector for its parent and add a + // CSS_TEXT_SELECTOR postfix to indicate that it's a text node. + nodeCssSelector: + nodeType === Node.TEXT_NODE + ? `${findCssSelector(node.parentNode)}${CSS_TEXT_SELECTOR}` + : findCssSelector(node), + }; +} + +/** + * Get a snapshot of the nsIAccessible object including its subtree. None of the subtree + * queried here is cached via accessible walker's refMap. + * @param {nsIAccessible} acc + * Accessible object to take a snapshot of. + * @param {nsIAccessibilityService} a11yService + * Accessibility service instance in the current process, used to get localized + * string representation of various accessible properties. + * @param {WindowGlobalTargetActor} targetActor + * @return {JSON} + * JSON snapshot of the accessibility tree with root at current accessible. + */ +function getSnapshot(acc, a11yService, targetActor) { + if (isDefunct(acc)) { + return { + states: [a11yService.getStringStates(0, STATE_DEFUNCT)], + }; + } + + const actions = []; + for (let i = 0; i < acc.actionCount; i++) { + actions.push(acc.getActionDescription(i)); + } + + const attributes = {}; + if (acc.attributes) { + for (const { key, value } of acc.attributes.enumerate()) { + attributes[key] = value; + } + } + + const state = {}; + const extState = {}; + acc.getState(state, extState); + const states = [...a11yService.getStringStates(state.value, extState.value)]; + + const children = []; + for (let child = acc.firstChild; child; child = child.nextSibling) { + // Ignore children from different documents when we have targets for every documents. + if ( + targetActor.ignoreSubFrames && + child.DOMNode.ownerDocument !== targetActor.contentDocument + ) { + continue; + } + children.push(getSnapshot(child, a11yService, targetActor)); + } + + const { nodeType, nodeCssSelector } = getNodeDescription(acc.DOMNode); + const snapshot = { + name: acc.name, + role: getStringRole(acc, a11yService), + actions, + value: acc.value, + nodeCssSelector, + nodeType, + description: acc.description, + keyboardShortcut: acc.accessKey || acc.keyboardShortcut, + childCount: acc.childCount, + indexInParent: acc.indexInParent, + states, + children, + attributes, + }; + const useChildTargetToFetchChildren = + acc.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME && + isFrameWithChildTarget(targetActor, acc.DOMNode); + if (useChildTargetToFetchChildren) { + snapshot.useChildTargetToFetchChildren = useChildTargetToFetchChildren; + snapshot.childCount = 1; + snapshot.contentDOMReference = lazy.ContentDOMReference.get(acc.DOMNode); + } + + return snapshot; +} + +/** + * Get a string indicating the role of the nsIAccessible object. + * An ARIA role token will be returned unless the role can't be mapped to an + * ARIA role (e.g. <iframe>), in which case a Gecko role string will be + * returned. + * @param {nsIAccessible} acc + * Accessible object to take a snapshot of. + * @param {nsIAccessibilityService} a11yService + * Accessibility service instance in the current process, used to get localized + * string representation of various accessible properties. + * @return String + */ +function getStringRole(acc, a11yService) { + let role = acc.computedARIARole; + if (!role) { + // We couldn't map to an ARIA role, so use a Gecko role string. + role = a11yService.getStringRole(acc.role); + } + return role; +} + +/** + * The AccessibleActor provides information about a given accessible object: its + * role, name, states, etc. + */ +class AccessibleActor extends Actor { + constructor(walker, rawAccessible) { + super(walker.conn, accessibleSpec); + this.walker = walker; + this.rawAccessible = rawAccessible; + + /** + * Indicates if the raw accessible is no longer alive. + * + * @return Boolean + */ + Object.defineProperty(this, "isDefunct", { + get() { + const defunct = isDefunct(this.rawAccessible); + if (defunct) { + delete this.isDefunct; + this.isDefunct = true; + return this.isDefunct; + } + + return defunct; + }, + configurable: true, + }); + } + + destroy() { + super.destroy(); + this.walker = null; + this.rawAccessible = null; + } + + get role() { + if (this.isDefunct) { + return null; + } + return getStringRole(this.rawAccessible, this.walker.a11yService); + } + + get name() { + if (this.isDefunct) { + return null; + } + return this.rawAccessible.name; + } + + get value() { + if (this.isDefunct) { + return null; + } + return this.rawAccessible.value; + } + + get description() { + if (this.isDefunct) { + return null; + } + return this.rawAccessible.description; + } + + get keyboardShortcut() { + if (this.isDefunct) { + return null; + } + // Gecko accessibility exposes two key bindings: Accessible::AccessKey and + // Accessible::KeyboardShortcut. The former is used for accesskey, where the latter + // is used for global shortcuts defined by XUL menu items, etc. Here - do what the + // Windows implementation does: try AccessKey first, and if that's empty, use + // KeyboardShortcut. + return this.rawAccessible.accessKey || this.rawAccessible.keyboardShortcut; + } + + get childCount() { + if (this.isDefunct) { + return 0; + } + // In case of a remote frame declare at least one child (the #document + // element) so that they can be expanded. + if (this.useChildTargetToFetchChildren) { + return 1; + } + + return this.rawAccessible.childCount; + } + + get domNodeType() { + if (this.isDefunct) { + return 0; + } + return this.rawAccessible.DOMNode ? this.rawAccessible.DOMNode.nodeType : 0; + } + + get parentAcc() { + if (this.isDefunct) { + return null; + } + return this.walker.addRef(this.rawAccessible.parent); + } + + children() { + const children = []; + if (this.isDefunct) { + return children; + } + + for ( + let child = this.rawAccessible.firstChild; + child; + child = child.nextSibling + ) { + children.push(this.walker.addRef(child)); + } + return children; + } + + get indexInParent() { + if (this.isDefunct) { + return -1; + } + + try { + return this.rawAccessible.indexInParent; + } catch (e) { + // Accessible is dead. + return -1; + } + } + + get actions() { + const actions = []; + if (this.isDefunct) { + return actions; + } + + for (let i = 0; i < this.rawAccessible.actionCount; i++) { + actions.push(this.rawAccessible.getActionDescription(i)); + } + return actions; + } + + get states() { + if (this.isDefunct) { + return []; + } + + const state = {}; + const extState = {}; + this.rawAccessible.getState(state, extState); + return [ + ...this.walker.a11yService.getStringStates(state.value, extState.value), + ]; + } + + get attributes() { + if (this.isDefunct || !this.rawAccessible.attributes) { + return {}; + } + + const attributes = {}; + for (const { key, value } of this.rawAccessible.attributes.enumerate()) { + attributes[key] = value; + } + + return attributes; + } + + get bounds() { + if (this.isDefunct) { + return null; + } + + let x = {}, + y = {}, + w = {}, + h = {}; + try { + this.rawAccessible.getBoundsInCSSPixels(x, y, w, h); + x = x.value; + y = y.value; + w = w.value; + h = h.value; + } catch (e) { + return null; + } + + // Check if accessible bounds are invalid. + const left = x, + right = x + w, + top = y, + bottom = y + h; + if (left === right || top === bottom) { + return null; + } + + return { x, y, w, h }; + } + + async getRelations() { + const relationObjects = []; + if (this.isDefunct) { + return relationObjects; + } + + const relations = [ + ...this.rawAccessible.getRelations().enumerate(Ci.nsIAccessibleRelation), + ]; + if (relations.length === 0) { + return relationObjects; + } + + const doc = await this.walker.getDocument(); + if (this.isDestroyed()) { + // This accessible actor is destroyed. + return relationObjects; + } + relations.forEach(relation => { + if (RELATIONS_TO_IGNORE.has(relation.relationType)) { + return; + } + + const type = this.walker.a11yService.getStringRelationType( + relation.relationType + ); + const targets = [...relation.getTargets().enumerate(Ci.nsIAccessible)]; + let relationObject; + for (const target of targets) { + let targetAcc; + try { + targetAcc = this.walker.attachAccessible(target, doc.rawAccessible); + } catch (e) { + // Target is not available. + } + + if (targetAcc) { + if (!relationObject) { + relationObject = { type, targets: [] }; + } + + relationObject.targets.push(targetAcc); + } + } + + if (relationObject) { + relationObjects.push(relationObject); + } + }); + + return relationObjects; + } + + get useChildTargetToFetchChildren() { + if (this.isDefunct) { + return false; + } + + return ( + this.rawAccessible.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME && + isFrameWithChildTarget( + this.walker.targetActor, + this.rawAccessible.DOMNode + ) + ); + } + + form() { + return { + actor: this.actorID, + role: this.role, + name: this.name, + useChildTargetToFetchChildren: this.useChildTargetToFetchChildren, + childCount: this.childCount, + checks: this._lastAudit, + }; + } + + /** + * Provide additional (full) information about the accessible object that is + * otherwise missing from the form. + * + * @return {Object} + * Object that contains accessible object information such as states, + * actions, attributes, etc. + */ + hydrate() { + return { + value: this.value, + description: this.description, + keyboardShortcut: this.keyboardShortcut, + domNodeType: this.domNodeType, + indexInParent: this.indexInParent, + states: this.states, + actions: this.actions, + attributes: this.attributes, + }; + } + + _isValidTextLeaf(rawAccessible) { + return ( + !isDefunct(rawAccessible) && + TEXT_ROLES.has(rawAccessible.role) && + rawAccessible.name && + !!rawAccessible.name.trim().length + ); + } + + /** + * Calculate the contrast ratio of the given accessible. + */ + async _getContrastRatio() { + if (!this._isValidTextLeaf(this.rawAccessible)) { + return null; + } + + const { bounds } = this; + if (!bounds) { + return null; + } + + const { DOMNode: rawNode } = this.rawAccessible; + const win = rawNode.ownerGlobal; + + // Keep the reference to the walker actor in case the actor gets destroyed + // during the colour contrast ratio calculation. + const { walker } = this; + await walker.clearStyles(win); + const contrastRatio = await getContrastRatioFor(rawNode.parentNode, { + bounds: getBounds(win, bounds), + win, + appliedColorMatrix: this.walker.colorMatrix, + }); + + if (this.isDestroyed()) { + // This accessible actor is destroyed. + return null; + } + await walker.restoreStyles(win); + + return contrastRatio; + } + + /** + * Run an accessibility audit for a given audit type. + * @param {String} type + * Type of an audit (Check AUDIT_TYPE in devtools/shared/constants + * to see available audit types). + * + * @return {null|Object} + * Object that contains accessible audit data for a given type or null + * if there's nothing to report for this accessible. + */ + _getAuditByType(type) { + switch (type) { + case AUDIT_TYPE.CONTRAST: + return this._getContrastRatio(); + case AUDIT_TYPE.KEYBOARD: + // Determine if keyboard accessibility is lacking where it is necessary. + return auditKeyboard(this.rawAccessible); + case AUDIT_TYPE.TEXT_LABEL: + // Determine if text alternative is missing for an accessible where it + // is necessary. + return auditTextLabel(this.rawAccessible); + default: + return null; + } + } + + /** + * Audit the state of the accessible object. + * + * @param {Object} options + * Options for running audit, may include: + * - types: Array of audit types to be performed during audit. + * + * @return {Object|null} + * Audit results for the accessible object. + */ + audit(options = {}) { + if (this._auditing) { + return this._auditing; + } + + const { types } = options; + let auditTypes = Object.values(AUDIT_TYPE); + if (types && types.length) { + auditTypes = auditTypes.filter(auditType => types.includes(auditType)); + } + + // For some reason keyboard checks for focus styling affect values (that are + // used by other types of checks (text names and values)) returned by + // accessible objects. This happens only when multiple checks are run at the + // same time (asynchronously) and the audit might return unexpected + // failures. We thus split the execution of the checks into two parts, first + // performing keyboard checks and only after the rest of the checks. See bug + // 1594743 for more detail. + let keyboardAuditResult; + const keyboardAuditIndex = auditTypes.indexOf(AUDIT_TYPE.KEYBOARD); + if (keyboardAuditIndex > -1) { + // If we are performing a keyboard audit, remove its value from the + // complete list and run it. + auditTypes.splice(keyboardAuditIndex, 1); + keyboardAuditResult = this._getAuditByType(AUDIT_TYPE.KEYBOARD); + } + + this._auditing = Promise.resolve(keyboardAuditResult) + .then(keyboardResult => { + const audits = auditTypes.map(auditType => + this._getAuditByType(auditType) + ); + + // If we are also performing a keyboard audit, add its type and its + // result back to the complete list of audits. + if (keyboardAuditIndex > -1) { + auditTypes.splice(keyboardAuditIndex, 0, AUDIT_TYPE.KEYBOARD); + audits.splice(keyboardAuditIndex, 0, keyboardResult); + } + + return Promise.all(audits); + }) + .then(results => { + if (this.isDefunct || this.isDestroyed()) { + return null; + } + + const audit = results.reduce((auditResults, result, index) => { + auditResults[auditTypes[index]] = result; + return auditResults; + }, {}); + this._lastAudit = this._lastAudit || {}; + Object.assign(this._lastAudit, audit); + events.emit(this, "audited", audit); + + return audit; + }) + .catch(error => { + if (!this.isDefunct && !this.isDestroyed()) { + throw error; + } + return null; + }) + .finally(() => { + this._auditing = null; + }); + + return this._auditing; + } + + snapshot() { + return getSnapshot( + this.rawAccessible, + this.walker.a11yService, + this.walker.targetActor + ); + } +} + +exports.AccessibleActor = AccessibleActor; |