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 {ImageNode} from "@lexical/rich-text/LexicalImageNode";
43 readonly container: HTMLDivElement;
44 readonly editor: LexicalEditor;
45 readonly outerHTML: string;
46 readonly innerHTML: string;
50 * @deprecated - Consider using `createTestContext` instead within the test case.
52 export function initializeUnitTest(
53 runTests: (testEnv: TestEnv) => void,
54 editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}},
57 _container: null as HTMLDivElement | null,
58 _editor: null as LexicalEditor | null,
60 if (!this._container) {
61 throw new Error('testEnv.container not initialized.');
63 return this._container;
65 set container(container) {
66 this._container = container;
70 throw new Error('testEnv.editor not initialized.');
75 this._editor = editor;
78 return (this.container.firstChild as HTMLElement).innerHTML;
81 return this.container.innerHTML;
84 this._container = null;
89 beforeEach(async () => {
92 testEnv.container = document.createElement('div');
93 document.body.appendChild(testEnv.container);
95 const editorEl = document.createElement('div');
96 editorEl.setAttribute('contenteditable', 'true');
97 testEnv.container.append(editorEl);
99 const lexicalEditor = createTestEditor(editorConfig);
100 lexicalEditor.setRootElement(editorEl);
101 testEnv.editor = lexicalEditor;
105 document.body.removeChild(testEnv.container);
112 export function initializeClipboard() {
113 Object.defineProperty(window, 'DragEvent', {
114 value: class DragEvent {},
116 Object.defineProperty(window, 'ClipboardEvent', {
117 value: class ClipboardEvent {},
121 export type SerializedTestElementNode = SerializedElementNode;
123 export class TestElementNode extends ElementNode {
124 static getType(): string {
128 static clone(node: TestElementNode) {
129 return new TestElementNode(node.__key);
133 serializedNode: SerializedTestElementNode,
134 ): TestInlineElementNode {
135 const node = $createTestInlineElementNode();
136 node.setDirection(serializedNode.direction);
140 exportJSON(): SerializedTestElementNode {
142 ...super.exportJSON(),
149 return document.createElement('div');
157 export function $createTestElementNode(): TestElementNode {
158 return new TestElementNode();
161 type SerializedTestTextNode = SerializedTextNode;
163 export class TestTextNode extends TextNode {
168 static clone(node: TestTextNode): TestTextNode {
169 return new TestTextNode(node.__text, node.__key);
172 static importJSON(serializedNode: SerializedTestTextNode): TestTextNode {
173 return new TestTextNode(serializedNode.text);
176 exportJSON(): SerializedTestTextNode {
178 ...super.exportJSON(),
185 export type SerializedTestInlineElementNode = SerializedElementNode;
187 export class TestInlineElementNode extends ElementNode {
188 static getType(): string {
189 return 'test_inline_block';
192 static clone(node: TestInlineElementNode) {
193 return new TestInlineElementNode(node.__key);
197 serializedNode: SerializedTestInlineElementNode,
198 ): TestInlineElementNode {
199 const node = $createTestInlineElementNode();
200 node.setDirection(serializedNode.direction);
204 exportJSON(): SerializedTestInlineElementNode {
206 ...super.exportJSON(),
207 type: 'test_inline_block',
213 return document.createElement('a');
225 export function $createTestInlineElementNode(): TestInlineElementNode {
226 return new TestInlineElementNode();
229 export type SerializedTestShadowRootNode = SerializedElementNode;
231 export class TestShadowRootNode extends ElementNode {
232 static getType(): string {
233 return 'test_shadow_root';
236 static clone(node: TestShadowRootNode) {
237 return new TestElementNode(node.__key);
241 serializedNode: SerializedTestShadowRootNode,
242 ): TestShadowRootNode {
243 const node = $createTestShadowRootNode();
244 node.setDirection(serializedNode.direction);
248 exportJSON(): SerializedTestShadowRootNode {
250 ...super.exportJSON(),
257 return document.createElement('div');
269 export function $createTestShadowRootNode(): TestShadowRootNode {
270 return new TestShadowRootNode();
273 export type SerializedTestSegmentedNode = SerializedTextNode;
275 export class TestSegmentedNode extends TextNode {
276 static getType(): string {
277 return 'test_segmented';
280 static clone(node: TestSegmentedNode): TestSegmentedNode {
281 return new TestSegmentedNode(node.__text, node.__key);
285 serializedNode: SerializedTestSegmentedNode,
286 ): TestSegmentedNode {
287 const node = $createTestSegmentedNode(serializedNode.text);
288 node.setFormat(serializedNode.format);
289 node.setDetail(serializedNode.detail);
290 node.setMode(serializedNode.mode);
291 node.setStyle(serializedNode.style);
295 exportJSON(): SerializedTestSegmentedNode {
297 ...super.exportJSON(),
298 type: 'test_segmented',
304 export function $createTestSegmentedNode(text: string): TestSegmentedNode {
305 return new TestSegmentedNode(text).setMode('segmented');
308 export type SerializedTestExcludeFromCopyElementNode = SerializedElementNode;
310 export class TestExcludeFromCopyElementNode extends ElementNode {
311 static getType(): string {
312 return 'test_exclude_from_copy_block';
315 static clone(node: TestExcludeFromCopyElementNode) {
316 return new TestExcludeFromCopyElementNode(node.__key);
320 serializedNode: SerializedTestExcludeFromCopyElementNode,
321 ): TestExcludeFromCopyElementNode {
322 const node = $createTestExcludeFromCopyElementNode();
323 node.setDirection(serializedNode.direction);
327 exportJSON(): SerializedTestExcludeFromCopyElementNode {
329 ...super.exportJSON(),
330 type: 'test_exclude_from_copy_block',
336 return document.createElement('div');
348 export function $createTestExcludeFromCopyElementNode(): TestExcludeFromCopyElementNode {
349 return new TestExcludeFromCopyElementNode();
352 export type SerializedTestDecoratorNode = SerializedLexicalNode;
354 export class TestDecoratorNode extends DecoratorNode<HTMLElement> {
355 static getType(): string {
356 return 'test_decorator';
359 static clone(node: TestDecoratorNode) {
360 return new TestDecoratorNode(node.__key);
364 serializedNode: SerializedTestDecoratorNode,
365 ): TestDecoratorNode {
366 return $createTestDecoratorNode();
369 exportJSON(): SerializedTestDecoratorNode {
371 ...super.exportJSON(),
372 type: 'test_decorator',
379 'test-decorator': (domNode: HTMLElement) => {
381 conversion: () => ({node: $createTestDecoratorNode()}),
389 element: document.createElement('test-decorator'),
394 return 'Hello world';
398 return document.createElement('span');
406 const decorator = document.createElement('span');
407 decorator.textContent = 'Hello world';
412 export function $createTestDecoratorNode(): TestDecoratorNode {
413 return new TestDecoratorNode();
416 const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>> = [
429 TestExcludeFromCopyElementNode,
431 TestInlineElementNode,
436 export function createTestEditor(
439 editorState?: EditorState;
440 theme?: EditorThemeClasses;
441 parentEditor?: LexicalEditor;
442 nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>;
443 onError?: (error: Error) => void;
444 disableEvents?: boolean;
449 const customNodes = config.nodes || [];
450 const editor = createEditor({
451 namespace: config.namespace,
456 nodes: DEFAULT_NODES.concat(customNodes),
462 export function createTestHeadlessEditor(
463 editorState?: EditorState,
465 return createHeadlessEditor({
467 onError: (error) => {
473 export function createTestContext(): EditorUiContext {
475 const container = document.createElement('div');
476 document.body.appendChild(container);
478 const scrollWrap = document.createElement('div');
479 const editorDOM = document.createElement('div');
480 editorDOM.setAttribute('contenteditable', 'true');
482 scrollWrap.append(editorDOM);
483 container.append(scrollWrap);
485 const editor = createTestEditor({
486 namespace: 'testing',
493 editor.setRootElement(editorDOM);
496 containerDOM: container,
498 editorDOM: editorDOM,
499 error(text: string | Error): void {
501 manager: new EditorUIManager(),
503 scrollDOM: scrollWrap,
504 translate(text: string): string {
509 context.manager.setContext(context);
514 export function destroyFromContext(context: EditorUiContext) {
515 context.containerDOM.remove();
518 export function $assertRangeSelection(selection: unknown): RangeSelection {
519 if (!$isRangeSelection(selection)) {
520 throw new Error(`Expected RangeSelection, got ${selection}`);
525 export function invariant(cond?: boolean, message?: string): asserts cond {
529 throw new Error(`Invariant: ${message}`);
532 export class ClipboardDataMock {
533 getData: jest.Mock<string, [string]>;
534 setData: jest.Mock<void, [string, string]>;
537 this.getData = jest.fn();
538 this.setData = jest.fn();
542 export class DataTransferMock implements DataTransfer {
543 _data: Map<string, string> = new Map();
544 get dropEffect(): DataTransfer['dropEffect'] {
545 throw new Error('Getter not implemented.');
547 get effectAllowed(): DataTransfer['effectAllowed'] {
548 throw new Error('Getter not implemented.');
550 get files(): FileList {
551 throw new Error('Getter not implemented.');
553 get items(): DataTransferItemList {
554 throw new Error('Getter not implemented.');
556 get types(): ReadonlyArray<string> {
557 return Array.from(this._data.keys());
559 clearData(dataType?: string): void {
562 getData(dataType: string): string {
563 return this._data.get(dataType) || '';
565 setData(dataType: string, data: string): void {
566 this._data.set(dataType, data);
568 setDragImage(image: Element, x: number, y: number): void {
573 export class EventMock implements Event {
574 get bubbles(): boolean {
575 throw new Error('Getter not implemented.');
577 get cancelBubble(): boolean {
578 throw new Error('Gettter not implemented.');
580 get cancelable(): boolean {
581 throw new Error('Gettter not implemented.');
583 get composed(): boolean {
584 throw new Error('Gettter not implemented.');
586 get currentTarget(): EventTarget | null {
587 throw new Error('Gettter not implemented.');
589 get defaultPrevented(): boolean {
590 throw new Error('Gettter not implemented.');
592 get eventPhase(): number {
593 throw new Error('Gettter not implemented.');
595 get isTrusted(): boolean {
596 throw new Error('Gettter not implemented.');
598 get returnValue(): boolean {
599 throw new Error('Gettter not implemented.');
601 get srcElement(): EventTarget | null {
602 throw new Error('Gettter not implemented.');
604 get target(): EventTarget | null {
605 throw new Error('Gettter not implemented.');
607 get timeStamp(): number {
608 throw new Error('Gettter not implemented.');
611 throw new Error('Gettter not implemented.');
613 composedPath(): EventTarget[] {
614 throw new Error('Method not implemented.');
618 bubbles?: boolean | undefined,
619 cancelable?: boolean | undefined,
621 throw new Error('Method not implemented.');
623 stopImmediatePropagation(): void {
626 stopPropagation(): void {
630 CAPTURING_PHASE = 1 as const;
631 AT_TARGET = 2 as const;
632 BUBBLING_PHASE = 3 as const;
638 export class KeyboardEventMock extends EventMock implements KeyboardEvent {
640 get charCode(): number {
641 throw new Error('Getter not implemented.');
644 throw new Error('Getter not implemented.');
647 get isComposing(): boolean {
648 throw new Error('Getter not implemented.');
651 throw new Error('Getter not implemented.');
653 get keyCode(): number {
654 throw new Error('Getter not implemented.');
656 get location(): number {
657 throw new Error('Getter not implemented.');
660 get repeat(): boolean {
661 throw new Error('Getter not implemented.');
664 constructor(type: void | string) {
667 getModifierState(keyArg: string): boolean {
668 throw new Error('Method not implemented.');
672 bubblesArg?: boolean | undefined,
673 cancelableArg?: boolean | undefined,
674 viewArg?: Window | null | undefined,
675 keyArg?: string | undefined,
676 locationArg?: number | undefined,
677 ctrlKey?: boolean | undefined,
678 altKey?: boolean | undefined,
679 shiftKey?: boolean | undefined,
680 metaKey?: boolean | undefined,
682 throw new Error('Method not implemented.');
684 DOM_KEY_LOCATION_STANDARD = 0 as const;
685 DOM_KEY_LOCATION_LEFT = 1 as const;
686 DOM_KEY_LOCATION_RIGHT = 2 as const;
687 DOM_KEY_LOCATION_NUMPAD = 3 as const;
688 get detail(): number {
689 throw new Error('Getter not implemented.');
691 get view(): Window | null {
692 throw new Error('Getter not implemented.');
694 get which(): number {
695 throw new Error('Getter not implemented.');
699 bubblesArg?: boolean | undefined,
700 cancelableArg?: boolean | undefined,
701 viewArg?: Window | null | undefined,
702 detailArg?: number | undefined,
704 throw new Error('Method not implemented.');
708 export function tabKeyboardEvent() {
709 return new KeyboardEventMock('keydown');
712 export function shiftTabKeyboardEvent() {
713 const keyboardEvent = new KeyboardEventMock('keydown');
714 keyboardEvent.shiftKey = true;
715 return keyboardEvent;
718 export function generatePermutations<T>(
720 maxLength = values.length,
722 if (maxLength > values.length) {
723 throw new Error('maxLength over values.length');
725 const result: T[][] = [];
726 const current: T[] = [];
727 const seen = new Set();
728 (function permutationsImpl() {
729 if (current.length > maxLength) {
732 result.push(current.slice());
733 for (let i = 0; i < values.length; i++) {
734 const key = values[i];
748 // This tag function is just used to trigger prettier auto-formatting.
749 // (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
750 export function html(
751 partials: TemplateStringsArray,
755 for (let i = 0; i < partials.length; i++) {
756 output += partials[i];
757 if (i < partials.length - 1) {
764 export function expectHtmlToBeEqual(expected: string, actual: string): void {
765 expect(formatHtml(expected)).toBe(formatHtml(actual));
768 type nodeTextShape = {
774 children?: (nodeShape|nodeTextShape)[];
777 export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape {
779 const children: SerializedLexicalNode[] = (node.children || []);
781 const shape: nodeShape = {
785 if (shape.type === 'text') {
787 return {text: node.text}
790 if (children.length > 0) {
791 shape.children = children.map(c => getNodeShape(c));
797 export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) {
798 const json = editor.getEditorState().toJSON();
799 const shape = getNodeShape(json.root) as nodeShape;
800 expect(shape.children).toMatchObject(expected);
804 * Expect a given prop within the JSON editor state structure to be the given value.
805 * Uses dot notation for the provided `propPath`. Example:
806 * 0.5.cat => First child, Sixth child, cat property
808 export function expectEditorStateJSONPropToEqual(editor: LexicalEditor, propPath: string, expected: any) {
809 let currentItem: any = editor.getEditorState().toJSON().root;
810 let currentPath = [];
811 const pathParts = propPath.split('.');
813 for (const part of pathParts) {
814 currentPath.push(part);
815 const childAccess = Number.isInteger(Number(part)) && Array.isArray(currentItem.children);
816 const target = childAccess ? currentItem.children : currentItem;
818 if (typeof target[part] === 'undefined') {
819 throw new Error(`Could not resolve editor state at path ${currentPath.join('.')}`)
821 currentItem = target[part];
824 expect(currentItem).toBe(expected);
827 function formatHtml(s: string): string {
828 return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
831 export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) {
832 const nodeDomEl = editor.getElementByKey(node.getKey());
833 const event = new KeyboardEvent('keydown', {
838 nodeDomEl?.dispatchEvent(event);
839 editor.commitUpdates();
842 export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) {
843 editor.getEditorState().read((): void => {
844 const node = $getSelection()?.getNodes()[0] || null;
846 dispatchKeydownEventForNode(node, editor, key);