JSDoc: Source: element.js

# Source: element.js

    /* 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";
    /* global XPCNativeWrapper */

    const {classes: Cc, interfaces: Ci, utils: Cu} = Components;

    Cu.import("chrome://marionette/content/assert.js");
    Cu.import("chrome://marionette/content/atom.js");
    const {
      InvalidSelectorError,
      NoSuchElementError,
      pprint,
      StaleElementReferenceError,
    } = Cu.import("chrome://marionette/content/error.js", {});
    Cu.import("chrome://marionette/content/wait.js");

    this.EXPORTED_SYMBOLS = ["element"];

    const XMLNS = "http://www.w3.org/1999/xhtml";
    const XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";

    const uuidGen = Cc["@mozilla.org/uuid-generator;1"]
        .getService(Ci.nsIUUIDGenerator);

    /**
     * This module provides shared functionality for dealing with DOM-
     * and web elements in Marionette.
     *
     * A web element is an abstraction used to identify an element when it
     * is transported across the protocol, between remote- and local ends.
     *
     * Each element has an associated web element reference (a UUID) that
     * uniquely identifies the the element across all browsing contexts. The
     * web element reference for every element representing the same element
     * is the same.
     *
     * The {@link element.Store} provides a mapping between web element
     * references and DOM elements for each browsing context.  It also provides
     * functionality for looking up and retrieving elements.
     *
     * @namespace
     */
    this.element = {};

    element.Key = "element-6066-11e4-a52e-4f735466cecf";
    element.LegacyKey = "ELEMENT";

    element.Strategy = {
      ClassName: "class name",
      Selector: "css selector",
      ID: "id",
      Name: "name",
      LinkText: "link text",
      PartialLinkText: "partial link text",
      TagName: "tag name",
      XPath: "xpath",
      Anon: "anon",
      AnonAttribute: "anon attribute",
    };

    /**
     * Stores known/seen elements and their associated web element
     * references.
     *
     * Elements are added by calling |add(el)| or |addAll(elements)|, and
     * may be queried by their web element reference using |get(element)|.
     *
     * @class
     * @memberof element
     */
    element.Store = class {
      constructor() {
        this.els = {};
        this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
      }

      clear() {
        this.els = {};
      }

      /**
       * Make a collection of elements seen.
       *
       * The oder of the returned web element references is guaranteed to
       * match that of the collection passed in.
       *
       * @param {NodeList} els
       *     Sequence of elements to add to set of seen elements.
       *
       * @return {Array.<WebElement>}
       *     List of the web element references associated with each element
       *     from |els|.
       */
      addAll(els) {
        let add = this.add.bind(this);
        return [...els].map(add);
      }

      /**
       * Make an element seen.
       *
       * @param {Element} el
       *    Element to add to set of seen elements.
       *
       * @return {string}
       *     Web element reference associated with element.
       */
      add(el) {
        for (let i in this.els) {
          let foundEl;
          try {
            foundEl = this.els[i].get();
          } catch (e) {}

          if (foundEl) {
            if (new XPCNativeWrapper(foundEl) == new XPCNativeWrapper(el)) {
              return i;
            }

          // cleanup reference to gc'd element
          } else {
            delete this.els[i];
          }
        }

        let id = element.generateUUID();
        this.els[id] = Cu.getWeakReference(el);
        return id;
      }

      /**
       * Determine if the provided web element reference has been seen
       * before/is in the element store.
       *
       * @param {string} uuid
       *     Element's associated web element reference.
       *
       * @return {boolean}
       *     True if element is in the store, false otherwise.
       */
      has(uuid) {
        return Object.keys(this.els).includes(uuid);
      }

      /**
       * Retrieve a DOM element by its unique web element reference/UUID.
       *
       * @param {string} uuid
       *     Web element reference, or UUID.
       *
       * @returns {Element}
       *     Element associated with reference.
       *
       * @throws {NoSuchElementError}
       *     If the web element reference <var>uuid</var> has not been
       *     seen before.
       * @throws {StaleElementReferenceError}
       *     If the element has gone stale, indicating it is no longer
       *     attached to the DOM, or its node document is no longer the
       *     active document.
       */
      get(uuid) {
        if (!this.has(uuid)) {
          throw new NoSuchElementError(
              "Web element reference not seen before: " + uuid);
        }

        let el;
        let ref = this.els[uuid];
        try {
          el = ref.get();
        } catch (e) {
          delete this.els[uuid];
        }

        if (element.isStale(el)) {
          throw new StaleElementReferenceError(
              pprint`The element reference of ${el || uuid} stale; ` +
                  "either the element is no longer attached to the DOM " +
                  "or the document has been refreshed");
        }

        return el;
      }
    };

    /**
     * Find a single element or a collection of elements starting at the
     * document root or a given node.
     *
     * If |timeout| is above 0, an implicit search technique is used.
     * This will wait for the duration of |timeout| for the element
     * to appear in the DOM.
     *
     * See the |element.Strategy| enum for a full list of supported
     * search strategies that can be passed to |strategy|.
     *
     * Available flags for <var>opts</var>:
     *
     * <dl>
     *   <dt><code>all</code>
     *   <dd>
     *     If true, a multi-element search selector is used and a sequence
     *     of elements will be returned.  Otherwise a single element.
     *
     *   <dt><code>timeout</code>
     *   <dd>
     *     Duration to wait before timing out the search.  If <code>all</code>
     *     is false, a {@link NoSuchElementError} is thrown if unable to
     *     find the element within the timeout duration.
     *
     *   <dt><code>startNode</code>
     *   <dd>Element to use as the root of the search.
     *
     * @param {Object.<string, WindowProxy>} container
     *     Window object and an optional shadow root that contains the
     *     root shadow DOM element.
     * @param {string} strategy
     *     Search strategy whereby to locate the element(s).
     * @param {string} selector
     *     Selector search pattern.  The selector must be compatible with
     *     the chosen search <var>strategy</var>.
     * @param {Object.<string, ?>} opts
     *     Options.
     *
     * @return {Promise.<(Element|Array.<Element>)>}
     *     Single element or a sequence of elements.
     *
     * @throws InvalidSelectorError
     *     If <var>strategy</var> is unknown.
     * @throws InvalidSelectorError
     *     If <var>selector</var> is malformed.
     * @throws NoSuchElementError
     *     If a single element is requested, this error will throw if the
     *     element is not found.
     */
    element.find = function(container, strategy, selector, opts = {}) {
      opts.all = !!opts.all;
      opts.timeout = opts.timeout || 0;

      let searchFn;
      if (opts.all) {
        searchFn = findElements.bind(this);
      } else {
        searchFn = findElement.bind(this);
      }

      return new Promise((resolve, reject) => {
        let findElements = wait.until((resolve, reject) => {
          let res = find_(container, strategy, selector, searchFn, opts);
          if (res.length > 0) {
            resolve(Array.from(res));
          } else {
            reject([]);
          }
        }, opts.timeout);

        findElements.then(foundEls => {
          // the following code ought to be moved into findElement
          // and findElements when bug 1254486 is addressed
          if (!opts.all && (!foundEls || foundEls.length == 0)) {
            let msg;
            switch (strategy) {
              case element.Strategy.AnonAttribute:
                msg = "Unable to locate anonymous element: " +
                    JSON.stringify(selector);
                break;

              default:
                msg = "Unable to locate element: " + selector;
            }

            reject(new NoSuchElementError(msg));
          }

          if (opts.all) {
            resolve(foundEls);
          }
          resolve(foundEls[0]);
        }, reject);
      });
    };

    function find_(container, strategy, selector, searchFn, opts) {
      let rootNode = container.shadowRoot || container.frame.document;
      let startNode;

      if (opts.startNode) {
        startNode = opts.startNode;
      } else {
        switch (strategy) {
          // For anonymous nodes the start node needs to be of type
          // DOMElement, which will refer to :root in case of a DOMDocument.
          case element.Strategy.Anon:
          case element.Strategy.AnonAttribute:
            if (rootNode instanceof Ci.nsIDOMDocument) {
              startNode = rootNode.documentElement;
            }
            break;

          default:
            startNode = rootNode;
        }
      }

      let res;
      try {
        res = searchFn(strategy, selector, rootNode, startNode);
      } catch (e) {
        throw new InvalidSelectorError(
            `Given ${strategy} expression "${selector}" is invalid: ${e}`);
      }

      if (res) {
        if (opts.all) {
          return res;
        }
        return [res];
      }
      return [];
    }

    /**
     * Find a single element by XPath expression.
     *
     * @param {DOMElement} root
     *     Document root
     * @param {DOMElement} startNode
     *     Where in the DOM hiearchy to begin searching.
     * @param {string} expr
     *     XPath search expression.
     *
     * @return {DOMElement}
     *     First element matching expression.
     */
    element.findByXPath = function(root, startNode, expr) {
      let iter = root.evaluate(expr, startNode, null,
          Ci.nsIDOMXPathResult.FIRST_ORDERED_NODE_TYPE, null);
      return iter.singleNodeValue;
    };

    /**
     * Find elements by XPath expression.
     *
     * @param {DOMElement} root
     *     Document root.
     * @param {DOMElement} startNode
     *     Where in the DOM hierarchy to begin searching.
     * @param {string} expr
     *     XPath search expression.
     *
     * @return {Array.<DOMElement>}
     *     Sequence of found elements matching expression.
     */
    element.findByXPathAll = function(root, startNode, expr) {
      let rv = [];
      let iter = root.evaluate(expr, startNode, null,
          Ci.nsIDOMXPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
      let el = iter.iterateNext();
      while (el) {
        rv.push(el);
        el = iter.iterateNext();
      }
      return rv;
    };

    /**
     * Find all hyperlinks dscendant of |node| which link text is |s|.
     *
     * @param {DOMElement} node
     *     Where in the DOM hierarchy to being searching.
     * @param {string} s
     *     Link text to search for.
     *
     * @return {Array.<DOMAnchorElement>}
     *     Sequence of link elements which text is |s|.
     */
    element.findByLinkText = function(node, s) {
      return filterLinks(node, link => link.text.trim() === s);
    };

    /**
     * Find all hyperlinks descendant of |node| which link text contains |s|.
     *
     * @param {DOMElement} node
     *     Where in the DOM hierachy to begin searching.
     * @param {string} s
     *     Link text to search for.
     *
     * @return {Array.<DOMAnchorElement>}
     *     Sequence of link elements which text containins |s|.
     */
    element.findByPartialLinkText = function(node, s) {
      return filterLinks(node, link => link.text.indexOf(s) != -1);
    };

    /**
     * Filters all hyperlinks that are descendant of |node| by |predicate|.
     *
     * @param {DOMElement} node
     *     Where in the DOM hierarchy to begin searching.
     * @param {function(DOMAnchorElement): boolean} predicate
     *     Function that determines if given link should be included in
     *     return value or filtered away.
     *
     * @return {Array.<DOMAnchorElement>}
     *     Sequence of link elements matching |predicate|.
     */
    function filterLinks(node, predicate) {
      let rv = [];
      for (let link of node.getElementsByTagName("a")) {
        if (predicate(link)) {
          rv.push(link);
        }
      }
      return rv;
    }

    /**
     * Finds a single element.
     *
     * @param {element.Strategy} using
     *     Selector strategy to use.
     * @param {string} value
     *     Selector expression.
     * @param {DOMElement} rootNode
     *     Document root.
     * @param {DOMElement=} startNode
     *     Optional node from which to start searching.
     *
     * @return {DOMElement}
     *     Found elements.
     *
     * @throws {InvalidSelectorError}
     *     If strategy |using| is not recognised.
     * @throws {Error}
     *     If selector expression |value| is malformed.
     */
    function findElement(using, value, rootNode, startNode) {
      switch (using) {
        case element.Strategy.ID:
          {
            if (startNode.getElementById) {
              return startNode.getElementById(value);
            }
            let expr = `.//*[@id="${value}"]`;
            return element.findByXPath( rootNode, startNode, expr);
          }

        case element.Strategy.Name:
          {
            if (startNode.getElementsByName) {
              return startNode.getElementsByName(value)[0];
            }
            let expr = `.//*[@name="${value}"]`;
            return element.findByXPath(rootNode, startNode, expr);
          }

        case element.Strategy.ClassName:
          // works for >= Firefox 3
          return startNode.getElementsByClassName(value)[0];

        case element.Strategy.TagName:
          // works for all elements
          return startNode.getElementsByTagName(value)[0];

        case element.Strategy.XPath:
          return element.findByXPath(rootNode, startNode, value);

        case element.Strategy.LinkText:
          for (let link of startNode.getElementsByTagName("a")) {
            if (link.text.trim() === value) {
              return link;
            }
          }
          return undefined;

        case element.Strategy.PartialLinkText:
          for (let link of startNode.getElementsByTagName("a")) {
            if (link.text.indexOf(value) != -1) {
              return link;
            }
          }
          return undefined;

        case element.Strategy.Selector:
          try {
            return startNode.querySelector(value);
          } catch (e) {
            throw new InvalidSelectorError(`${e.message}: "${value}"`);
          }

        case element.Strategy.Anon:
          return rootNode.getAnonymousNodes(startNode);

        case element.Strategy.AnonAttribute:
          let attr = Object.keys(value)[0];
          return rootNode.getAnonymousElementByAttribute(
              startNode, attr, value[attr]);
      }

      throw new InvalidSelectorError(`No such strategy: ${using}`);
    }

    /**
     * Find multiple elements.
     *
     * @param {element.Strategy} using
     *     Selector strategy to use.
     * @param {string} value
     *     Selector expression.
     * @param {DOMElement} rootNode
     *     Document root.
     * @param {DOMElement=} startNode
     *     Optional node from which to start searching.
     *
     * @return {DOMElement}
     *     Found elements.
     *
     * @throws {InvalidSelectorError}
     *     If strategy |using| is not recognised.
     * @throws {Error}
     *     If selector expression |value| is malformed.
     */
    function findElements(using, value, rootNode, startNode) {
      switch (using) {
        case element.Strategy.ID:
          value = `.//*[@id="${value}"]`;

        // fall through
        case element.Strategy.XPath:
          return element.findByXPathAll(rootNode, startNode, value);

        case element.Strategy.Name:
          if (startNode.getElementsByName) {
            return startNode.getElementsByName(value);
          }
          return element.findByXPathAll(
              rootNode, startNode, `.//*[@name="${value}"]`);

        case element.Strategy.ClassName:
          return startNode.getElementsByClassName(value);

        case element.Strategy.TagName:
          return startNode.getElementsByTagName(value);

        case element.Strategy.LinkText:
          return element.findByLinkText(startNode, value);

        case element.Strategy.PartialLinkText:
          return element.findByPartialLinkText(startNode, value);

        case element.Strategy.Selector:
          return startNode.querySelectorAll(value);

        case element.Strategy.Anon:
          return rootNode.getAnonymousNodes(startNode);

        case element.Strategy.AnonAttribute:
          let attr = Object.keys(value)[0];
          let el = rootNode.getAnonymousElementByAttribute(
              startNode, attr, value[attr]);
          if (el) {
            return [el];
          }
          return [];

        default:
          throw new InvalidSelectorError(`No such strategy: ${using}`);
      }
    }

    /** Determines if |obj| is an HTML or JS collection. */
    element.isCollection = function(seq) {
      switch (Object.prototype.toString.call(seq)) {
        case "[object Arguments]":
        case "[object Array]":
        case "[object FileList]":
        case "[object HTMLAllCollection]":
        case "[object HTMLCollection]":
        case "[object HTMLFormControlsCollection]":
        case "[object HTMLOptionsCollection]":
        case "[object NodeList]":
          return true;

        default:
          return false;
      }
    };

    element.makeWebElement = function(uuid) {
      return {
        [element.Key]: uuid,
        [element.LegacyKey]: uuid,
      };
    };

    /**
     * Checks if |ref| has either |element.Key| or |element.LegacyKey|
     * as properties.
     *
     * @param {Object.<string, string>} ref
     *     Object that represents a web element reference.
     * @return {boolean}
     *     True if |ref| has either expected property.
     */
    element.isWebElementReference = function(ref) {
      let properties = Object.getOwnPropertyNames(ref);
      return properties.includes(element.Key) ||
          properties.includes(element.LegacyKey);
    };

    element.generateUUID = function() {
      let uuid = uuidGen.generateUUID().toString();
      return uuid.substring(1, uuid.length - 1);
    };

    /**
     * Determines if <var>el</var> is stale.
     *
     * A stale element is an element no longer attached to the DOM or which
     * node document is not the active document.
     *
     * @param {Element} el
     *     DOM element to check for staleness.
     *
     * @return {boolean}
     *     True if <var>el</var> is stale, false otherwise.
     */
    element.isStale = function(el) {
      if (!el) {
        return true;
      }

      let doc = el.ownerDocument;
      let win = doc.defaultView;
      if (!win || el.ownerDocument !== win.document) {
        return true;
      }

      return !el.isConnected;
    };

    /**
     * This function generates a pair of coordinates relative to the viewport
     * given a target element and coordinates relative to that element's
     * top-left corner.
     *
     * @param {Node} node
     *     Target node.
     * @param {number=} xOffset
     *     Horizontal offset relative to target's top-left corner.
     *     Defaults to the centre of the target's bounding box.
     * @param {number=} yOffset
     *     Vertical offset relative to target's top-left corner.  Defaults to
     *     the centre of the target's bounding box.
     *
     * @return {Object.<string, number>}
     *     X- and Y coordinates.
     *
     * @throws TypeError
     *     If |xOffset| or |yOffset| are not numbers.
     */
    element.coordinates = function(
        node, xOffset = undefined, yOffset = undefined) {

      let box = node.getBoundingClientRect();

      if (typeof xOffset == "undefined" || xOffset === null) {
        xOffset = box.width / 2.0;
      }
      if (typeof yOffset == "undefined" || yOffset === null) {
        yOffset = box.height / 2.0;
      }

      if (typeof yOffset != "number" || typeof xOffset != "number") {
        throw new TypeError("Offset must be a number");
      }

      return {
        x: box.left + xOffset,
        y: box.top + yOffset,
      };
    };

    /**
     * This function returns true if the node is in the viewport.
     *
     * @param {Element} el
     *     Target element.
     * @param {number=} x
     *     Horizontal offset relative to target.  Defaults to the centre of
     *     the target's bounding box.
     * @param {number=} y
     *     Vertical offset relative to target.  Defaults to the centre of
     *     the target's bounding box.
     *
     * @return {boolean}
     *     True if if |el| is in viewport, false otherwise.
     */
    element.inViewport = function(el, x = undefined, y = undefined) {
      let win = el.ownerGlobal;
      let c = element.coordinates(el, x, y);
      let vp = {
        top: win.pageYOffset,
        left: win.pageXOffset,
        bottom: (win.pageYOffset + win.innerHeight),
        right: (win.pageXOffset + win.innerWidth),
      };

      return (vp.left <= c.x + win.pageXOffset &&
          c.x + win.pageXOffset <= vp.right &&
          vp.top <= c.y + win.pageYOffset &&
          c.y + win.pageYOffset <= vp.bottom);
    };

    /**
     * Gets the element's container element.
     *
     * An element container is defined by the WebDriver
     * specification to be an <option> element in a valid element context
     * (https://html.spec.whatwg.org/#concept-element-contexts), meaning
     * that it has an ancestral element that is either <datalist> or <select>.
     *
     * If the element does not have a valid context, its container element
     * is itself.
     *
     * @param {Element} el
     *     Element to get the container of.
     *
     * @return {Element}
     *     Container element of |el|.
     */
    element.getContainer = function(el) {
      if (el.localName != "option") {
        return el;
      }

      function validContext(ctx) {
        return ctx.localName == "datalist" || ctx.localName == "select";
      }

      // does <option> have a valid context,
      // meaning is it a child of <datalist> or <select>?
      let parent = el;
      while (parent.parentNode && !validContext(parent)) {
        parent = parent.parentNode;
      }

      if (!validContext(parent)) {
        return el;
      }
      return parent;
    };

    /**
     * An element is in view if it is a member of its own pointer-interactable
     * paint tree.
     *
     * This means an element is considered to be in view, but not necessarily
     * pointer-interactable, if it is found somewhere in the
     * |elementsFromPoint| list at |el|'s in-view centre coordinates.
     *
     * Before running the check, we change |el|'s pointerEvents style property
     * to "auto", since elements without pointer events enabled do not turn
     * up in the paint tree we get from document.elementsFromPoint.  This is
     * a specialisation that is only relevant when checking if the element is
     * in view.
     *
     * @param {Element} el
     *     Element to check if is in view.
     *
     * @return {boolean}
     *     True if |el| is inside the viewport, or false otherwise.
     */
    element.isInView = function(el) {
      let originalPointerEvents = el.style.pointerEvents;
      try {
        el.style.pointerEvents = "auto";
        const tree = element.getPointerInteractablePaintTree(el);
        return tree.includes(el);
      } finally {
        el.style.pointerEvents = originalPointerEvents;
      }
    };

    /**
     * This function throws the visibility of the element error if the element is
     * not displayed or the given coordinates are not within the viewport.
     *
     * @param {Element} el
     *     Element to check if visible.
     * @param {number=} x
     *     Horizontal offset relative to target.  Defaults to the centre of
     *     the target's bounding box.
     * @param {number=} y
     *     Vertical offset relative to target.  Defaults to the centre of
     *     the target's bounding box.
     *
     * @return {boolean}
     *     True if visible, false otherwise.
     */
    element.isVisible = function(el, x = undefined, y = undefined) {
      let win = el.ownerGlobal;

      // Bug 1094246: webdriver's isShown doesn't work with content xul
      if (!element.isXULElement(el) && !atom.isElementDisplayed(el, win)) {
        return false;
      }

      if (el.tagName.toLowerCase() == "body") {
        return true;
      }

      if (!element.inViewport(el, x, y)) {
        element.scrollIntoView(el);
        if (!element.inViewport(el)) {
          return false;
        }
      }
      return true;
    };

    /**
     * A pointer-interactable element is defined to be the first
     * non-transparent element, defined by the paint order found at the centre
     * point of its rectangle that is inside the viewport, excluding the size
     * of any rendered scrollbars.
     *
     * An element is obscured if the pointer-interactable paint tree at its
     * centre point is empty, or the first element in this tree is not an
     * inclusive descendant of itself.
     *
     * @param {DOMElement} el
     *     Element determine if is pointer-interactable.
     *
     * @return {boolean}
     *     True if element is obscured, false otherwise.
     */
    element.isObscured = function(el) {
      let tree = element.getPointerInteractablePaintTree(el);
      return !el.contains(tree[0]);
    };

    // TODO(ato): Only used by deprecated action API
    // https://bugzil.la/1354578
    /**
     * Calculate the in-view centre point of the area of the given DOM client
     * rectangle that is inside the viewport.
     *
     * @param {DOMRect} rect
     *     Element off a DOMRect sequence produced by calling
     *     <code>getClientRects</code> on an {@link Element}.
     * @param {WindowProxy} window
     *     Current window global.
     *
     * @return {Map.<string, number>}
     *     X and Y coordinates that denotes the in-view centre point of
     *     <var>rect</var>.
     */
    element.getInViewCentrePoint = function(rect, window) {
      const {max, min} = Math;

      let x = {
        left: max(0, min(rect.x, rect.x + rect.width)),
        right: min(window.innerWidth, max(rect.x, rect.x + rect.width)),
      };
      let y = {
        top: max(0, min(rect.y, rect.y + rect.height)),
        bottom: min(window.innerHeight, max(rect.y, rect.y + rect.height)),
      };

      return {
        x: (x.left + x.right) / 2,
        y: (y.top + y.bottom) / 2,
      };
    };

    /**
     * Produces a pointer-interactable elements tree from a given element.
     *
     * The tree is defined by the paint order found at the centre point of
     * the element's rectangle that is inside the viewport, excluding the size
     * of any rendered scrollbars.
     *
     * @param {DOMElement} el
     *     Element to determine if is pointer-interactable.
     *
     * @return {Array.<DOMElement>}
     *     Sequence of elements in paint order.
     */
    element.getPointerInteractablePaintTree = function(el) {
      const doc = el.ownerDocument;
      const win = doc.defaultView;
      const container = {frame: win};
      const rootNode = el.getRootNode();

      // Include shadow DOM host only if the element's root node is not the
      // owner document.
      if (rootNode !== doc) {
        container.shadowRoot = rootNode;
      }

      // pointer-interactable elements tree, step 1
      if (!el.isConnected) {
        return [];
      }

      // steps 2-3
      let rects = el.getClientRects();
      if (rects.length == 0) {
        return [];
      }

      // step 4
      let centre = element.getInViewCentrePoint(rects[0], win);

      // step 5
      return doc.elementsFromPoint(centre.x, centre.y);
    };

    // TODO(ato): Not implemented.
    // In fact, it's not defined in the spec.
    element.isKeyboardInteractable = function(el) {
      return true;
    };

    /**
     * Attempts to scroll into view |el|.
     *
     * @param {DOMElement} el
     *     Element to scroll into view.
     */
    element.scrollIntoView = function(el) {
      if (el.scrollIntoView) {
        el.scrollIntoView({block: "end", inline: "nearest", behavior: "instant"});
      }
    };

    element.isXULElement = function(el) {
      return el.namespaceURI === XULNS;
    };

    const boolEls = {
      audio: ["autoplay", "controls", "loop", "muted"],
      button: ["autofocus", "disabled", "formnovalidate"],
      details: ["open"],
      dialog: ["open"],
      fieldset: ["disabled"],
      form: ["novalidate"],
      iframe: ["allowfullscreen"],
      img: ["ismap"],
      input: [
        "autofocus",
        "checked",
        "disabled",
        "formnovalidate",
        "multiple",
        "readonly",
        "required",
      ],
      keygen: ["autofocus", "disabled"],
      menuitem: ["checked", "default", "disabled"],
      object: ["typemustmatch"],
      ol: ["reversed"],
      optgroup: ["disabled"],
      option: ["disabled", "selected"],
      script: ["async", "defer"],
      select: ["autofocus", "disabled", "multiple", "required"],
      textarea: ["autofocus", "disabled", "readonly", "required"],
      track: ["default"],
      video: ["autoplay", "controls", "loop", "muted"],
    };

    /**
     * Tests if the attribute is a boolean attribute on element.
     *
     * @param {DOMElement} el
     *     Element to test if |attr| is a boolean attribute on.
     * @param {string} attr
     *     Attribute to test is a boolean attribute.
     *
     * @return {boolean}
     *     True if the attribute is boolean, false otherwise.
     */
    element.isBooleanAttribute = function(el, attr) {
      if (el.namespaceURI !== XMLNS) {
        return false;
      }

      // global boolean attributes that apply to all HTML elements,
      // except for custom elements
      const customElement = !el.localName.includes("-");
      if ((attr == "hidden" || attr == "itemscope") && customElement) {
        return true;
      }

      if (!boolEls.hasOwnProperty(el.localName)) {
        return false;
      }
      return boolEls[el.localName].includes(attr);
    };