/** * EditorTestUtils is a helper utilities to test HTML editor. This can be * instantiated per an editing host. If you test `designMode`, the editing * host should be the element. * Note that if you want to use sendKey in a sub-document, you need to include * testdriver.js (and related files) from the sub-document before creating this. */ class EditorTestUtils { kShift = "\uE008"; kMeta = "\uE03d"; kControl = "\uE009"; kAlt = "\uE00A"; editingHost; constructor(aEditingHost, aHarnessWindow = window) { this.editingHost = aEditingHost; if (aHarnessWindow != this.window && this.window.test_driver) { this.window.test_driver.set_test_context(aHarnessWindow); } } get document() { return this.editingHost.ownerDocument; } get window() { return this.document.defaultView; } get selection() { return this.window.getSelection(); } sendKey(key, modifier) { if (!modifier) { return new this.window.test_driver.Actions() .keyDown(key) .keyUp(key) .send(); } return new this.window.test_driver.Actions() .keyDown(modifier) .keyDown(key) .keyUp(key) .keyUp(modifier) .send(); } sendDeleteKey(modifier) { const kDeleteKey = "\uE017"; return this.sendKey(kDeleteKey, modifier); } sendBackspaceKey(modifier) { const kBackspaceKey = "\uE003"; return this.sendKey(kBackspaceKey, modifier); } sendArrowLeftKey(modifier) { const kArrowLeft = "\uE012"; return this.sendKey(kArrowLeft, modifier); } sendArrowRightKey(modifier) { const kArrowRight = "\uE014"; return this.sendKey(kArrowRight, modifier); } sendHomeKey(modifier) { const kHome = "\uE011"; return this.sendKey(kHome, modifier); } sendEndKey(modifier) { const kEnd = "\uE010"; return this.sendKey(kEnd, modifier); } sendEnterKey(modifier) { const kEnter = "\uE007"; return this.sendKey(kEnter, modifier); } sendSelectAllShortcutKey() { return this.sendKey( "a", this.window.navigator.platform.includes("Mac") ? this.kMeta : this.kControl ); } // Similar to `setupDiv` in editing/include/tests.js, this method sets // innerHTML value of this.editingHost, and sets multiple selection ranges // specified with the markers. // - `[` specifies start boundary in a text node // - `{` specifies start boundary before a node // - `]` specifies end boundary in a text node // - `}` specifies end boundary after a node setupEditingHost(innerHTMLWithRangeMarkers) { const startBoundaries = innerHTMLWithRangeMarkers.match(/\{|\[/g) || []; const endBoundaries = innerHTMLWithRangeMarkers.match(/\}|\]/g) || []; if (startBoundaries.length !== endBoundaries.length) { throw "Should match number of open/close markers"; } this.editingHost.innerHTML = innerHTMLWithRangeMarkers; this.editingHost.focus(); if (startBoundaries.length === 0) { // Don't remove the range for now since some tests may assume that // setting innerHTML does not remove all selection ranges. return; } let getNextRangeAndDeleteMarker = startNode => { let getNextLeafNode = node => { let inclusiveDeepestFirstChildNode = container => { while (container.firstChild) { container = container.firstChild; } return container; }; if (node.hasChildNodes()) { return inclusiveDeepestFirstChildNode(node); } if (node === this.editingHost) { return null; } if (node.nextSibling) { return inclusiveDeepestFirstChildNode(node.nextSibling); } let nextSibling = (child => { for ( let parent = child.parentElement; parent && parent != this.editingHost; parent = parent.parentElement ) { if (parent.nextSibling) { return parent.nextSibling; } } return null; })(node); if (!nextSibling) { return null; } return inclusiveDeepestFirstChildNode(nextSibling); }; let scanMarkerInTextNode = (textNode, offset) => { return /[\{\[\]\}]/.exec(textNode.data.substr(offset)); }; let startMarker = ((startContainer, startOffset) => { let scanStartMakerInTextNode = (textNode, offset) => { let scanResult = scanMarkerInTextNode(textNode, offset); if (scanResult === null) { return null; } if (scanResult[0] === "}" || scanResult[0] === "]") { throw "An end marker is found before a start marker"; } return { marker: scanResult[0], container: textNode, offset: scanResult.index + offset, }; }; if (startContainer.nodeType === Node.TEXT_NODE) { let scanResult = scanStartMakerInTextNode( startContainer, startOffset ); if (scanResult !== null) { return scanResult; } } let nextNode = startContainer; while ((nextNode = getNextLeafNode(nextNode))) { if (nextNode.nodeType === Node.TEXT_NODE) { let scanResult = scanStartMakerInTextNode(nextNode, 0); if (scanResult !== null) { return scanResult; } continue; } } return null; })(startNode, 0); if (startMarker === null) { return null; } let endMarker = ((startContainer, startOffset) => { let scanEndMarkerInTextNode = (textNode, offset) => { let scanResult = scanMarkerInTextNode(textNode, offset); if (scanResult === null) { return null; } if (scanResult[0] === "{" || scanResult[0] === "[") { throw "A start marker is found before an end marker"; } return { marker: scanResult[0], container: textNode, offset: scanResult.index + offset, }; }; if (startContainer.nodeType === Node.TEXT_NODE) { let scanResult = scanEndMarkerInTextNode(startContainer, startOffset); if (scanResult !== null) { return scanResult; } } let nextNode = startContainer; while ((nextNode = getNextLeafNode(nextNode))) { if (nextNode.nodeType === Node.TEXT_NODE) { let scanResult = scanEndMarkerInTextNode(nextNode, 0); if (scanResult !== null) { return scanResult; } continue; } } return null; })(startMarker.container, startMarker.offset + 1); if (endMarker === null) { throw "Found an open marker, but not found corresponding close marker"; } let indexOfContainer = (container, child) => { let offset = 0; for (let node = container.firstChild; node; node = node.nextSibling) { if (node == child) { return offset; } offset++; } throw "child must be a child node of container"; }; let deleteFoundMarkers = () => { let removeNode = node => { let container = node.parentElement; let offset = indexOfContainer(container, node); node.remove(); return { container, offset }; }; if (startMarker.container == endMarker.container) { // If the text node becomes empty, remove it and set collapsed range // to the position where there is the text node. if (startMarker.container.length === 2) { if (!/[\[\{][\]\}]/.test(startMarker.container.data)) { throw `Unexpected text node (data: "${startMarker.container.data}")`; } let { container, offset } = removeNode(startMarker.container); startMarker.container = endMarker.container = container; startMarker.offset = endMarker.offset = offset; startMarker.marker = endMarker.marker = ""; return; } startMarker.container.data = `${startMarker.container.data.substring( 0, startMarker.offset )}${startMarker.container.data.substring( startMarker.offset + 1, endMarker.offset )}${startMarker.container.data.substring(endMarker.offset + 1)}`; if (startMarker.offset >= startMarker.container.length) { startMarker.offset = endMarker.offset = startMarker.container.length; return; } endMarker.offset--; // remove the start marker's length if (endMarker.offset > endMarker.container.length) { endMarker.offset = endMarker.container.length; } return; } if (startMarker.container.length === 1) { let { container, offset } = removeNode(startMarker.container); startMarker.container = container; startMarker.offset = offset; startMarker.marker = ""; } else { startMarker.container.data = `${startMarker.container.data.substring( 0, startMarker.offset )}${startMarker.container.data.substring(startMarker.offset + 1)}`; } if (endMarker.container.length === 1) { let { container, offset } = removeNode(endMarker.container); endMarker.container = container; endMarker.offset = offset; endMarker.marker = ""; } else { endMarker.container.data = `${endMarker.container.data.substring( 0, endMarker.offset )}${endMarker.container.data.substring(endMarker.offset + 1)}`; } }; deleteFoundMarkers(); let handleNodeSelectMarker = () => { if (startMarker.marker === "{") { if (startMarker.offset === 0) { // The range start with the text node. let container = startMarker.container.parentElement; startMarker.offset = indexOfContainer( container, startMarker.container ); startMarker.container = container; } else if (startMarker.offset === startMarker.container.data.length) { // The range start after the text node. let container = startMarker.container.parentElement; startMarker.offset = indexOfContainer(container, startMarker.container) + 1; startMarker.container = container; } else { throw 'Start marker "{" is allowed start or end of a text node'; } } if (endMarker.marker === "}") { if (endMarker.offset === 0) { // The range ends before the text node. let container = endMarker.container.parentElement; endMarker.offset = indexOfContainer(container, endMarker.container); endMarker.container = container; } else if (endMarker.offset === endMarker.container.data.length) { // The range ends with the text node. let container = endMarker.container.parentElement; endMarker.offset = indexOfContainer(container, endMarker.container) + 1; endMarker.container = container; } else { throw 'End marker "}" is allowed start or end of a text node'; } } }; handleNodeSelectMarker(); let range = document.createRange(); range.setStart(startMarker.container, startMarker.offset); range.setEnd(endMarker.container, endMarker.offset); return range; }; let ranges = []; for ( let range = getNextRangeAndDeleteMarker(this.editingHost.firstChild); range; range = getNextRangeAndDeleteMarker(range.endContainer) ) { ranges.push(range); } this.selection.removeAllRanges(); for (let range of ranges) { this.selection.addRange(range); } if (this.selection.rangeCount != ranges.length) { throw `Failed to set selection to the given ranges whose length is ${ranges.length}, but only ${this.selection.rangeCount} ranges are added`; } } // Originated from normalizeSerializedStyle in include/tests.js normalizeStyleAttributeValues() { for (const element of Array.from( this.editingHost.querySelectorAll("[style]") )) { element.setAttribute( "style", element .getAttribute("style") // Random spacing differences .replace(/; ?$/, "") .replace(/: /g, ":") // Gecko likes "transparent" .replace(/transparent/g, "rgba(0, 0, 0, 0)") // WebKit likes to look overly precise .replace(/, 0.496094\)/g, ", 0.5)") // Gecko converts anything with full alpha to "transparent" which // then becomes "rgba(0, 0, 0, 0)", so we have to make other // browsers match .replace(/rgba\([0-9]+, [0-9]+, [0-9]+, 0\)/g, "rgba(0, 0, 0, 0)") ); } } }