tor-browser

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

editor-test-utils.js (18807B)


      1 /**
      2 * EditorTestUtils is a helper utilities to test HTML editor.  This can be
      3 * instantiated per an editing host.  If you test `designMode`, the editing
      4 * host should be the <body> element.
      5 * Note that if you want to use sendKey in a sub-document, you need to include
      6 * testdriver.js (and related files) from the sub-document before creating this.
      7 */
      8 class EditorTestUtils {
      9  kShift = "\uE008";
     10  kMeta = "\uE03d";
     11  kControl = "\uE009";
     12  kAlt = "\uE00A";
     13 
     14  editingHost;
     15 
     16  constructor(aEditingHost, aHarnessWindow = window) {
     17    this.editingHost = aEditingHost;
     18    if (aHarnessWindow != this.window && this.window.test_driver) {
     19      this.window.test_driver.set_test_context(aHarnessWindow);
     20    }
     21  }
     22 
     23  get document() {
     24    return this.editingHost.ownerDocument;
     25  }
     26  get window() {
     27    return this.document.defaultView;
     28  }
     29  get selection() {
     30    return this.window.getSelection();
     31  }
     32 
     33  // Return a modifier to delete per word.
     34  get deleteWordModifier() {
     35    return this.window.navigator.platform.includes("Mac") ? this.kAlt : this.kControl;
     36  }
     37 
     38  sendKey(key, modifier) {
     39    if (!modifier) {
     40      // send_keys requires element in the light DOM.
     41      const elementInLightDOM = (e => {
     42        const doc = e.ownerDocument;
     43        while (e.getRootNode({composed:false}) !== doc) {
     44          e = e.getRootNode({composed:false}).host;
     45        }
     46        return e;
     47      })(this.editingHost);
     48      return this.window.test_driver.send_keys(elementInLightDOM, key)
     49        .catch(() => {
     50          return new this.window.test_driver.Actions()
     51          .keyDown(key)
     52          .keyUp(key)
     53          .send();
     54        });
     55    }
     56    return new this.window.test_driver.Actions()
     57      .keyDown(modifier)
     58      .keyDown(key)
     59      .keyUp(key)
     60      .keyUp(modifier)
     61      .send();
     62  }
     63 
     64  sendDeleteKey(modifier) {
     65    const kDeleteKey = "\uE017";
     66    return this.sendKey(kDeleteKey, modifier);
     67  }
     68 
     69  sendBackspaceKey(modifier) {
     70    const kBackspaceKey = "\uE003";
     71    return this.sendKey(kBackspaceKey, modifier);
     72  }
     73 
     74  sendArrowLeftKey(modifier) {
     75    const kArrowLeft = "\uE012";
     76    return this.sendKey(kArrowLeft, modifier);
     77  }
     78 
     79  sendArrowRightKey(modifier) {
     80    const kArrowRight = "\uE014";
     81    return this.sendKey(kArrowRight, modifier);
     82  }
     83 
     84  sendMoveWordLeftKey(modifier) {
     85    const kArrowLeft = "\uE012";
     86    return this.sendKey(
     87      kArrowLeft,
     88      this.window.navigator.platform.includes("Mac")
     89        ? this.kAlt
     90        : this.kControl
     91    );
     92  }
     93 
     94  sendMoveWordRightKey(modifier) {
     95    const kArrowRight = "\uE014";
     96    return this.sendKey(
     97      kArrowRight,
     98      this.window.navigator.platform.includes("Mac")
     99        ? this.kAlt
    100        : this.kControl
    101    );
    102  }
    103 
    104  sendHomeKey(modifier) {
    105    const kHome = "\uE011";
    106    return this.sendKey(kHome, modifier);
    107  }
    108 
    109  sendEndKey(modifier) {
    110    const kEnd = "\uE010";
    111    return this.sendKey(kEnd, modifier);
    112  }
    113 
    114  sendEnterKey(modifier) {
    115    const kEnter = "\uE007";
    116    return this.sendKey(kEnter, modifier);
    117  }
    118 
    119  sendSelectAllShortcutKey() {
    120    return this.sendKey(
    121      "a",
    122      this.window.navigator.platform.includes("Mac")
    123        ? this.kMeta
    124        : this.kControl
    125    );
    126  }
    127 
    128  sendCopyShortcutKey() {
    129    return this.sendKey(
    130      "c",
    131      this.window.navigator.platform.includes("Mac")
    132        ? this.kMeta
    133        : this.kControl
    134    );
    135  }
    136 
    137  sendCutShortcutKey() {
    138    return this.sendKey(
    139      "x",
    140      this.window.navigator.platform.includes("Mac")
    141        ? this.kMeta
    142        : this.kControl
    143    );
    144  }
    145 
    146  sendPasteShortcutKey() {
    147    return this.sendKey(
    148      "v",
    149      this.window.navigator.platform.includes("Mac")
    150        ? this.kMeta
    151        : this.kControl
    152    );
    153  }
    154 
    155  sendPasteAsPlaintextShortcutKey() {
    156    // Ctrl/Cmd - Shift - v on Chrome and Firefox
    157    // Cmd - Alt - Shift - v on Safari
    158    const accel = this.window.navigator.platform.includes("Mac") ? this.kMeta : this.kControl;
    159    const isSafari = this.window.navigator.userAgent.includes("Safari");
    160    let actions = new this.window.test_driver.Actions();
    161    actions = actions.keyDown(accel).keyDown(this.kShift);
    162    if (isSafari) {
    163      actions = actions.keyDown(this.kAlt);
    164    }
    165    actions = actions.keyDown("v").keyUp("v");
    166    actions = actions.keyUp(accel).keyUp(this.kShift);
    167    if (isSafari) {
    168      actions = actions.keyUp(this.kAlt);
    169    }
    170    return actions.send();
    171  }
    172 
    173  // Similar to `setupDiv` in editing/include/tests.js, this method sets
    174  // innerHTML value of this.editingHost, and sets multiple selection ranges
    175  // specified with the markers.
    176  // - `[` specifies start boundary in a text node
    177  // - `{` specifies start boundary before a node
    178  // - `]` specifies end boundary in a text node
    179  // - `}` specifies end boundary after a node
    180  //
    181  // options can have following fields:
    182  // - selection: how to set selection, "addRange" (default),
    183  //              "setBaseAndExtent", "setBaseAndExtent-reverse".
    184  setupEditingHost(innerHTMLWithRangeMarkers, options = {}) {
    185    if (!options.selection) {
    186      options.selection = "addRange";
    187    }
    188    const startBoundaries = innerHTMLWithRangeMarkers.match(/\{|\[/g) || [];
    189    const endBoundaries = innerHTMLWithRangeMarkers.match(/\}|\]/g) || [];
    190    if (startBoundaries.length !== endBoundaries.length) {
    191      throw "Should match number of open/close markers";
    192    }
    193 
    194    this.editingHost.innerHTML = innerHTMLWithRangeMarkers;
    195    this.editingHost.focus();
    196 
    197    if (startBoundaries.length === 0) {
    198      // Don't remove the range for now since some tests may assume that
    199      // setting innerHTML does not remove all selection ranges.
    200      return;
    201    }
    202 
    203    let getNextRangeAndDeleteMarker = startNode => {
    204      let getNextLeafNode = node => {
    205        let inclusiveDeepestFirstChildNode = container => {
    206          while (container.firstChild) {
    207            container = container.firstChild;
    208          }
    209          return container;
    210        };
    211        if (node.hasChildNodes()) {
    212          return inclusiveDeepestFirstChildNode(node);
    213        }
    214        if (node === this.editingHost) {
    215          return null;
    216        }
    217        if (node.nextSibling) {
    218          return inclusiveDeepestFirstChildNode(node.nextSibling);
    219        }
    220        let nextSibling = (child => {
    221          for (
    222            let parent = child.parentElement;
    223            parent && parent != this.editingHost;
    224            parent = parent.parentElement
    225          ) {
    226            if (parent.nextSibling) {
    227              return parent.nextSibling;
    228            }
    229          }
    230          return null;
    231        })(node);
    232        if (!nextSibling) {
    233          return null;
    234        }
    235        return inclusiveDeepestFirstChildNode(nextSibling);
    236      };
    237      let scanMarkerInTextNode = (textNode, offset) => {
    238        return /[\{\[\]\}]/.exec(textNode.data.substr(offset));
    239      };
    240      let startMarker = ((startContainer, startOffset) => {
    241        let scanStartMakerInTextNode = (textNode, offset) => {
    242          let scanResult = scanMarkerInTextNode(textNode, offset);
    243          if (scanResult === null) {
    244            return null;
    245          }
    246          if (scanResult[0] === "}" || scanResult[0] === "]") {
    247            throw "An end marker is found before a start marker";
    248          }
    249          return {
    250            marker: scanResult[0],
    251            container: textNode,
    252            offset: scanResult.index + offset,
    253          };
    254        };
    255        if (startContainer.nodeType === Node.TEXT_NODE) {
    256          let scanResult = scanStartMakerInTextNode(
    257            startContainer,
    258            startOffset
    259          );
    260          if (scanResult !== null) {
    261            return scanResult;
    262          }
    263        }
    264        let nextNode = startContainer;
    265        while ((nextNode = getNextLeafNode(nextNode))) {
    266          if (nextNode.nodeType === Node.TEXT_NODE) {
    267            let scanResult = scanStartMakerInTextNode(nextNode, 0);
    268            if (scanResult !== null) {
    269              return scanResult;
    270            }
    271            continue;
    272          }
    273        }
    274        return null;
    275      })(startNode, 0);
    276      if (startMarker === null) {
    277        return null;
    278      }
    279      let endMarker = ((startContainer, startOffset) => {
    280        let scanEndMarkerInTextNode = (textNode, offset) => {
    281          let scanResult = scanMarkerInTextNode(textNode, offset);
    282          if (scanResult === null) {
    283            return null;
    284          }
    285          if (scanResult[0] === "{" || scanResult[0] === "[") {
    286            throw "A start marker is found before an end marker";
    287          }
    288          return {
    289            marker: scanResult[0],
    290            container: textNode,
    291            offset: scanResult.index + offset,
    292          };
    293        };
    294        if (startContainer.nodeType === Node.TEXT_NODE) {
    295          let scanResult = scanEndMarkerInTextNode(startContainer, startOffset);
    296          if (scanResult !== null) {
    297            return scanResult;
    298          }
    299        }
    300        let nextNode = startContainer;
    301        while ((nextNode = getNextLeafNode(nextNode))) {
    302          if (nextNode.nodeType === Node.TEXT_NODE) {
    303            let scanResult = scanEndMarkerInTextNode(nextNode, 0);
    304            if (scanResult !== null) {
    305              return scanResult;
    306            }
    307            continue;
    308          }
    309        }
    310        return null;
    311      })(startMarker.container, startMarker.offset + 1);
    312      if (endMarker === null) {
    313        throw "Found an open marker, but not found corresponding close marker";
    314      }
    315      let indexOfContainer = (container, child) => {
    316        let offset = 0;
    317        for (let node = container.firstChild; node; node = node.nextSibling) {
    318          if (node == child) {
    319            return offset;
    320          }
    321          offset++;
    322        }
    323        throw "child must be a child node of container";
    324      };
    325      let deleteFoundMarkers = () => {
    326        let removeNode = node => {
    327          let container = node.parentElement;
    328          let offset = indexOfContainer(container, node);
    329          node.remove();
    330          return { container, offset };
    331        };
    332        if (startMarker.container == endMarker.container) {
    333          // If the text node becomes empty, remove it and set collapsed range
    334          // to the position where there is the text node.
    335          if (startMarker.container.length === 2) {
    336            if (!/[\[\{][\]\}]/.test(startMarker.container.data)) {
    337              throw `Unexpected text node (data: "${startMarker.container.data}")`;
    338            }
    339            let { container, offset } = removeNode(startMarker.container);
    340            startMarker.container = endMarker.container = container;
    341            startMarker.offset = endMarker.offset = offset;
    342            startMarker.marker = endMarker.marker = "";
    343            return;
    344          }
    345          startMarker.container.data = `${startMarker.container.data.substring(
    346            0,
    347            startMarker.offset
    348          )}${startMarker.container.data.substring(
    349            startMarker.offset + 1,
    350            endMarker.offset
    351          )}${startMarker.container.data.substring(endMarker.offset + 1)}`;
    352          if (startMarker.offset >= startMarker.container.length) {
    353            startMarker.offset = endMarker.offset =
    354              startMarker.container.length;
    355            return;
    356          }
    357          endMarker.offset--; // remove the start marker's length
    358          if (endMarker.offset > endMarker.container.length) {
    359            endMarker.offset = endMarker.container.length;
    360          }
    361          return;
    362        }
    363        if (startMarker.container.length === 1) {
    364          let { container, offset } = removeNode(startMarker.container);
    365          startMarker.container = container;
    366          startMarker.offset = offset;
    367          startMarker.marker = "";
    368        } else {
    369          startMarker.container.data = `${startMarker.container.data.substring(
    370            0,
    371            startMarker.offset
    372          )}${startMarker.container.data.substring(startMarker.offset + 1)}`;
    373        }
    374        if (endMarker.container.length === 1) {
    375          let { container, offset } = removeNode(endMarker.container);
    376          endMarker.container = container;
    377          endMarker.offset = offset;
    378          endMarker.marker = "";
    379        } else {
    380          endMarker.container.data = `${endMarker.container.data.substring(
    381            0,
    382            endMarker.offset
    383          )}${endMarker.container.data.substring(endMarker.offset + 1)}`;
    384        }
    385      };
    386      deleteFoundMarkers();
    387 
    388      let handleNodeSelectMarker = () => {
    389        if (startMarker.marker === "{") {
    390          if (startMarker.offset === 0) {
    391            // The range start with the text node.
    392            let container = startMarker.container.parentElement;
    393            startMarker.offset = indexOfContainer(
    394              container,
    395              startMarker.container
    396            );
    397            startMarker.container = container;
    398          } else if (startMarker.offset === startMarker.container.data.length) {
    399            // The range start after the text node.
    400            let container = startMarker.container.parentElement;
    401            startMarker.offset =
    402              indexOfContainer(container, startMarker.container) + 1;
    403            startMarker.container = container;
    404          } else {
    405            throw 'Start marker "{" is allowed start or end of a text node';
    406          }
    407        }
    408        if (endMarker.marker === "}") {
    409          if (endMarker.offset === 0) {
    410            // The range ends before the text node.
    411            let container = endMarker.container.parentElement;
    412            endMarker.offset = indexOfContainer(container, endMarker.container);
    413            endMarker.container = container;
    414          } else if (endMarker.offset === endMarker.container.data.length) {
    415            // The range ends with the text node.
    416            let container = endMarker.container.parentElement;
    417            endMarker.offset =
    418              indexOfContainer(container, endMarker.container) + 1;
    419            endMarker.container = container;
    420          } else {
    421            throw 'End marker "}" is allowed start or end of a text node';
    422          }
    423        }
    424      };
    425      handleNodeSelectMarker();
    426 
    427      let range = document.createRange();
    428      range.setStart(startMarker.container, startMarker.offset);
    429      range.setEnd(endMarker.container, endMarker.offset);
    430      return range;
    431    };
    432 
    433    let ranges = [];
    434    for (
    435      let range = getNextRangeAndDeleteMarker(this.editingHost.firstChild);
    436      range;
    437      range = getNextRangeAndDeleteMarker(range.endContainer)
    438    ) {
    439      ranges.push(range);
    440    }
    441 
    442    if (options.selection != "addRange" && ranges.length > 1) {
    443      throw `Failed due to invalid selection option, ${options.selection}, for multiple selection ranges`;
    444    }
    445 
    446    this.selection.removeAllRanges();
    447    for (const range of ranges) {
    448      if (options.selection == "addRange") {
    449        this.selection.addRange(range);
    450      } else if (options.selection == "setBaseAndExtent") {
    451        this.selection.setBaseAndExtent(
    452          range.startContainer,
    453          range.startOffset,
    454          range.endContainer,
    455          range.endOffset
    456        );
    457      } else if (options.selection == "setBaseAndExtent-reverse") {
    458        this.selection.setBaseAndExtent(
    459          range.endContainer,
    460          range.endOffset,
    461          range.startContainer,
    462          range.startOffset
    463        );
    464      } else {
    465        throw `Failed due to invalid selection option, ${options.selection}`;
    466      }
    467    }
    468 
    469    if (this.selection.rangeCount != ranges.length) {
    470      throw `Failed to set selection to the given ranges whose length is ${ranges.length}, but only ${this.selection.rangeCount} ranges are added`;
    471    }
    472  }
    473 
    474  // Originated from normalizeSerializedStyle in include/tests.js
    475  normalizeStyleAttributeValues() {
    476    for (const element of Array.from(
    477      this.editingHost.querySelectorAll("[style]")
    478    )) {
    479      element.setAttribute(
    480        "style",
    481        element
    482          .getAttribute("style")
    483          // Random spacing differences
    484          .replace(/; ?$/, "")
    485          .replace(/: /g, ":")
    486          // Gecko likes "transparent"
    487          .replace(/transparent/g, "rgba(0, 0, 0, 0)")
    488          // WebKit likes to look overly precise
    489          .replace(/, 0.496094\)/g, ", 0.5)")
    490          // Gecko converts anything with full alpha to "transparent" which
    491          // then becomes "rgba(0, 0, 0, 0)", so we have to make other
    492          // browsers match
    493          .replace(/rgba\([0-9]+, [0-9]+, [0-9]+, 0\)/g, "rgba(0, 0, 0, 0)")
    494      );
    495    }
    496  }
    497 
    498  static getRangeArrayDescription(arrayOfRanges) {
    499    if (arrayOfRanges === null) {
    500      return "null";
    501    }
    502    if (arrayOfRanges === undefined) {
    503      return "undefined";
    504    }
    505    if (!Array.isArray(arrayOfRanges)) {
    506      return "Unknown Object";
    507    }
    508    if (arrayOfRanges.length === 0) {
    509      return "[]";
    510    }
    511    let result = "";
    512    for (let range of arrayOfRanges) {
    513      if (result === "") {
    514        result = "[";
    515      } else {
    516        result += ",";
    517      }
    518      result += `{${EditorTestUtils.getRangeDescription(range)}}`;
    519    }
    520    result += "]";
    521    return result;
    522  }
    523 
    524  static getNodeDescription(node) {
    525    if (!node) {
    526      return "null";
    527    }
    528    switch (node.nodeType) {
    529      case Node.TEXT_NODE:
    530      case Node.COMMENT_NODE:
    531      case Node.CDATA_SECTION_NODE:
    532        return `${node.nodeName} "${node.data.replaceAll("\n", "\\\\n")}"`;
    533      case Node.ELEMENT_NODE:
    534        return `<${node.nodeName.toLowerCase()}${
    535            node.hasAttribute("id") ? ` id="${node.getAttribute("id")}"` : ""
    536          }${
    537            node.hasAttribute("class") ? ` class="${node.getAttribute("class")}"` : ""
    538          }${
    539            node.hasAttribute("contenteditable")
    540              ? ` contenteditable="${node.getAttribute("contenteditable")}"`
    541              : ""
    542          }${
    543            node.inert ? ` inert` : ""
    544          }${
    545            node.hidden ? ` hidden` : ""
    546          }${
    547            node.readonly ? ` readonly` : ""
    548          }${
    549            node.disabled ? ` disabled` : ""
    550          }>`;
    551      default:
    552        return `${node.nodeName}`;
    553    }
    554  }
    555 
    556  static getRangeDescription(range) {
    557    if (range === null) {
    558      return "null";
    559    }
    560    if (range === undefined) {
    561      return "undefined";
    562    }
    563    return range.startContainer == range.endContainer &&
    564      range.startOffset == range.endOffset
    565      ? `(${EditorTestUtils.getNodeDescription(range.startContainer)}, ${range.startOffset})`
    566      : `(${EditorTestUtils.getNodeDescription(range.startContainer)}, ${
    567          range.startOffset
    568        }) - (${EditorTestUtils.getNodeDescription(range.endContainer)}, ${range.endOffset})`;
    569  }
    570 
    571  static waitForRender() {
    572    return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
    573  }
    574 
    575 }