2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import {createHeadlessEditor} from '@lexical/headless';
10 import {AutoLinkNode, LinkNode} from '@lexical/link';
11 import {ListItemNode, ListNode} from '@lexical/list';
13 import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
27 SerializedElementNode,
28 SerializedLexicalNode,
36 LexicalNodeReplacement,
37 } from '../../LexicalEditor';
38 import {resetRandomKey} from '../../LexicalUtils';
39 import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
40 import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
41 import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
42 import {EditorUiContext} from "../../../../ui/framework/core";
43 import {EditorUIManager} from "../../../../ui/framework/manager";
44 import {registerRichText} from "@lexical/rich-text";
48 readonly container: HTMLDivElement;
49 readonly editor: LexicalEditor;
50 readonly outerHTML: string;
51 readonly innerHTML: string;
54 export function initializeUnitTest(
55 runTests: (testEnv: TestEnv) => void,
56 editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}},
59 _container: null as HTMLDivElement | null,
60 _editor: null as LexicalEditor | null,
62 if (!this._container) {
63 throw new Error('testEnv.container not initialized.');
65 return this._container;
67 set container(container) {
68 this._container = container;
72 throw new Error('testEnv.editor not initialized.');
77 this._editor = editor;
80 return (this.container.firstChild as HTMLElement).innerHTML;
83 return this.container.innerHTML;
86 this._container = null;
91 beforeEach(async () => {
94 testEnv.container = document.createElement('div');
95 document.body.appendChild(testEnv.container);
97 const editorEl = document.createElement('div');
98 editorEl.setAttribute('contenteditable', 'true');
99 testEnv.container.append(editorEl);
101 const lexicalEditor = createTestEditor(editorConfig);
102 lexicalEditor.setRootElement(editorEl);
103 testEnv.editor = lexicalEditor;
107 document.body.removeChild(testEnv.container);
114 export function initializeClipboard() {
115 Object.defineProperty(window, 'DragEvent', {
116 value: class DragEvent {},
118 Object.defineProperty(window, 'ClipboardEvent', {
119 value: class ClipboardEvent {},
123 export type SerializedTestElementNode = SerializedElementNode;
125 export class TestElementNode extends ElementNode {
126 static getType(): string {
130 static clone(node: TestElementNode) {
131 return new TestElementNode(node.__key);
135 serializedNode: SerializedTestElementNode,
136 ): TestInlineElementNode {
137 const node = $createTestInlineElementNode();
138 node.setDirection(serializedNode.direction);
142 exportJSON(): SerializedTestElementNode {
144 ...super.exportJSON(),
151 return document.createElement('div');
159 export function $createTestElementNode(): TestElementNode {
160 return new TestElementNode();
163 type SerializedTestTextNode = SerializedTextNode;
165 export class TestTextNode extends TextNode {
170 static clone(node: TestTextNode): TestTextNode {
171 return new TestTextNode(node.__text, node.__key);
174 static importJSON(serializedNode: SerializedTestTextNode): TestTextNode {
175 return new TestTextNode(serializedNode.text);
178 exportJSON(): SerializedTestTextNode {
180 ...super.exportJSON(),
187 export type SerializedTestInlineElementNode = SerializedElementNode;
189 export class TestInlineElementNode extends ElementNode {
190 static getType(): string {
191 return 'test_inline_block';
194 static clone(node: TestInlineElementNode) {
195 return new TestInlineElementNode(node.__key);
199 serializedNode: SerializedTestInlineElementNode,
200 ): TestInlineElementNode {
201 const node = $createTestInlineElementNode();
202 node.setDirection(serializedNode.direction);
206 exportJSON(): SerializedTestInlineElementNode {
208 ...super.exportJSON(),
209 type: 'test_inline_block',
215 return document.createElement('a');
227 export function $createTestInlineElementNode(): TestInlineElementNode {
228 return new TestInlineElementNode();
231 export type SerializedTestShadowRootNode = SerializedElementNode;
233 export class TestShadowRootNode extends ElementNode {
234 static getType(): string {
235 return 'test_shadow_root';
238 static clone(node: TestShadowRootNode) {
239 return new TestElementNode(node.__key);
243 serializedNode: SerializedTestShadowRootNode,
244 ): TestShadowRootNode {
245 const node = $createTestShadowRootNode();
246 node.setDirection(serializedNode.direction);
250 exportJSON(): SerializedTestShadowRootNode {
252 ...super.exportJSON(),
259 return document.createElement('div');
271 export function $createTestShadowRootNode(): TestShadowRootNode {
272 return new TestShadowRootNode();
275 export type SerializedTestSegmentedNode = SerializedTextNode;
277 export class TestSegmentedNode extends TextNode {
278 static getType(): string {
279 return 'test_segmented';
282 static clone(node: TestSegmentedNode): TestSegmentedNode {
283 return new TestSegmentedNode(node.__text, node.__key);
287 serializedNode: SerializedTestSegmentedNode,
288 ): TestSegmentedNode {
289 const node = $createTestSegmentedNode(serializedNode.text);
290 node.setFormat(serializedNode.format);
291 node.setDetail(serializedNode.detail);
292 node.setMode(serializedNode.mode);
293 node.setStyle(serializedNode.style);
297 exportJSON(): SerializedTestSegmentedNode {
299 ...super.exportJSON(),
300 type: 'test_segmented',
306 export function $createTestSegmentedNode(text: string): TestSegmentedNode {
307 return new TestSegmentedNode(text).setMode('segmented');
310 export type SerializedTestExcludeFromCopyElementNode = SerializedElementNode;
312 export class TestExcludeFromCopyElementNode extends ElementNode {
313 static getType(): string {
314 return 'test_exclude_from_copy_block';
317 static clone(node: TestExcludeFromCopyElementNode) {
318 return new TestExcludeFromCopyElementNode(node.__key);
322 serializedNode: SerializedTestExcludeFromCopyElementNode,
323 ): TestExcludeFromCopyElementNode {
324 const node = $createTestExcludeFromCopyElementNode();
325 node.setDirection(serializedNode.direction);
329 exportJSON(): SerializedTestExcludeFromCopyElementNode {
331 ...super.exportJSON(),
332 type: 'test_exclude_from_copy_block',
338 return document.createElement('div');
350 export function $createTestExcludeFromCopyElementNode(): TestExcludeFromCopyElementNode {
351 return new TestExcludeFromCopyElementNode();
354 export type SerializedTestDecoratorNode = SerializedLexicalNode;
356 export class TestDecoratorNode extends DecoratorNode<HTMLElement> {
357 static getType(): string {
358 return 'test_decorator';
361 static clone(node: TestDecoratorNode) {
362 return new TestDecoratorNode(node.__key);
366 serializedNode: SerializedTestDecoratorNode,
367 ): TestDecoratorNode {
368 return $createTestDecoratorNode();
371 exportJSON(): SerializedTestDecoratorNode {
373 ...super.exportJSON(),
374 type: 'test_decorator',
381 'test-decorator': (domNode: HTMLElement) => {
383 conversion: () => ({node: $createTestDecoratorNode()}),
391 element: document.createElement('test-decorator'),
396 return 'Hello world';
400 return document.createElement('span');
408 const decorator = document.createElement('span');
409 decorator.textContent = 'Hello world';
414 export function $createTestDecoratorNode(): TestDecoratorNode {
415 return new TestDecoratorNode();
418 const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>> = [
431 TestExcludeFromCopyElementNode,
433 TestInlineElementNode,
438 export function createTestEditor(
441 editorState?: EditorState;
442 theme?: EditorThemeClasses;
443 parentEditor?: LexicalEditor;
444 nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>;
445 onError?: (error: Error) => void;
446 disableEvents?: boolean;
451 const customNodes = config.nodes || [];
452 const editor = createEditor({
453 namespace: config.namespace,
458 nodes: DEFAULT_NODES.concat(customNodes),
464 export function createTestHeadlessEditor(
465 editorState?: EditorState,
467 return createHeadlessEditor({
469 onError: (error) => {
475 export function createTestContext(): EditorUiContext {
477 const container = document.createElement('div');
478 document.body.appendChild(container);
480 const scrollWrap = document.createElement('div');
481 const editorDOM = document.createElement('div');
482 editorDOM.setAttribute('contenteditable', 'true');
484 scrollWrap.append(editorDOM);
485 container.append(scrollWrap);
487 const editor = createTestEditor({
488 namespace: 'testing',
492 editor.setRootElement(editorDOM);
495 containerDOM: container,
497 editorDOM: editorDOM,
498 error(text: string | Error): void {
500 manager: new EditorUIManager(),
502 scrollDOM: scrollWrap,
503 translate(text: string): string {
508 context.manager.setContext(context);
513 export function destroyFromContext(context: EditorUiContext) {
514 context.containerDOM.remove();
517 export function $assertRangeSelection(selection: unknown): RangeSelection {
518 if (!$isRangeSelection(selection)) {
519 throw new Error(`Expected RangeSelection, got ${selection}`);
524 export function invariant(cond?: boolean, message?: string): asserts cond {
528 throw new Error(`Invariant: ${message}`);
531 export class ClipboardDataMock {
532 getData: jest.Mock<string, [string]>;
533 setData: jest.Mock<void, [string, string]>;
536 this.getData = jest.fn();
537 this.setData = jest.fn();
541 export class DataTransferMock implements DataTransfer {
542 _data: Map<string, string> = new Map();
543 get dropEffect(): DataTransfer['dropEffect'] {
544 throw new Error('Getter not implemented.');
546 get effectAllowed(): DataTransfer['effectAllowed'] {
547 throw new Error('Getter not implemented.');
549 get files(): FileList {
550 throw new Error('Getter not implemented.');
552 get items(): DataTransferItemList {
553 throw new Error('Getter not implemented.');
555 get types(): ReadonlyArray<string> {
556 return Array.from(this._data.keys());
558 clearData(dataType?: string): void {
561 getData(dataType: string): string {
562 return this._data.get(dataType) || '';
564 setData(dataType: string, data: string): void {
565 this._data.set(dataType, data);
567 setDragImage(image: Element, x: number, y: number): void {
572 export class EventMock implements Event {
573 get bubbles(): boolean {
574 throw new Error('Getter not implemented.');
576 get cancelBubble(): boolean {
577 throw new Error('Gettter not implemented.');
579 get cancelable(): boolean {
580 throw new Error('Gettter not implemented.');
582 get composed(): boolean {
583 throw new Error('Gettter not implemented.');
585 get currentTarget(): EventTarget | null {
586 throw new Error('Gettter not implemented.');
588 get defaultPrevented(): boolean {
589 throw new Error('Gettter not implemented.');
591 get eventPhase(): number {
592 throw new Error('Gettter not implemented.');
594 get isTrusted(): boolean {
595 throw new Error('Gettter not implemented.');
597 get returnValue(): boolean {
598 throw new Error('Gettter not implemented.');
600 get srcElement(): EventTarget | null {
601 throw new Error('Gettter not implemented.');
603 get target(): EventTarget | null {
604 throw new Error('Gettter not implemented.');
606 get timeStamp(): number {
607 throw new Error('Gettter not implemented.');
610 throw new Error('Gettter not implemented.');
612 composedPath(): EventTarget[] {
613 throw new Error('Method not implemented.');
617 bubbles?: boolean | undefined,
618 cancelable?: boolean | undefined,
620 throw new Error('Method not implemented.');
622 stopImmediatePropagation(): void {
625 stopPropagation(): void {
629 CAPTURING_PHASE = 1 as const;
630 AT_TARGET = 2 as const;
631 BUBBLING_PHASE = 3 as const;
637 export class KeyboardEventMock extends EventMock implements KeyboardEvent {
639 get charCode(): number {
640 throw new Error('Getter not implemented.');
643 throw new Error('Getter not implemented.');
646 get isComposing(): boolean {
647 throw new Error('Getter not implemented.');
650 throw new Error('Getter not implemented.');
652 get keyCode(): number {
653 throw new Error('Getter not implemented.');
655 get location(): number {
656 throw new Error('Getter not implemented.');
659 get repeat(): boolean {
660 throw new Error('Getter not implemented.');
663 constructor(type: void | string) {
666 getModifierState(keyArg: string): boolean {
667 throw new Error('Method not implemented.');
671 bubblesArg?: boolean | undefined,
672 cancelableArg?: boolean | undefined,
673 viewArg?: Window | null | undefined,
674 keyArg?: string | undefined,
675 locationArg?: number | undefined,
676 ctrlKey?: boolean | undefined,
677 altKey?: boolean | undefined,
678 shiftKey?: boolean | undefined,
679 metaKey?: boolean | undefined,
681 throw new Error('Method not implemented.');
683 DOM_KEY_LOCATION_STANDARD = 0 as const;
684 DOM_KEY_LOCATION_LEFT = 1 as const;
685 DOM_KEY_LOCATION_RIGHT = 2 as const;
686 DOM_KEY_LOCATION_NUMPAD = 3 as const;
687 get detail(): number {
688 throw new Error('Getter not implemented.');
690 get view(): Window | null {
691 throw new Error('Getter not implemented.');
693 get which(): number {
694 throw new Error('Getter not implemented.');
698 bubblesArg?: boolean | undefined,
699 cancelableArg?: boolean | undefined,
700 viewArg?: Window | null | undefined,
701 detailArg?: number | undefined,
703 throw new Error('Method not implemented.');
707 export function tabKeyboardEvent() {
708 return new KeyboardEventMock('keydown');
711 export function shiftTabKeyboardEvent() {
712 const keyboardEvent = new KeyboardEventMock('keydown');
713 keyboardEvent.shiftKey = true;
714 return keyboardEvent;
717 export function generatePermutations<T>(
719 maxLength = values.length,
721 if (maxLength > values.length) {
722 throw new Error('maxLength over values.length');
724 const result: T[][] = [];
725 const current: T[] = [];
726 const seen = new Set();
727 (function permutationsImpl() {
728 if (current.length > maxLength) {
731 result.push(current.slice());
732 for (let i = 0; i < values.length; i++) {
733 const key = values[i];
747 // This tag function is just used to trigger prettier auto-formatting.
748 // (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
749 export function html(
750 partials: TemplateStringsArray,
754 for (let i = 0; i < partials.length; i++) {
755 output += partials[i];
756 if (i < partials.length - 1) {
763 export function expectHtmlToBeEqual(expected: string, actual: string): void {
764 expect(formatHtml(expected)).toBe(formatHtml(actual));
767 function formatHtml(s: string): string {
768 return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
771 export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) {
772 const nodeDomEl = editor.getElementByKey(node.getKey());
773 const event = new KeyboardEvent('keydown', {
778 nodeDomEl?.dispatchEvent(event);
779 editor.commitUpdates();
782 export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) {
783 editor.getEditorState().read((): void => {
784 const node = $getSelection()?.getNodes()[0] || null;
786 dispatchKeydownEventForNode(node, editor, key);