summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/accessibility
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/server/actors/accessibility
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/actors/accessibility')
-rw-r--r--devtools/server/actors/accessibility/accessibility.js130
-rw-r--r--devtools/server/actors/accessibility/accessible.js675
-rw-r--r--devtools/server/actors/accessibility/audit/contrast.js306
-rw-r--r--devtools/server/actors/accessibility/audit/keyboard.js514
-rw-r--r--devtools/server/actors/accessibility/audit/moz.build12
-rw-r--r--devtools/server/actors/accessibility/audit/text-label.js438
-rw-r--r--devtools/server/actors/accessibility/constants.js59
-rw-r--r--devtools/server/actors/accessibility/moz.build20
-rw-r--r--devtools/server/actors/accessibility/parent-accessibility.js154
-rw-r--r--devtools/server/actors/accessibility/simulator.js81
-rw-r--r--devtools/server/actors/accessibility/walker.js1324
-rw-r--r--devtools/server/actors/accessibility/worker.js105
12 files changed, 3818 insertions, 0 deletions
diff --git a/devtools/server/actors/accessibility/accessibility.js b/devtools/server/actors/accessibility/accessibility.js
new file mode 100644
index 0000000000..6bdf0e9f32
--- /dev/null
+++ b/devtools/server/actors/accessibility/accessibility.js
@@ -0,0 +1,130 @@
+/* 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 {
+ accessibilitySpec,
+} = require("resource://devtools/shared/specs/accessibility.js");
+
+loader.lazyRequireGetter(
+ this,
+ "AccessibleWalkerActor",
+ "resource://devtools/server/actors/accessibility/walker.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "SimulatorActor",
+ "resource://devtools/server/actors/accessibility/simulator.js",
+ true
+);
+
+/**
+ * The AccessibilityActor is a top level container actor that initializes
+ * accessible walker and is the top-most point of interaction for accessibility
+ * tools UI for a top level content process.
+ */
+class AccessibilityActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, accessibilitySpec);
+ // This event is fired when accessibility service is initialized or shut
+ // down. "init" and "shutdown" events are only relayed when the enabled
+ // state matches the event (e.g. the event came from the same process as
+ // the actor).
+ Services.obs.addObserver(this, "a11y-init-or-shutdown");
+ this.targetActor = targetActor;
+ }
+
+ getTraits() {
+ // The traits are used to know if accessibility actors support particular
+ // API on the server side.
+ return {
+ // @backward-compat { version 84 } Fixed on the server by Bug 1654956.
+ tabbingOrder: true,
+ };
+ }
+
+ bootstrap() {
+ return {
+ enabled: this.enabled,
+ };
+ }
+
+ get enabled() {
+ return Services.appinfo.accessibilityEnabled;
+ }
+
+ /**
+ * Observe Accessibility service init and shutdown events. It relays these
+ * events to AccessibilityFront if the event is fired for the a11y service
+ * that lives in the same process.
+ *
+ * @param {null} subject
+ * Not used.
+ * @param {String} topic
+ * Name of the a11y service event: "a11y-init-or-shutdown".
+ * @param {String} data
+ * "0" corresponds to shutdown and "1" to init.
+ */
+ observe(subject, topic, data) {
+ const enabled = data === "1";
+ if (enabled && this.enabled) {
+ this.emit("init");
+ } else if (!enabled && !this.enabled) {
+ if (this.walker) {
+ this.walker.reset();
+ }
+
+ this.emit("shutdown");
+ }
+ }
+
+ /**
+ * Get or create AccessibilityWalker actor, similar to WalkerActor.
+ *
+ * @return {Object}
+ * AccessibleWalkerActor for the current tab.
+ */
+ getWalker() {
+ if (!this.walker) {
+ this.walker = new AccessibleWalkerActor(this.conn, this.targetActor);
+ this.manage(this.walker);
+ }
+ return this.walker;
+ }
+
+ /**
+ * Get or create Simulator actor, managed by AccessibilityActor,
+ * only if webrender is enabled. Simulator applies color filters on an entire
+ * viewport. This needs to be done using webrender and not an SVG
+ * <feColorMatrix> since it is accelerated and scrolling with filter applied
+ * needs to be smooth (Bug1431466).
+ *
+ * @return {Object|null}
+ * SimulatorActor for the current tab.
+ */
+ getSimulator() {
+ if (!this.simulator) {
+ this.simulator = new SimulatorActor(this.conn, this.targetActor);
+ this.manage(this.simulator);
+ }
+
+ return this.simulator;
+ }
+
+ /**
+ * Destroy accessibility actor. This method also shutsdown accessibility
+ * service if possible.
+ */
+ async destroy() {
+ super.destroy();
+ Services.obs.removeObserver(this, "a11y-init-or-shutdown");
+ this.walker = null;
+ this.targetActor = null;
+ }
+}
+
+exports.AccessibilityActor = AccessibilityActor;
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;
diff --git a/devtools/server/actors/accessibility/audit/contrast.js b/devtools/server/actors/accessibility/audit/contrast.js
new file mode 100644
index 0000000000..68e7b497f8
--- /dev/null
+++ b/devtools/server/actors/accessibility/audit/contrast.js
@@ -0,0 +1,306 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getCurrentZoom",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "addPseudoClassLock",
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "removePseudoClassLock",
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getContrastRatioAgainstBackground",
+ "resource://devtools/shared/accessibility.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getTextProperties",
+ "resource://devtools/shared/accessibility.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "DevToolsWorker",
+ "resource://devtools/shared/worker/worker.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "InspectorActorUtils",
+ "resource://devtools/server/actors/inspector/utils.js"
+);
+
+const WORKER_URL = "resource://devtools/server/actors/accessibility/worker.js";
+const HIGHLIGHTED_PSEUDO_CLASS = ":-moz-devtools-highlighted";
+const {
+ LARGE_TEXT: { BOLD_LARGE_TEXT_MIN_PIXELS, LARGE_TEXT_MIN_PIXELS },
+} = require("resource://devtools/shared/accessibility.js");
+
+loader.lazyGetter(this, "worker", () => new DevToolsWorker(WORKER_URL));
+
+/**
+ * Get canvas rendering context for the current target window bound by the bounds of the
+ * accessible objects.
+ * @param {Object} win
+ * Current target window.
+ * @param {Object} bounds
+ * Bounds for the accessible object.
+ * @param {Object} zoom
+ * Current zoom level for the window.
+ * @param {Object} scale
+ * Scale value to scale down the drawn image.
+ * @param {null|DOMNode} node
+ * If not null, a node that corresponds to the accessible object to be used to
+ * make its text color transparent.
+ * @return {CanvasRenderingContext2D}
+ * Canvas rendering context for the current window.
+ */
+function getImageCtx(win, bounds, zoom, scale, node) {
+ const doc = win.document;
+ const canvas = doc.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
+
+ const { left, top, width, height } = bounds;
+ canvas.width = width * zoom * scale;
+ canvas.height = height * zoom * scale;
+ const ctx = canvas.getContext("2d", { alpha: false });
+ ctx.imageSmoothingEnabled = false;
+ ctx.scale(scale, scale);
+
+ // If node is passed, make its color related text properties invisible.
+ if (node) {
+ addPseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS);
+ }
+
+ ctx.drawWindow(
+ win,
+ left * zoom,
+ top * zoom,
+ width * zoom,
+ height * zoom,
+ "#fff",
+ ctx.DRAWWINDOW_USE_WIDGET_LAYERS
+ );
+
+ // Restore all inline styling.
+ if (node) {
+ removePseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS);
+ }
+
+ return ctx;
+}
+
+/**
+ * Calculate the transformed RGBA when a color matrix is set in docShell by
+ * multiplying the color matrix with the RGBA vector.
+ *
+ * @param {Array} rgba
+ * Original RGBA array which we want to transform.
+ * @param {Array} colorMatrix
+ * Flattened 4x5 color matrix that is set in docShell.
+ * A 4x5 matrix of the form:
+ * 1 2 3 4 5
+ * 6 7 8 9 10
+ * 11 12 13 14 15
+ * 16 17 18 19 20
+ * will be set in docShell as:
+ * [1, 6, 11, 16, 2, 7, 12, 17, 3, 8, 13, 18, 4, 9, 14, 19, 5, 10, 15, 20]
+ * @return {Array}
+ * Transformed RGBA after the color matrix is multiplied with the original RGBA.
+ */
+function getTransformedRGBA(rgba, colorMatrix) {
+ const transformedRGBA = [0, 0, 0, 0];
+
+ // Only use the first four columns of the color matrix corresponding to R, G, B and A
+ // color channels respectively. The fifth column is a fixed offset that does not need
+ // to be considered for the matrix multiplication. We end up multiplying a 4x4 color
+ // matrix with a 4x1 RGBA vector.
+ for (let i = 0; i < 16; i++) {
+ const row = i % 4;
+ const col = Math.floor(i / 4);
+ transformedRGBA[row] += colorMatrix[i] * rgba[col];
+ }
+
+ return transformedRGBA;
+}
+
+/**
+ * Find RGBA or a range of RGBAs for the background pixels under the text.
+ *
+ * @param {DOMNode} node
+ * Node for which we want to get the background color data.
+ * @param {Object} options
+ * - bounds {Object}
+ * Bounds for the accessible object.
+ * - win {Object}
+ * Target window.
+ * - size {Number}
+ * Font size of the selected text node
+ * - isBoldText {Boolean}
+ * True if selected text node is bold
+ * @return {Object}
+ * Object with one or more of the following RGBA fields: value, min, max
+ */
+function getBackgroundFor(node, { win, bounds, size, isBoldText }) {
+ const zoom = 1 / getCurrentZoom(win);
+ // When calculating colour contrast, we traverse image data for text nodes that are
+ // drawn both with and without transparent text. Image data arrays are typically really
+ // big. In cases when the font size is fairly large or when the page is zoomed in image
+ // data is especially large (retrieving it and/or traversing it takes significant amount
+ // of time). Here we optimize the size of the image data by scaling down the drawn nodes
+ // to a size where their text size equals either BOLD_LARGE_TEXT_MIN_PIXELS or
+ // LARGE_TEXT_MIN_PIXELS (lower threshold for large text size) depending on the font
+ // weight.
+ //
+ // IMPORTANT: this optimization, in some cases where background colour is non-uniform
+ // (gradient or image), can result in small (not noticeable) blending of the background
+ // colours. In turn this might affect the reported values of the contrast ratio. The
+ // delta is fairly small (<0.1) to noticeably skew the results.
+ //
+ // NOTE: this optimization does not help in cases where contrast is being calculated for
+ // nodes with a lot of text.
+ let scale =
+ ((isBoldText ? BOLD_LARGE_TEXT_MIN_PIXELS : LARGE_TEXT_MIN_PIXELS) / size) *
+ zoom;
+ // We do not need to scale the images if the font is smaller than large or if the page
+ // is zoomed out (scaling in this case would've been scaling up).
+ scale = scale > 1 ? 1 : scale;
+
+ const textContext = getImageCtx(win, bounds, zoom, scale);
+ const backgroundContext = getImageCtx(win, bounds, zoom, scale, node);
+
+ const { data: dataText } = textContext.getImageData(
+ 0,
+ 0,
+ bounds.width * scale,
+ bounds.height * scale
+ );
+ const { data: dataBackground } = backgroundContext.getImageData(
+ 0,
+ 0,
+ bounds.width * scale,
+ bounds.height * scale
+ );
+
+ return worker.performTask(
+ "getBgRGBA",
+ {
+ dataTextBuf: dataText.buffer,
+ dataBackgroundBuf: dataBackground.buffer,
+ },
+ [dataText.buffer, dataBackground.buffer]
+ );
+}
+
+/**
+ * Calculates the contrast ratio of the referenced DOM node.
+ *
+ * @param {DOMNode} node
+ * The node for which we want to calculate the contrast ratio.
+ * @param {Object} options
+ * - bounds {Object}
+ * Bounds for the accessible object.
+ * - win {Object}
+ * Target window.
+ * - appliedColorMatrix {Array|null}
+ * Simulation color matrix applied to
+ * to the viewport, if it exists.
+ * @return {Object}
+ * An object that may contain one or more of the following fields: error,
+ * isLargeText, value, min, max values for contrast.
+ */
+async function getContrastRatioFor(node, options = {}) {
+ const computedStyle = CssLogic.getComputedStyle(node);
+ const props = computedStyle ? getTextProperties(computedStyle) : null;
+
+ if (!props) {
+ return {
+ error: true,
+ };
+ }
+
+ const { isLargeText, isBoldText, size, opacity } = props;
+ const { appliedColorMatrix } = options;
+ const color = appliedColorMatrix
+ ? getTransformedRGBA(props.color, appliedColorMatrix)
+ : props.color;
+ let rgba = await getBackgroundFor(node, {
+ ...options,
+ isBoldText,
+ size,
+ });
+
+ if (!rgba) {
+ // Fallback (original) contrast calculation algorithm. It tries to get the
+ // closest background colour for the node and use it to calculate contrast.
+ const backgroundColor = InspectorActorUtils.getClosestBackgroundColor(node);
+ const backgroundImage = InspectorActorUtils.getClosestBackgroundImage(node);
+
+ if (backgroundImage !== "none") {
+ // Both approaches failed, at this point we don't have a better one yet.
+ return {
+ error: true,
+ };
+ }
+
+ let { r, g, b, a } = InspectorUtils.colorToRGBA(backgroundColor);
+ // If the element has opacity in addition to background alpha value, take it
+ // into account. TODO: this does not handle opacity set on ancestor
+ // elements (see bug https://bugzilla.mozilla.org/show_bug.cgi?id=1544721).
+ if (opacity < 1) {
+ a = opacity * a;
+ }
+
+ return getContrastRatioAgainstBackground(
+ {
+ value: appliedColorMatrix
+ ? getTransformedRGBA([r, g, b, a], appliedColorMatrix)
+ : [r, g, b, a],
+ },
+ {
+ color,
+ isLargeText,
+ }
+ );
+ }
+
+ if (appliedColorMatrix) {
+ rgba = rgba.value
+ ? {
+ value: getTransformedRGBA(rgba.value, appliedColorMatrix),
+ }
+ : {
+ min: getTransformedRGBA(rgba.min, appliedColorMatrix),
+ max: getTransformedRGBA(rgba.max, appliedColorMatrix),
+ };
+ }
+
+ return getContrastRatioAgainstBackground(rgba, {
+ color,
+ isLargeText,
+ });
+}
+
+exports.getContrastRatioFor = getContrastRatioFor;
+exports.getBackgroundFor = getBackgroundFor;
diff --git a/devtools/server/actors/accessibility/audit/keyboard.js b/devtools/server/actors/accessibility/audit/keyboard.js
new file mode 100644
index 0000000000..d1b13dbbf6
--- /dev/null
+++ b/devtools/server/actors/accessibility/audit/keyboard.js
@@ -0,0 +1,514 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getCSSStyleRules",
+ "resource://devtools/shared/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "nodeConstants",
+ "resource://devtools/shared/dom-node-constants.js"
+);
+loader.lazyRequireGetter(
+ this,
+ ["isDefunct", "getAriaRoles"],
+ "resource://devtools/server/actors/utils/accessibility.js",
+ true
+);
+
+const {
+ accessibility: {
+ AUDIT_TYPE: { KEYBOARD },
+ ISSUE_TYPE: {
+ [KEYBOARD]: {
+ FOCUSABLE_NO_SEMANTICS,
+ FOCUSABLE_POSITIVE_TABINDEX,
+ INTERACTIVE_NO_ACTION,
+ INTERACTIVE_NOT_FOCUSABLE,
+ MOUSE_INTERACTIVE_ONLY,
+ NO_FOCUS_VISIBLE,
+ },
+ },
+ SCORES: { FAIL, WARNING },
+ },
+} = require("resource://devtools/shared/constants.js");
+
+// Specified by the author CSS rule type.
+const STYLE_RULE = 1;
+
+// Accessible action for showing long description.
+const CLICK_ACTION = "click";
+
+/**
+ * Focus specific pseudo classes that the keyboard audit simulates to determine
+ * focus styling.
+ */
+const FOCUS_PSEUDO_CLASS = ":focus";
+const MOZ_FOCUSRING_PSEUDO_CLASS = ":-moz-focusring";
+
+const KEYBOARD_FOCUSABLE_ROLES = new Set([
+ Ci.nsIAccessibleRole.ROLE_BUTTONMENU,
+ Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
+ Ci.nsIAccessibleRole.ROLE_COMBOBOX,
+ Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX,
+ Ci.nsIAccessibleRole.ROLE_ENTRY,
+ Ci.nsIAccessibleRole.ROLE_LINK,
+ Ci.nsIAccessibleRole.ROLE_LISTBOX,
+ Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT,
+ Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
+ Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
+ Ci.nsIAccessibleRole.ROLE_SLIDER,
+ Ci.nsIAccessibleRole.ROLE_SPINBUTTON,
+ Ci.nsIAccessibleRole.ROLE_SUMMARY,
+ Ci.nsIAccessibleRole.ROLE_SWITCH,
+ Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
+]);
+
+const INTERACTIVE_ROLES = new Set([
+ ...KEYBOARD_FOCUSABLE_ROLES,
+ Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM,
+ Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
+ Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION,
+ Ci.nsIAccessibleRole.ROLE_MENUITEM,
+ Ci.nsIAccessibleRole.ROLE_OPTION,
+ Ci.nsIAccessibleRole.ROLE_OUTLINE,
+ Ci.nsIAccessibleRole.ROLE_OUTLINEITEM,
+ Ci.nsIAccessibleRole.ROLE_PAGETAB,
+ Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM,
+ Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM,
+ Ci.nsIAccessibleRole.ROLE_RICH_OPTION,
+]);
+
+const INTERACTIVE_IF_FOCUSABLE_ROLES = new Set([
+ // If article is focusable, we can assume it is inside a feed.
+ Ci.nsIAccessibleRole.ROLE_ARTICLE,
+ // Column header can be focusable.
+ Ci.nsIAccessibleRole.ROLE_COLUMNHEADER,
+ Ci.nsIAccessibleRole.ROLE_GRID_CELL,
+ Ci.nsIAccessibleRole.ROLE_MENUBAR,
+ Ci.nsIAccessibleRole.ROLE_MENUPOPUP,
+ Ci.nsIAccessibleRole.ROLE_PAGETABLIST,
+ // Row header can be focusable.
+ Ci.nsIAccessibleRole.ROLE_ROWHEADER,
+ Ci.nsIAccessibleRole.ROLE_SCROLLBAR,
+ Ci.nsIAccessibleRole.ROLE_SEPARATOR,
+ Ci.nsIAccessibleRole.ROLE_TOOLBAR,
+]);
+
+/**
+ * Determine if a node is dead or is not an element node.
+ *
+ * @param {DOMNode} node
+ * Node to be tested for validity.
+ *
+ * @returns {Boolean}
+ * True if the node is either dead or is not an element node.
+ */
+function isInvalidNode(node) {
+ return (
+ !node ||
+ Cu.isDeadWrapper(node) ||
+ node.nodeType !== nodeConstants.ELEMENT_NODE ||
+ !node.ownerGlobal
+ );
+}
+
+/**
+ * Determine if accessible is focusable with the keyboard.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible for which to determine if it is keyboard focusable.
+ *
+ * @returns {Boolean}
+ * True if focusable with the keyboard.
+ */
+function isKeyboardFocusable(accessible) {
+ const state = {};
+ accessible.getState(state, {});
+ // State will be focusable even if the tabindex is negative.
+ return (
+ state.value & Ci.nsIAccessibleStates.STATE_FOCUSABLE &&
+ // Platform accessibility will still report STATE_FOCUSABLE even with the
+ // tabindex="-1" so we need to check that it is >= 0 to be considered
+ // keyboard focusable.
+ accessible.DOMNode.tabIndex > -1
+ );
+}
+
+/**
+ * Determine if a current node has focus specific styling by applying a
+ * focus-related pseudo class (such as :focus or :-moz-focusring) to a focusable
+ * node.
+ *
+ * @param {DOMNode} focusableNode
+ * Node to apply focus-related pseudo class to.
+ * @param {DOMNode} currentNode
+ * Node to be checked for having focus specific styling.
+ * @param {String} pseudoClass
+ * A focus related pseudo-class to be simulated for style comparison.
+ *
+ * @returns {Boolean}
+ * True if the currentNode has focus specific styling.
+ */
+function hasStylesForFocusRelatedPseudoClass(
+ focusableNode,
+ currentNode,
+ pseudoClass
+) {
+ const defaultRules = getCSSStyleRules(currentNode);
+
+ InspectorUtils.addPseudoClassLock(focusableNode, pseudoClass);
+
+ // Determine a set of properties that are specific to CSS rules that are only
+ // present when a focus related pseudo-class is locked in.
+ const tempRules = getCSSStyleRules(currentNode);
+ const properties = new Set();
+ for (const rule of tempRules) {
+ if (rule.type !== STYLE_RULE) {
+ continue;
+ }
+
+ if (!defaultRules.includes(rule)) {
+ for (let index = 0; index < rule.style.length; index++) {
+ properties.add(rule.style.item(index));
+ }
+ }
+ }
+
+ // If there are no focus specific CSS rules or properties, currentNode does
+ // node have any focus specific styling, we are done.
+ if (properties.size === 0) {
+ InspectorUtils.removePseudoClassLock(focusableNode, pseudoClass);
+ return false;
+ }
+
+ // Determine values for properties that are focus specific.
+ const tempStyle = CssLogic.getComputedStyle(currentNode);
+ const focusStyle = {};
+ for (const name of properties.values()) {
+ focusStyle[name] = tempStyle.getPropertyValue(name);
+ }
+
+ InspectorUtils.removePseudoClassLock(focusableNode, pseudoClass);
+
+ // If values for focus specific properties are different from default style
+ // values, assume we have focus spefic styles for the currentNode.
+ const defaultStyle = CssLogic.getComputedStyle(currentNode);
+ for (const name of properties.values()) {
+ if (defaultStyle.getPropertyValue(name) !== focusStyle[name]) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Check if an element node (currentNode) has distinct focus styling. This
+ * function also takes into account a case when focus styling is applied to a
+ * descendant too.
+ *
+ * @param {DOMNode} focusableNode
+ * Node to apply focus-related pseudo class to.
+ * @param {DOMNode} currentNode
+ * Node to be checked for having focus specific styling.
+ *
+ * @returns {Boolean}
+ * True if the node or its descendant has distinct focus styling.
+ */
+function hasFocusStyling(focusableNode, currentNode) {
+ if (isInvalidNode(currentNode)) {
+ return false;
+ }
+
+ // Check if an element node has distinct :-moz-focusring styling.
+ const hasStylesForMozFocusring = hasStylesForFocusRelatedPseudoClass(
+ focusableNode,
+ currentNode,
+ MOZ_FOCUSRING_PSEUDO_CLASS
+ );
+ if (hasStylesForMozFocusring) {
+ return true;
+ }
+
+ // Check if an element node has distinct :focus styling.
+ const hasStylesForFocus = hasStylesForFocusRelatedPseudoClass(
+ focusableNode,
+ currentNode,
+ FOCUS_PSEUDO_CLASS
+ );
+ if (hasStylesForFocus) {
+ return true;
+ }
+
+ // If no element specific focus styles where found, check if its element
+ // children have them.
+ for (
+ let child = currentNode.firstElementChild;
+ child;
+ child = currentNode.nextnextElementSibling
+ ) {
+ if (hasFocusStyling(focusableNode, child)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * A rule that determines if a focusable accessible object has appropriate focus
+ * styling.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible to be checked for being focusable and having focus
+ * styling.
+ *
+ * @return {null|Object}
+ * Null if accessible has keyboard focus styling, audit report object
+ * otherwise.
+ */
+function focusStyleRule(accessible) {
+ const { DOMNode } = accessible;
+ if (isInvalidNode(DOMNode)) {
+ return null;
+ }
+
+ // Ignore non-focusable elements.
+ if (!isKeyboardFocusable(accessible)) {
+ return null;
+ }
+
+ if (hasFocusStyling(DOMNode, DOMNode)) {
+ return null;
+ }
+
+ // If no browser or author focus styling was found, check if the node is a
+ // widget that is themed by platform native theme.
+ if (InspectorUtils.isElementThemed(DOMNode)) {
+ return null;
+ }
+
+ return { score: WARNING, issue: NO_FOCUS_VISIBLE };
+}
+
+/**
+ * A rule that determines if an interactive accessible has any associated
+ * accessible actions with it. If the element is interactive but and has no
+ * actions, assistive technology users will not be able to interact with it.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible to be checked for being interactive and having accessible
+ * actions.
+ *
+ * @return {null|Object}
+ * Null if accessible is not interactive or if it is and it has
+ * accessible action associated with it, audit report object otherwise.
+ */
+function interactiveRule(accessible) {
+ if (!INTERACTIVE_ROLES.has(accessible.role)) {
+ return null;
+ }
+
+ if (accessible.actionCount > 0) {
+ return null;
+ }
+
+ return { score: FAIL, issue: INTERACTIVE_NO_ACTION };
+}
+
+/**
+ * A rule that determines if an interactive accessible is also focusable when
+ * not disabled.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible to be checked for being interactive and being focusable
+ * when enabled.
+ *
+ * @return {null|Object}
+ * Null if accessible is not interactive or if it is and it is focusable
+ * when enabled, audit report object otherwise.
+ */
+function focusableRule(accessible) {
+ if (!KEYBOARD_FOCUSABLE_ROLES.has(accessible.role)) {
+ return null;
+ }
+
+ const state = {};
+ accessible.getState(state, {});
+ // We only expect in interactive accessible object to be focusable if it is
+ // not disabled.
+ if (state.value & Ci.nsIAccessibleStates.STATE_UNAVAILABLE) {
+ return null;
+ }
+
+ if (isKeyboardFocusable(accessible)) {
+ return null;
+ }
+
+ const ariaRoles = getAriaRoles(accessible);
+ if (
+ ariaRoles &&
+ (ariaRoles.includes("combobox") || ariaRoles.includes("listbox"))
+ ) {
+ // Do not force ARIA combobox or listbox to be focusable.
+ return null;
+ }
+
+ return { score: FAIL, issue: INTERACTIVE_NOT_FOCUSABLE };
+}
+
+/**
+ * A rule that determines if a focusable accessible has an associated
+ * interactive role.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible to be checked for having an interactive role if it is
+ * focusable.
+ *
+ * @return {null|Object}
+ * Null if accessible is not interactive or if it is and it has an
+ * interactive role, audit report object otherwise.
+ */
+function semanticsRule(accessible) {
+ if (
+ INTERACTIVE_ROLES.has(accessible.role) ||
+ // Visible listboxes will have focusable state when inside comboboxes.
+ accessible.role === Ci.nsIAccessibleRole.ROLE_COMBOBOX_LIST
+ ) {
+ return null;
+ }
+
+ if (isKeyboardFocusable(accessible)) {
+ if (INTERACTIVE_IF_FOCUSABLE_ROLES.has(accessible.role)) {
+ return null;
+ }
+
+ // ROLE_TABLE is used for grids too which are considered interactive.
+ if (accessible.role === Ci.nsIAccessibleRole.ROLE_TABLE) {
+ const ariaRoles = getAriaRoles(accessible);
+ if (ariaRoles && ariaRoles.includes("grid")) {
+ return null;
+ }
+ }
+
+ return { score: WARNING, issue: FOCUSABLE_NO_SEMANTICS };
+ }
+
+ const state = {};
+ accessible.getState(state, {});
+ if (
+ // Ignore text leafs.
+ accessible.role === Ci.nsIAccessibleRole.ROLE_TEXT_LEAF ||
+ // Ignore accessibles with no accessible actions.
+ accessible.actionCount === 0 ||
+ // Ignore labels that have a label for relation with their target because
+ // they are clickable.
+ (accessible.role === Ci.nsIAccessibleRole.ROLE_LABEL &&
+ accessible.getRelationByType(Ci.nsIAccessibleRelation.RELATION_LABEL_FOR)
+ .targetsCount > 0) ||
+ // Ignore images that are inside an anchor (have linked state).
+ (accessible.role === Ci.nsIAccessibleRole.ROLE_GRAPHIC &&
+ state.value & Ci.nsIAccessibleStates.STATE_LINKED)
+ ) {
+ return null;
+ }
+
+ // Ignore anything but a click action in the list of actions.
+ for (let i = 0; i < accessible.actionCount; i++) {
+ if (accessible.getActionName(i) === CLICK_ACTION) {
+ return { score: FAIL, issue: MOUSE_INTERACTIVE_ONLY };
+ }
+ }
+
+ return null;
+}
+
+/**
+ * A rule that determines if an element associated with a focusable accessible
+ * has a positive tabindex.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible to be checked for having an element with positive tabindex
+ * attribute.
+ *
+ * @return {null|Object}
+ * Null if accessible is not focusable or if it is and its element's
+ * tabindex attribute is less than 1, audit report object otherwise.
+ */
+function tabIndexRule(accessible) {
+ const { DOMNode } = accessible;
+ if (isInvalidNode(DOMNode)) {
+ return null;
+ }
+
+ if (!isKeyboardFocusable(accessible)) {
+ return null;
+ }
+
+ if (DOMNode.tabIndex > 0) {
+ return { score: WARNING, issue: FOCUSABLE_POSITIVE_TABINDEX };
+ }
+
+ return null;
+}
+
+function auditKeyboard(accessible) {
+ if (isDefunct(accessible)) {
+ return null;
+ }
+ // Do not test anything on accessible objects for documents or frames.
+ if (
+ accessible.role === Ci.nsIAccessibleRole.ROLE_DOCUMENT ||
+ accessible.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME
+ ) {
+ return null;
+ }
+
+ // Check if interactive accessible can be used by the assistive
+ // technology.
+ let issue = interactiveRule(accessible);
+ if (issue) {
+ return issue;
+ }
+
+ // Check if interactive accessible is also focusable when enabled.
+ issue = focusableRule(accessible);
+ if (issue) {
+ return issue;
+ }
+
+ // Check if accessible object has an element with a positive tabindex.
+ issue = tabIndexRule(accessible);
+ if (issue) {
+ return issue;
+ }
+
+ // Check if a focusable accessible has interactive semantics.
+ issue = semanticsRule(accessible);
+ if (issue) {
+ return issue;
+ }
+
+ // Check if focusable accessible has associated focus styling.
+ issue = focusStyleRule(accessible);
+ if (issue) {
+ return issue;
+ }
+
+ return issue;
+}
+
+module.exports.auditKeyboard = auditKeyboard;
diff --git a/devtools/server/actors/accessibility/audit/moz.build b/devtools/server/actors/accessibility/audit/moz.build
new file mode 100644
index 0000000000..01bd0af849
--- /dev/null
+++ b/devtools/server/actors/accessibility/audit/moz.build
@@ -0,0 +1,12 @@
+# 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/.
+
+DevToolsModules(
+ "contrast.js",
+ "keyboard.js",
+ "text-label.js",
+)
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "Accessibility Tools")
diff --git a/devtools/server/actors/accessibility/audit/text-label.js b/devtools/server/actors/accessibility/audit/text-label.js
new file mode 100644
index 0000000000..8570c5cce8
--- /dev/null
+++ b/devtools/server/actors/accessibility/audit/text-label.js
@@ -0,0 +1,438 @@
+/* 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 {
+ accessibility: {
+ AUDIT_TYPE: { TEXT_LABEL },
+ ISSUE_TYPE,
+ SCORES: { BEST_PRACTICES, FAIL, WARNING },
+ },
+} = require("resource://devtools/shared/constants.js");
+
+const {
+ AREA_NO_NAME_FROM_ALT,
+ DIALOG_NO_NAME,
+ DOCUMENT_NO_TITLE,
+ EMBED_NO_NAME,
+ FIGURE_NO_NAME,
+ FORM_FIELDSET_NO_NAME,
+ FORM_FIELDSET_NO_NAME_FROM_LEGEND,
+ FORM_NO_NAME,
+ FORM_NO_VISIBLE_NAME,
+ FORM_OPTGROUP_NO_NAME_FROM_LABEL,
+ FRAME_NO_NAME,
+ HEADING_NO_CONTENT,
+ HEADING_NO_NAME,
+ IFRAME_NO_NAME_FROM_TITLE,
+ IMAGE_NO_NAME,
+ INTERACTIVE_NO_NAME,
+ MATHML_GLYPH_NO_NAME,
+ TOOLBAR_NO_NAME,
+} = ISSUE_TYPE[TEXT_LABEL];
+
+/**
+ * Check if the accessible is visible to the assistive technology.
+ * @param {nsIAccessible} accessible
+ * Accessible object to be tested for visibility.
+ *
+ * @returns {Boolean}
+ * True if accessible object is visible to assistive technology.
+ */
+function isVisible(accessible) {
+ const state = {};
+ accessible.getState(state, {});
+ return !(state.value & Ci.nsIAccessibleStates.STATE_INVISIBLE);
+}
+
+/**
+ * Get related accessible objects that are targets of labelled by relation e.g.
+ * labels.
+ * @param {nsIAccessible} accessible
+ * Accessible objects to get labels for.
+ *
+ * @returns {Array}
+ * A list of accessible objects that are labels for a given accessible.
+ */
+function getLabels(accessible) {
+ const relation = accessible.getRelationByType(
+ Ci.nsIAccessibleRelation.RELATION_LABELLED_BY
+ );
+ return [...relation.getTargets().enumerate(Ci.nsIAccessible)];
+}
+
+/**
+ * Get a trimmed name of the accessible object.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible objects to get a name for.
+ *
+ * @returns {null|String}
+ * Trimmed name of the accessible object if available.
+ */
+function getAccessibleName(accessible) {
+ return accessible.name && accessible.name.trim();
+}
+
+/**
+ * A text label rule for accessible objects that must have a non empty
+ * accessible name.
+ *
+ * @returns {null|Object}
+ * Failure audit report if accessible object has no or empty name, null
+ * otherwise.
+ */
+const mustHaveNonEmptyNameRule = function (issue, accessible) {
+ const name = getAccessibleName(accessible);
+ return name ? null : { score: FAIL, issue };
+};
+
+/**
+ * A text label rule for accessible objects that should have a non empty
+ * accessible name as a best practice.
+ *
+ * @returns {null|Object}
+ * Best practices audit report if accessible object has no or empty
+ * name, null otherwise.
+ */
+const shouldHaveNonEmptyNameRule = function (issue, accessible) {
+ const name = getAccessibleName(accessible);
+ return name ? null : { score: BEST_PRACTICES, issue };
+};
+
+/**
+ * A text label rule for accessible objects that can be activated via user
+ * action and must have a non-empty name.
+ *
+ * @returns {null|Object}
+ * Failure audit report if interactive accessible object has no or
+ * empty name, null otherwise.
+ */
+const interactiveRule = mustHaveNonEmptyNameRule.bind(
+ null,
+ INTERACTIVE_NO_NAME
+);
+
+/**
+ * A text label rule for accessible objects that correspond to dialogs and thus
+ * should have a non-empty name.
+ *
+ * @returns {null|Object}
+ * Best practices audit report if dialog accessible object has no or
+ * empty name, null otherwise.
+ */
+const dialogRule = shouldHaveNonEmptyNameRule.bind(null, DIALOG_NO_NAME);
+
+/**
+ * A text label rule for accessible objects that provide visual information
+ * (images, canvas, etc.) and must have a defined name (that can be empty, e.g.
+ * "").
+ *
+ * @returns {null|Object}
+ * Failure audit report if interactive accessible object has no name,
+ * null otherwise.
+ */
+const imageRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ return name != null ? null : { score: FAIL, issue: IMAGE_NO_NAME };
+};
+
+/**
+ * A text label rule for accessible objects that correspond to form elements.
+ * These objects must have a non-empty name and must have a visible label.
+ *
+ * @returns {null|Object}
+ * Failure audit report if form element accessible object has no name,
+ * warning if the name does not come from a visible label, null
+ * otherwise.
+ */
+const formRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ if (!name) {
+ return { score: FAIL, issue: FORM_NO_NAME };
+ }
+
+ const labels = getLabels(accessible);
+ const hasNameFromVisibleLabel = labels.some(label => isVisible(label));
+
+ return hasNameFromVisibleLabel
+ ? null
+ : { score: WARNING, issue: FORM_NO_VISIBLE_NAME };
+};
+
+/**
+ * A text label rule for elements that map to ROLE_GROUPING:
+ * * <OPTGROUP> must have a non-empty name and must be provided via the
+ * "label" attribute.
+ * * <FIELDSET> must have a non-empty name and must be provided via the
+ * corresponding <LEGEND> element.
+ *
+ * @returns {null|Object}
+ * Failure audit report if form grouping accessible object has no name,
+ * or has a name that is not derived from a required location, null
+ * otherwise.
+ */
+const formGroupingRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ const { DOMNode } = accessible;
+
+ switch (DOMNode.nodeName) {
+ case "OPTGROUP":
+ return name && DOMNode.label && DOMNode.label.trim() === name
+ ? null
+ : {
+ score: FAIL,
+ issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL,
+ };
+ case "FIELDSET":
+ if (!name) {
+ return { score: FAIL, issue: FORM_FIELDSET_NO_NAME };
+ }
+
+ const labels = getLabels(accessible);
+ const hasNameFromLegend = labels.some(
+ label =>
+ label.DOMNode.nodeName === "LEGEND" &&
+ label.name &&
+ label.name.trim() === name &&
+ isVisible(label)
+ );
+
+ return hasNameFromLegend
+ ? null
+ : {
+ score: WARNING,
+ issue: FORM_FIELDSET_NO_NAME_FROM_LEGEND,
+ };
+ default:
+ return null;
+ }
+};
+
+/**
+ * A text label rule for elements that map to ROLE_TEXT_CONTAINER:
+ * * <METER> mapps to ROLE_TEXT_CONTAINER and must have a name provided via
+ * the visible label. Note: Will only work when bug 559770 is resolved (right
+ * now, unlabelled meters are not mapped to an accessible object).
+ *
+ * @returns {null|Object}
+ * Failure audit report depending on requirements for dialogs or form
+ * meter element, null otherwise.
+ */
+const textContainerRule = function (accessible) {
+ const { DOMNode } = accessible;
+
+ switch (DOMNode.nodeName) {
+ case "DIALOG":
+ return dialogRule(accessible);
+ case "METER":
+ return formRule(accessible);
+ default:
+ return null;
+ }
+};
+
+/**
+ * A text label rule for elements that map to ROLE_INTERNAL_FRAME:
+ * * <OBJECT> maps to ROLE_INTERNAL_FRAME. Check the type attribute and whether
+ * it includes "image/" (e.g. image/jpeg, image/png, image/gif). If so, audit
+ * it the same way other image roles are audited.
+ * * <EMBED> maps to ROLE_INTERNAL_FRAME and must have a non-empty name.
+ * * <FRAME> and <IFRAME> map to ROLE_INTERNAL_FRAME and must have a non-empty
+ * title attribute.
+ *
+ * @returns {null|Object}
+ * Failure audit report if the internal frame accessible object name is
+ * not provided or if it is not derived from a required location, null
+ * otherwise.
+ */
+const internalFrameRule = function (accessible) {
+ const { DOMNode } = accessible;
+ switch (DOMNode.nodeName) {
+ case "FRAME":
+ return mustHaveNonEmptyNameRule(FRAME_NO_NAME, accessible);
+ case "IFRAME":
+ const name = getAccessibleName(accessible);
+ const title = DOMNode.title && DOMNode.title.trim();
+
+ return title && title === name
+ ? null
+ : { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE };
+ case "OBJECT": {
+ const type = DOMNode.getAttribute("type");
+ if (!type || !type.startsWith("image/")) {
+ return null;
+ }
+
+ return imageRule(accessible);
+ }
+ case "EMBED": {
+ const type = DOMNode.getAttribute("type");
+ if (!type || !type.startsWith("image/")) {
+ return mustHaveNonEmptyNameRule(EMBED_NO_NAME, accessible);
+ }
+ return imageRule(accessible);
+ }
+ default:
+ return null;
+ }
+};
+
+/**
+ * A text label rule for accessible objects that represent documents and should
+ * have title element provided.
+ *
+ * @returns {null|Object}
+ * Failure audit report if document accessible object has no or empty
+ * title, null otherwise.
+ */
+const documentRule = function (accessible) {
+ const title = accessible.DOMNode.title && accessible.DOMNode.title.trim();
+ return title ? null : { score: FAIL, issue: DOCUMENT_NO_TITLE };
+};
+
+/**
+ * A text label rule for accessible objects that correspond to headings and thus
+ * must be non-empty.
+ *
+ * @returns {null|Object}
+ * Failure audit report if heading accessible object has no or
+ * empty name or if its text content is empty, null otherwise.
+ */
+const headingRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ if (!name) {
+ return { score: FAIL, issue: HEADING_NO_NAME };
+ }
+
+ const content =
+ accessible.DOMNode.textContent && accessible.DOMNode.textContent.trim();
+ return content ? null : { score: WARNING, issue: HEADING_NO_CONTENT };
+};
+
+/**
+ * A text label rule for accessible objects that represent toolbars and must
+ * have a non-empty name if there is more than one toolbar present.
+ *
+ * @returns {null|Object}
+ * Failure audit report if toolbar accessible object is not the only
+ * toolbar in the document and has no or empty title, null otherwise.
+ */
+const toolbarRule = function (accessible) {
+ const toolbars =
+ accessible.DOMNode.ownerDocument.querySelectorAll(`[role="toolbar"]`);
+
+ return toolbars.length > 1
+ ? mustHaveNonEmptyNameRule(TOOLBAR_NO_NAME, accessible)
+ : null;
+};
+
+/**
+ * A text label rule for accessible objects that represent link (anchors, areas)
+ * and must have a non-empty name.
+ *
+ * @returns {null|Object}
+ * Failure audit report if link accessible object has no or empty name,
+ * or in case when it's an <area> element with href attribute the name
+ * is not specified by an alt attribute, null otherwise.
+ */
+const linkRule = function (accessible) {
+ const { DOMNode } = accessible;
+ if (DOMNode.nodeName === "AREA" && DOMNode.hasAttribute("href")) {
+ const alt = DOMNode.getAttribute("alt");
+ const name = getAccessibleName(accessible);
+ return alt && alt.trim() === name
+ ? null
+ : { score: FAIL, issue: AREA_NO_NAME_FROM_ALT };
+ }
+
+ return interactiveRule(accessible);
+};
+
+/**
+ * A text label rule for accessible objects that are used to display
+ * non-standard symbols where existing Unicode characters are not available and
+ * must have a non-empty name.
+ *
+ * @returns {null|Object}
+ * Failure audit report if mglyph accessible object has no or empty
+ * name, and no or empty alt attribute, null otherwise.
+ */
+const mathmlGlyphRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ if (name) {
+ return null;
+ }
+
+ const { DOMNode } = accessible;
+ const alt = DOMNode.getAttribute("alt");
+ return alt && alt.trim()
+ ? null
+ : { score: FAIL, issue: MATHML_GLYPH_NO_NAME };
+};
+
+const RULES = {
+ [Ci.nsIAccessibleRole.ROLE_BUTTONMENU]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_CANVAS]: imageRule,
+ [Ci.nsIAccessibleRole.ROLE_CHECKBUTTON]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_COLUMNHEADER]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_COMBOBOX]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_DIAGRAM]: imageRule,
+ [Ci.nsIAccessibleRole.ROLE_DIALOG]: dialogRule,
+ [Ci.nsIAccessibleRole.ROLE_DOCUMENT]: documentRule,
+ [Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_ENTRY]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_FIGURE]: shouldHaveNonEmptyNameRule.bind(
+ null,
+ FIGURE_NO_NAME
+ ),
+ [Ci.nsIAccessibleRole.ROLE_GRAPHIC]: imageRule,
+ [Ci.nsIAccessibleRole.ROLE_GROUPING]: formGroupingRule,
+ [Ci.nsIAccessibleRole.ROLE_HEADING]: headingRule,
+ [Ci.nsIAccessibleRole.ROLE_IMAGE_MAP]: imageRule,
+ [Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME]: internalFrameRule,
+ [Ci.nsIAccessibleRole.ROLE_LINK]: linkRule,
+ [Ci.nsIAccessibleRole.ROLE_LISTBOX]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_MATHML_GLYPH]: mathmlGlyphRule,
+ [Ci.nsIAccessibleRole.ROLE_MENUITEM]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_OPTION]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_OUTLINEITEM]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_PAGETAB]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_PROGRESSBAR]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_PUSHBUTTON]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_RADIOBUTTON]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_ROWHEADER]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_SLIDER]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_SPINBUTTON]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_SWITCH]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_TEXT_CONTAINER]: textContainerRule,
+ [Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_TOOLBAR]: toolbarRule,
+};
+
+/**
+ * Perform audit for WCAG 1.1 criteria related to providing alternative text
+ * depending on the type of content.
+ * @param {nsIAccessible} accessible
+ * Accessible object to be tested to determine if it requires and has
+ * an appropriate text alternative.
+ *
+ * @return {null|Object}
+ * Null if accessible does not need or has the right text alternative,
+ * audit data otherwise. This data is used in the accessibility panel
+ * for its audit filters, audit badges, sidebar checks section and
+ * highlighter.
+ */
+function auditTextLabel(accessible) {
+ const rule = RULES[accessible.role];
+ return rule ? rule(accessible) : null;
+}
+
+module.exports.auditTextLabel = auditTextLabel;
diff --git a/devtools/server/actors/accessibility/constants.js b/devtools/server/actors/accessibility/constants.js
new file mode 100644
index 0000000000..6035b5c844
--- /dev/null
+++ b/devtools/server/actors/accessibility/constants.js
@@ -0,0 +1,59 @@
+/* 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 {
+ accessibility: {
+ SIMULATION_TYPE: {
+ ACHROMATOPSIA,
+ DEUTERANOPIA,
+ PROTANOPIA,
+ TRITANOPIA,
+ CONTRAST_LOSS,
+ },
+ },
+} = require("resource://devtools/shared/constants.js");
+
+/**
+ * Constants used in accessibility actors.
+ */
+
+// Color blindness matrix values taken from Machado et al. (2009), https://doi.org/10.1109/TVCG.2009.113:
+// https://www.inf.ufrgs.br/~oliveira/pubs_files/CVD_Simulation/CVD_Simulation.html
+// Contrast loss matrix values are for 50% contrast (see https://docs.rainmeter.net/tips/colormatrix-guide/,
+// and https://stackoverflow.com/questions/23865511/contrast-with-color-matrix). The matrices are flattened
+// 4x5 matrices, needed for docShell setColorMatrix method. i.e. A 4x5 matrix of the form:
+// 1 2 3 4 5
+// 6 7 8 9 10
+// 11 12 13 14 15
+// 16 17 18 19 20
+// will be need to be set in docShell as:
+// [1, 6, 11, 16, 2, 7, 12, 17, 3, 8, 13, 18, 4, 9, 14, 19, 5, 10, 15, 20]
+const COLOR_TRANSFORMATION_MATRICES = {
+ NONE: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0],
+ [ACHROMATOPSIA]: [
+ 0.299, 0.299, 0.299, 0, 0.587, 0.587, 0.587, 0, 0.114, 0.114, 0.114, 0, 0,
+ 0, 0, 1, 0, 0, 0, 0,
+ ],
+ [PROTANOPIA]: [
+ 0.152286, 0.114503, -0.003882, 0, 1.052583, 0.786281, -0.048116, 0,
+ -0.204868, 0.099216, 1.051998, 0, 0, 0, 0, 1, 0, 0, 0, 0,
+ ],
+ [DEUTERANOPIA]: [
+ 0.367322, 0.280085, -0.01182, 0, 0.860646, 0.672501, 0.04294, 0, -0.227968,
+ 0.047413, 0.968881, 0, 0, 0, 0, 1, 0, 0, 0, 0,
+ ],
+ [TRITANOPIA]: [
+ 1.255528, -0.078411, 0.004733, 0, -0.076749, 0.930809, 0.691367, 0,
+ -0.178779, 0.147602, 0.3039, 0, 0, 0, 0, 1, 0, 0, 0, 0,
+ ],
+ [CONTRAST_LOSS]: [
+ 0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 0.5, 0.25, 0.25, 0.25, 0,
+ ],
+};
+
+exports.simulation = {
+ COLOR_TRANSFORMATION_MATRICES,
+};
diff --git a/devtools/server/actors/accessibility/moz.build b/devtools/server/actors/accessibility/moz.build
new file mode 100644
index 0000000000..4da1cd0b24
--- /dev/null
+++ b/devtools/server/actors/accessibility/moz.build
@@ -0,0 +1,20 @@
+# 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/.
+
+DIRS += [
+ "audit",
+]
+
+DevToolsModules(
+ "accessibility.js",
+ "accessible.js",
+ "constants.js",
+ "parent-accessibility.js",
+ "simulator.js",
+ "walker.js",
+ "worker.js",
+)
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "Accessibility Tools")
diff --git a/devtools/server/actors/accessibility/parent-accessibility.js b/devtools/server/actors/accessibility/parent-accessibility.js
new file mode 100644
index 0000000000..fd2c945ea7
--- /dev/null
+++ b/devtools/server/actors/accessibility/parent-accessibility.js
@@ -0,0 +1,154 @@
+/* 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 {
+ parentAccessibilitySpec,
+} = require("resource://devtools/shared/specs/accessibility.js");
+
+const PREF_ACCESSIBILITY_FORCE_DISABLED = "accessibility.force_disabled";
+
+class ParentAccessibilityActor extends Actor {
+ constructor(conn) {
+ super(conn, parentAccessibilitySpec);
+
+ this.userPref = Services.prefs.getIntPref(
+ PREF_ACCESSIBILITY_FORCE_DISABLED
+ );
+
+ if (this.enabled && !this.accService) {
+ // Set a local reference to an accessibility service if accessibility was
+ // started elsewhere to ensure that parent process a11y service does not
+ // get GC'ed away.
+ this.accService = Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ }
+
+ Services.obs.addObserver(this, "a11y-consumers-changed");
+ Services.prefs.addObserver(PREF_ACCESSIBILITY_FORCE_DISABLED, this);
+ }
+
+ bootstrap() {
+ return {
+ canBeDisabled: this.canBeDisabled,
+ canBeEnabled: this.canBeEnabled,
+ };
+ }
+
+ observe(subject, topic, data) {
+ if (topic === "a11y-consumers-changed") {
+ // This event is fired when accessibility service consumers change. Since
+ // this observer lives in parent process there are 2 possible consumers of
+ // a11y service: XPCOM and PlatformAPI (e.g. screen readers). We only care
+ // about PlatformAPI consumer changes because when set, we can no longer
+ // disable accessibility service.
+ const { PlatformAPI } = JSON.parse(data);
+ this.emit("can-be-disabled-change", !PlatformAPI);
+ } else if (
+ !this.disabling &&
+ topic === "nsPref:changed" &&
+ data === PREF_ACCESSIBILITY_FORCE_DISABLED
+ ) {
+ // PREF_ACCESSIBILITY_FORCE_DISABLED preference change event. When set to
+ // >=1, it means that the user wants to disable accessibility service and
+ // prevent it from starting in the future. Note: we also check
+ // this.disabling state when handling this pref change because this is how
+ // we disable the accessibility inspector itself.
+ this.emit("can-be-enabled-change", this.canBeEnabled);
+ }
+ }
+
+ /**
+ * A getter that indicates if accessibility service is enabled.
+ *
+ * @return {Boolean}
+ * True if accessibility service is on.
+ */
+ get enabled() {
+ return Services.appinfo.accessibilityEnabled;
+ }
+
+ /**
+ * A getter that indicates if the accessibility service can be disabled.
+ *
+ * @return {Boolean}
+ * True if accessibility service can be disabled.
+ */
+ get canBeDisabled() {
+ if (this.enabled) {
+ const a11yService = Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ const { PlatformAPI } = JSON.parse(a11yService.getConsumers());
+ return !PlatformAPI;
+ }
+
+ return true;
+ }
+
+ /**
+ * A getter that indicates if the accessibility service can be enabled.
+ *
+ * @return {Boolean}
+ * True if accessibility service can be enabled.
+ */
+ get canBeEnabled() {
+ return Services.prefs.getIntPref(PREF_ACCESSIBILITY_FORCE_DISABLED) < 1;
+ }
+
+ /**
+ * Enable accessibility service (via XPCOM service).
+ */
+ enable() {
+ if (this.enabled || !this.canBeEnabled) {
+ return;
+ }
+
+ this.accService = Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ }
+
+ /**
+ * Force disable accessibility service. This method removes the reference to
+ * the XPCOM a11y service object and flips the
+ * PREF_ACCESSIBILITY_FORCE_DISABLED preference on and off to shutdown a11y
+ * service.
+ */
+ disable() {
+ if (!this.enabled || !this.canBeDisabled) {
+ return;
+ }
+
+ this.disabling = true;
+ this.accService = null;
+ // Set PREF_ACCESSIBILITY_FORCE_DISABLED to 1 to force disable
+ // accessibility service. This is the only way to guarantee an immediate
+ // accessibility service shutdown in all processes. This also prevents
+ // accessibility service from starting up in the future.
+ Services.prefs.setIntPref(PREF_ACCESSIBILITY_FORCE_DISABLED, 1);
+ // Set PREF_ACCESSIBILITY_FORCE_DISABLED back to previous default or user
+ // set value. This will not start accessibility service until the user
+ // activates it again. It simply ensures that accessibility service can
+ // start again (when value is below 1).
+ Services.prefs.setIntPref(PREF_ACCESSIBILITY_FORCE_DISABLED, this.userPref);
+ delete this.disabling;
+ }
+
+ /**
+ * Destroy the helper class, remove all listeners and if possible disable
+ * accessibility service in the parent process.
+ */
+ destroy() {
+ this.disable();
+ super.destroy();
+ Services.obs.removeObserver(this, "a11y-consumers-changed");
+ Services.prefs.removeObserver(PREF_ACCESSIBILITY_FORCE_DISABLED, this);
+ }
+}
+
+exports.ParentAccessibilityActor = ParentAccessibilityActor;
diff --git a/devtools/server/actors/accessibility/simulator.js b/devtools/server/actors/accessibility/simulator.js
new file mode 100644
index 0000000000..4f7e059d8c
--- /dev/null
+++ b/devtools/server/actors/accessibility/simulator.js
@@ -0,0 +1,81 @@
+/* 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 {
+ simulatorSpec,
+} = require("resource://devtools/shared/specs/accessibility.js");
+
+const {
+ simulation: { COLOR_TRANSFORMATION_MATRICES },
+} = require("resource://devtools/server/actors/accessibility/constants.js");
+
+/**
+ * The SimulatorActor is responsible for setting color matrices
+ * based on the simulation type specified.
+ */
+class SimulatorActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, simulatorSpec);
+ this.targetActor = targetActor;
+ }
+
+ /**
+ * Simulates a type of visual impairment (i.e. color blindness or contrast loss).
+ *
+ * @param {Object} options
+ * Properties: {Array} types
+ * Contains the types of visual impairment(s) to be simulated.
+ * Set default color matrix if array is empty.
+ * @return {Boolean}
+ * True if matrix was successfully applied, false otherwise.
+ */
+ simulate(options) {
+ if (options.types.length > 1) {
+ return false;
+ }
+
+ return this.setColorMatrix(
+ COLOR_TRANSFORMATION_MATRICES[
+ options.types.length === 1 ? options.types[0] : "NONE"
+ ]
+ );
+ }
+
+ setColorMatrix(colorMatrix) {
+ if (!this.docShell) {
+ return false;
+ }
+
+ try {
+ this.docShell.setColorMatrix(colorMatrix);
+ } catch (error) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Disables all simulations by setting the default color matrix.
+ */
+ disable() {
+ this.simulate({ types: [] });
+ }
+
+ destroy() {
+ super.destroy();
+
+ this.disable();
+ this.targetActor = null;
+ }
+
+ get docShell() {
+ return this.targetActor && this.targetActor.docShell;
+ }
+}
+
+exports.SimulatorActor = SimulatorActor;
diff --git a/devtools/server/actors/accessibility/walker.js b/devtools/server/actors/accessibility/walker.js
new file mode 100644
index 0000000000..a742914109
--- /dev/null
+++ b/devtools/server/actors/accessibility/walker.js
@@ -0,0 +1,1324 @@
+/* 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 {
+ accessibleWalkerSpec,
+} = require("resource://devtools/shared/specs/accessibility.js");
+
+const {
+ simulation: { COLOR_TRANSFORMATION_MATRICES },
+} = require("resource://devtools/server/actors/accessibility/constants.js");
+
+loader.lazyRequireGetter(
+ this,
+ "AccessibleActor",
+ "resource://devtools/server/actors/accessibility/accessible.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["CustomHighlighterActor"],
+ "resource://devtools/server/actors/highlighters.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "DevToolsUtils",
+ "resource://devtools/shared/DevToolsUtils.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "events",
+ "resource://devtools/shared/event-emitter.js"
+);
+loader.lazyRequireGetter(
+ this,
+ ["getCurrentZoom", "isWindowIncluded", "isFrameWithChildTarget"],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "isXUL",
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ [
+ "isDefunct",
+ "loadSheetForBackgroundCalculation",
+ "removeSheetForBackgroundCalculation",
+ ],
+ "resource://devtools/server/actors/utils/accessibility.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "accessibility",
+ "resource://devtools/shared/constants.js",
+ true
+);
+
+const kStateHover = 0x00000004; // ElementState::HOVER
+
+const {
+ EVENT_TEXT_CHANGED,
+ EVENT_TEXT_INSERTED,
+ EVENT_TEXT_REMOVED,
+ EVENT_ACCELERATOR_CHANGE,
+ EVENT_ACTION_CHANGE,
+ EVENT_DEFACTION_CHANGE,
+ EVENT_DESCRIPTION_CHANGE,
+ EVENT_DOCUMENT_ATTRIBUTES_CHANGED,
+ EVENT_HIDE,
+ EVENT_NAME_CHANGE,
+ EVENT_OBJECT_ATTRIBUTE_CHANGED,
+ EVENT_REORDER,
+ EVENT_STATE_CHANGE,
+ EVENT_TEXT_ATTRIBUTE_CHANGED,
+ EVENT_VALUE_CHANGE,
+} = Ci.nsIAccessibleEvent;
+
+// TODO: We do not need this once bug 1422913 is fixed. We also would not need
+// to fire a name change event for an accessible that has an updated subtree and
+// that has its name calculated from the said subtree.
+const NAME_FROM_SUBTREE_RULE_ROLES = new Set([
+ Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWN,
+ Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWNGRID,
+ Ci.nsIAccessibleRole.ROLE_BUTTONMENU,
+ Ci.nsIAccessibleRole.ROLE_CELL,
+ Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
+ Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM,
+ Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
+ Ci.nsIAccessibleRole.ROLE_COLUMN,
+ Ci.nsIAccessibleRole.ROLE_COLUMNHEADER,
+ Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION,
+ Ci.nsIAccessibleRole.ROLE_DEFINITION,
+ Ci.nsIAccessibleRole.ROLE_GRID_CELL,
+ Ci.nsIAccessibleRole.ROLE_HEADING,
+ Ci.nsIAccessibleRole.ROLE_HELPBALLOON,
+ Ci.nsIAccessibleRole.ROLE_HTML_CONTAINER,
+ Ci.nsIAccessibleRole.ROLE_KEY,
+ Ci.nsIAccessibleRole.ROLE_LABEL,
+ Ci.nsIAccessibleRole.ROLE_LINK,
+ Ci.nsIAccessibleRole.ROLE_LISTITEM,
+ Ci.nsIAccessibleRole.ROLE_MATHML_IDENTIFIER,
+ Ci.nsIAccessibleRole.ROLE_MATHML_NUMBER,
+ Ci.nsIAccessibleRole.ROLE_MATHML_OPERATOR,
+ Ci.nsIAccessibleRole.ROLE_MATHML_TEXT,
+ Ci.nsIAccessibleRole.ROLE_MATHML_STRING_LITERAL,
+ Ci.nsIAccessibleRole.ROLE_MATHML_GLYPH,
+ Ci.nsIAccessibleRole.ROLE_MENUITEM,
+ Ci.nsIAccessibleRole.ROLE_OPTION,
+ Ci.nsIAccessibleRole.ROLE_OUTLINEITEM,
+ Ci.nsIAccessibleRole.ROLE_PAGETAB,
+ Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM,
+ Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
+ Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
+ Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM,
+ Ci.nsIAccessibleRole.ROLE_RICH_OPTION,
+ Ci.nsIAccessibleRole.ROLE_ROW,
+ Ci.nsIAccessibleRole.ROLE_ROWHEADER,
+ Ci.nsIAccessibleRole.ROLE_SUMMARY,
+ Ci.nsIAccessibleRole.ROLE_SWITCH,
+ Ci.nsIAccessibleRole.ROLE_TABLE_COLUMN_HEADER,
+ Ci.nsIAccessibleRole.ROLE_TABLE_ROW_HEADER,
+ Ci.nsIAccessibleRole.ROLE_TEAR_OFF_MENU_ITEM,
+ Ci.nsIAccessibleRole.ROLE_TERM,
+ Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
+ Ci.nsIAccessibleRole.ROLE_TOOLTIP,
+]);
+
+const IS_OSX = Services.appinfo.OS === "Darwin";
+
+const {
+ SCORES: { BEST_PRACTICES, FAIL, WARNING },
+} = accessibility;
+
+/**
+ * Helper function that determines if nsIAccessible object is in stale state. When an
+ * object is stale it means its subtree is not up to date.
+ *
+ * @param {nsIAccessible} accessible
+ * object to be tested.
+ * @return {Boolean}
+ * True if accessible object is stale, false otherwise.
+ */
+function isStale(accessible) {
+ const extraState = {};
+ accessible.getState({}, extraState);
+ // extraState.value is a bitmask. We are applying bitwise AND to mask out
+ // irrelevant states.
+ return !!(extraState.value & Ci.nsIAccessibleStates.EXT_STATE_STALE);
+}
+
+/**
+ * Get accessibility audit starting with the passed accessible object as a root.
+ *
+ * @param {Object} acc
+ * AccessibileActor to be used as the root for the audit.
+ * @param {Object} options
+ * Options for running audit, may include:
+ * - types: Array of audit types to be performed during audit.
+ * @param {Map} report
+ * An accumulator map to be used to store audit information.
+ * @param {Object} progress
+ * An audit project object that is used to track the progress of the
+ * audit and send progress "audit-event" events to the client.
+ */
+function getAudit(acc, options, report, progress) {
+ if (acc.isDefunct) {
+ return;
+ }
+
+ // Audit returns a promise, save the actual value in the report.
+ report.set(
+ acc,
+ acc.audit(options).then(result => {
+ report.set(acc, result);
+ progress.increment();
+ })
+ );
+
+ for (const child of acc.children()) {
+ getAudit(child, options, report, progress);
+ }
+}
+
+/**
+ * A helper class that is used to track audit progress and send progress events
+ * to the client.
+ */
+class AuditProgress {
+ constructor(walker) {
+ this.completed = 0;
+ this.percentage = 0;
+ this.walker = walker;
+ }
+
+ setTotal(size) {
+ this.size = size;
+ }
+
+ notify() {
+ this.walker.emit("audit-event", {
+ type: "progress",
+ progress: {
+ total: this.size,
+ percentage: this.percentage,
+ completed: this.completed,
+ },
+ });
+ }
+
+ increment() {
+ this.completed++;
+ const { completed, size } = this;
+ if (!size) {
+ return;
+ }
+
+ const percentage = Math.round((completed / size) * 100);
+ if (percentage > this.percentage) {
+ this.percentage = percentage;
+ this.notify();
+ }
+ }
+
+ destroy() {
+ this.walker = null;
+ }
+}
+
+/**
+ * The AccessibleWalkerActor stores a cache of AccessibleActors that represent
+ * accessible objects in a given document.
+ *
+ * It is also responsible for implicitely initializing and shutting down
+ * accessibility engine by storing a reference to the XPCOM accessibility
+ * service.
+ */
+class AccessibleWalkerActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, accessibleWalkerSpec);
+ this.targetActor = targetActor;
+ this.refMap = new Map();
+ this._loadedSheets = new WeakMap();
+ this.setA11yServiceGetter();
+ this.onPick = this.onPick.bind(this);
+ this.onHovered = this.onHovered.bind(this);
+ this._preventContentEvent = this._preventContentEvent.bind(this);
+ this.onKey = this.onKey.bind(this);
+ this.onFocusIn = this.onFocusIn.bind(this);
+ this.onFocusOut = this.onFocusOut.bind(this);
+ this.onHighlighterEvent = this.onHighlighterEvent.bind(this);
+ }
+
+ get highlighter() {
+ if (!this._highlighter) {
+ this._highlighter = new CustomHighlighterActor(
+ this,
+ "AccessibleHighlighter"
+ );
+
+ this.manage(this._highlighter);
+ this._highlighter.on("highlighter-event", this.onHighlighterEvent);
+ }
+
+ return this._highlighter;
+ }
+
+ get tabbingOrderHighlighter() {
+ if (!this._tabbingOrderHighlighter) {
+ this._tabbingOrderHighlighter = new CustomHighlighterActor(
+ this,
+ "TabbingOrderHighlighter"
+ );
+
+ this.manage(this._tabbingOrderHighlighter);
+ }
+
+ return this._tabbingOrderHighlighter;
+ }
+
+ setA11yServiceGetter() {
+ DevToolsUtils.defineLazyGetter(this, "a11yService", () => {
+ Services.obs.addObserver(this, "accessible-event");
+ return Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ });
+ }
+
+ get rootWin() {
+ return this.targetActor && this.targetActor.window;
+ }
+
+ get rootDoc() {
+ return this.targetActor && this.targetActor.window.document;
+ }
+
+ get isXUL() {
+ return isXUL(this.rootWin);
+ }
+
+ get colorMatrix() {
+ if (!this.targetActor.docShell) {
+ return null;
+ }
+
+ const colorMatrix = this.targetActor.docShell.getColorMatrix();
+ if (
+ colorMatrix.length === 0 ||
+ colorMatrix === COLOR_TRANSFORMATION_MATRICES.NONE
+ ) {
+ return null;
+ }
+
+ return colorMatrix;
+ }
+
+ reset() {
+ try {
+ Services.obs.removeObserver(this, "accessible-event");
+ } catch (e) {
+ // Accessible event observer might not have been initialized if a11y
+ // service was never used.
+ }
+
+ this.cancelPick();
+
+ // Clean up accessible actors cache.
+ this.clearRefs();
+
+ this._childrenPromise = null;
+ delete this.a11yService;
+ this.setA11yServiceGetter();
+ }
+
+ /**
+ * Remove existing cache (of accessible actors) from tree.
+ */
+ clearRefs() {
+ for (const actor of this.refMap.values()) {
+ actor.destroy();
+ }
+ }
+
+ destroy() {
+ super.destroy();
+
+ this.reset();
+
+ if (this._highlighter) {
+ this._highlighter.off("highlighter-event", this.onHighlighterEvent);
+ this._highlighter = null;
+ }
+
+ if (this._tabbingOrderHighlighter) {
+ this._tabbingOrderHighlighter = null;
+ }
+
+ this.targetActor = null;
+ this.refMap = null;
+ }
+
+ getRef(rawAccessible) {
+ return this.refMap.get(rawAccessible);
+ }
+
+ addRef(rawAccessible) {
+ let actor = this.refMap.get(rawAccessible);
+ if (actor) {
+ return actor;
+ }
+
+ actor = new AccessibleActor(this, rawAccessible);
+ // Add the accessible actor as a child of this accessible walker actor,
+ // assigning it an actorID.
+ this.manage(actor);
+ this.refMap.set(rawAccessible, actor);
+
+ return actor;
+ }
+
+ /**
+ * Clean up accessible actors cache for a given accessible's subtree.
+ *
+ * @param {null|nsIAccessible} rawAccessible
+ */
+ purgeSubtree(rawAccessible) {
+ if (!rawAccessible) {
+ return;
+ }
+
+ try {
+ for (
+ let child = rawAccessible.firstChild;
+ child;
+ child = child.nextSibling
+ ) {
+ this.purgeSubtree(child);
+ }
+ } catch (e) {
+ // rawAccessible or its descendants are defunct.
+ }
+
+ const actor = this.getRef(rawAccessible);
+ if (actor) {
+ actor.destroy();
+ }
+ }
+
+ unmanage(actor) {
+ if (actor instanceof AccessibleActor) {
+ this.refMap.delete(actor.rawAccessible);
+ }
+ Actor.prototype.unmanage.call(this, actor);
+ }
+
+ /**
+ * A helper method. Accessibility walker is assumed to have only 1 child which
+ * is the top level document.
+ */
+ async children() {
+ if (this._childrenPromise) {
+ return this._childrenPromise;
+ }
+
+ this._childrenPromise = Promise.all([this.getDocument()]);
+ const children = await this._childrenPromise;
+ this._childrenPromise = null;
+ return children;
+ }
+
+ /**
+ * A promise for a root document accessible actor that only resolves when its
+ * corresponding document accessible object is fully loaded.
+ *
+ * @return {Promise}
+ */
+ getDocument() {
+ if (!this.rootDoc || !this.rootDoc.documentElement) {
+ return this.once("document-ready").then(docAcc => this.addRef(docAcc));
+ }
+
+ if (this.isXUL) {
+ const doc = this.addRef(this.getRawAccessibleFor(this.rootDoc));
+ return Promise.resolve(doc);
+ }
+
+ const doc = this.getRawAccessibleFor(this.rootDoc);
+
+ // For non-visible same-process iframes we don't get a document and
+ // won't get a "document-ready" event.
+ if (!doc && !this.rootWin.windowGlobalChild.isProcessRoot) {
+ // We can ignore such document as there won't be anything to audit in them.
+ return null;
+ }
+
+ if (!doc || isStale(doc)) {
+ return this.once("document-ready").then(docAcc => this.addRef(docAcc));
+ }
+
+ return Promise.resolve(this.addRef(doc));
+ }
+
+ /**
+ * Get an accessible actor for a domnode actor.
+ * @param {Object} domNode
+ * domnode actor for which accessible actor is being created.
+ * @return {Promse}
+ * A promise that resolves when accessible actor is created for a
+ * domnode actor.
+ */
+ getAccessibleFor(domNode) {
+ // We need to make sure that the document is loaded processed by a11y first.
+ return this.getDocument().then(() => {
+ const rawAccessible = this.getRawAccessibleFor(domNode.rawNode);
+ // Not all DOM nodes have corresponding accessible objects. It's usually
+ // the case where there is no semantics or relevance to the accessibility
+ // client.
+ if (!rawAccessible) {
+ return null;
+ }
+
+ return this.addRef(rawAccessible);
+ });
+ }
+
+ /**
+ * Get a raw accessible object for a raw node.
+ * @param {DOMNode} rawNode
+ * Raw node for which accessible object is being retrieved.
+ * @return {nsIAccessible}
+ * Accessible object for a given DOMNode.
+ */
+ getRawAccessibleFor(rawNode) {
+ // Accessible can only be retrieved iff accessibility service is enabled.
+ if (!Services.appinfo.accessibilityEnabled) {
+ return null;
+ }
+
+ return this.a11yService.getAccessibleFor(rawNode);
+ }
+
+ async getAncestry(accessible) {
+ if (!accessible || accessible.indexInParent === -1) {
+ return [];
+ }
+ const doc = await this.getDocument();
+ if (!doc) {
+ return [];
+ }
+
+ const ancestry = [];
+ if (accessible === doc) {
+ return ancestry;
+ }
+
+ try {
+ let parent = accessible;
+ while (parent && (parent = parent.parentAcc) && parent != doc) {
+ ancestry.push(parent);
+ }
+ ancestry.push(doc);
+ } catch (error) {
+ throw new Error(`Failed to get ancestor for ${accessible}: ${error}`);
+ }
+
+ return ancestry.map(parent => ({
+ accessible: parent,
+ children: parent.children(),
+ }));
+ }
+
+ /**
+ * Run accessibility audit and return relevant ancestries for AccessibleActors
+ * that have non-empty audit checks.
+ *
+ * @param {Object} options
+ * Options for running audit, may include:
+ * - types: Array of audit types to be performed during audit.
+ *
+ * @return {Promise}
+ * A promise that resolves when the audit is complete and all relevant
+ * ancestries are calculated.
+ */
+ async audit(options) {
+ const doc = await this.getDocument();
+ if (!doc) {
+ return [];
+ }
+
+ const report = new Map();
+ this._auditProgress = new AuditProgress(this);
+ getAudit(doc, options, report, this._auditProgress);
+ this._auditProgress.setTotal(report.size);
+ await Promise.all(report.values());
+
+ const ancestries = [];
+ for (const [acc, audit] of report.entries()) {
+ // Filter out audits that have no failing checks.
+ if (
+ audit &&
+ Object.values(audit).some(
+ check =>
+ check != null &&
+ !check.error &&
+ [BEST_PRACTICES, FAIL, WARNING].includes(check.score)
+ )
+ ) {
+ ancestries.push(this.getAncestry(acc));
+ }
+ }
+
+ return Promise.all(ancestries);
+ }
+
+ /**
+ * Start accessibility audit. The result of this function will not be an audit
+ * report. Instead, an "audit-event" event will be fired when the audit is
+ * completed or fails.
+ *
+ * @param {Object} options
+ * Options for running audit, may include:
+ * - types: Array of audit types to be performed during audit.
+ */
+ startAudit(options) {
+ // Audit is already running, wait for the "audit-event" event.
+ if (this._auditing) {
+ return;
+ }
+
+ this._auditing = this.audit(options)
+ // We do not want to block on audit request, instead fire "audit-event"
+ // event when internal audit is finished or failed.
+ .then(ancestries =>
+ this.emit("audit-event", {
+ type: "completed",
+ ancestries,
+ })
+ )
+ .catch(() => this.emit("audit-event", { type: "error" }))
+ .finally(() => {
+ this._auditing = null;
+ if (this._auditProgress) {
+ this._auditProgress.destroy();
+ this._auditProgress = null;
+ }
+ });
+ }
+
+ onHighlighterEvent(data) {
+ this.emit("highlighter-event", data);
+ }
+
+ /**
+ * Accessible event observer function.
+ *
+ * @param {Ci.nsIAccessibleEvent} subject
+ * accessible event object.
+ */
+ // eslint-disable-next-line complexity
+ observe(subject) {
+ const event = subject.QueryInterface(Ci.nsIAccessibleEvent);
+ const rawAccessible = event.accessible;
+ const accessible = this.getRef(rawAccessible);
+
+ if (rawAccessible instanceof Ci.nsIAccessibleDocument && !accessible) {
+ const rootDocAcc = this.getRawAccessibleFor(this.rootDoc);
+ if (rawAccessible === rootDocAcc && !isStale(rawAccessible)) {
+ this.clearRefs();
+ // If it's a top level document notify listeners about the document
+ // being ready.
+ events.emit(this, "document-ready", rawAccessible);
+ }
+ }
+
+ switch (event.eventType) {
+ case EVENT_STATE_CHANGE:
+ const { state, isEnabled } = event.QueryInterface(
+ Ci.nsIAccessibleStateChangeEvent
+ );
+ const isBusy = state & Ci.nsIAccessibleStates.STATE_BUSY;
+ if (accessible) {
+ // Only propagate state change events for active accessibles.
+ if (isBusy && isEnabled) {
+ if (rawAccessible instanceof Ci.nsIAccessibleDocument) {
+ // Remove existing cache from tree.
+ this.clearRefs();
+ }
+ return;
+ }
+ events.emit(accessible, "states-change", accessible.states);
+ }
+
+ break;
+ case EVENT_NAME_CHANGE:
+ if (accessible) {
+ events.emit(
+ accessible,
+ "name-change",
+ rawAccessible.name,
+ event.DOMNode == this.rootDoc
+ ? undefined
+ : this.getRef(rawAccessible.parent)
+ );
+ }
+ break;
+ case EVENT_VALUE_CHANGE:
+ if (accessible) {
+ events.emit(accessible, "value-change", rawAccessible.value);
+ }
+ break;
+ case EVENT_DESCRIPTION_CHANGE:
+ if (accessible) {
+ events.emit(
+ accessible,
+ "description-change",
+ rawAccessible.description
+ );
+ }
+ break;
+ case EVENT_REORDER:
+ if (accessible) {
+ accessible
+ .children()
+ .forEach(child =>
+ events.emit(child, "index-in-parent-change", child.indexInParent)
+ );
+ events.emit(accessible, "reorder", rawAccessible.childCount);
+ }
+ break;
+ case EVENT_HIDE:
+ if (event.DOMNode == this.rootDoc) {
+ this.clearRefs();
+ } else {
+ this.purgeSubtree(rawAccessible);
+ }
+ break;
+ case EVENT_DEFACTION_CHANGE:
+ case EVENT_ACTION_CHANGE:
+ if (accessible) {
+ events.emit(accessible, "actions-change", accessible.actions);
+ }
+ break;
+ case EVENT_TEXT_CHANGED:
+ case EVENT_TEXT_INSERTED:
+ case EVENT_TEXT_REMOVED:
+ if (accessible) {
+ events.emit(accessible, "text-change");
+ if (NAME_FROM_SUBTREE_RULE_ROLES.has(rawAccessible.role)) {
+ events.emit(
+ accessible,
+ "name-change",
+ rawAccessible.name,
+ event.DOMNode == this.rootDoc
+ ? undefined
+ : this.getRef(rawAccessible.parent)
+ );
+ }
+ }
+ break;
+ case EVENT_DOCUMENT_ATTRIBUTES_CHANGED:
+ case EVENT_OBJECT_ATTRIBUTE_CHANGED:
+ case EVENT_TEXT_ATTRIBUTE_CHANGED:
+ if (accessible) {
+ events.emit(accessible, "attributes-change", accessible.attributes);
+ }
+ break;
+ // EVENT_ACCELERATOR_CHANGE is currently not fired by gecko accessibility.
+ case EVENT_ACCELERATOR_CHANGE:
+ if (accessible) {
+ events.emit(
+ accessible,
+ "shortcut-change",
+ accessible.keyboardShortcut
+ );
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Ensure that nothing interferes with the audit for an accessible object
+ * (CSS, overlays) by load accessibility highlighter style sheet used for
+ * preventing transitions and applying transparency when calculating colour
+ * contrast as well as temporarily hiding accessible highlighter overlay.
+ * @param {Object} win
+ * Window where highlighting happens.
+ */
+ async clearStyles(win) {
+ const requests = this._loadedSheets.get(win);
+ if (requests != null) {
+ this._loadedSheets.set(win, requests + 1);
+ return;
+ }
+
+ // Disable potential mouse driven transitions (This is important because accessibility
+ // highlighter temporarily modifies text color related CSS properties. In case where
+ // there are transitions that affect them, there might be unexpected side effects when
+ // taking a snapshot for contrast measurement).
+ loadSheetForBackgroundCalculation(win);
+ this._loadedSheets.set(win, 1);
+ await this.hideHighlighter();
+ }
+
+ /**
+ * Restore CSS and overlays that could've interfered with the audit for an
+ * accessible object by unloading accessibility highlighter style sheet used
+ * for preventing transitions and applying transparency when calculating
+ * colour contrast and potentially restoring accessible highlighter overlay.
+ * @param {Object} win
+ * Window where highlighting was happenning.
+ */
+ async restoreStyles(win) {
+ const requests = this._loadedSheets.get(win);
+ if (!requests) {
+ return;
+ }
+
+ if (requests > 1) {
+ this._loadedSheets.set(win, requests - 1);
+ return;
+ }
+
+ await this.showHighlighter();
+ removeSheetForBackgroundCalculation(win);
+ this._loadedSheets.delete(win);
+ }
+
+ async hideHighlighter() {
+ // TODO: Fix this workaround that temporarily removes higlighter bounds
+ // overlay that can interfere with the contrast ratio calculation.
+ if (this._highlighter) {
+ const highlighter = this._highlighter.instance;
+ await highlighter.isReady;
+ highlighter.hideAccessibleBounds();
+ }
+ }
+
+ async showHighlighter() {
+ // TODO: Fix this workaround that temporarily removes higlighter bounds
+ // overlay that can interfere with the contrast ratio calculation.
+ if (this._highlighter) {
+ const highlighter = this._highlighter.instance;
+ await highlighter.isReady;
+ highlighter.showAccessibleBounds();
+ }
+ }
+
+ /**
+ * Public method used to show an accessible object highlighter on the client
+ * side.
+ *
+ * @param {Object} accessible
+ * AccessibleActor to be highlighted.
+ * @param {Object} options
+ * Object used for passing options. Available options:
+ * - duration {Number}
+ * Duration of time that the highlighter should be shown.
+ * @return {Boolean}
+ * True if highlighter shows the accessible object.
+ */
+ async highlightAccessible(accessible, options = {}) {
+ this.unhighlight();
+ // Do not highlight if accessible is dead.
+ if (!accessible || accessible.isDefunct || accessible.indexInParent < 0) {
+ return false;
+ }
+
+ this._highlightingAccessible = accessible;
+ const { bounds } = accessible;
+ if (!bounds) {
+ return false;
+ }
+
+ const { DOMNode: rawNode } = accessible.rawAccessible;
+ const audit = await accessible.audit();
+ if (this._highlightingAccessible !== accessible) {
+ return false;
+ }
+
+ const { name, role } = accessible;
+ const { highlighter } = this;
+ await highlighter.instance.isReady;
+ if (this._highlightingAccessible !== accessible) {
+ return false;
+ }
+
+ const shown = highlighter.show(
+ { rawNode },
+ { ...options, ...bounds, name, role, audit, isXUL: this.isXUL }
+ );
+ this._highlightingAccessible = null;
+
+ return shown;
+ }
+
+ /**
+ * Public method used to hide an accessible object highlighter on the client
+ * side.
+ */
+ unhighlight() {
+ if (!this._highlighter) {
+ return;
+ }
+
+ this.highlighter.hide();
+ this._highlightingAccessible = null;
+ }
+
+ /**
+ * Picking state that indicates if picking is currently enabled and, if so,
+ * what the current and hovered accessible objects are.
+ */
+ _isPicking = false;
+ _currentAccessible = null;
+
+ /**
+ * Check is event handling is allowed.
+ */
+ _isEventAllowed({ view }) {
+ return (
+ this.rootWin instanceof Ci.nsIDOMChromeWindow ||
+ isWindowIncluded(this.rootWin, view)
+ );
+ }
+
+ /**
+ * Check if the DOM event received when picking shold be ignored.
+ * @param {Event} event
+ */
+ _ignoreEventWhenPicking(event) {
+ return (
+ !this._isPicking ||
+ // If the DOM event is about a remote frame, only the WalkerActor for that
+ // remote frame target should emit RDP events (hovered/picked/...). And
+ // all other WalkerActor for intermediate iframe and top level document
+ // targets should stay silent.
+ isFrameWithChildTarget(
+ this.targetActor,
+ event.originalTarget || event.target
+ )
+ );
+ }
+
+ _preventContentEvent(event) {
+ if (this._ignoreEventWhenPicking(event)) {
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ const target = event.originalTarget || event.target;
+ if (target !== this._currentTarget) {
+ this._resetStateAndReleaseTarget();
+ this._currentTarget = target;
+ // We use InspectorUtils to save the original hover content state of the target
+ // element (that includes its hover state). In order to not trigger any visual
+ // changes to the element that depend on its hover state we remove the state while
+ // the element is the most current target of the highlighter.
+ //
+ // TODO: This logic can be removed if/when we can use elementsAtPoint API for
+ // determining topmost DOMNode that corresponds to specific coordinates. We would
+ // then be able to use a highlighter overlay that would prevent all pointer events
+ // to content but still render highlighter for the node/element correctly.
+ this._currentTargetHoverState =
+ InspectorUtils.getContentState(target) & kStateHover;
+ InspectorUtils.removeContentState(target, kStateHover);
+ }
+ }
+
+ /**
+ * Click event handler for when picking is enabled.
+ *
+ * @param {Object} event
+ * Current click event.
+ */
+ onPick(event) {
+ if (this._ignoreEventWhenPicking(event)) {
+ return;
+ }
+
+ this._preventContentEvent(event);
+ if (!this._isEventAllowed(event)) {
+ return;
+ }
+
+ // If shift is pressed, this is only a preview click, send the event to
+ // the client, but don't stop picking.
+ if (event.shiftKey) {
+ if (!this._currentAccessible) {
+ this._currentAccessible = this._findAndAttachAccessible(event);
+ }
+ events.emit(this, "picker-accessible-previewed", this._currentAccessible);
+ return;
+ }
+
+ this._unsetPickerEnvironment();
+ this._isPicking = false;
+ if (!this._currentAccessible) {
+ this._currentAccessible = this._findAndAttachAccessible(event);
+ }
+ events.emit(this, "picker-accessible-picked", this._currentAccessible);
+ }
+
+ /**
+ * Hover event handler for when picking is enabled.
+ *
+ * @param {Object} event
+ * Current hover event.
+ */
+ async onHovered(event) {
+ if (this._ignoreEventWhenPicking(event)) {
+ return;
+ }
+
+ this._preventContentEvent(event);
+ if (!this._isEventAllowed(event)) {
+ return;
+ }
+
+ const accessible = this._findAndAttachAccessible(event);
+ if (!accessible || this._currentAccessible === accessible) {
+ return;
+ }
+
+ this._currentAccessible = accessible;
+ // Highlight current accessible and by the time we are done, if accessible that was
+ // highlighted is not current any more (user moved the mouse to a new node) highlight
+ // the most current accessible again.
+ const shown = await this.highlightAccessible(accessible);
+ if (this._isPicking && shown && accessible === this._currentAccessible) {
+ events.emit(this, "picker-accessible-hovered", accessible);
+ }
+ }
+
+ /**
+ * Keyboard event handler for when picking is enabled.
+ *
+ * @param {Object} event
+ * Current keyboard event.
+ */
+ onKey(event) {
+ if (!this._currentAccessible || this._ignoreEventWhenPicking(event)) {
+ return;
+ }
+
+ this._preventContentEvent(event);
+ if (!this._isEventAllowed(event)) {
+ return;
+ }
+
+ /**
+ * KEY: Action/scope
+ * ENTER/CARRIAGE_RETURN: Picks current accessible
+ * ESC/CTRL+SHIFT+C: Cancels picker
+ */
+ switch (event.keyCode) {
+ // Select the element.
+ case event.DOM_VK_RETURN:
+ this.onPick(event);
+ break;
+ // Cancel pick mode.
+ case event.DOM_VK_ESCAPE:
+ this.cancelPick();
+ events.emit(this, "picker-accessible-canceled");
+ break;
+ case event.DOM_VK_C:
+ if (
+ (IS_OSX && event.metaKey && event.altKey) ||
+ (!IS_OSX && event.ctrlKey && event.shiftKey)
+ ) {
+ this.cancelPick();
+ events.emit(this, "picker-accessible-canceled");
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Picker method that starts picker content listeners.
+ */
+ pick() {
+ if (!this._isPicking) {
+ this._isPicking = true;
+ this._setPickerEnvironment();
+ }
+ }
+
+ /**
+ * This pick method also focuses the highlighter's target window.
+ */
+ pickAndFocus() {
+ this.pick();
+ this.rootWin.focus();
+ }
+
+ attachAccessible(rawAccessible, accessibleDocument) {
+ // If raw accessible object is defunct or detached, no need to cache it and
+ // its ancestry.
+ if (
+ !rawAccessible ||
+ isDefunct(rawAccessible) ||
+ rawAccessible.indexInParent < 0
+ ) {
+ return null;
+ }
+
+ const accessible = this.addRef(rawAccessible);
+ // There is a chance that ancestry lookup can fail if the accessible is in
+ // the detached subtree. At that point the root accessible object would be
+ // defunct and accessing it via parent property will throw.
+ try {
+ let parent = accessible;
+ while (parent && parent.rawAccessible != accessibleDocument) {
+ parent = parent.parentAcc;
+ }
+ } catch (error) {
+ throw new Error(`Failed to get ancestor for ${accessible}: ${error}`);
+ }
+
+ return accessible;
+ }
+
+ get pixelRatio() {
+ return this.rootWin.devicePixelRatio;
+ }
+
+ /**
+ * Find deepest accessible object that corresponds to the screen coordinates of the
+ * mouse pointer and attach it to the AccessibilityWalker tree.
+ *
+ * @param {Object} event
+ * Correspoinding content event.
+ * @return {null|Object}
+ * Accessible object, if available, that corresponds to a DOM node.
+ */
+ _findAndAttachAccessible(event) {
+ const target = event.originalTarget || event.target;
+ const docAcc = this.getRawAccessibleFor(this.rootDoc);
+ const win = target.ownerGlobal;
+ const zoom = this.isXUL ? 1 : getCurrentZoom(win);
+ const scale = this.pixelRatio / zoom;
+ const rawAccessible = docAcc.getDeepestChildAtPointInProcess(
+ event.screenX * scale,
+ event.screenY * scale
+ );
+ return this.attachAccessible(rawAccessible, docAcc);
+ }
+
+ /**
+ * Start picker content listeners.
+ */
+ _setPickerEnvironment() {
+ const target = this.targetActor.chromeEventHandler;
+ target.addEventListener("mousemove", this.onHovered, true);
+ target.addEventListener("click", this.onPick, true);
+ target.addEventListener("mousedown", this._preventContentEvent, true);
+ target.addEventListener("mouseup", this._preventContentEvent, true);
+ target.addEventListener("mouseover", this._preventContentEvent, true);
+ target.addEventListener("mouseout", this._preventContentEvent, true);
+ target.addEventListener("mouseleave", this._preventContentEvent, true);
+ target.addEventListener("mouseenter", this._preventContentEvent, true);
+ target.addEventListener("dblclick", this._preventContentEvent, true);
+ target.addEventListener("keydown", this.onKey, true);
+ target.addEventListener("keyup", this._preventContentEvent, true);
+ }
+
+ /**
+ * If content is still alive, stop picker content listeners, reset the hover state for
+ * last target element.
+ */
+ _unsetPickerEnvironment() {
+ const target = this.targetActor.chromeEventHandler;
+
+ if (!target) {
+ return;
+ }
+
+ target.removeEventListener("mousemove", this.onHovered, true);
+ target.removeEventListener("click", this.onPick, true);
+ target.removeEventListener("mousedown", this._preventContentEvent, true);
+ target.removeEventListener("mouseup", this._preventContentEvent, true);
+ target.removeEventListener("mouseover", this._preventContentEvent, true);
+ target.removeEventListener("mouseout", this._preventContentEvent, true);
+ target.removeEventListener("mouseleave", this._preventContentEvent, true);
+ target.removeEventListener("mouseenter", this._preventContentEvent, true);
+ target.removeEventListener("dblclick", this._preventContentEvent, true);
+ target.removeEventListener("keydown", this.onKey, true);
+ target.removeEventListener("keyup", this._preventContentEvent, true);
+
+ this._resetStateAndReleaseTarget();
+ }
+
+ /**
+ * When using accessibility highlighter, we keep track of the most current event pointer
+ * event target. In order to update or release the target, we need to make sure we set
+ * the content state (using InspectorUtils) to its original value.
+ *
+ * TODO: This logic can be removed if/when we can use elementsAtPoint API for
+ * determining topmost DOMNode that corresponds to specific coordinates. We would then
+ * be able to use a highlighter overlay that would prevent all pointer events to content
+ * but still render highlighter for the node/element correctly.
+ */
+ _resetStateAndReleaseTarget() {
+ if (!this._currentTarget) {
+ return;
+ }
+
+ try {
+ if (this._currentTargetHoverState) {
+ InspectorUtils.setContentState(this._currentTarget, kStateHover);
+ }
+ } catch (e) {
+ // DOMNode is already dead.
+ }
+
+ this._currentTarget = null;
+ this._currentTargetState = null;
+ }
+
+ /**
+ * Cacncel picker pick. Remvoe all content listeners and hide the highlighter.
+ */
+ cancelPick() {
+ this.unhighlight();
+
+ if (this._isPicking) {
+ this._unsetPickerEnvironment();
+ this._isPicking = false;
+ this._currentAccessible = null;
+ }
+ }
+
+ /**
+ * Indicates that the tabbing order current active element (focused) is being
+ * tracked.
+ */
+ _isTrackingTabbingOrderFocus = false;
+
+ /**
+ * Current focused element in the tabbing order.
+ */
+ _currentFocusedTabbingOrder = null;
+
+ /**
+ * Focusin event handler for when interacting with tabbing order overlay.
+ *
+ * @param {Object} event
+ * Most recent focusin event.
+ */
+ async onFocusIn(event) {
+ if (!this._isTrackingTabbingOrderFocus) {
+ return;
+ }
+
+ const target = event.originalTarget || event.target;
+ if (target === this._currentFocusedTabbingOrder) {
+ return;
+ }
+
+ this._currentFocusedTabbingOrder = target;
+ this.tabbingOrderHighlighter._highlighter.updateFocus({
+ node: target,
+ focused: true,
+ });
+ }
+
+ /**
+ * Focusout event handler for when interacting with tabbing order overlay.
+ *
+ * @param {Object} event
+ * Most recent focusout event.
+ */
+ async onFocusOut(event) {
+ if (
+ !this._isTrackingTabbingOrderFocus ||
+ !this._currentFocusedTabbingOrder
+ ) {
+ return;
+ }
+
+ const target = event.originalTarget || event.target;
+ // Sanity check.
+ if (target !== this._currentFocusedTabbingOrder) {
+ console.warn(
+ `focusout target: ${target} does not match current focused element in tabbing order: ${this._currentFocusedTabbingOrder}`
+ );
+ }
+
+ this.tabbingOrderHighlighter._highlighter.updateFocus({
+ node: this._currentFocusedTabbingOrder,
+ focused: false,
+ });
+ this._currentFocusedTabbingOrder = null;
+ }
+
+ /**
+ * Show tabbing order overlay for a given target.
+ *
+ * @param {Object} elm
+ * domnode actor to be used as the starting point for generating the
+ * tabbing order.
+ * @param {Number} index
+ * Starting index for the tabbing order.
+ *
+ * @return {JSON}
+ * Tabbing order information for the last element in the tabbing
+ * order. It includes a ContentDOMReference for the node and a tabbing
+ * index. If we are at the end of the tabbing order for the top level
+ * content document, the ContentDOMReference will be null. If focus
+ * manager discovered a remote IFRAME, then the ContentDOMReference
+ * references the IFRAME itself.
+ */
+ showTabbingOrder(elm, index) {
+ // Start track focus related events (only once). `showTabbingOrder` will be
+ // called multiple times for a given target if it contains other remote
+ // targets.
+ if (!this._isTrackingTabbingOrderFocus) {
+ this._isTrackingTabbingOrderFocus = true;
+ const target = this.targetActor.chromeEventHandler;
+ target.addEventListener("focusin", this.onFocusIn, true);
+ target.addEventListener("focusout", this.onFocusOut, true);
+ }
+
+ return this.tabbingOrderHighlighter.show(elm, { index });
+ }
+
+ /**
+ * Hide tabbing order overlay for a given target.
+ */
+ hideTabbingOrder() {
+ if (!this._tabbingOrderHighlighter) {
+ return;
+ }
+
+ this.tabbingOrderHighlighter.hide();
+ if (!this._isTrackingTabbingOrderFocus) {
+ return;
+ }
+
+ this._isTrackingTabbingOrderFocus = false;
+ this._currentFocusedTabbingOrder = null;
+ const target = this.targetActor.chromeEventHandler;
+ if (target) {
+ target.removeEventListener("focusin", this.onFocusIn, true);
+ target.removeEventListener("focusout", this.onFocusOut, true);
+ }
+ }
+}
+
+exports.AccessibleWalkerActor = AccessibleWalkerActor;
diff --git a/devtools/server/actors/accessibility/worker.js b/devtools/server/actors/accessibility/worker.js
new file mode 100644
index 0000000000..67fed4d22b
--- /dev/null
+++ b/devtools/server/actors/accessibility/worker.js
@@ -0,0 +1,105 @@
+/* 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";
+
+/* eslint-env worker */
+
+/**
+ * Import `createTask` to communicate with `devtools/shared/worker`.
+ */
+importScripts("resource://gre/modules/workers/require.js");
+const { createTask } = require("resource://devtools/shared/worker/helper.js");
+
+/**
+ * @see LineGraphWidget.prototype.setDataFromTimestamps in Graphs.js
+ * @param number id
+ * @param array timestamps
+ * @param number interval
+ * @param number duration
+ */
+createTask(self, "getBgRGBA", ({ dataTextBuf, dataBackgroundBuf }) =>
+ getBgRGBA(dataTextBuf, dataBackgroundBuf)
+);
+
+/**
+ * Calculates the luminance of a rgba tuple based on the formula given in
+ * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
+ *
+ * @param {Array} rgba An array with [r,g,b,a] values.
+ * @return {Number} The calculated luminance.
+ */
+function calculateLuminance(rgba) {
+ for (let i = 0; i < 3; i++) {
+ rgba[i] /= 255;
+ rgba[i] =
+ rgba[i] < 0.03928
+ ? rgba[i] / 12.92
+ : Math.pow((rgba[i] + 0.055) / 1.055, 2.4);
+ }
+ return 0.2126 * rgba[0] + 0.7152 * rgba[1] + 0.0722 * rgba[2];
+}
+
+/**
+ * Get RGBA or a range of RGBAs for the background pixels under the text. If luminance is
+ * uniform, only return one value of RGBA, otherwise return values that correspond to the
+ * min and max luminances.
+ * @param {ImageData} dataTextBuf
+ * pixel data for the accessible object with text visible.
+ * @param {ImageData} dataBackgroundBuf
+ * pixel data for the accessible object with transparent text.
+ * @return {Object}
+ * RGBA or a range of RGBAs with min and max values.
+ */
+function getBgRGBA(dataTextBuf, dataBackgroundBuf) {
+ let min = [0, 0, 0, 1];
+ let max = [255, 255, 255, 1];
+ let minLuminance = 1;
+ let maxLuminance = 0;
+ const luminances = {};
+ const dataText = new Uint8ClampedArray(dataTextBuf);
+ const dataBackground = new Uint8ClampedArray(dataBackgroundBuf);
+
+ let foundDistinctColor = false;
+ for (let i = 0; i < dataText.length; i = i + 4) {
+ const tR = dataText[i];
+ const bgR = dataBackground[i];
+ const tG = dataText[i + 1];
+ const bgG = dataBackground[i + 1];
+ const tB = dataText[i + 2];
+ const bgB = dataBackground[i + 2];
+
+ // Ignore pixels that are the same where pixels that are different between the two
+ // images are assumed to belong to the text within the node.
+ if (tR === bgR && tG === bgG && tB === bgB) {
+ continue;
+ }
+
+ foundDistinctColor = true;
+
+ const bgColor = `rgb(${bgR}, ${bgG}, ${bgB})`;
+ let luminance = luminances[bgColor];
+
+ if (!luminance) {
+ // Calculate luminance for the RGB value and store it to only measure once.
+ luminance = calculateLuminance([bgR, bgG, bgB]);
+ luminances[bgColor] = luminance;
+ }
+
+ if (minLuminance >= luminance) {
+ minLuminance = luminance;
+ min = [bgR, bgG, bgB, 1];
+ }
+
+ if (maxLuminance <= luminance) {
+ maxLuminance = luminance;
+ max = [bgR, bgG, bgB, 1];
+ }
+ }
+
+ if (!foundDistinctColor) {
+ return null;
+ }
+
+ return minLuminance === maxLuminance ? { value: max } : { min, max };
+}