tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

DOM.sys.mjs (33408B)


      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/. */
      4 
      5 const lazy = {};
      6 
      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 });
     12 
     13 const ORDERED_NODE_ITERATOR_TYPE = 5;
     14 const FIRST_ORDERED_NODE_TYPE = 9;
     15 
     16 const DOCUMENT_FRAGMENT_NODE = 11;
     17 const ELEMENT_NODE = 1;
     18 
     19 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
     20 
     21 /** XUL elements that support checked property. */
     22 const XUL_CHECKED_ELS = new Set(["button", "checkbox", "toolbarbutton"]);
     23 
     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 ]);
     33 
     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 = {};
     49 
     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",
     59 };
     60 
     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 {Record<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;
    103 
    104  let searchFn;
    105  if (all) {
    106    searchFn = findElements.bind(this);
    107  } else {
    108    searchFn = findElement.bind(this);
    109  }
    110 
    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    );
    130 
    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      }
    138 
    139      if (all) {
    140        resolve(foundEls);
    141      }
    142      resolve(foundEls[0]);
    143    }, reject);
    144  });
    145 };
    146 
    147 async function find_(
    148  container,
    149  strategy,
    150  selector,
    151  searchFn,
    152  { startNode = null, all = false } = {}
    153 ) {
    154  let rootNode;
    155 
    156  if (dom.isShadowRoot(startNode)) {
    157    rootNode = startNode.ownerDocument;
    158  } else {
    159    rootNode = container.frame.document;
    160  }
    161 
    162  if (!startNode) {
    163    startNode = rootNode;
    164  }
    165 
    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  }
    174 
    175  if (res) {
    176    if (all) {
    177      return res;
    178    }
    179    return [res];
    180  }
    181  return [];
    182 }
    183 
    184 /**
    185 * Find a single element by XPath expression.
    186 *
    187 * @param {Document} document
    188 *     Document root.
    189 * @param {Element} startNode
    190 *     Where in the DOM hierarchy to begin searching.
    191 * @param {string} expression
    192 *     XPath search expression.
    193 *
    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;
    206 };
    207 
    208 /**
    209 * Find elements by XPath expression.
    210 *
    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.
    217 *
    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  }
    234 };
    235 
    236 /**
    237 * Find all hyperlinks descendant of <var>startNode</var> which
    238 * link text is <var>linkText</var>.
    239 *
    240 * @param {Element} startNode
    241 *     Where in the DOM hierarchy to begin searching.
    242 * @param {string} linkText
    243 *     Link text to search for.
    244 *
    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  });
    253 };
    254 
    255 /**
    256 * Find all hyperlinks descendant of <var>startNode</var> which
    257 * link text contains <var>linkText</var>.
    258 *
    259 * @param {Element} startNode
    260 *     Where in the DOM hierarchy to begin searching.
    261 * @param {string} linkText
    262 *     Link text to search for.
    263 *
    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);
    271 
    272    return visibleText.includes(linkText);
    273  });
    274 };
    275 
    276 /**
    277 * Filters all hyperlinks that are descendant of <var>startNode</var>
    278 * by <var>predicate</var>.
    279 *
    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.
    285 *
    286 * @returns {Array.<HTMLAnchorElement>}
    287 *     Array of link elements matching <var>predicate</var>.
    288 */
    289 async function filterLinks(startNode, predicate) {
    290  const links = [];
    291 
    292  for (const link of getLinks(startNode)) {
    293    if (await predicate(link)) {
    294      links.push(link);
    295    }
    296  }
    297 
    298  return links;
    299 }
    300 
    301 /**
    302 * Finds a single element.
    303 *
    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.
    312 *
    313 * @returns {Element}
    314 *     Found element.
    315 *
    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    }
    335 
    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    }
    343 
    344    case dom.Strategy.ClassName:
    345      return startNode.getElementsByClassName(selector)[0];
    346 
    347    case dom.Strategy.TagName:
    348      return startNode.getElementsByTagName(selector)[0];
    349 
    350    case dom.Strategy.XPath:
    351      return dom.findByXPath(document, startNode, selector);
    352 
    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    }
    366 
    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    }
    380 
    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  }
    390 
    391  throw new lazy.error.InvalidSelectorError(`No such strategy: ${strategy}`);
    392 }
    393 
    394 /**
    395 * Find multiple elements.
    396 *
    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.
    405 *
    406 * @returns {Array.<Element>}
    407 *     Found elements.
    408 *
    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}"]`;
    423 
    424    // fall through
    425    case dom.Strategy.XPath:
    426      return [...dom.findByXPathAll(document, startNode, selector)];
    427 
    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      ];
    435 
    436    case dom.Strategy.ClassName:
    437      return startNode.getElementsByClassName(selector);
    438 
    439    case dom.Strategy.TagName:
    440      return startNode.getElementsByTagName(selector);
    441 
    442    case dom.Strategy.LinkText:
    443      return [...(await dom.findByLinkText(startNode, selector))];
    444 
    445    case dom.Strategy.PartialLinkText:
    446      return [...(await dom.findByPartialLinkText(startNode, selector))];
    447 
    448    case dom.Strategy.Selector:
    449      return startNode.querySelectorAll(selector);
    450 
    451    default:
    452      throw new lazy.error.InvalidSelectorError(
    453        `No such strategy: ${strategy}`
    454      );
    455  }
    456 }
    457 
    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");
    464 }
    465 
    466 /**
    467 * Finds the closest parent node of <var>startNode</var> matching a CSS
    468 * <var>selector</var> expression.
    469 *
    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.
    475 *
    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;
    488 };
    489 
    490 /**
    491 * Determines if <var>obj<var> is an HTML or JS collection.
    492 *
    493 * @param {object} seq
    494 *     Type to determine.
    495 *
    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;
    511 
    512    default:
    513      return false;
    514  }
    515 };
    516 
    517 /**
    518 * Determines if <var>shadowRoot</var> is detached.
    519 *
    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.
    522 *
    523 * @param {ShadowRoot} shadowRoot
    524 *     ShadowRoot to check for detached state.
    525 *
    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);
    531 };
    532 
    533 /**
    534 * Determines if <var>el</var> is stale.
    535 *
    536 * An element is stale if its node document is not the active document
    537 * or if it is not connected.
    538 *
    539 * @param {Element} el
    540 *     Element to check for staleness.
    541 *
    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  }
    550 
    551  return !el.ownerDocument.isActive() || !el.isConnected;
    552 };
    553 
    554 /**
    555 * Determine if <var>el</var> is selected or not.
    556 *
    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.
    561 *
    562 * @param {Element} el
    563 *     Element to test if selected.
    564 *
    565 * @returns {boolean}
    566 *     True if element is selected, false otherwise.
    567 */
    568 dom.isSelected = function (el) {
    569  if (!el) {
    570    return false;
    571  }
    572 
    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  }
    586 
    587  return false;
    588 };
    589 
    590 /**
    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.
    594 *
    595 * @param {Element} el
    596 *     Element to test is read only.
    597 *
    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  );
    607 };
    608 
    609 /**
    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.
    613 *
    614 * @param {Element} el
    615 *     Element to test for disabledness.
    616 *
    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  }
    624 
    625  // Selenium expects that even an enabled "option" element that is a child
    626  // of a disabled "optgroup" or "select" element to be disabled.
    627  if (["optgroup", "option"].includes(el.localName) && !el.disabled) {
    628    const parent = dom.findClosest(el, "optgroup,select");
    629    return dom.isDisabled(parent);
    630  }
    631 
    632  return el.matches(":disabled");
    633 };
    634 
    635 /**
    636 * Denotes elements that can be used for typing and clearing.
    637 *
    638 * Elements that are considered WebDriver-editable are non-readonly
    639 * and non-disabled <code>&lt;input&gt;</code> elements in the Text,
    640 * Search, URL, Telephone, Email, Password, Date, Month, Date and
    641 * Time Local, Number, Range, Color, and File Upload states, and
    642 * <code>&lt;textarea&gt;</code> elements.
    643 *
    644 * @param {Element} el
    645 *     Element to test.
    646 *
    647 * @returns {boolean}
    648 *     True if editable, false otherwise.
    649 */
    650 dom.isMutableFormControl = function (el) {
    651  if (!dom.isDOMElement(el)) {
    652    return false;
    653  }
    654  if (dom.isReadOnly(el) || dom.isDisabled(el)) {
    655    return false;
    656  }
    657 
    658  if (el.localName == "textarea") {
    659    return true;
    660  }
    661 
    662  if (el.localName != "input") {
    663    return false;
    664  }
    665 
    666  switch (el.type) {
    667    case "color":
    668    case "date":
    669    case "datetime-local":
    670    case "email":
    671    case "file":
    672    case "month":
    673    case "number":
    674    case "password":
    675    case "range":
    676    case "search":
    677    case "tel":
    678    case "text":
    679    case "time":
    680    case "url":
    681    case "week":
    682      return true;
    683 
    684    default:
    685      return false;
    686  }
    687 };
    688 
    689 /**
    690 * An editing host is a node that is either an HTML element with a
    691 * <code>contenteditable</code> attribute, or the HTML element child
    692 * of a document whose <code>designMode</code> is enabled.
    693 *
    694 * @param {Element} el
    695 *     Element to determine if is an editing host.
    696 *
    697 * @returns {boolean}
    698 *     True if editing host, false otherwise.
    699 */
    700 dom.isEditingHost = function (el) {
    701  return (
    702    dom.isDOMElement(el) &&
    703    (el.isContentEditable || el.ownerDocument.designMode == "on")
    704  );
    705 };
    706 
    707 /**
    708 * Determines if an element is editable according to WebDriver.
    709 *
    710 * An element is considered editable if it is not read-only or
    711 * disabled, and one of the following conditions are met:
    712 *
    713 * <ul>
    714 * <li>It is a <code>&lt;textarea&gt;</code> element.
    715 *
    716 * <li>It is an <code>&lt;input&gt;</code> element that is not of
    717 * the <code>checkbox</code>, <code>radio</code>, <code>hidden</code>,
    718 * <code>submit</code>, <code>button</code>, or <code>image</code> types.
    719 *
    720 * <li>It is content-editable.
    721 *
    722 * <li>It belongs to a document in design mode.
    723 * </ul>
    724 *
    725 * @param {Element} el
    726 *     Element to test if editable.
    727 *
    728 * @returns {boolean}
    729 *     True if editable, false otherwise.
    730 */
    731 dom.isEditable = function (el) {
    732  if (!dom.isDOMElement(el)) {
    733    return false;
    734  }
    735 
    736  if (dom.isReadOnly(el) || dom.isDisabled(el)) {
    737    return false;
    738  }
    739 
    740  return dom.isMutableFormControl(el) || dom.isEditingHost(el);
    741 };
    742 
    743 /**
    744 * This function generates a pair of coordinates relative to the viewport
    745 * given a target element and coordinates relative to that element's
    746 * top-left corner.
    747 *
    748 * @param {Node} node
    749 *     Target node.
    750 * @param {number=} xOffset
    751 *     Horizontal offset relative to target's top-left corner.
    752 *     Defaults to the centre of the target's bounding box.
    753 * @param {number=} yOffset
    754 *     Vertical offset relative to target's top-left corner.  Defaults to
    755 *     the centre of the target's bounding box.
    756 *
    757 * @returns {Record<string, number>}
    758 *     X- and Y coordinates.
    759 *
    760 * @throws TypeError
    761 *     If <var>xOffset</var> or <var>yOffset</var> are not numbers.
    762 */
    763 dom.coordinates = function (node, xOffset = undefined, yOffset = undefined) {
    764  let box = node.getBoundingClientRect();
    765 
    766  if (typeof xOffset == "undefined" || xOffset === null) {
    767    xOffset = box.width / 2.0;
    768  }
    769  if (typeof yOffset == "undefined" || yOffset === null) {
    770    yOffset = box.height / 2.0;
    771  }
    772 
    773  if (typeof yOffset != "number" || typeof xOffset != "number") {
    774    throw new TypeError("Offset must be a number");
    775  }
    776 
    777  return {
    778    x: box.left + xOffset,
    779    y: box.top + yOffset,
    780  };
    781 };
    782 
    783 /**
    784 * This function returns true if the node is in the viewport.
    785 *
    786 * @param {Element} el
    787 *     Target element.
    788 * @param {number=} x
    789 *     Horizontal offset relative to target.  Defaults to the centre of
    790 *     the target's bounding box.
    791 * @param {number=} y
    792 *     Vertical offset relative to target.  Defaults to the centre of
    793 *     the target's bounding box.
    794 *
    795 * @returns {boolean}
    796 *     True if if <var>el</var> is in viewport, false otherwise.
    797 */
    798 dom.inViewport = function (el, x = undefined, y = undefined) {
    799  let win = el.ownerGlobal;
    800  let c = dom.coordinates(el, x, y);
    801  let vp = {
    802    top: win.pageYOffset,
    803    left: win.pageXOffset,
    804    bottom: win.pageYOffset + win.innerHeight,
    805    right: win.pageXOffset + win.innerWidth,
    806  };
    807 
    808  return (
    809    vp.left <= c.x + win.pageXOffset &&
    810    c.x + win.pageXOffset <= vp.right &&
    811    vp.top <= c.y + win.pageYOffset &&
    812    c.y + win.pageYOffset <= vp.bottom
    813  );
    814 };
    815 
    816 /**
    817 * Gets the element's container element.
    818 *
    819 * An element container is defined by the WebDriver
    820 * specification to be an <tt>&lt;option&gt;</tt> element in a
    821 * <a href="https://html.spec.whatwg.org/#concept-element-contexts">valid
    822 * element context</a>, meaning that it has an ancestral element
    823 * that is either <tt>&lt;datalist&gt;</tt> or <tt>&lt;select&gt;</tt>.
    824 *
    825 * If the element does not have a valid context, its container element
    826 * is itself.
    827 *
    828 * @param {Element} el
    829 *     Element to get the container of.
    830 *
    831 * @returns {Element}
    832 *     Container element of <var>el</var>.
    833 */
    834 dom.getContainer = function (el) {
    835  // Does <option> or <optgroup> have a valid context,
    836  // meaning is it a child of <datalist> or <select>?
    837  if (["option", "optgroup"].includes(el.localName)) {
    838    return dom.findClosest(el, "datalist,select") || el;
    839  }
    840 
    841  return el;
    842 };
    843 
    844 /**
    845 * An element is in view if it is a member of its own pointer-interactable
    846 * paint tree.
    847 *
    848 * This means an element is considered to be in view, but not necessarily
    849 * pointer-interactable, if it is found somewhere in the
    850 * <code>elementsFromPoint</code> list at <var>el</var>'s in-view
    851 * centre coordinates.
    852 *
    853 * Before running the check, we change <var>el</var>'s pointerEvents
    854 * style property to "auto", since elements without pointer events
    855 * enabled do not turn up in the paint tree we get from
    856 * document.elementsFromPoint.  This is a specialisation that is only
    857 * relevant when checking if the element is in view.
    858 *
    859 * @param {Element} el
    860 *     Element to check if is in view.
    861 *
    862 * @returns {boolean}
    863 *     True if <var>el</var> is inside the viewport, or false otherwise.
    864 */
    865 dom.isInView = function (el) {
    866  let originalPointerEvents = el.style.pointerEvents;
    867  let originalStyleAttrValue = el.getAttribute("style");
    868 
    869  try {
    870    el.style.pointerEvents = "auto";
    871    const tree = dom.getPointerInteractablePaintTree(el);
    872 
    873    // Bug 1413493 - <tr> is not part of the returned paint tree yet. As
    874    // workaround check the visibility based on the first contained cell.
    875    if (el.localName === "tr" && el.cells && el.cells.length) {
    876      return tree.includes(el.cells[0]);
    877    }
    878 
    879    return tree.includes(el);
    880  } finally {
    881    el.style.pointerEvents = originalPointerEvents;
    882    if (originalStyleAttrValue === null) {
    883      el.removeAttribute("style");
    884    } else if (el.getAttribute("style") != originalStyleAttrValue) {
    885      el.setAttribute("style", originalStyleAttrValue);
    886    }
    887  }
    888 };
    889 
    890 /**
    891 * This function throws the visibility of the element error if the element is
    892 * not displayed or the given coordinates are not within the viewport.
    893 *
    894 * @param {Element} el
    895 *     Element to check if visible.
    896 * @param {number=} x
    897 *     Horizontal offset relative to target.  Defaults to the centre of
    898 *     the target's bounding box.
    899 * @param {number=} y
    900 *     Vertical offset relative to target.  Defaults to the centre of
    901 *     the target's bounding box.
    902 *
    903 * @returns {boolean}
    904 *     True if visible, false otherwise.
    905 */
    906 dom.isVisible = async function (el, x = undefined, y = undefined) {
    907  let win = el.ownerGlobal;
    908 
    909  if (!(await lazy.atom.isElementDisplayed(el, win))) {
    910    return false;
    911  }
    912 
    913  if (el.tagName.toLowerCase() == "body") {
    914    return true;
    915  }
    916 
    917  if (!dom.inViewport(el, x, y)) {
    918    dom.scrollIntoView(el);
    919    if (!dom.inViewport(el)) {
    920      return false;
    921    }
    922  }
    923  return true;
    924 };
    925 
    926 /**
    927 * A pointer-interactable element is defined to be the first
    928 * non-transparent element, defined by the paint order found at the centre
    929 * point of its rectangle that is inside the viewport, excluding the size
    930 * of any rendered scrollbars.
    931 *
    932 * An element is obscured if the pointer-interactable paint tree at its
    933 * centre point is empty, or the first element in this tree is not an
    934 * inclusive descendant of itself.
    935 *
    936 * @param {DOMElement} el
    937 *     Element determine if is pointer-interactable.
    938 *
    939 * @returns {boolean}
    940 *     True if element is obscured, false otherwise.
    941 */
    942 dom.isObscured = function (el) {
    943  let tree = dom.getPointerInteractablePaintTree(el);
    944  return !el.contains(tree[0]);
    945 };
    946 
    947 // TODO(ato): Only used by deprecated action API
    948 // https://bugzil.la/1354578
    949 /**
    950 * Calculates the in-view centre point of an element's client rect.
    951 *
    952 * The portion of an element that is said to be _in view_, is the
    953 * intersection of two squares: the first square being the initial
    954 * viewport, and the second a DOM element.  From this square we
    955 * calculate the in-view _centre point_ and convert it into CSS pixels.
    956 *
    957 * Although Gecko's system internals allow click points to be
    958 * given in floating point precision, the DOM operates in CSS pixels.
    959 * When the in-view centre point is later used to retrieve a coordinate's
    960 * paint tree, we need to ensure to operate in the same language.
    961 *
    962 * As a word of warning, there appears to be inconsistencies between
    963 * how `DOMElement.elementsFromPoint` and `DOMWindowUtils.sendMouseEvent`
    964 * internally rounds (ceils/floors) coordinates.
    965 *
    966 * @param {DOMRect} rect
    967 *     Element off a DOMRect sequence produced by calling
    968 *     `getClientRects` on an {@link Element}.
    969 * @param {WindowProxy} win
    970 *     Current window global.
    971 *
    972 * @returns {Map.<string, number>}
    973 *     X and Y coordinates that denotes the in-view centre point of
    974 *     `rect`.
    975 */
    976 dom.getInViewCentrePoint = function (rect, win) {
    977  const { floor, max, min } = Math;
    978 
    979  // calculate the intersection of the rect that is inside the viewport
    980  let visible = {
    981    left: max(0, min(rect.x, rect.x + rect.width)),
    982    right: min(win.innerWidth, max(rect.x, rect.x + rect.width)),
    983    top: max(0, min(rect.y, rect.y + rect.height)),
    984    bottom: min(win.innerHeight, max(rect.y, rect.y + rect.height)),
    985  };
    986 
    987  // arrive at the centre point of the visible rectangle
    988  let x = (visible.left + visible.right) / 2.0;
    989  let y = (visible.top + visible.bottom) / 2.0;
    990 
    991  // convert to CSS pixels, as centre point can be float
    992  x = floor(x);
    993  y = floor(y);
    994 
    995  return { x, y };
    996 };
    997 
    998 /**
    999 * Produces a pointer-interactable elements tree from a given element.
   1000 *
   1001 * The tree is defined by the paint order found at the centre point of
   1002 * the element's rectangle that is inside the viewport, excluding the size
   1003 * of any rendered scrollbars.
   1004 *
   1005 * @param {DOMElement} el
   1006 *     Element to determine if is pointer-interactable.
   1007 *
   1008 * @returns {Array.<DOMElement>}
   1009 *     Sequence of elements in paint order.
   1010 */
   1011 dom.getPointerInteractablePaintTree = function (el) {
   1012  const win = el.ownerGlobal;
   1013  const rootNode = el.getRootNode();
   1014 
   1015  // pointer-interactable elements tree, step 1
   1016  if (!el.isConnected) {
   1017    return [];
   1018  }
   1019 
   1020  // steps 2-3
   1021  let rects = el.getClientRects();
   1022  if (!rects.length) {
   1023    return [];
   1024  }
   1025 
   1026  // step 4
   1027  let centre = dom.getInViewCentrePoint(rects[0], win);
   1028 
   1029  // step 5
   1030  return rootNode.elementsFromPoint(centre.x, centre.y);
   1031 };
   1032 
   1033 // TODO(ato): Not implemented.
   1034 // In fact, it's not defined in the spec.
   1035 dom.isKeyboardInteractable = () => true;
   1036 
   1037 /**
   1038 * Attempts to scroll `el` into view.
   1039 *
   1040 * @param {DOMElement} el
   1041 *     Element to scroll into view.
   1042 */
   1043 dom.scrollIntoView = function (el) {
   1044  if (el.scrollIntoView) {
   1045    el.scrollIntoView({ behavior: "instant", block: "end", inline: "nearest" });
   1046  }
   1047 };
   1048 
   1049 /**
   1050 * Ascertains whether <var>obj</var> is a DOM-, SVG-, or XUL element.
   1051 *
   1052 * @param {object} obj
   1053 *     Object thought to be an <code>Element</code> or
   1054 *     <code>XULElement</code>.
   1055 *
   1056 * @returns {boolean}
   1057 *     True if <var>obj</var> is an element, false otherwise.
   1058 */
   1059 dom.isElement = function (obj) {
   1060  return dom.isDOMElement(obj) || dom.isXULElement(obj);
   1061 };
   1062 
   1063 dom.isEnabled = function (el) {
   1064  let enabled = false;
   1065 
   1066  if (el.ownerDocument.contentType !== "text/xml") {
   1067    enabled = !dom.isDisabled(el);
   1068  }
   1069 
   1070  return enabled;
   1071 };
   1072 
   1073 /**
   1074 * Returns the shadow root of an element.
   1075 *
   1076 * @param {Element} el
   1077 *     Element thought to have a <code>shadowRoot</code>
   1078 * @returns {ShadowRoot}
   1079 *     Shadow root of the element.
   1080 */
   1081 dom.getShadowRoot = function (el) {
   1082  const shadowRoot = el.openOrClosedShadowRoot;
   1083  if (!shadowRoot) {
   1084    throw new lazy.error.NoSuchShadowRootError();
   1085  }
   1086  return shadowRoot;
   1087 };
   1088 
   1089 /**
   1090 * Ascertains whether <var>node</var> is a shadow root.
   1091 *
   1092 * @param {ShadowRoot} node
   1093 *   The node that will be checked to see if it has a shadow root
   1094 *
   1095 * @returns {boolean}
   1096 *     True if <var>node</var> is a shadow root, false otherwise.
   1097 */
   1098 dom.isShadowRoot = function (node) {
   1099  return (
   1100    node &&
   1101    node.nodeType === DOCUMENT_FRAGMENT_NODE &&
   1102    node.containingShadowRoot == node
   1103  );
   1104 };
   1105 
   1106 /**
   1107 * Ascertains whether <var>obj</var> is a DOM element.
   1108 *
   1109 * @param {object} obj
   1110 *     Object to check.
   1111 *
   1112 * @returns {boolean}
   1113 *     True if <var>obj</var> is a DOM element, false otherwise.
   1114 */
   1115 dom.isDOMElement = function (obj) {
   1116  return obj && obj.nodeType == ELEMENT_NODE && !dom.isXULElement(obj);
   1117 };
   1118 
   1119 /**
   1120 * Ascertains whether <var>obj</var> is a XUL element.
   1121 *
   1122 * @param {object} obj
   1123 *     Object to check.
   1124 *
   1125 * @returns {boolean}
   1126 *     True if <var>obj</var> is a XULElement, false otherwise.
   1127 */
   1128 dom.isXULElement = function (obj) {
   1129  return obj && obj.nodeType === ELEMENT_NODE && obj.namespaceURI === XUL_NS;
   1130 };
   1131 
   1132 /**
   1133 * Ascertains whether <var>node</var> is in a privileged document.
   1134 *
   1135 * @param {Node} node
   1136 *     Node to check.
   1137 *
   1138 * @returns {boolean}
   1139 *     True if <var>node</var> is in a privileged document,
   1140 *     false otherwise.
   1141 */
   1142 dom.isInPrivilegedDocument = function (node) {
   1143  return !!node?.nodePrincipal?.isSystemPrincipal;
   1144 };
   1145 
   1146 /**
   1147 * Ascertains whether <var>obj</var> is a <code>WindowProxy</code>.
   1148 *
   1149 * @param {object} obj
   1150 *     Object to check.
   1151 *
   1152 * @returns {boolean}
   1153 *     True if <var>obj</var> is a DOM window.
   1154 */
   1155 dom.isDOMWindow = function (obj) {
   1156  // TODO(ato): This should use Object.prototype.toString.call(node)
   1157  // but it's not clear how to write a good xpcshell test for that,
   1158  // seeing as we stub out a WindowProxy.
   1159  return (
   1160    typeof obj == "object" &&
   1161    obj !== null &&
   1162    typeof obj.toString == "function" &&
   1163    obj.toString() == "[object Window]" &&
   1164    obj.self === obj
   1165  );
   1166 };
   1167 
   1168 const boolEls = {
   1169  audio: ["autoplay", "controls", "loop", "muted"],
   1170  button: ["autofocus", "disabled", "formnovalidate"],
   1171  details: ["open"],
   1172  dialog: ["open"],
   1173  fieldset: ["disabled"],
   1174  form: ["novalidate"],
   1175  iframe: ["allowfullscreen"],
   1176  img: ["ismap"],
   1177  input: [
   1178    "autofocus",
   1179    "checked",
   1180    "disabled",
   1181    "formnovalidate",
   1182    "multiple",
   1183    "readonly",
   1184    "required",
   1185  ],
   1186  keygen: ["autofocus", "disabled"],
   1187  menuitem: ["checked", "default", "disabled"],
   1188  ol: ["reversed"],
   1189  optgroup: ["disabled"],
   1190  option: ["disabled", "selected"],
   1191  script: ["async", "defer"],
   1192  select: ["autofocus", "disabled", "multiple", "required"],
   1193  textarea: ["autofocus", "disabled", "readonly", "required"],
   1194  track: ["default"],
   1195  video: ["autoplay", "controls", "loop", "muted"],
   1196 };
   1197 
   1198 /**
   1199 * Tests if the attribute is a boolean attribute on element.
   1200 *
   1201 * @param {Element} el
   1202 *     Element to test if <var>attr</var> is a boolean attribute on.
   1203 * @param {string} attr
   1204 *     Attribute to test is a boolean attribute.
   1205 *
   1206 * @returns {boolean}
   1207 *     True if the attribute is boolean, false otherwise.
   1208 */
   1209 dom.isBooleanAttribute = function (el, attr) {
   1210  if (!dom.isDOMElement(el)) {
   1211    return false;
   1212  }
   1213 
   1214  // global boolean attributes that apply to all HTML elements,
   1215  // except for custom elements
   1216  const customElement = !el.localName.includes("-");
   1217  if ((attr == "hidden" || attr == "itemscope") && customElement) {
   1218    return true;
   1219  }
   1220 
   1221  if (!boolEls.hasOwnProperty(el.localName)) {
   1222    return false;
   1223  }
   1224  return boolEls[el.localName].includes(attr);
   1225 };