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 {HeadingNode, QuoteNode} from '@lexical/rich-text';
14 import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
27 SerializedElementNode,
28 SerializedLexicalNode,
36 LexicalNodeReplacement,
37 } from '../../LexicalEditor';
38 import {resetRandomKey} from '../../LexicalUtils';
42 readonly container: HTMLDivElement;
43 readonly editor: LexicalEditor;
44 readonly outerHTML: string;
45 readonly innerHTML: string;
48 export function initializeUnitTest(
49 runTests: (testEnv: TestEnv) => void,
50 editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}},
53 _container: null as HTMLDivElement | null,
54 _editor: null as LexicalEditor | null,
56 if (!this._container) {
57 throw new Error('testEnv.container not initialized.');
59 return this._container;
61 set container(container) {
62 this._container = container;
66 throw new Error('testEnv.editor not initialized.');
71 this._editor = editor;
74 return (this.container.firstChild as HTMLElement).innerHTML;
77 return this.container.innerHTML;
80 this._container = null;
85 beforeEach(async () => {
88 testEnv.container = document.createElement('div');
89 document.body.appendChild(testEnv.container);
91 const editorEl = document.createElement('div');
92 editorEl.setAttribute('contenteditable', 'true');
93 testEnv.container.append(editorEl);
95 const lexicalEditor = createTestEditor(editorConfig);
96 lexicalEditor.setRootElement(editorEl);
97 testEnv.editor = lexicalEditor;
101 document.body.removeChild(testEnv.container);
108 export function initializeClipboard() {
109 Object.defineProperty(window, 'DragEvent', {
110 value: class DragEvent {},
112 Object.defineProperty(window, 'ClipboardEvent', {
113 value: class ClipboardEvent {},
117 export type SerializedTestElementNode = SerializedElementNode;
119 export class TestElementNode extends ElementNode {
120 static getType(): string {
124 static clone(node: TestElementNode) {
125 return new TestElementNode(node.__key);
129 serializedNode: SerializedTestElementNode,
130 ): TestInlineElementNode {
131 const node = $createTestInlineElementNode();
132 node.setFormat(serializedNode.format);
133 node.setIndent(serializedNode.indent);
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.setFormat(serializedNode.format);
199 node.setIndent(serializedNode.indent);
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.setFormat(serializedNode.format);
245 node.setIndent(serializedNode.indent);
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.setFormat(serializedNode.format);
326 node.setIndent(serializedNode.indent);
327 node.setDirection(serializedNode.direction);
331 exportJSON(): SerializedTestExcludeFromCopyElementNode {
333 ...super.exportJSON(),
334 type: 'test_exclude_from_copy_block',
340 return document.createElement('div');
352 export function $createTestExcludeFromCopyElementNode(): TestExcludeFromCopyElementNode {
353 return new TestExcludeFromCopyElementNode();
356 export type SerializedTestDecoratorNode = SerializedLexicalNode;
358 export class TestDecoratorNode extends DecoratorNode<HTMLElement> {
359 static getType(): string {
360 return 'test_decorator';
363 static clone(node: TestDecoratorNode) {
364 return new TestDecoratorNode(node.__key);
368 serializedNode: SerializedTestDecoratorNode,
369 ): TestDecoratorNode {
370 return $createTestDecoratorNode();
373 exportJSON(): SerializedTestDecoratorNode {
375 ...super.exportJSON(),
376 type: 'test_decorator',
383 'test-decorator': (domNode: HTMLElement) => {
385 conversion: () => ({node: $createTestDecoratorNode()}),
393 element: document.createElement('test-decorator'),
398 return 'Hello world';
402 return document.createElement('span');
410 const decorator = document.createElement('span');
411 decorator.textContent = 'Hello world';
416 export function $createTestDecoratorNode(): TestDecoratorNode {
417 return new TestDecoratorNode();
420 const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>> = [
432 TestExcludeFromCopyElementNode,
434 TestInlineElementNode,
439 export function createTestEditor(
442 editorState?: EditorState;
443 theme?: EditorThemeClasses;
444 parentEditor?: LexicalEditor;
445 nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>;
446 onError?: (error: Error) => void;
447 disableEvents?: boolean;
452 const customNodes = config.nodes || [];
453 const editor = createEditor({
454 namespace: config.namespace,
459 nodes: DEFAULT_NODES.concat(customNodes),
464 export function createTestHeadlessEditor(
465 editorState?: EditorState,
467 return createHeadlessEditor({
469 onError: (error) => {
475 export function $assertRangeSelection(selection: unknown): RangeSelection {
476 if (!$isRangeSelection(selection)) {
477 throw new Error(`Expected RangeSelection, got ${selection}`);
482 export function invariant(cond?: boolean, message?: string): asserts cond {
486 throw new Error(`Invariant: ${message}`);
489 export class ClipboardDataMock {
490 getData: jest.Mock<string, [string]>;
491 setData: jest.Mock<void, [string, string]>;
494 this.getData = jest.fn();
495 this.setData = jest.fn();
499 export class DataTransferMock implements DataTransfer {
500 _data: Map<string, string> = new Map();
501 get dropEffect(): DataTransfer['dropEffect'] {
502 throw new Error('Getter not implemented.');
504 get effectAllowed(): DataTransfer['effectAllowed'] {
505 throw new Error('Getter not implemented.');
507 get files(): FileList {
508 throw new Error('Getter not implemented.');
510 get items(): DataTransferItemList {
511 throw new Error('Getter not implemented.');
513 get types(): ReadonlyArray<string> {
514 return Array.from(this._data.keys());
516 clearData(dataType?: string): void {
519 getData(dataType: string): string {
520 return this._data.get(dataType) || '';
522 setData(dataType: string, data: string): void {
523 this._data.set(dataType, data);
525 setDragImage(image: Element, x: number, y: number): void {
530 export class EventMock implements Event {
531 get bubbles(): boolean {
532 throw new Error('Getter not implemented.');
534 get cancelBubble(): boolean {
535 throw new Error('Gettter not implemented.');
537 get cancelable(): boolean {
538 throw new Error('Gettter not implemented.');
540 get composed(): boolean {
541 throw new Error('Gettter not implemented.');
543 get currentTarget(): EventTarget | null {
544 throw new Error('Gettter not implemented.');
546 get defaultPrevented(): boolean {
547 throw new Error('Gettter not implemented.');
549 get eventPhase(): number {
550 throw new Error('Gettter not implemented.');
552 get isTrusted(): boolean {
553 throw new Error('Gettter not implemented.');
555 get returnValue(): boolean {
556 throw new Error('Gettter not implemented.');
558 get srcElement(): EventTarget | null {
559 throw new Error('Gettter not implemented.');
561 get target(): EventTarget | null {
562 throw new Error('Gettter not implemented.');
564 get timeStamp(): number {
565 throw new Error('Gettter not implemented.');
568 throw new Error('Gettter not implemented.');
570 composedPath(): EventTarget[] {
571 throw new Error('Method not implemented.');
575 bubbles?: boolean | undefined,
576 cancelable?: boolean | undefined,
578 throw new Error('Method not implemented.');
580 stopImmediatePropagation(): void {
583 stopPropagation(): void {
587 CAPTURING_PHASE = 1 as const;
588 AT_TARGET = 2 as const;
589 BUBBLING_PHASE = 3 as const;
595 export class KeyboardEventMock extends EventMock implements KeyboardEvent {
597 get charCode(): number {
598 throw new Error('Getter not implemented.');
601 throw new Error('Getter not implemented.');
604 get isComposing(): boolean {
605 throw new Error('Getter not implemented.');
608 throw new Error('Getter not implemented.');
610 get keyCode(): number {
611 throw new Error('Getter not implemented.');
613 get location(): number {
614 throw new Error('Getter not implemented.');
617 get repeat(): boolean {
618 throw new Error('Getter not implemented.');
621 constructor(type: void | string) {
624 getModifierState(keyArg: string): boolean {
625 throw new Error('Method not implemented.');
629 bubblesArg?: boolean | undefined,
630 cancelableArg?: boolean | undefined,
631 viewArg?: Window | null | undefined,
632 keyArg?: string | undefined,
633 locationArg?: number | undefined,
634 ctrlKey?: boolean | undefined,
635 altKey?: boolean | undefined,
636 shiftKey?: boolean | undefined,
637 metaKey?: boolean | undefined,
639 throw new Error('Method not implemented.');
641 DOM_KEY_LOCATION_STANDARD = 0 as const;
642 DOM_KEY_LOCATION_LEFT = 1 as const;
643 DOM_KEY_LOCATION_RIGHT = 2 as const;
644 DOM_KEY_LOCATION_NUMPAD = 3 as const;
645 get detail(): number {
646 throw new Error('Getter not implemented.');
648 get view(): Window | null {
649 throw new Error('Getter not implemented.');
651 get which(): number {
652 throw new Error('Getter not implemented.');
656 bubblesArg?: boolean | undefined,
657 cancelableArg?: boolean | undefined,
658 viewArg?: Window | null | undefined,
659 detailArg?: number | undefined,
661 throw new Error('Method not implemented.');
665 export function tabKeyboardEvent() {
666 return new KeyboardEventMock('keydown');
669 export function shiftTabKeyboardEvent() {
670 const keyboardEvent = new KeyboardEventMock('keydown');
671 keyboardEvent.shiftKey = true;
672 return keyboardEvent;
675 export function generatePermutations<T>(
677 maxLength = values.length,
679 if (maxLength > values.length) {
680 throw new Error('maxLength over values.length');
682 const result: T[][] = [];
683 const current: T[] = [];
684 const seen = new Set();
685 (function permutationsImpl() {
686 if (current.length > maxLength) {
689 result.push(current.slice());
690 for (let i = 0; i < values.length; i++) {
691 const key = values[i];
705 // This tag function is just used to trigger prettier auto-formatting.
706 // (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
707 export function html(
708 partials: TemplateStringsArray,
712 for (let i = 0; i < partials.length; i++) {
713 output += partials[i];
714 if (i < partials.length - 1) {
721 export function expectHtmlToBeEqual(expected: string, actual: string): void {
722 expect(formatHtml(expected)).toBe(formatHtml(actual));
725 function formatHtml(s: string): string {
726 return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();