Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
1b575ba
Add Lexical Markdown characterization tests
screendriver Aug 19, 2026
3c44404
Add rich-text Markdown characterization tests
screendriver Aug 19, 2026
a44f6bb
Characterize Lexical Markdown round trips
screendriver Aug 19, 2026
57b9de1
Characterize rich-text paste behavior
screendriver Aug 19, 2026
36e672a
Characterize emoji and mention transformations
screendriver Aug 19, 2026
c9fd92d
Characterize mentions and emoji transformations
screendriver Aug 19, 2026
daebea8
Characterize draft message serialization
screendriver Aug 19, 2026
739c5f8
Characterize mention parsing and segmentation
screendriver Aug 19, 2026
efadc18
Use type aliases in characterization tests
screendriver Aug 19, 2026
d96b0b2
Avoid inline destructuring in characterization tests
screendriver Aug 19, 2026
d759a82
Characterize carriage-return paste normalization
screendriver Aug 19, 2026
0283a5c
Characterize list indentation command behavior
screendriver Aug 19, 2026
db39652
Characterize maximum list indentation depth
screendriver Aug 19, 2026
3b06f45
Characterize global editor event handling
screendriver Aug 19, 2026
bf83d2b
Characterize Wire rich-text editor behavior
screendriver Aug 19, 2026
98a1716
Characterize blockquote keyboard behavior
screendriver Aug 19, 2026
6f35503
Characterize link and autolink editor behavior
screendriver Aug 19, 2026
5922539
Characterize draft state restoration in Lexical editor
screendriver Aug 19, 2026
e718d8a
Characterize edited message restoration in Lexical editor
screendriver Aug 19, 2026
a004c26
Characterize draft state persistence in Lexical editor
screendriver Aug 19, 2026
48cf346
Characterize rich-text paste behavior in Lexical editor
screendriver Aug 19, 2026
391f793
Characterize Lexical history behavior in editor plugin
screendriver Aug 19, 2026
bbeb50e
Characterize emoji picker selection in the browser editor
screendriver Aug 19, 2026
0fbb011
Characterize EmojiNode serialization and DOM rendering
screendriver Aug 19, 2026
7519478
Characterize MentionNode serialization and DOM export
screendriver Aug 19, 2026
8aca4ba
Characterize Mention keyboard selection and deletion
screendriver Aug 19, 2026
6ca04e3
Characterize Lexical selection information for mentions
screendriver Aug 19, 2026
6fc5191
Characterize link creation from Lexical selections
screendriver Aug 19, 2026
1344981
Characterize selected-node behavior across Lexical selections
screendriver Aug 19, 2026
e422cee
Characterize heading command Markdown behavior
screendriver Aug 19, 2026
e9ac2a7
Characterize mention suggestion keyboard navigation
screendriver Aug 19, 2026
2f51dc6
Characterize Typeahead scroll parent resolution
screendriver Aug 19, 2026
ae4d6c0
Characterize Markdown shortcut typing behavior
screendriver Aug 19, 2026
4b929e9
Characterize fenced code block Markdown behavior
screendriver Aug 19, 2026
9b4f7b7
Characterize inline Markdown formatting edge cases
screendriver Aug 19, 2026
535190c
Characterize Lexical Markdown rendering boundary
screendriver Aug 19, 2026
63545ac
Characterize fenced code rendering boundary
screendriver Aug 19, 2026
9d3c459
Characterize Wire rich-text editor behavior
screendriver Aug 19, 2026
f7fcc15
Characterize block formatting toolbar commands
screendriver Aug 20, 2026
45dfa6e
Characterize inline formatting toolbar commands
screendriver Aug 20, 2026
c717d63
Characterize Wire rich-text editor behavior with Lexical tests
screendriver Aug 20, 2026
c5b1587
Characterize RichTextEditor message and draft boundaries
screendriver Aug 20, 2026
5bcb1ed
Characterize inline emoji replacement command behavior
screendriver Aug 20, 2026
58c8eed
Characterize rich-text link creation and editing
screendriver Aug 20, 2026
5324daa
Characterize RichTextEditor mention extraction
screendriver Aug 20, 2026
89ca35a
Characterize Lexical code highlighting behavior
screendriver Aug 20, 2026
8761018
Characterize edited-message block restoration
screendriver Aug 20, 2026
7c2e2c9
Characterize draft restoration for custom nodes
screendriver Aug 20, 2026
938efac
Characterize RichTextEditor emoji replacement boundary
screendriver Aug 20, 2026
706cb2c
Characterize draft saving for custom emoji nodes
screendriver Aug 20, 2026
3740f21
Characterize edited-message modification round trips
screendriver Aug 20, 2026
168f4d8
Characterize pasted date-like list syntax
screendriver Aug 20, 2026
28de9ef
Characterize list item creation on Enter
screendriver Aug 20, 2026
172917b
Characterize list item removal on Backspace
screendriver Aug 20, 2026
ae09298
Characterize list item deletion on Delete
screendriver Aug 20, 2026
4be1ae4
Characterize maximum list indentation behavior
screendriver Aug 20, 2026
08259ab
Characterize list Tab indentation behavior
screendriver Aug 20, 2026
04e6085
Characterize list formatting toggle behavior
screendriver Aug 20, 2026
e8efa42
Characterize raw edited-message mention restoration
screendriver Aug 20, 2026
fe78cf5
Characterize ambiguous Markdown typing shortcuts
screendriver Aug 20, 2026
6515910
Characterize preview-mode HTML link pasting
screendriver Aug 20, 2026
eeb3bb1
Characterize pasted ordinary mention markup fallback
screendriver Aug 20, 2026
7569d20
Characterize history for custom mention nodes
screendriver Aug 20, 2026
ad526c3
Characterize history for custom emoji nodes
screendriver Aug 20, 2026
2da3d0d
Characterize RichTextEditor Enter sending behavior
screendriver Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* Wire
* Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/

import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {$convertToMarkdownString} from '@lexical/markdown';
import {$createParagraphNode, $createTextNode, $getRoot, LexicalEditor} from 'lexical';
import {noop} from 'noop-esm';
import {act, renderHook} from '@testing-library/react';
import {type FunctionComponent, type ReactNode} from 'react';

import {editorConfig} from '../editorConfig';
import {markdownTransformers} from '../utils/markdownTransformers';
import {useBlockquoteState} from './useBlockquoteState/useBlockquoteState';
import {useCodeBlockState} from './useCodeBlockState/useCodeBlockState';

type LexicalComposerTestWrapperProps = {
readonly children: ReactNode;
};

type FormatBlockCommands = {
readonly editor: LexicalEditor;
readonly formatBlockquote: () => void;
readonly formatCodeBlock: () => void;
};

function throwEditorError(error: unknown): never {
throw error;
}

const LexicalComposerTestWrapper: FunctionComponent<LexicalComposerTestWrapperProps> = props => {
const {children} = props;

return <LexicalComposer initialConfig={{...editorConfig, onError: throwEditorError}}>{children}</LexicalComposer>;
};

function useFormatBlockCommands(): FormatBlockCommands {
const [editor] = useLexicalComposerContext();
const {formatBlockquote} = useBlockquoteState();
const {formatCodeBlock} = useCodeBlockState();

return {editor, formatBlockquote, formatCodeBlock};
}

function setSelectedParagraph(editor: LexicalEditor, text: string): void {
editor.update(
() => {
const paragraphNode = $createParagraphNode();
const textNode = $createTextNode(text);
paragraphNode.append(textNode);
$getRoot().clear().append(paragraphNode);
textNode.select(0, text.length);
},
{discrete: true},
);
}

function getMarkdown(editor: LexicalEditor): string {
return editor.getEditorState().read(() => {
return $convertToMarkdownString(markdownTransformers, undefined, true);
});
}

function flushEditorUpdate(editor: LexicalEditor): void {
editor.update(noop, {discrete: true});
}

describe('FormatToolbar block commands', () => {
it('formats a selected paragraph as a blockquote and toggles it back to a paragraph', () => {
const {result} = renderHook(useFormatBlockCommands, {wrapper: LexicalComposerTestWrapper});
const {editor, formatBlockquote} = result.current;

act(() => {
setSelectedParagraph(editor, 'quoted text');
});

act(() => {
formatBlockquote();
});

act(() => {
flushEditorUpdate(editor);
});

expect(getMarkdown(editor)).toBe('> quoted text');

act(() => {
formatBlockquote();
});

act(() => {
flushEditorUpdate(editor);
});

expect(getMarkdown(editor)).toBe('quoted text');
});

it('formats a selected paragraph as a code block and toggles it back to a paragraph', () => {
const {result} = renderHook(useFormatBlockCommands, {wrapper: LexicalComposerTestWrapper});
const {editor, formatCodeBlock} = result.current;

act(() => {
setSelectedParagraph(editor, 'const value = 1;');
});

act(() => {
formatCodeBlock();
});

act(() => {
flushEditorUpdate(editor);
});

expect(getMarkdown(editor)).toBe('```\nconst value = 1;\n```');

act(() => {
formatCodeBlock();
});

act(() => {
flushEditorUpdate(editor);
});

expect(getMarkdown(editor)).toBe('const value = 1;');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* Wire
* Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/

import {registerRichText} from '@lexical/rich-text';
import {
FORMAT_TEXT_COMMAND,
$createParagraphNode,
$createTextNode,
$getRoot,
LexicalEditor,
TextFormatType,
} from 'lexical';

import {
createWireLexicalEditorTestHarness,
WireLexicalEditorTestHarness,
} from '../testSupport/createWireLexicalEditorTestHarness';

type InlineFormat = Extract<TextFormatType, 'bold' | 'italic' | 'strikethrough' | 'code'>;

type InlineFormatCharacterizationTestCase = {
readonly description: string;
readonly format: InlineFormat;
readonly expectedMarkdown: string;
};

type RegisteredRichTextTestFunction = (harness: WireLexicalEditorTestHarness) => void;
type InlineFormatTestFunction = () => void;

const inlineFormatCharacterizationTestCases: readonly InlineFormatCharacterizationTestCase[] = [
{
description: 'bold text',
format: 'bold',
expectedMarkdown: '**formatted text**',
},
{
description: 'italic text',
format: 'italic',
expectedMarkdown: '*formatted text*',
},
{
description: 'strikethrough text',
format: 'strikethrough',
expectedMarkdown: '~~formatted text~~',
},
{
description: 'inline code text',
format: 'code',
expectedMarkdown: '`formatted text`',
},
];

function setSelectedText(editor: LexicalEditor, text: string): void {
editor.update(
() => {
const paragraphNode = $createParagraphNode();
const textNode = $createTextNode(text);
paragraphNode.append(textNode);
$getRoot().clear().append(paragraphNode);
textNode.select(0, text.length);
},
{discrete: true},
);
}

function dispatchFormatCommand(editor: LexicalEditor, format: InlineFormat): boolean {
let wasHandled = false;

editor.update(
() => {
wasHandled = editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
},
{discrete: true},
);

return wasHandled;
}

function withRegisteredRichText(testFunction: RegisteredRichTextTestFunction): InlineFormatTestFunction {
return () => {
const harness = createWireLexicalEditorTestHarness();
const unregisterRichText = registerRichText(harness.editor);

try {
testFunction(harness);
} finally {
unregisterRichText();
}
};
}

describe('Wire Lexical inline format commands', () => {
for (const characterizationTestCase of inlineFormatCharacterizationTestCases) {
it(
`formats and unformats ${characterizationTestCase.description}`,
withRegisteredRichText(harness => {
setSelectedText(harness.editor, 'formatted text');

expect(dispatchFormatCommand(harness.editor, characterizationTestCase.format)).toBe(true);
expect(harness.exportMarkdown()).toBe(characterizationTestCase.expectedMarkdown);

expect(dispatchFormatCommand(harness.editor, characterizationTestCase.format)).toBe(true);
expect(harness.exportMarkdown()).toBe('formatted text');
}),
);
}

it(
'exports combined bold and italic formatting using the current Markdown representation',
withRegisteredRichText(harness => {
setSelectedText(harness.editor, 'formatted text');

expect(dispatchFormatCommand(harness.editor, 'bold')).toBe(true);
expect(dispatchFormatCommand(harness.editor, 'italic')).toBe(true);

expect(harness.exportMarkdown()).toBe('***formatted text***');
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Wire
* Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/

import {$convertToMarkdownString} from '@lexical/markdown';
import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical';

import {createWireLexicalEditorTestHarness} from '../../testSupport/createWireLexicalEditorTestHarness';
import {markdownTransformers} from '../../utils/markdownTransformers';
import {headingCommand} from './headingCommand';

type HeadingCommandResult = {
readonly wasHandled: boolean;
readonly markdown: string;
};

function executeHeadingCommand(paragraphText: string, selectParagraphText: boolean): HeadingCommandResult {
const harness = createWireLexicalEditorTestHarness();
let wasHandled = false;

harness.editor.update(
() => {
const paragraphNode = $createParagraphNode();
const textNode = $createTextNode(paragraphText);
paragraphNode.append(textNode);
$getRoot().clear().append(paragraphNode);

if (selectParagraphText) {
textNode.select(0, paragraphText.length);
}

wasHandled = headingCommand();
},
{discrete: true},
);

const markdown = harness.editor.getEditorState().read(() => {
return $convertToMarkdownString(markdownTransformers, undefined, true);
});

return {wasHandled, markdown};
}

describe('headingCommand', () => {
it('converts the selected paragraph to an H1 heading', () => {
const actualResult = executeHeadingCommand('Heading text', true);

expect(actualResult).toEqual({wasHandled: true, markdown: '# Heading text'});
});

it('returns handled without changing a document that has no range selection', () => {
const actualResult = executeHeadingCommand('Plain text', false);

expect(actualResult).toEqual({wasHandled: true, markdown: 'Plain text'});
});
});
Loading
Loading