Bug 1883912: Enable Intl.ListFormat test for "unit" style. r=spidermonkey-reviewers...
[gecko.git] / remote / marionette / accessibility.sys.mjs
blobc500f2121ef15a4715bd7a0788152c8d1407b94d
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 const lazy = {};
7 ChromeUtils.defineESModuleGetters(lazy, {
8   error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
9   Log: "chrome://remote/content/shared/Log.sys.mjs",
10   waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs",
11 });
13 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
14   lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
17 ChromeUtils.defineLazyGetter(lazy, "service", () => {
18   try {
19     return Cc["@mozilla.org/accessibilityService;1"].getService(
20       Ci.nsIAccessibilityService
21     );
22   } catch (e) {
23     lazy.logger.warn("Accessibility module is not present");
24     return undefined;
25   }
26 });
28 /** @namespace */
29 export const accessibility = {
30   get service() {
31     return lazy.service;
32   },
35 /**
36  * Accessible states used to check element"s state from the accessiblity API
37  * perspective.
38  *
39  * Note: if gecko is built with --disable-accessibility, the interfaces
40  * are not defined. This is why we use getters instead to be able to use
41  * these statically.
42  */
43 accessibility.State = {
44   get Unavailable() {
45     return Ci.nsIAccessibleStates.STATE_UNAVAILABLE;
46   },
47   get Focusable() {
48     return Ci.nsIAccessibleStates.STATE_FOCUSABLE;
49   },
50   get Selectable() {
51     return Ci.nsIAccessibleStates.STATE_SELECTABLE;
52   },
53   get Selected() {
54     return Ci.nsIAccessibleStates.STATE_SELECTED;
55   },
58 /**
59  * Accessible object roles that support some action.
60  */
61 accessibility.ActionableRoles = new Set([
62   "checkbutton",
63   "check menu item",
64   "check rich option",
65   "combobox",
66   "combobox option",
67   "entry",
68   "key",
69   "link",
70   "listbox option",
71   "listbox rich option",
72   "menuitem",
73   "option",
74   "outlineitem",
75   "pagetab",
76   "pushbutton",
77   "radiobutton",
78   "radio menu item",
79   "rowheader",
80   "slider",
81   "spinbutton",
82   "switch",
83 ]);
85 /**
86  * Factory function that constructs a new {@code accessibility.Checks}
87  * object with enforced strictness or not.
88  */
89 accessibility.get = function (strict = false) {
90   return new accessibility.Checks(!!strict);
93 /**
94  * Wait for the document accessibility state to be different from STATE_BUSY.
95  *
96  * @param {Document} doc
97  *     The document to wait for.
98  * @returns {Promise}
99  *     A promise which resolves when the document's accessibility state is no
100  *     longer busy.
101  */
102 function waitForDocumentAccessibility(doc) {
103   const documentAccessible = accessibility.service.getAccessibleFor(doc);
104   const state = {};
105   documentAccessible.getState(state, {});
106   if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) {
107     return Promise.resolve();
108   }
110   // Accessibility for the doc is busy, so wait for the state to change.
111   return lazy.waitForObserverTopic("accessible-event", {
112     checkFn: subject => {
113       // If event type does not match expected type, skip the event.
114       // If event's accessible does not match expected accessible,
115       // skip the event.
116       const event = subject.QueryInterface(Ci.nsIAccessibleEvent);
117       return (
118         event.eventType === Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE &&
119         event.accessible === documentAccessible
120       );
121     },
122   });
126  * Retrieve the Accessible for the provided element.
128  * @param {Element} element
129  *     The element for which we need to retrieve the accessible.
131  * @returns {nsIAccessible|null}
132  *     The Accessible object corresponding to the provided element or null if
133  *     the accessibility service is not available.
134  */
135 accessibility.getAccessible = async function (element) {
136   if (!accessibility.service) {
137     return null;
138   }
140   // First, wait for accessibility to be ready for the element's document.
141   await waitForDocumentAccessibility(element.ownerDocument);
143   const acc = accessibility.service.getAccessibleFor(element);
144   if (acc) {
145     return acc;
146   }
148   // The Accessible doesn't exist yet. This can happen because a11y tree
149   // mutations happen during refresh driver ticks. Stop the refresh driver from
150   // doing its regular ticks and force two refresh driver ticks: the first to
151   // let layout update and notify a11y, and the second to let a11y process
152   // updates.
153   const windowUtils = element.ownerGlobal.windowUtils;
154   windowUtils.advanceTimeAndRefresh(0);
155   windowUtils.advanceTimeAndRefresh(0);
156   // Go back to normal refresh driver ticks.
157   windowUtils.restoreNormalRefresh();
158   return accessibility.service.getAccessibleFor(element);
162  * Component responsible for interacting with platform accessibility
163  * API.
165  * Its methods serve as wrappers for testing content and chrome
166  * accessibility as well as accessibility of user interactions.
167  */
168 accessibility.Checks = class {
169   /**
170    * @param {boolean} strict
171    *     Flag indicating whether the accessibility issue should be logged
172    *     or cause an error to be thrown.  Default is to log to stdout.
173    */
174   constructor(strict) {
175     this.strict = strict;
176   }
178   /**
179    * Assert that the element has a corresponding accessible object, and retrieve
180    * this accessible. Note that if the accessibility.Checks component was
181    * created in non-strict mode, this helper will not attempt to resolve the
182    * accessible at all and will simply return null.
183    *
184    * @param {DOMElement|XULElement} element
185    *     Element to get the accessible object for.
186    * @param {boolean=} mustHaveAccessible
187    *     Flag indicating that the element must have an accessible object.
188    *     Defaults to not require this.
189    *
190    * @returns {Promise.<nsIAccessible>}
191    *     Promise with an accessibility object for the given element.
192    */
193   async assertAccessible(element, mustHaveAccessible = false) {
194     if (!this.strict) {
195       return null;
196     }
198     const accessible = await accessibility.getAccessible(element);
199     if (!accessible && mustHaveAccessible) {
200       this.error("Element does not have an accessible object", element);
201     }
203     return accessible;
204   }
206   /**
207    * Test if the accessible has a role that supports some arbitrary
208    * action.
209    *
210    * @param {nsIAccessible} accessible
211    *     Accessible object.
212    *
213    * @returns {boolean}
214    *     True if an actionable role is found on the accessible, false
215    *     otherwise.
216    */
217   isActionableRole(accessible) {
218     return accessibility.ActionableRoles.has(
219       accessibility.service.getStringRole(accessible.role)
220     );
221   }
223   /**
224    * Test if an accessible has at least one action that it supports.
225    *
226    * @param {nsIAccessible} accessible
227    *     Accessible object.
228    *
229    * @returns {boolean}
230    *     True if the accessible has at least one supported action,
231    *     false otherwise.
232    */
233   hasActionCount(accessible) {
234     return accessible.actionCount > 0;
235   }
237   /**
238    * Test if an accessible has a valid name.
239    *
240    * @param {nsIAccessible} accessible
241    *     Accessible object.
242    *
243    * @returns {boolean}
244    *     True if the accessible has a non-empty valid name, or false if
245    *     this is not the case.
246    */
247   hasValidName(accessible) {
248     return accessible.name && accessible.name.trim();
249   }
251   /**
252    * Test if an accessible has a {@code hidden} attribute.
253    *
254    * @param {nsIAccessible} accessible
255    *     Accessible object.
256    *
257    * @returns {boolean}
258    *     True if the accessible object has a {@code hidden} attribute,
259    *     false otherwise.
260    */
261   hasHiddenAttribute(accessible) {
262     let hidden = false;
263     try {
264       hidden = accessible.attributes.getStringProperty("hidden");
265     } catch (e) {}
266     // if the property is missing, error will be thrown
267     return hidden && hidden === "true";
268   }
270   /**
271    * Verify if an accessible has a given state.
272    * Test if an accessible has a given state.
273    *
274    * @param {nsIAccessible} accessible
275    *     Accessible object to test.
276    * @param {number} stateToMatch
277    *     State to match.
278    *
279    * @returns {boolean}
280    *     True if |accessible| has |stateToMatch|, false otherwise.
281    */
282   matchState(accessible, stateToMatch) {
283     let state = {};
284     accessible.getState(state, {});
285     return !!(state.value & stateToMatch);
286   }
288   /**
289    * Test if an accessible is hidden from the user.
290    *
291    * @param {nsIAccessible} accessible
292    *     Accessible object.
293    *
294    * @returns {boolean}
295    *     True if element is hidden from user, false otherwise.
296    */
297   isHidden(accessible) {
298     if (!accessible) {
299       return true;
300     }
302     while (accessible) {
303       if (this.hasHiddenAttribute(accessible)) {
304         return true;
305       }
306       accessible = accessible.parent;
307     }
308     return false;
309   }
311   /**
312    * Test if the element's visible state corresponds to its accessibility
313    * API visibility.
314    *
315    * @param {nsIAccessible} accessible
316    *     Accessible object.
317    * @param {DOMElement|XULElement} element
318    *     Element associated with |accessible|.
319    * @param {boolean} visible
320    *     Visibility state of |element|.
321    *
322    * @throws ElementNotAccessibleError
323    *     If |element|'s visibility state does not correspond to
324    *     |accessible|'s.
325    */
326   assertVisible(accessible, element, visible) {
327     let hiddenAccessibility = this.isHidden(accessible);
329     let message;
330     if (visible && hiddenAccessibility) {
331       message =
332         "Element is not currently visible via the accessibility API " +
333         "and may not be manipulated by it";
334     } else if (!visible && !hiddenAccessibility) {
335       message =
336         "Element is currently only visible via the accessibility API " +
337         "and can be manipulated by it";
338     }
339     this.error(message, element);
340   }
342   /**
343    * Test if the element's unavailable accessibility state matches the
344    * enabled state.
345    *
346    * @param {nsIAccessible} accessible
347    *     Accessible object.
348    * @param {DOMElement|XULElement} element
349    *     Element associated with |accessible|.
350    * @param {boolean} enabled
351    *     Enabled state of |element|.
352    *
353    * @throws ElementNotAccessibleError
354    *     If |element|'s enabled state does not match |accessible|'s.
355    */
356   assertEnabled(accessible, element, enabled) {
357     if (!accessible) {
358       return;
359     }
361     let win = element.ownerGlobal;
362     let disabledAccessibility = this.matchState(
363       accessible,
364       accessibility.State.Unavailable
365     );
366     let explorable =
367       win.getComputedStyle(element).getPropertyValue("pointer-events") !==
368       "none";
370     let message;
371     if (!explorable && !disabledAccessibility) {
372       message =
373         "Element is enabled but is not explorable via the " +
374         "accessibility API";
375     } else if (enabled && disabledAccessibility) {
376       message = "Element is enabled but disabled via the accessibility API";
377     } else if (!enabled && !disabledAccessibility) {
378       message = "Element is disabled but enabled via the accessibility API";
379     }
380     this.error(message, element);
381   }
383   /**
384    * Test if it is possible to activate an element with the accessibility
385    * API.
386    *
387    * @param {nsIAccessible} accessible
388    *     Accessible object.
389    * @param {DOMElement|XULElement} element
390    *     Element associated with |accessible|.
391    *
392    * @throws ElementNotAccessibleError
393    *     If it is impossible to activate |element| with |accessible|.
394    */
395   assertActionable(accessible, element) {
396     if (!accessible) {
397       return;
398     }
400     let message;
401     if (!this.hasActionCount(accessible)) {
402       message = "Element does not support any accessible actions";
403     } else if (!this.isActionableRole(accessible)) {
404       message =
405         "Element does not have a correct accessibility role " +
406         "and may not be manipulated via the accessibility API";
407     } else if (!this.hasValidName(accessible)) {
408       message = "Element is missing an accessible name";
409     } else if (!this.matchState(accessible, accessibility.State.Focusable)) {
410       message = "Element is not focusable via the accessibility API";
411     }
413     this.error(message, element);
414   }
416   /**
417    * Test that an element's selected state corresponds to its
418    * accessibility API selected state.
419    *
420    * @param {nsIAccessible} accessible
421    *     Accessible object.
422    * @param {DOMElement|XULElement} element
423    *     Element associated with |accessible|.
424    * @param {boolean} selected
425    *     The |element|s selected state.
426    *
427    * @throws ElementNotAccessibleError
428    *     If |element|'s selected state does not correspond to
429    *     |accessible|'s.
430    */
431   assertSelected(accessible, element, selected) {
432     if (!accessible) {
433       return;
434     }
436     // element is not selectable via the accessibility API
437     if (!this.matchState(accessible, accessibility.State.Selectable)) {
438       return;
439     }
441     let selectedAccessibility = this.matchState(
442       accessible,
443       accessibility.State.Selected
444     );
446     let message;
447     if (selected && !selectedAccessibility) {
448       message =
449         "Element is selected but not selected via the accessibility API";
450     } else if (!selected && selectedAccessibility) {
451       message =
452         "Element is not selected but selected via the accessibility API";
453     }
454     this.error(message, element);
455   }
457   /**
458    * Throw an error if strict accessibility checks are enforced and log
459    * the error to the log.
460    *
461    * @param {string} message
462    * @param {DOMElement|XULElement} element
463    *     Element that caused an error.
464    *
465    * @throws ElementNotAccessibleError
466    *     If |strict| is true.
467    */
468   error(message, element) {
469     if (!message || !this.strict) {
470       return;
471     }
472     if (element) {
473       let { id, tagName, className } = element;
474       message += `: id: ${id}, tagName: ${tagName}, className: ${className}`;
475     }
477     throw new lazy.error.ElementNotAccessibleError(message);
478   }