diff options
Diffstat (limited to 'testing/web-platform/tests/editing/include/editor-test-utils.js')
-rw-r--r-- | testing/web-platform/tests/editing/include/editor-test-utils.js | 387 |
1 files changed, 387 insertions, 0 deletions
diff --git a/testing/web-platform/tests/editing/include/editor-test-utils.js b/testing/web-platform/tests/editing/include/editor-test-utils.js new file mode 100644 index 0000000000..8d8e778836 --- /dev/null +++ b/testing/web-platform/tests/editing/include/editor-test-utils.js @@ -0,0 +1,387 @@ +/** + * 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 <body> 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)") + ); + } + } +} |