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 }