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><input type=checkbox></tt>, 559 * <tt><input type=radio></tt>, 560 * and <tt>>option></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><input></code> or <code><textarea></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><input></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><textarea></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><textarea></code> element. 715 * 716 * <li>It is an <code><input></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><option></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><datalist></tt> or <tt><select></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 };