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,
33 import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor';
34 import {resetRandomKey} from '../../LexicalUtils';
35 import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
36 import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
37 import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
38 import {EditorUiContext} from "../../../../ui/framework/core";
39 import {EditorUIManager} from "../../../../ui/framework/manager";
40 import {turtle} from "@codemirror/legacy-modes/mode/turtle";
44 readonly container: HTMLDivElement;
45 readonly editor: LexicalEditor;
46 readonly outerHTML: string;
47 readonly innerHTML: string;
50 export function initializeUnitTest(
51 runTests: (testEnv: TestEnv) => void,
52 editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}},
55 _container: null as HTMLDivElement | null,
56 _editor: null as LexicalEditor | null,
58 if (!this._container) {
59 throw new Error('testEnv.container not initialized.');
61 return this._container;
63 set container(container) {
64 this._container = container;
68 throw new Error('testEnv.editor not initialized.');
73 this._editor = editor;
76 return (this.container.firstChild as HTMLElement).innerHTML;
79 return this.container.innerHTML;
82 this._container = null;
87 beforeEach(async () => {
90 testEnv.container = document.createElement('div');
91 document.body.appendChild(testEnv.container);
93 const editorEl = document.createElement('div');
94 editorEl.setAttribute('contenteditable', 'true');
95 testEnv.container.append(editorEl);
97 const lexicalEditor = createTestEditor(editorConfig);
98 lexicalEditor.setRootElement(editorEl);
99 testEnv.editor = lexicalEditor;
103 document.body.removeChild(testEnv.container);
110 export function initializeClipboard() {
111 Object.defineProperty(window, 'DragEvent', {
112 value: class DragEvent {},
114 Object.defineProperty(window, 'ClipboardEvent', {
115 value: class ClipboardEvent {},
119 export type SerializedTestElementNode = SerializedElementNode;
121 export class TestElementNode extends ElementNode {
122 static getType(): string {
126 static clone(node: TestElementNode) {
127 return new TestElementNode(node.__key);
131 serializedNode: SerializedTestElementNode,
132 ): TestInlineElementNode {
133 const node = $createTestInlineElementNode();
134 node.setDirection(serializedNode.direction);
138 exportJSON(): SerializedTestElementNode {
140 ...super.exportJSON(),
147 return document.createElement('div');
155 export function $createTestElementNode(): TestElementNode {
156 return new TestElementNode();
159 type SerializedTestTextNode = SerializedTextNode;
161 export class TestTextNode extends TextNode {
166 static clone(node: TestTextNode): TestTextNode {
167 return new TestTextNode(node.__text, node.__key);
170 static importJSON(serializedNode: SerializedTestTextNode): TestTextNode {
171 return new TestTextNode(serializedNode.text);
174 exportJSON(): SerializedTestTextNode {
176 ...super.exportJSON(),
183 export type SerializedTestInlineElementNode = SerializedElementNode;
185 export class TestInlineElementNode extends ElementNode {
186 static getType(): string {
187 return 'test_inline_block';
190 static clone(node: TestInlineElementNode) {
191 return new TestInlineElementNode(node.__key);
195 serializedNode: SerializedTestInlineElementNode,
196 ): TestInlineElementNode {
197 const node = $createTestInlineElementNode();
198 node.setDirection(serializedNode.direction);
202 exportJSON(): SerializedTestInlineElementNode {
204 ...super.exportJSON(),
205 type: 'test_inline_block',
211 return document.createElement('a');
223 export function $createTestInlineElementNode(): TestInlineElementNode {
224 return new TestInlineElementNode();
227 export type SerializedTestShadowRootNode = SerializedElementNode;
229 export class TestShadowRootNode extends ElementNode {
230 static getType(): string {
231 return 'test_shadow_root';
234 static clone(node: TestShadowRootNode) {
235 return new TestElementNode(node.__key);
239 serializedNode: SerializedTestShadowRootNode,
240 ): TestShadowRootNode {
241 const node = $createTestShadowRootNode();
242 node.setDirection(serializedNode.direction);
246 exportJSON(): SerializedTestShadowRootNode {
248 ...super.exportJSON(),
255 return document.createElement('div');
267 export function $createTestShadowRootNode(): TestShadowRootNode {
268 return new TestShadowRootNode();
271 export type SerializedTestSegmentedNode = SerializedTextNode;
273 export class TestSegmentedNode extends TextNode {
274 static getType(): string {
275 return 'test_segmented';
278 static clone(node: TestSegmentedNode): TestSegmentedNode {
279 return new TestSegmentedNode(node.__text, node.__key);
283 serializedNode: SerializedTestSegmentedNode,
284 ): TestSegmentedNode {
285 const node = $createTestSegmentedNode(serializedNode.text);
286 node.setFormat(serializedNode.format);
287 node.setDetail(serializedNode.detail);
288 node.setMode(serializedNode.mode);
289 node.setStyle(serializedNode.style);
293 exportJSON(): SerializedTestSegmentedNode {
295 ...super.exportJSON(),
296 type: 'test_segmented',
302 export function $createTestSegmentedNode(text: string): TestSegmentedNode {
303 return new TestSegmentedNode(text).setMode('segmented');
306 export type SerializedTestExcludeFromCopyElementNode = SerializedElementNode;
308 export class TestExcludeFromCopyElementNode extends ElementNode {
309 static getType(): string {
310 return 'test_exclude_from_copy_block';
313 static clone(node: TestExcludeFromCopyElementNode) {
314 return new TestExcludeFromCopyElementNode(node.__key);
318 serializedNode: SerializedTestExcludeFromCopyElementNode,
319 ): TestExcludeFromCopyElementNode {
320 const node = $createTestExcludeFromCopyElementNode();
321 node.setDirection(serializedNode.direction);
325 exportJSON(): SerializedTestExcludeFromCopyElementNode {
327 ...super.exportJSON(),
328 type: 'test_exclude_from_copy_block',
334 return document.createElement('div');
346 export function $createTestExcludeFromCopyElementNode(): TestExcludeFromCopyElementNode {
347 return new TestExcludeFromCopyElementNode();
350 export type SerializedTestDecoratorNode = SerializedLexicalNode;
352 export class TestDecoratorNode extends DecoratorNode<HTMLElement> {
353 static getType(): string {
354 return 'test_decorator';
357 static clone(node: TestDecoratorNode) {
358 return new TestDecoratorNode(node.__key);
362 serializedNode: SerializedTestDecoratorNode,
363 ): TestDecoratorNode {
364 return $createTestDecoratorNode();
367 exportJSON(): SerializedTestDecoratorNode {
369 ...super.exportJSON(),
370 type: 'test_decorator',
377 'test-decorator': (domNode: HTMLElement) => {
379 conversion: () => ({node: $createTestDecoratorNode()}),
387 element: document.createElement('test-decorator'),
392 return 'Hello world';
396 return document.createElement('span');
404 const decorator = document.createElement('span');
405 decorator.textContent = 'Hello world';
410 export function $createTestDecoratorNode(): TestDecoratorNode {
411 return new TestDecoratorNode();
414 const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>> = [
427 TestExcludeFromCopyElementNode,
429 TestInlineElementNode,
434 export function createTestEditor(
437 editorState?: EditorState;
438 theme?: EditorThemeClasses;
439 parentEditor?: LexicalEditor;
440 nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>;
441 onError?: (error: Error) => void;
442 disableEvents?: boolean;
447 const customNodes = config.nodes || [];
448 const editor = createEditor({
449 namespace: config.namespace,
454 nodes: DEFAULT_NODES.concat(customNodes),
460 export function createTestHeadlessEditor(
461 editorState?: EditorState,
463 return createHeadlessEditor({
465 onError: (error) => {
471 export function createTestContext(): EditorUiContext {
473 const container = document.createElement('div');
474 document.body.appendChild(container);
476 const scrollWrap = document.createElement('div');
477 const editorDOM = document.createElement('div');
478 editorDOM.setAttribute('contenteditable', 'true');
480 scrollWrap.append(editorDOM);
481 container.append(scrollWrap);
483 const editor = createTestEditor({
484 namespace: 'testing',
488 editor.setRootElement(editorDOM);
491 containerDOM: container,
493 editorDOM: editorDOM,
494 error(text: string | Error): void {
496 manager: new EditorUIManager(),
498 scrollDOM: scrollWrap,
499 translate(text: string): string {
504 context.manager.setContext(context);
509 export function destroyFromContext(context: EditorUiContext) {
510 context.containerDOM.remove();
513 export function $assertRangeSelection(selection: unknown): RangeSelection {
514 if (!$isRangeSelection(selection)) {
515 throw new Error(`Expected RangeSelection, got ${selection}`);
520 export function invariant(cond?: boolean, message?: string): asserts cond {
524 throw new Error(`Invariant: ${message}`);
527 export class ClipboardDataMock {
528 getData: jest.Mock<string, [string]>;
529 setData: jest.Mock<void, [string, string]>;
532 this.getData = jest.fn();
533 this.setData = jest.fn();
537 export class DataTransferMock implements DataTransfer {
538 _data: Map<string, string> = new Map();
539 get dropEffect(): DataTransfer['dropEffect'] {
540 throw new Error('Getter not implemented.');
542 get effectAllowed(): DataTransfer['effectAllowed'] {
543 throw new Error('Getter not implemented.');
545 get files(): FileList {
546 throw new Error('Getter not implemented.');
548 get items(): DataTransferItemList {
549 throw new Error('Getter not implemented.');
551 get types(): ReadonlyArray<string> {
552 return Array.from(this._data.keys());
554 clearData(dataType?: string): void {
557 getData(dataType: string): string {
558 return this._data.get(dataType) || '';
560 setData(dataType: string, data: string): void {
561 this._data.set(dataType, data);
563 setDragImage(image: Element, x: number, y: number): void {
568 export class EventMock implements Event {
569 get bubbles(): boolean {
570 throw new Error('Getter not implemented.');
572 get cancelBubble(): boolean {
573 throw new Error('Gettter not implemented.');
575 get cancelable(): boolean {
576 throw new Error('Gettter not implemented.');
578 get composed(): boolean {
579 throw new Error('Gettter not implemented.');
581 get currentTarget(): EventTarget | null {
582 throw new Error('Gettter not implemented.');
584 get defaultPrevented(): boolean {
585 throw new Error('Gettter not implemented.');
587 get eventPhase(): number {
588 throw new Error('Gettter not implemented.');
590 get isTrusted(): boolean {
591 throw new Error('Gettter not implemented.');
593 get returnValue(): boolean {
594 throw new Error('Gettter not implemented.');
596 get srcElement(): EventTarget | null {
597 throw new Error('Gettter not implemented.');
599 get target(): EventTarget | null {
600 throw new Error('Gettter not implemented.');
602 get timeStamp(): number {
603 throw new Error('Gettter not implemented.');
606 throw new Error('Gettter not implemented.');
608 composedPath(): EventTarget[] {
609 throw new Error('Method not implemented.');
613 bubbles?: boolean | undefined,
614 cancelable?: boolean | undefined,
616 throw new Error('Method not implemented.');
618 stopImmediatePropagation(): void {
621 stopPropagation(): void {
625 CAPTURING_PHASE = 1 as const;
626 AT_TARGET = 2 as const;
627 BUBBLING_PHASE = 3 as const;
633 export class KeyboardEventMock extends EventMock implements KeyboardEvent {
635 get charCode(): number {
636 throw new Error('Getter not implemented.');
639 throw new Error('Getter not implemented.');
642 get isComposing(): boolean {
643 throw new Error('Getter not implemented.');
646 throw new Error('Getter not implemented.');
648 get keyCode(): number {
649 throw new Error('Getter not implemented.');
651 get location(): number {
652 throw new Error('Getter not implemented.');
655 get repeat(): boolean {
656 throw new Error('Getter not implemented.');
659 constructor(type: void | string) {
662 getModifierState(keyArg: string): boolean {
663 throw new Error('Method not implemented.');
667 bubblesArg?: boolean | undefined,
668 cancelableArg?: boolean | undefined,
669 viewArg?: Window | null | undefined,
670 keyArg?: string | undefined,
671 locationArg?: number | undefined,
672 ctrlKey?: boolean | undefined,
673 altKey?: boolean | undefined,
674 shiftKey?: boolean | undefined,
675 metaKey?: boolean | undefined,
677 throw new Error('Method not implemented.');
679 DOM_KEY_LOCATION_STANDARD = 0 as const;
680 DOM_KEY_LOCATION_LEFT = 1 as const;
681 DOM_KEY_LOCATION_RIGHT = 2 as const;
682 DOM_KEY_LOCATION_NUMPAD = 3 as const;
683 get detail(): number {
684 throw new Error('Getter not implemented.');
686 get view(): Window | null {
687 throw new Error('Getter not implemented.');
689 get which(): number {
690 throw new Error('Getter not implemented.');
694 bubblesArg?: boolean | undefined,
695 cancelableArg?: boolean | undefined,
696 viewArg?: Window | null | undefined,
697 detailArg?: number | undefined,
699 throw new Error('Method not implemented.');
703 export function tabKeyboardEvent() {
704 return new KeyboardEventMock('keydown');
707 export function shiftTabKeyboardEvent() {
708 const keyboardEvent = new KeyboardEventMock('keydown');
709 keyboardEvent.shiftKey = true;
710 return keyboardEvent;
713 export function generatePermutations<T>(
715 maxLength = values.length,
717 if (maxLength > values.length) {
718 throw new Error('maxLength over values.length');
720 const result: T[][] = [];
721 const current: T[] = [];
722 const seen = new Set();
723 (function permutationsImpl() {
724 if (current.length > maxLength) {
727 result.push(current.slice());
728 for (let i = 0; i < values.length; i++) {
729 const key = values[i];
743 // This tag function is just used to trigger prettier auto-formatting.
744 // (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
745 export function html(
746 partials: TemplateStringsArray,
750 for (let i = 0; i < partials.length; i++) {
751 output += partials[i];
752 if (i < partials.length - 1) {
759 export function expectHtmlToBeEqual(expected: string, actual: string): void {
760 expect(formatHtml(expected)).toBe(formatHtml(actual));
763 type nodeTextShape = {
769 children?: (nodeShape|nodeTextShape)[];
772 export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape {
774 const children: SerializedLexicalNode[] = (node.children || []);
776 const shape: nodeShape = {
780 if (shape.type === 'text') {
782 return {text: node.text}
785 if (children.length > 0) {
786 shape.children = children.map(c => getNodeShape(c));
792 export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) {
793 const json = editor.getEditorState().toJSON();
794 const shape = getNodeShape(json.root) as nodeShape;
795 expect(shape.children).toMatchObject(expected);
798 function formatHtml(s: string): string {
799 return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
802 export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) {
803 const nodeDomEl = editor.getElementByKey(node.getKey());
804 const event = new KeyboardEvent('keydown', {
809 nodeDomEl?.dispatchEvent(event);
810 editor.commitUpdates();
813 export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) {
814 editor.getEditorState().read((): void => {
815 const node = $getSelection()?.getNodes()[0] || null;
817 dispatchKeydownEventForNode(node, editor, key);