Bug 1869043 add a main thread record of track audio outputs r=padenot
[gecko.git] / remote / shared / DOM.sys.mjs
blob664f02328c520df90f0d9025a81f4f7a976e97ba
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   atom: "chrome://remote/content/marionette/atom.sys.mjs",
9   error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
10   PollPromise: "chrome://remote/content/marionette/sync.sys.mjs",
11 });
13 const ORDERED_NODE_ITERATOR_TYPE = 5;
14 const FIRST_ORDERED_NODE_TYPE = 9;
16 const DOCUMENT_FRAGMENT_NODE = 11;
17 const ELEMENT_NODE = 1;
19 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
21 /** XUL elements that support checked property. */
22 const XUL_CHECKED_ELS = new Set(["button", "checkbox", "toolbarbutton"]);
24 /** XUL elements that support selected property. */
25 const XUL_SELECTED_ELS = new Set([
26   "menu",
27   "menuitem",
28   "menuseparator",
29   "radio",
30   "richlistitem",
31   "tab",
32 ]);
34 /**
35  * This module provides shared functionality for dealing with DOM-
36  * and web elements in Marionette.
37  *
38  * A web element is an abstraction used to identify an element when it
39  * is transported across the protocol, between remote- and local ends.
40  *
41  * Each element has an associated web element reference (a UUID) that
42  * uniquely identifies the the element across all browsing contexts. The
43  * web element reference for every element representing the same element
44  * is the same.
45  *
46  * @namespace
47  */
48 export const dom = {};
50 dom.Strategy = {
51   ClassName: "class name",
52   Selector: "css selector",
53   ID: "id",
54   Name: "name",
55   LinkText: "link text",
56   PartialLinkText: "partial link text",
57   TagName: "tag name",
58   XPath: "xpath",
61 /**
62  * Find a single element or a collection of elements starting at the
63  * document root or a given node.
64  *
65  * If |timeout| is above 0, an implicit search technique is used.
66  * This will wait for the duration of <var>timeout</var> for the
67  * element to appear in the DOM.
68  *
69  * See the {@link dom.Strategy} enum for a full list of supported
70  * search strategies that can be passed to <var>strategy</var>.
71  *
72  * @param {Object<string, WindowProxy>} container
73  *     Window object.
74  * @param {string} strategy
75  *     Search strategy whereby to locate the element(s).
76  * @param {string} selector
77  *     Selector search pattern.  The selector must be compatible with
78  *     the chosen search <var>strategy</var>.
79  * @param {object=} options
80  * @param {boolean=} options.all
81  *     If true, a multi-element search selector is used and a sequence of
82  *     elements will be returned, otherwise a single element. Defaults to false.
83  * @param {Element=} options.startNode
84  *     Element to use as the root of the search.
85  * @param {number=} options.timeout
86  *     Duration to wait before timing out the search.  If <code>all</code>
87  *     is false, a {@link NoSuchElementError} is thrown if unable to
88  *     find the element within the timeout duration.
89  *
90  * @returns {Promise.<(Element|Array.<Element>)>}
91  *     Single element or a sequence of elements.
92  *
93  * @throws InvalidSelectorError
94  *     If <var>strategy</var> is unknown.
95  * @throws InvalidSelectorError
96  *     If <var>selector</var> is malformed.
97  * @throws NoSuchElementError
98  *     If a single element is requested, this error will throw if the
99  *     element is not found.
100  */
101 dom.find = function (container, strategy, selector, options = {}) {
102   const { all = false, startNode, timeout = 0 } = options;
104   let searchFn;
105   if (all) {
106     searchFn = findElements.bind(this);
107   } else {
108     searchFn = findElement.bind(this);
109   }
111   return new Promise((resolve, reject) => {
112     let findElements = new lazy.PollPromise(
113       async (resolve, reject) => {
114         try {
115           let res = await find_(container, strategy, selector, searchFn, {
116             all,
117             startNode,
118           });
119           if (res.length) {
120             resolve(Array.from(res));
121           } else {
122             reject([]);
123           }
124         } catch (e) {
125           reject(e);
126         }
127       },
128       { timeout }
129     );
131     findElements.then(foundEls => {
132       // the following code ought to be moved into findElement
133       // and findElements when bug 1254486 is addressed
134       if (!all && (!foundEls || !foundEls.length)) {
135         let msg = `Unable to locate element: ${selector}`;
136         reject(new lazy.error.NoSuchElementError(msg));
137       }
139       if (all) {
140         resolve(foundEls);
141       }
142       resolve(foundEls[0]);
143     }, reject);
144   });
147 async function find_(
148   container,
149   strategy,
150   selector,
151   searchFn,
152   { startNode = null, all = false } = {}
153 ) {
154   let rootNode;
156   if (dom.isShadowRoot(startNode)) {
157     rootNode = startNode.ownerDocument;
158   } else {
159     rootNode = container.frame.document;
160   }
162   if (!startNode) {
163     startNode = rootNode;
164   }
166   let res;
167   try {
168     res = await searchFn(strategy, selector, rootNode, startNode);
169   } catch (e) {
170     throw new lazy.error.InvalidSelectorError(
171       `Given ${strategy} expression "${selector}" is invalid: ${e}`
172     );
173   }
175   if (res) {
176     if (all) {
177       return res;
178     }
179     return [res];
180   }
181   return [];
185  * Find a single element by XPath expression.
187  * @param {Document} document
188  *     Document root.
189  * @param {Element} startNode
190  *     Where in the DOM hiearchy to begin searching.
191  * @param {string} expression
192  *     XPath search expression.
194  * @returns {Node}
195  *     First element matching <var>expression</var>.
196  */
197 dom.findByXPath = function (document, startNode, expression) {
198   let iter = document.evaluate(
199     expression,
200     startNode,
201     null,
202     FIRST_ORDERED_NODE_TYPE,
203     null
204   );
205   return iter.singleNodeValue;
209  * Find elements by XPath expression.
211  * @param {Document} document
212  *     Document root.
213  * @param {Element} startNode
214  *     Where in the DOM hierarchy to begin searching.
215  * @param {string} expression
216  *     XPath search expression.
218  * @returns {Iterable.<Node>}
219  *     Iterator over nodes matching <var>expression</var>.
220  */
221 dom.findByXPathAll = function* (document, startNode, expression) {
222   let iter = document.evaluate(
223     expression,
224     startNode,
225     null,
226     ORDERED_NODE_ITERATOR_TYPE,
227     null
228   );
229   let el = iter.iterateNext();
230   while (el) {
231     yield el;
232     el = iter.iterateNext();
233   }
237  * Find all hyperlinks descendant of <var>startNode</var> which
238  * link text is <var>linkText</var>.
240  * @param {Element} startNode
241  *     Where in the DOM hierarchy to begin searching.
242  * @param {string} linkText
243  *     Link text to search for.
245  * @returns {Iterable.<HTMLAnchorElement>}
246  *     Sequence of link elements which text is <var>s</var>.
247  */
248 dom.findByLinkText = function (startNode, linkText) {
249   return filterLinks(startNode, async link => {
250     const visibleText = await lazy.atom.getVisibleText(link, link.ownerGlobal);
251     return visibleText.trim() === linkText;
252   });
256  * Find all hyperlinks descendant of <var>startNode</var> which
257  * link text contains <var>linkText</var>.
259  * @param {Element} startNode
260  *     Where in the DOM hierachy to begin searching.
261  * @param {string} linkText
262  *     Link text to search for.
264  * @returns {Iterable.<HTMLAnchorElement>}
265  *     Iterator of link elements which text containins
266  *     <var>linkText</var>.
267  */
268 dom.findByPartialLinkText = function (startNode, linkText) {
269   return filterLinks(startNode, async link => {
270     const visibleText = await lazy.atom.getVisibleText(link, link.ownerGlobal);
272     return visibleText.includes(linkText);
273   });
277  * Filters all hyperlinks that are descendant of <var>startNode</var>
278  * by <var>predicate</var>.
280  * @param {Element} startNode
281  *     Where in the DOM hierarchy to begin searching.
282  * @param {function(HTMLAnchorElement): boolean} predicate
283  *     Function that determines if given link should be included in
284  *     return value or filtered away.
286  * @returns {Array.<HTMLAnchorElement>}
287  *     Array of link elements matching <var>predicate</var>.
288  */
289 async function filterLinks(startNode, predicate) {
290   const links = [];
292   for (const link of getLinks(startNode)) {
293     if (await predicate(link)) {
294       links.push(link);
295     }
296   }
298   return links;
302  * Finds a single element.
304  * @param {dom.Strategy} strategy
305  *     Selector strategy to use.
306  * @param {string} selector
307  *     Selector expression.
308  * @param {Document} document
309  *     Document root.
310  * @param {Element=} startNode
311  *     Optional Element from which to start searching.
313  * @returns {Element}
314  *     Found element.
316  * @throws {InvalidSelectorError}
317  *     If strategy <var>using</var> is not recognised.
318  * @throws {Error}
319  *     If selector expression <var>selector</var> is malformed.
320  */
321 async function findElement(
322   strategy,
323   selector,
324   document,
325   startNode = undefined
326 ) {
327   switch (strategy) {
328     case dom.Strategy.ID: {
329       if (startNode.getElementById) {
330         return startNode.getElementById(selector);
331       }
332       let expr = `.//*[@id="${selector}"]`;
333       return dom.findByXPath(document, startNode, expr);
334     }
336     case dom.Strategy.Name: {
337       if (startNode.getElementsByName) {
338         return startNode.getElementsByName(selector)[0];
339       }
340       let expr = `.//*[@name="${selector}"]`;
341       return dom.findByXPath(document, startNode, expr);
342     }
344     case dom.Strategy.ClassName:
345       return startNode.getElementsByClassName(selector)[0];
347     case dom.Strategy.TagName:
348       return startNode.getElementsByTagName(selector)[0];
350     case dom.Strategy.XPath:
351       return dom.findByXPath(document, startNode, selector);
353     case dom.Strategy.LinkText: {
354       const links = getLinks(startNode);
355       for (const link of links) {
356         const visibleText = await lazy.atom.getVisibleText(
357           link,
358           link.ownerGlobal
359         );
360         if (visibleText.trim() === selector) {
361           return link;
362         }
363       }
364       return undefined;
365     }
367     case dom.Strategy.PartialLinkText: {
368       const links = getLinks(startNode);
369       for (const link of links) {
370         const visibleText = await lazy.atom.getVisibleText(
371           link,
372           link.ownerGlobal
373         );
374         if (visibleText.includes(selector)) {
375           return link;
376         }
377       }
378       return undefined;
379     }
381     case dom.Strategy.Selector:
382       try {
383         return startNode.querySelector(selector);
384       } catch (e) {
385         throw new lazy.error.InvalidSelectorError(
386           `${e.message}: "${selector}"`
387         );
388       }
389   }
391   throw new lazy.error.InvalidSelectorError(`No such strategy: ${strategy}`);
395  * Find multiple elements.
397  * @param {dom.Strategy} strategy
398  *     Selector strategy to use.
399  * @param {string} selector
400  *     Selector expression.
401  * @param {Document} document
402  *     Document root.
403  * @param {Element=} startNode
404  *     Optional Element from which to start searching.
406  * @returns {Array.<Element>}
407  *     Found elements.
409  * @throws {InvalidSelectorError}
410  *     If strategy <var>strategy</var> is not recognised.
411  * @throws {Error}
412  *     If selector expression <var>selector</var> is malformed.
413  */
414 async function findElements(
415   strategy,
416   selector,
417   document,
418   startNode = undefined
419 ) {
420   switch (strategy) {
421     case dom.Strategy.ID:
422       selector = `.//*[@id="${selector}"]`;
424     // fall through
425     case dom.Strategy.XPath:
426       return [...dom.findByXPathAll(document, startNode, selector)];
428     case dom.Strategy.Name:
429       if (startNode.getElementsByName) {
430         return startNode.getElementsByName(selector);
431       }
432       return [
433         ...dom.findByXPathAll(document, startNode, `.//*[@name="${selector}"]`),
434       ];
436     case dom.Strategy.ClassName:
437       return startNode.getElementsByClassName(selector);
439     case dom.Strategy.TagName:
440       return startNode.getElementsByTagName(selector);
442     case dom.Strategy.LinkText:
443       return [...(await dom.findByLinkText(startNode, selector))];
445     case dom.Strategy.PartialLinkText:
446       return [...(await dom.findByPartialLinkText(startNode, selector))];
448     case dom.Strategy.Selector:
449       return startNode.querySelectorAll(selector);
451     default:
452       throw new lazy.error.InvalidSelectorError(
453         `No such strategy: ${strategy}`
454       );
455   }
458 function getLinks(startNode) {
459   // DocumentFragment doesn't have `getElementsByTagName` so using `querySelectorAll`.
460   if (dom.isShadowRoot(startNode)) {
461     return startNode.querySelectorAll("a");
462   }
463   return startNode.getElementsByTagName("a");
467  * Finds the closest parent node of <var>startNode</var> matching a CSS
468  * <var>selector</var> expression.
470  * @param {Node} startNode
471  *     Cycle through <var>startNode</var>'s parent nodes in tree-order
472  *     and return the first match to <var>selector</var>.
473  * @param {string} selector
474  *     CSS selector expression.
476  * @returns {Node=}
477  *     First match to <var>selector</var>, or null if no match was found.
478  */
479 dom.findClosest = function (startNode, selector) {
480   let node = startNode;
481   while (node.parentNode && node.parentNode.nodeType == ELEMENT_NODE) {
482     node = node.parentNode;
483     if (node.matches(selector)) {
484       return node;
485     }
486   }
487   return null;
491  * Determines if <var>obj<var> is an HTML or JS collection.
493  * @param {object} seq
494  *     Type to determine.
496  * @returns {boolean}
497  *     True if <var>seq</va> is a collection.
498  */
499 dom.isCollection = function (seq) {
500   switch (Object.prototype.toString.call(seq)) {
501     case "[object Arguments]":
502     case "[object Array]":
503     case "[object DOMTokenList]":
504     case "[object FileList]":
505     case "[object HTMLAllCollection]":
506     case "[object HTMLCollection]":
507     case "[object HTMLFormControlsCollection]":
508     case "[object HTMLOptionsCollection]":
509     case "[object NodeList]":
510       return true;
512     default:
513       return false;
514   }
518  * Determines if <var>shadowRoot</var> is detached.
520  * A ShadowRoot is detached if its node document is not the active document
521  * or if the element node referred to as its host is stale.
523  * @param {ShadowRoot} shadowRoot
524  *     ShadowRoot to check for detached state.
526  * @returns {boolean}
527  *     True if <var>shadowRoot</var> is detached, false otherwise.
528  */
529 dom.isDetached = function (shadowRoot) {
530   return !shadowRoot.ownerDocument.isActive() || dom.isStale(shadowRoot.host);
534  * Determines if <var>el</var> is stale.
536  * An element is stale if its node document is not the active document
537  * or if it is not connected.
539  * @param {Element} el
540  *     Element to check for staleness.
542  * @returns {boolean}
543  *     True if <var>el</var> is stale, false otherwise.
544  */
545 dom.isStale = function (el) {
546   if (!el.ownerGlobal) {
547     // Without a valid inner window the document is basically closed.
548     return true;
549   }
551   return !el.ownerDocument.isActive() || !el.isConnected;
555  * Determine if <var>el</var> is selected or not.
557  * This operation only makes sense on
558  * <tt>&lt;input type=checkbox&gt;</tt>,
559  * <tt>&lt;input type=radio&gt;</tt>,
560  * and <tt>&gt;option&gt;</tt> elements.
562  * @param {Element} el
563  *     Element to test if selected.
565  * @returns {boolean}
566  *     True if element is selected, false otherwise.
567  */
568 dom.isSelected = function (el) {
569   if (!el) {
570     return false;
571   }
573   if (dom.isXULElement(el)) {
574     if (XUL_CHECKED_ELS.has(el.tagName)) {
575       return el.checked;
576     } else if (XUL_SELECTED_ELS.has(el.tagName)) {
577       return el.selected;
578     }
579   } else if (dom.isDOMElement(el)) {
580     if (el.localName == "input" && ["checkbox", "radio"].includes(el.type)) {
581       return el.checked;
582     } else if (el.localName == "option") {
583       return el.selected;
584     }
585   }
587   return false;
591  * An element is considered read only if it is an
592  * <code>&lt;input&gt;</code> or <code>&lt;textarea&gt;</code>
593  * element whose <code>readOnly</code> content IDL attribute is set.
595  * @param {Element} el
596  *     Element to test is read only.
598  * @returns {boolean}
599  *     True if element is read only.
600  */
601 dom.isReadOnly = function (el) {
602   return (
603     dom.isDOMElement(el) &&
604     ["input", "textarea"].includes(el.localName) &&
605     el.readOnly
606   );
610  * An element is considered disabled if it is a an element
611  * that can be disabled, or it belongs to a container group which
612  * <code>disabled</code> content IDL attribute affects it.
614  * @param {Element} el
615  *     Element to test for disabledness.
617  * @returns {boolean}
618  *     True if element, or its container group, is disabled.
619  */
620 dom.isDisabled = function (el) {
621   if (!dom.isDOMElement(el)) {
622     return false;
623   }
625   switch (el.localName) {
626     case "option":
627     case "optgroup":
628       if (el.disabled) {
629         return true;
630       }
631       let parent = dom.findClosest(el, "optgroup,select");
632       return dom.isDisabled(parent);
634     case "button":
635     case "input":
636     case "select":
637     case "textarea":
638       return el.disabled;
640     default:
641       return false;
642   }
646  * Denotes elements that can be used for typing and clearing.
648  * Elements that are considered WebDriver-editable are non-readonly
649  * and non-disabled <code>&lt;input&gt;</code> elements in the Text,
650  * Search, URL, Telephone, Email, Password, Date, Month, Date and
651  * Time Local, Number, Range, Color, and File Upload states, and
652  * <code>&lt;textarea&gt;</code> elements.
654  * @param {Element} el
655  *     Element to test.
657  * @returns {boolean}
658  *     True if editable, false otherwise.
659  */
660 dom.isMutableFormControl = function (el) {
661   if (!dom.isDOMElement(el)) {
662     return false;
663   }
664   if (dom.isReadOnly(el) || dom.isDisabled(el)) {
665     return false;
666   }
668   if (el.localName == "textarea") {
669     return true;
670   }
672   if (el.localName != "input") {
673     return false;
674   }
676   switch (el.type) {
677     case "color":
678     case "date":
679     case "datetime-local":
680     case "email":
681     case "file":
682     case "month":
683     case "number":
684     case "password":
685     case "range":
686     case "search":
687     case "tel":
688     case "text":
689     case "time":
690     case "url":
691     case "week":
692       return true;
694     default:
695       return false;
696   }
700  * An editing host is a node that is either an HTML element with a
701  * <code>contenteditable</code> attribute, or the HTML element child
702  * of a document whose <code>designMode</code> is enabled.
704  * @param {Element} el
705  *     Element to determine if is an editing host.
707  * @returns {boolean}
708  *     True if editing host, false otherwise.
709  */
710 dom.isEditingHost = function (el) {
711   return (
712     dom.isDOMElement(el) &&
713     (el.isContentEditable || el.ownerDocument.designMode == "on")
714   );
718  * Determines if an element is editable according to WebDriver.
720  * An element is considered editable if it is not read-only or
721  * disabled, and one of the following conditions are met:
723  * <ul>
724  * <li>It is a <code>&lt;textarea&gt;</code> element.
726  * <li>It is an <code>&lt;input&gt;</code> element that is not of
727  * the <code>checkbox</code>, <code>radio</code>, <code>hidden</code>,
728  * <code>submit</code>, <code>button</code>, or <code>image</code> types.
730  * <li>It is content-editable.
732  * <li>It belongs to a document in design mode.
733  * </ul>
735  * @param {Element} el
736  *     Element to test if editable.
738  * @returns {boolean}
739  *     True if editable, false otherwise.
740  */
741 dom.isEditable = function (el) {
742   if (!dom.isDOMElement(el)) {
743     return false;
744   }
746   if (dom.isReadOnly(el) || dom.isDisabled(el)) {
747     return false;
748   }
750   return dom.isMutableFormControl(el) || dom.isEditingHost(el);
754  * This function generates a pair of coordinates relative to the viewport
755  * given a target element and coordinates relative to that element's
756  * top-left corner.
758  * @param {Node} node
759  *     Target node.
760  * @param {number=} xOffset
761  *     Horizontal offset relative to target's top-left corner.
762  *     Defaults to the centre of the target's bounding box.
763  * @param {number=} yOffset
764  *     Vertical offset relative to target's top-left corner.  Defaults to
765  *     the centre of the target's bounding box.
767  * @returns {Object<string, number>}
768  *     X- and Y coordinates.
770  * @throws TypeError
771  *     If <var>xOffset</var> or <var>yOffset</var> are not numbers.
772  */
773 dom.coordinates = function (node, xOffset = undefined, yOffset = undefined) {
774   let box = node.getBoundingClientRect();
776   if (typeof xOffset == "undefined" || xOffset === null) {
777     xOffset = box.width / 2.0;
778   }
779   if (typeof yOffset == "undefined" || yOffset === null) {
780     yOffset = box.height / 2.0;
781   }
783   if (typeof yOffset != "number" || typeof xOffset != "number") {
784     throw new TypeError("Offset must be a number");
785   }
787   return {
788     x: box.left + xOffset,
789     y: box.top + yOffset,
790   };
794  * This function returns true if the node is in the viewport.
796  * @param {Element} el
797  *     Target element.
798  * @param {number=} x
799  *     Horizontal offset relative to target.  Defaults to the centre of
800  *     the target's bounding box.
801  * @param {number=} y
802  *     Vertical offset relative to target.  Defaults to the centre of
803  *     the target's bounding box.
805  * @returns {boolean}
806  *     True if if <var>el</var> is in viewport, false otherwise.
807  */
808 dom.inViewport = function (el, x = undefined, y = undefined) {
809   let win = el.ownerGlobal;
810   let c = dom.coordinates(el, x, y);
811   let vp = {
812     top: win.pageYOffset,
813     left: win.pageXOffset,
814     bottom: win.pageYOffset + win.innerHeight,
815     right: win.pageXOffset + win.innerWidth,
816   };
818   return (
819     vp.left <= c.x + win.pageXOffset &&
820     c.x + win.pageXOffset <= vp.right &&
821     vp.top <= c.y + win.pageYOffset &&
822     c.y + win.pageYOffset <= vp.bottom
823   );
827  * Gets the element's container element.
829  * An element container is defined by the WebDriver
830  * specification to be an <tt>&lt;option&gt;</tt> element in a
831  * <a href="https://html.spec.whatwg.org/#concept-element-contexts">valid
832  * element context</a>, meaning that it has an ancestral element
833  * that is either <tt>&lt;datalist&gt;</tt> or <tt>&lt;select&gt;</tt>.
835  * If the element does not have a valid context, its container element
836  * is itself.
838  * @param {Element} el
839  *     Element to get the container of.
841  * @returns {Element}
842  *     Container element of <var>el</var>.
843  */
844 dom.getContainer = function (el) {
845   // Does <option> or <optgroup> have a valid context,
846   // meaning is it a child of <datalist> or <select>?
847   if (["option", "optgroup"].includes(el.localName)) {
848     return dom.findClosest(el, "datalist,select") || el;
849   }
851   return el;
855  * An element is in view if it is a member of its own pointer-interactable
856  * paint tree.
858  * This means an element is considered to be in view, but not necessarily
859  * pointer-interactable, if it is found somewhere in the
860  * <code>elementsFromPoint</code> list at <var>el</var>'s in-view
861  * centre coordinates.
863  * Before running the check, we change <var>el</var>'s pointerEvents
864  * style property to "auto", since elements without pointer events
865  * enabled do not turn up in the paint tree we get from
866  * document.elementsFromPoint.  This is a specialisation that is only
867  * relevant when checking if the element is in view.
869  * @param {Element} el
870  *     Element to check if is in view.
872  * @returns {boolean}
873  *     True if <var>el</var> is inside the viewport, or false otherwise.
874  */
875 dom.isInView = function (el) {
876   let originalPointerEvents = el.style.pointerEvents;
878   try {
879     el.style.pointerEvents = "auto";
880     const tree = dom.getPointerInteractablePaintTree(el);
882     // Bug 1413493 - <tr> is not part of the returned paint tree yet. As
883     // workaround check the visibility based on the first contained cell.
884     if (el.localName === "tr" && el.cells && el.cells.length) {
885       return tree.includes(el.cells[0]);
886     }
888     return tree.includes(el);
889   } finally {
890     el.style.pointerEvents = originalPointerEvents;
891   }
895  * This function throws the visibility of the element error if the element is
896  * not displayed or the given coordinates are not within the viewport.
898  * @param {Element} el
899  *     Element to check if visible.
900  * @param {number=} x
901  *     Horizontal offset relative to target.  Defaults to the centre of
902  *     the target's bounding box.
903  * @param {number=} y
904  *     Vertical offset relative to target.  Defaults to the centre of
905  *     the target's bounding box.
907  * @returns {boolean}
908  *     True if visible, false otherwise.
909  */
910 dom.isVisible = async function (el, x = undefined, y = undefined) {
911   let win = el.ownerGlobal;
913   if (!(await lazy.atom.isElementDisplayed(el, win))) {
914     return false;
915   }
917   if (el.tagName.toLowerCase() == "body") {
918     return true;
919   }
921   if (!dom.inViewport(el, x, y)) {
922     dom.scrollIntoView(el);
923     if (!dom.inViewport(el)) {
924       return false;
925     }
926   }
927   return true;
931  * A pointer-interactable element is defined to be the first
932  * non-transparent element, defined by the paint order found at the centre
933  * point of its rectangle that is inside the viewport, excluding the size
934  * of any rendered scrollbars.
936  * An element is obscured if the pointer-interactable paint tree at its
937  * centre point is empty, or the first element in this tree is not an
938  * inclusive descendant of itself.
940  * @param {DOMElement} el
941  *     Element determine if is pointer-interactable.
943  * @returns {boolean}
944  *     True if element is obscured, false otherwise.
945  */
946 dom.isObscured = function (el) {
947   let tree = dom.getPointerInteractablePaintTree(el);
948   return !el.contains(tree[0]);
951 // TODO(ato): Only used by deprecated action API
952 // https://bugzil.la/1354578
954  * Calculates the in-view centre point of an element's client rect.
956  * The portion of an element that is said to be _in view_, is the
957  * intersection of two squares: the first square being the initial
958  * viewport, and the second a DOM element.  From this square we
959  * calculate the in-view _centre point_ and convert it into CSS pixels.
961  * Although Gecko's system internals allow click points to be
962  * given in floating point precision, the DOM operates in CSS pixels.
963  * When the in-view centre point is later used to retrieve a coordinate's
964  * paint tree, we need to ensure to operate in the same language.
966  * As a word of warning, there appears to be inconsistencies between
967  * how `DOMElement.elementsFromPoint` and `DOMWindowUtils.sendMouseEvent`
968  * internally rounds (ceils/floors) coordinates.
970  * @param {DOMRect} rect
971  *     Element off a DOMRect sequence produced by calling
972  *     `getClientRects` on an {@link Element}.
973  * @param {WindowProxy} win
974  *     Current window global.
976  * @returns {Map.<string, number>}
977  *     X and Y coordinates that denotes the in-view centre point of
978  *     `rect`.
979  */
980 dom.getInViewCentrePoint = function (rect, win) {
981   const { floor, max, min } = Math;
983   // calculate the intersection of the rect that is inside the viewport
984   let visible = {
985     left: max(0, min(rect.x, rect.x + rect.width)),
986     right: min(win.innerWidth, max(rect.x, rect.x + rect.width)),
987     top: max(0, min(rect.y, rect.y + rect.height)),
988     bottom: min(win.innerHeight, max(rect.y, rect.y + rect.height)),
989   };
991   // arrive at the centre point of the visible rectangle
992   let x = (visible.left + visible.right) / 2.0;
993   let y = (visible.top + visible.bottom) / 2.0;
995   // convert to CSS pixels, as centre point can be float
996   x = floor(x);
997   y = floor(y);
999   return { x, y };
1003  * Produces a pointer-interactable elements tree from a given element.
1005  * The tree is defined by the paint order found at the centre point of
1006  * the element's rectangle that is inside the viewport, excluding the size
1007  * of any rendered scrollbars.
1009  * @param {DOMElement} el
1010  *     Element to determine if is pointer-interactable.
1012  * @returns {Array.<DOMElement>}
1013  *     Sequence of elements in paint order.
1014  */
1015 dom.getPointerInteractablePaintTree = function (el) {
1016   const win = el.ownerGlobal;
1017   const rootNode = el.getRootNode();
1019   // pointer-interactable elements tree, step 1
1020   if (!el.isConnected) {
1021     return [];
1022   }
1024   // steps 2-3
1025   let rects = el.getClientRects();
1026   if (!rects.length) {
1027     return [];
1028   }
1030   // step 4
1031   let centre = dom.getInViewCentrePoint(rects[0], win);
1033   // step 5
1034   return rootNode.elementsFromPoint(centre.x, centre.y);
1037 // TODO(ato): Not implemented.
1038 // In fact, it's not defined in the spec.
1039 dom.isKeyboardInteractable = () => true;
1042  * Attempts to scroll into view |el|.
1044  * @param {DOMElement} el
1045  *     Element to scroll into view.
1046  */
1047 dom.scrollIntoView = function (el) {
1048   if (el.scrollIntoView) {
1049     el.scrollIntoView({ block: "end", inline: "nearest" });
1050   }
1054  * Ascertains whether <var>obj</var> is a DOM-, SVG-, or XUL element.
1056  * @param {object} obj
1057  *     Object thought to be an <code>Element</code> or
1058  *     <code>XULElement</code>.
1060  * @returns {boolean}
1061  *     True if <var>obj</var> is an element, false otherwise.
1062  */
1063 dom.isElement = function (obj) {
1064   return dom.isDOMElement(obj) || dom.isXULElement(obj);
1068  * Returns the shadow root of an element.
1070  * @param {Element} el
1071  *     Element thought to have a <code>shadowRoot</code>
1072  * @returns {ShadowRoot}
1073  *     Shadow root of the element.
1074  */
1075 dom.getShadowRoot = function (el) {
1076   const shadowRoot = el.openOrClosedShadowRoot;
1077   if (!shadowRoot) {
1078     throw new lazy.error.NoSuchShadowRootError();
1079   }
1080   return shadowRoot;
1084  * Ascertains whether <var>node</var> is a shadow root.
1086  * @param {ShadowRoot} node
1087  *   The node that will be checked to see if it has a shadow root
1089  * @returns {boolean}
1090  *     True if <var>node</var> is a shadow root, false otherwise.
1091  */
1092 dom.isShadowRoot = function (node) {
1093   return (
1094     node &&
1095     node.nodeType === DOCUMENT_FRAGMENT_NODE &&
1096     node.containingShadowRoot == node
1097   );
1101  * Ascertains whether <var>obj</var> is a DOM element.
1103  * @param {object} obj
1104  *     Object to check.
1106  * @returns {boolean}
1107  *     True if <var>obj</var> is a DOM element, false otherwise.
1108  */
1109 dom.isDOMElement = function (obj) {
1110   return obj && obj.nodeType == ELEMENT_NODE && !dom.isXULElement(obj);
1114  * Ascertains whether <var>obj</var> is a XUL element.
1116  * @param {object} obj
1117  *     Object to check.
1119  * @returns {boolean}
1120  *     True if <var>obj</var> is a XULElement, false otherwise.
1121  */
1122 dom.isXULElement = function (obj) {
1123   return obj && obj.nodeType === ELEMENT_NODE && obj.namespaceURI === XUL_NS;
1127  * Ascertains whether <var>node</var> is in a privileged document.
1129  * @param {Node} node
1130  *     Node to check.
1132  * @returns {boolean}
1133  *     True if <var>node</var> is in a privileged document,
1134  *     false otherwise.
1135  */
1136 dom.isInPrivilegedDocument = function (node) {
1137   return !!node?.nodePrincipal?.isSystemPrincipal;
1141  * Ascertains whether <var>obj</var> is a <code>WindowProxy</code>.
1143  * @param {object} obj
1144  *     Object to check.
1146  * @returns {boolean}
1147  *     True if <var>obj</var> is a DOM window.
1148  */
1149 dom.isDOMWindow = function (obj) {
1150   // TODO(ato): This should use Object.prototype.toString.call(node)
1151   // but it's not clear how to write a good xpcshell test for that,
1152   // seeing as we stub out a WindowProxy.
1153   return (
1154     typeof obj == "object" &&
1155     obj !== null &&
1156     typeof obj.toString == "function" &&
1157     obj.toString() == "[object Window]" &&
1158     obj.self === obj
1159   );
1162 const boolEls = {
1163   audio: ["autoplay", "controls", "loop", "muted"],
1164   button: ["autofocus", "disabled", "formnovalidate"],
1165   details: ["open"],
1166   dialog: ["open"],
1167   fieldset: ["disabled"],
1168   form: ["novalidate"],
1169   iframe: ["allowfullscreen"],
1170   img: ["ismap"],
1171   input: [
1172     "autofocus",
1173     "checked",
1174     "disabled",
1175     "formnovalidate",
1176     "multiple",
1177     "readonly",
1178     "required",
1179   ],
1180   keygen: ["autofocus", "disabled"],
1181   menuitem: ["checked", "default", "disabled"],
1182   ol: ["reversed"],
1183   optgroup: ["disabled"],
1184   option: ["disabled", "selected"],
1185   script: ["async", "defer"],
1186   select: ["autofocus", "disabled", "multiple", "required"],
1187   textarea: ["autofocus", "disabled", "readonly", "required"],
1188   track: ["default"],
1189   video: ["autoplay", "controls", "loop", "muted"],
1193  * Tests if the attribute is a boolean attribute on element.
1195  * @param {Element} el
1196  *     Element to test if <var>attr</var> is a boolean attribute on.
1197  * @param {string} attr
1198  *     Attribute to test is a boolean attribute.
1200  * @returns {boolean}
1201  *     True if the attribute is boolean, false otherwise.
1202  */
1203 dom.isBooleanAttribute = function (el, attr) {
1204   if (!dom.isDOMElement(el)) {
1205     return false;
1206   }
1208   // global boolean attributes that apply to all HTML elements,
1209   // except for custom elements
1210   const customElement = !el.localName.includes("-");
1211   if ((attr == "hidden" || attr == "itemscope") && customElement) {
1212     return true;
1213   }
1215   if (!boolEls.hasOwnProperty(el.localName)) {
1216     return false;
1217   }
1218   return boolEls[el.localName].includes(attr);