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";
42 readonly container: HTMLDivElement;
43 readonly editor: LexicalEditor;
44 readonly outerHTML: string;
45 readonly innerHTML: string;
49 * @deprecated - Consider using `createTestContext` instead within the test case.
51 export function initializeUnitTest(
52 runTests: (testEnv: TestEnv) => void,
53 editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}},
56 _container: null as HTMLDivElement | null,
57 _editor: null as LexicalEditor | null,
59 if (!this._container) {
60 throw new Error('testEnv.container not initialized.');
62 return this._container;
64 set container(container) {
65 this._container = container;
69 throw new Error('testEnv.editor not initialized.');
74 this._editor = editor;
77 return (this.container.firstChild as HTMLElement).innerHTML;
80 return this.container.innerHTML;
83 this._container = null;
88 beforeEach(async () => {
91 testEnv.container = document.createElement('div');
92 document.body.appendChild(testEnv.container);
94 const editorEl = document.createElement('div');
95 editorEl.setAttribute('contenteditable', 'true');
96 testEnv.container.append(editorEl);
98 const lexicalEditor = createTestEditor(editorConfig);
99 lexicalEditor.setRootElement(editorEl);
100 testEnv.editor = lexicalEditor;
104 document.body.removeChild(testEnv.container);
111 export function initializeClipboard() {
112 Object.defineProperty(window, 'DragEvent', {
113 value: class DragEvent {},
115 Object.defineProperty(window, 'ClipboardEvent', {
116 value: class ClipboardEvent {},
120 export type SerializedTestElementNode = SerializedElementNode;
122 export class TestElementNode extends ElementNode {
123 static getType(): string {
127 static clone(node: TestElementNode) {
128 return new TestElementNode(node.__key);
132 serializedNode: SerializedTestElementNode,
133 ): TestInlineElementNode {
134 const node = $createTestInlineElementNode();
135 node.setDirection(serializedNode.direction);
139 exportJSON(): SerializedTestElementNode {
141 ...super.exportJSON(),
148 return document.createElement('div');
156 export function $createTestElementNode(): TestElementNode {
157 return new TestElementNode();
160 type SerializedTestTextNode = SerializedTextNode;
162 export class TestTextNode extends TextNode {
167 static clone(node: TestTextNode): TestTextNode {
168 return new TestTextNode(node.__text, node.__key);
171 static importJSON(serializedNode: SerializedTestTextNode): TestTextNode {
172 return new TestTextNode(serializedNode.text);
175 exportJSON(): SerializedTestTextNode {
177 ...super.exportJSON(),
184 export type SerializedTestInlineElementNode = SerializedElementNode;
186 export class TestInlineElementNode extends ElementNode {
187 static getType(): string {
188 return 'test_inline_block';
191 static clone(node: TestInlineElementNode) {
192 return new TestInlineElementNode(node.__key);
196 serializedNode: SerializedTestInlineElementNode,
197 ): TestInlineElementNode {
198 const node = $createTestInlineElementNode();
199 node.setDirection(serializedNode.direction);
203 exportJSON(): SerializedTestInlineElementNode {
205 ...super.exportJSON(),
206 type: 'test_inline_block',
212 return document.createElement('a');
224 export function $createTestInlineElementNode(): TestInlineElementNode {
225 return new TestInlineElementNode();
228 export type SerializedTestShadowRootNode = SerializedElementNode;
230 export class TestShadowRootNode extends ElementNode {
231 static getType(): string {
232 return 'test_shadow_root';
235 static clone(node: TestShadowRootNode) {
236 return new TestElementNode(node.__key);
240 serializedNode: SerializedTestShadowRootNode,
241 ): TestShadowRootNode {
242 const node = $createTestShadowRootNode();
243 node.setDirection(serializedNode.direction);
247 exportJSON(): SerializedTestShadowRootNode {
249 ...super.exportJSON(),
256 return document.createElement('div');
268 export function $createTestShadowRootNode(): TestShadowRootNode {
269 return new TestShadowRootNode();
272 export type SerializedTestSegmentedNode = SerializedTextNode;
274 export class TestSegmentedNode extends TextNode {
275 static getType(): string {
276 return 'test_segmented';
279 static clone(node: TestSegmentedNode): TestSegmentedNode {
280 return new TestSegmentedNode(node.__text, node.__key);
284 serializedNode: SerializedTestSegmentedNode,
285 ): TestSegmentedNode {
286 const node = $createTestSegmentedNode(serializedNode.text);
287 node.setFormat(serializedNode.format);
288 node.setDetail(serializedNode.detail);
289 node.setMode(serializedNode.mode);
290 node.setStyle(serializedNode.style);
294 exportJSON(): SerializedTestSegmentedNode {
296 ...super.exportJSON(),
297 type: 'test_segmented',
303 export function $createTestSegmentedNode(text: string): TestSegmentedNode {
304 return new TestSegmentedNode(text).setMode('segmented');
307 export type SerializedTestExcludeFromCopyElementNode = SerializedElementNode;
309 export class TestExcludeFromCopyElementNode extends ElementNode {
310 static getType(): string {
311 return 'test_exclude_from_copy_block';
314 static clone(node: TestExcludeFromCopyElementNode) {
315 return new TestExcludeFromCopyElementNode(node.__key);
319 serializedNode: SerializedTestExcludeFromCopyElementNode,
320 ): TestExcludeFromCopyElementNode {
321 const node = $createTestExcludeFromCopyElementNode();
322 node.setDirection(serializedNode.direction);
326 exportJSON(): SerializedTestExcludeFromCopyElementNode {
328 ...super.exportJSON(),
329 type: 'test_exclude_from_copy_block',
335 return document.createElement('div');
347 export function $createTestExcludeFromCopyElementNode(): TestExcludeFromCopyElementNode {
348 return new TestExcludeFromCopyElementNode();
351 export type SerializedTestDecoratorNode = SerializedLexicalNode;
353 export class TestDecoratorNode extends DecoratorNode<HTMLElement> {
354 static getType(): string {
355 return 'test_decorator';
358 static clone(node: TestDecoratorNode) {
359 return new TestDecoratorNode(node.__key);
363 serializedNode: SerializedTestDecoratorNode,
364 ): TestDecoratorNode {
365 return $createTestDecoratorNode();
368 exportJSON(): SerializedTestDecoratorNode {
370 ...super.exportJSON(),
371 type: 'test_decorator',
378 'test-decorator': (domNode: HTMLElement) => {
380 conversion: () => ({node: $createTestDecoratorNode()}),
388 element: document.createElement('test-decorator'),
393 return 'Hello world';
397 return document.createElement('span');
405 const decorator = document.createElement('span');
406 decorator.textContent = 'Hello world';
411 export function $createTestDecoratorNode(): TestDecoratorNode {
412 return new TestDecoratorNode();
415 const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>> = [
428 TestExcludeFromCopyElementNode,
430 TestInlineElementNode,
435 export function createTestEditor(
438 editorState?: EditorState;
439 theme?: EditorThemeClasses;
440 parentEditor?: LexicalEditor;
441 nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>;
442 onError?: (error: Error) => void;
443 disableEvents?: boolean;
448 const customNodes = config.nodes || [];
449 const editor = createEditor({
450 namespace: config.namespace,
455 nodes: DEFAULT_NODES.concat(customNodes),
461 export function createTestHeadlessEditor(
462 editorState?: EditorState,
464 return createHeadlessEditor({
466 onError: (error) => {
472 export function createTestContext(): EditorUiContext {
474 const container = document.createElement('div');
475 document.body.appendChild(container);
477 const scrollWrap = document.createElement('div');
478 const editorDOM = document.createElement('div');
479 editorDOM.setAttribute('contenteditable', 'true');
481 scrollWrap.append(editorDOM);
482 container.append(scrollWrap);
484 const editor = createTestEditor({
485 namespace: 'testing',
489 editor.setRootElement(editorDOM);
492 containerDOM: container,
494 editorDOM: editorDOM,
495 error(text: string | Error): void {
497 manager: new EditorUIManager(),
499 scrollDOM: scrollWrap,
500 translate(text: string): string {
505 context.manager.setContext(context);
510 export function destroyFromContext(context: EditorUiContext) {
511 context.containerDOM.remove();
514 export function $assertRangeSelection(selection: unknown): RangeSelection {
515 if (!$isRangeSelection(selection)) {
516 throw new Error(`Expected RangeSelection, got ${selection}`);
521 export function invariant(cond?: boolean, message?: string): asserts cond {
525 throw new Error(`Invariant: ${message}`);
528 export class ClipboardDataMock {
529 getData: jest.Mock<string, [string]>;
530 setData: jest.Mock<void, [string, string]>;
533 this.getData = jest.fn();
534 this.setData = jest.fn();
538 export class DataTransferMock implements DataTransfer {
539 _data: Map<string, string> = new Map();
540 get dropEffect(): DataTransfer['dropEffect'] {
541 throw new Error('Getter not implemented.');
543 get effectAllowed(): DataTransfer['effectAllowed'] {
544 throw new Error('Getter not implemented.');
546 get files(): FileList {
547 throw new Error('Getter not implemented.');
549 get items(): DataTransferItemList {
550 throw new Error('Getter not implemented.');
552 get types(): ReadonlyArray<string> {
553 return Array.from(this._data.keys());
555 clearData(dataType?: string): void {
558 getData(dataType: string): string {
559 return this._data.get(dataType) || '';
561 setData(dataType: string, data: string): void {
562 this._data.set(dataType, data);
564 setDragImage(image: Element, x: number, y: number): void {
569 export class EventMock implements Event {
570 get bubbles(): boolean {
571 throw new Error('Getter not implemented.');
573 get cancelBubble(): boolean {
574 throw new Error('Gettter not implemented.');
576 get cancelable(): boolean {
577 throw new Error('Gettter not implemented.');
579 get composed(): boolean {
580 throw new Error('Gettter not implemented.');
582 get currentTarget(): EventTarget | null {
583 throw new Error('Gettter not implemented.');
585 get defaultPrevented(): boolean {
586 throw new Error('Gettter not implemented.');
588 get eventPhase(): number {
589 throw new Error('Gettter not implemented.');
591 get isTrusted(): boolean {
592 throw new Error('Gettter not implemented.');
594 get returnValue(): boolean {
595 throw new Error('Gettter not implemented.');
597 get srcElement(): EventTarget | null {
598 throw new Error('Gettter not implemented.');
600 get target(): EventTarget | null {
601 throw new Error('Gettter not implemented.');
603 get timeStamp(): number {
604 throw new Error('Gettter not implemented.');
607 throw new Error('Gettter not implemented.');
609 composedPath(): EventTarget[] {
610 throw new Error('Method not implemented.');
614 bubbles?: boolean | undefined,
615 cancelable?: boolean | undefined,
617 throw new Error('Method not implemented.');
619 stopImmediatePropagation(): void {
622 stopPropagation(): void {
626 CAPTURING_PHASE = 1 as const;
627 AT_TARGET = 2 as const;
628 BUBBLING_PHASE = 3 as const;
634 export class KeyboardEventMock extends EventMock implements KeyboardEvent {
636 get charCode(): number {
637 throw new Error('Getter not implemented.');
640 throw new Error('Getter not implemented.');
643 get isComposing(): boolean {
644 throw new Error('Getter not implemented.');
647 throw new Error('Getter not implemented.');
649 get keyCode(): number {
650 throw new Error('Getter not implemented.');
652 get location(): number {
653 throw new Error('Getter not implemented.');
656 get repeat(): boolean {
657 throw new Error('Getter not implemented.');
660 constructor(type: void | string) {
663 getModifierState(keyArg: string): boolean {
664 throw new Error('Method not implemented.');
668 bubblesArg?: boolean | undefined,
669 cancelableArg?: boolean | undefined,
670 viewArg?: Window | null | undefined,
671 keyArg?: string | undefined,
672 locationArg?: number | undefined,
673 ctrlKey?: boolean | undefined,
674 altKey?: boolean | undefined,
675 shiftKey?: boolean | undefined,
676 metaKey?: boolean | undefined,
678 throw new Error('Method not implemented.');
680 DOM_KEY_LOCATION_STANDARD = 0 as const;
681 DOM_KEY_LOCATION_LEFT = 1 as const;
682 DOM_KEY_LOCATION_RIGHT = 2 as const;
683 DOM_KEY_LOCATION_NUMPAD = 3 as const;
684 get detail(): number {
685 throw new Error('Getter not implemented.');
687 get view(): Window | null {
688 throw new Error('Getter not implemented.');
690 get which(): number {
691 throw new Error('Getter not implemented.');
695 bubblesArg?: boolean | undefined,
696 cancelableArg?: boolean | undefined,
697 viewArg?: Window | null | undefined,
698 detailArg?: number | undefined,
700 throw new Error('Method not implemented.');
704 export function tabKeyboardEvent() {
705 return new KeyboardEventMock('keydown');
708 export function shiftTabKeyboardEvent() {
709 const keyboardEvent = new KeyboardEventMock('keydown');
710 keyboardEvent.shiftKey = true;
711 return keyboardEvent;
714 export function generatePermutations<T>(
716 maxLength = values.length,
718 if (maxLength > values.length) {
719 throw new Error('maxLength over values.length');
721 const result: T[][] = [];
722 const current: T[] = [];
723 const seen = new Set();
724 (function permutationsImpl() {
725 if (current.length > maxLength) {
728 result.push(current.slice());
729 for (let i = 0; i < values.length; i++) {
730 const key = values[i];
744 // This tag function is just used to trigger prettier auto-formatting.
745 // (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
746 export function html(
747 partials: TemplateStringsArray,
751 for (let i = 0; i < partials.length; i++) {
752 output += partials[i];
753 if (i < partials.length - 1) {
760 export function expectHtmlToBeEqual(expected: string, actual: string): void {
761 expect(formatHtml(expected)).toBe(formatHtml(actual));
764 type nodeTextShape = {
770 children?: (nodeShape|nodeTextShape)[];
773 export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape {
775 const children: SerializedLexicalNode[] = (node.children || []);
777 const shape: nodeShape = {
781 if (shape.type === 'text') {
783 return {text: node.text}
786 if (children.length > 0) {
787 shape.children = children.map(c => getNodeShape(c));
793 export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) {
794 const json = editor.getEditorState().toJSON();
795 const shape = getNodeShape(json.root) as nodeShape;
796 expect(shape.children).toMatchObject(expected);
800 * Expect a given prop within the JSON editor state structure to be the given value.
801 * Uses dot notation for the provided `propPath`. Example:
802 * 0.5.cat => First child, Sixth child, cat property
804 export function expectEditorStateJSONPropToEqual(editor: LexicalEditor, propPath: string, expected: any) {
805 let currentItem: any = editor.getEditorState().toJSON().root;
806 let currentPath = [];
807 const pathParts = propPath.split('.');
809 for (const part of pathParts) {
810 currentPath.push(part);
811 const childAccess = Number.isInteger(Number(part)) && Array.isArray(currentItem.children);
812 const target = childAccess ? currentItem.children : currentItem;
814 if (typeof target[part] === 'undefined') {
815 throw new Error(`Could not resolve editor state at path ${currentPath.join('.')}`)
817 currentItem = target[part];
820 expect(currentItem).toBe(expected);
823 function formatHtml(s: string): string {
824 return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
827 export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) {
828 const nodeDomEl = editor.getElementByKey(node.getKey());
829 const event = new KeyboardEvent('keydown', {
834 nodeDomEl?.dispatchEvent(event);
835 editor.commitUpdates();
838 export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) {
839 editor.getEditorState().read((): void => {
840 const node = $getSelection()?.getNodes()[0] || null;
842 dispatchKeydownEventForNode(node, editor, key);