Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -1,5 +1,5 @@
import { act } from 'react';
import { page, userEvent } from 'vitest/browser';
import { page, userEvent, utils } from 'vitest/browser';

import { renderRichEditorInDOM } from '../utils/renderEditorInDOM';
import { selectContent } from '../utils/utils';
Expand Down Expand Up @@ -110,3 +110,174 @@ test('item nesting can be decreased and empty list will be removed', async () =>
.poll(() => onChanged)
.toHaveBeenLastCalledWith('- [ ] 1\n- [ ] 2\n- [ ] 3\n- [ ] 4\n- [ ] 5\n');
});

describe('Regressions', () => {
describe('Different lists separated by blank lines remain separate', () => {
[
{
name: 'regular list and checklist',
expectedLists: 2,
content: [
'- foo',
'- bar',
'- baz',
'',
'- [ ] 1',
'- [ ] 2',
'- [ ] 3',
].join('\n'),
},
{
name: 'regular list and ordered list',
expectedLists: 2,
content: ['- foo', '- bar', '- baz', '', '1. 1', '2. 2', '3. 3'].join(
'\n',
),
},
{
name: 'regular list, ordered list, checkbox list',
expectedLists: 3,
content: [
'- foo',
'- bar',
'- baz',
'',
'1. 1',
'2. 2',
'3. 3',
'',
'- [ ] 1',
'- [ ] 2',
'- [ ] 3',
].join('\n'),
},
].forEach(({ name, content, expectedLists }) =>
test(name, async () => {
const { render, getMarkdown, getEditor } = await renderRichEditorInDOM({
value: content,
});

expect(
page.getByRole('list').all(),
'There are expected number of lists',
).toHaveLength(expectedLists);

const rootNode = getEditor().getRootElement();
const getHTML = () => utils.prettyDOM(rootNode);

const htmlV1 = getHTML();
const markdownV1 = getMarkdown();

// Cleanup and re-render
await render({ value: '' });
await expect.poll(() => rootNode).toHaveTextContent('');

// Make sure the roundtrip are consistent
await render({ value: markdownV1 });
expect(getHTML()).toBe(htmlV1);
expect(getMarkdown()).toBe(markdownV1);
}),
);
});

test('3 empty lines splits the lists', async () => {
const editor1 = await renderRichEditorInDOM({
value: '',
});

const editor1Locator = page
.elementLocator(editor1.getEditor().getRootElement()!)
.first();

// Create 2 lists
await act(async () => {
await editor1Locator.click();
});
await editor1.insert({ type: 'list', data: { type: 'unordered' } });
await act(async () => {
await userEvent.keyboard('foo');
await userEvent.keyboard('{Enter}'.repeat(3));
});

await editor1.insert({ type: 'list', data: { type: 'unordered' } });
await act(async () => {
await userEvent.keyboard('bar');
});

expect(editor1Locator.getByRole('list').all()).toHaveLength(2);
expect(editor1Locator.getByRole('listitem').all()).toHaveLength(2);

// Create another editor with serialized content
const editor2 = await renderRichEditorInDOM({
value: editor1.getMarkdown(),
});

const editor2Locator = page.elementLocator(editor2.getEditor().getRootElement()!);
expect(editor2Locator.getByRole('list').all()).toHaveLength(2);
expect(editor2Locator.getByRole('listitem').all()).toHaveLength(2);

// Content must be equal
expect(editor1Locator.element()).not.toBe(editor2Locator.element());
expect(utils.prettyDOM(editor2Locator)).toBe(utils.prettyDOM(editor1Locator));
expect(editor2.getMarkdown()).toBe(editor1.getMarkdown());
});

test('nested items stay nested when a regular list is followed by a checklist', async () => {
const value = `
- foo
- bar
- baz

- [ ] 1
- [ ] 2
- [ ] 3
- [ ] 4
- [ ] 5
`.trim();

await renderRichEditorInDOM({ value });

const editor = page.getByRole('textbox');
expect(editor.element().querySelectorAll(':scope > ul')).toHaveLength(2);
expect(editor.getByRole('list').all()).toHaveLength(5);
expect(editor.getByRole('checkbox')).toHaveLength(5);
});

test('blank-separated sibling items of the same kind remain one list', async () => {
await renderRichEditorInDOM({ value: '- foo\n\n- bar' });

expect(page.getByRole('list').all()).toHaveLength(1);
});

test('list kind changes split items even without a blank line', async () => {
await renderRichEditorInDOM({
value: '- regular\n- [ ] task\n- regular again',
});

expect(page.getByRole('list').all()).toHaveLength(3);
expect(page.getByRole('checkbox')).toHaveLength(1);
});

test('checked and unchecked task items remain in the same checklist', async () => {
await renderRichEditorInDOM({
value: '- [ ] first\n\n- [x] second\n - [ ] third',
});

expect(page.getByRole('list').all()).toHaveLength(1);
expect(page.getByRole('checkbox')).toHaveLength(3);
});

test('ordered list numbering is retained after a task item splits the list', async () => {
await renderRichEditorInDOM({
value: '3. regular\n4. [ ] task\n5. regular again',
});

const editor = page.getByRole('textbox');
const orderedLists = editor.element().querySelectorAll(':scope > ol');

expect(orderedLists).toHaveLength(2);
expect(orderedLists[0].getAttribute('start')).toBe('3');
expect(orderedLists[1].getAttribute('start')).toBe('5');
expect(editor.getByRole('checkbox')).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { RichEditorContentProps } from '@features/NoteEditor/RichEditor/RichEdit
import { ThemeProvider } from '@features/ThemeProvider';
import { createTestStore } from '@tests/utils/redux';

import { $convertToMarkdownString } from '../../plugins/Markdown/markdownParser';

// Mock useUrlOpener to avoid importing monaco-editor-core in tests,
// which causes Vite module resolution errors during test setup
vi.mock('@hooks/useUrlOpener', () => ({
Expand Down Expand Up @@ -73,6 +75,8 @@ export type RichEditorTestAPI = {
format: (format: TextFormat) => Promise<void>;
command: (format: CommandsPayload) => Promise<void>;
getEditor(): LexicalEditor;
getMarkdown(): string;
render: (props: RichEditorContentProps) => Promise<void>;
};

export const renderRichEditorInDOM = async ({
Expand Down Expand Up @@ -114,7 +118,14 @@ export const renderRichEditorInDOM = async ({
document.body.appendChild(container);

const root = createRoot(container);
act(() => root.render(renderEditor({ ...props, editorRef })));

const render = (props: RichEditorContentProps) => {
return act(() =>
root.render(renderEditor({ ...props, editorRef })),
) as unknown as Promise<void>;
};

render(props);

const destroy = () => {
act(() => {
Expand Down Expand Up @@ -163,5 +174,14 @@ export const renderRichEditorInDOM = async ({
if (!editor) throw new Error('Error instance is not set');
return editor;
},

getMarkdown() {
const editor = editorRef.current;
if (!editor) throw new Error('Error instance is not set');

return editor.read(() => $convertToMarkdownString());
},

render,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { page } from 'vitest/browser';
import { renderRichEditorInDOM } from '@features/NoteEditor/RichEditor/__tests__/utils/renderEditorInDOM';

test('same-kind lists separated by a large gap retain their boundaries and empty items', async () => {
const source = [
'- first',
...Array(10).fill('-'),
...Array(100).fill(''),
'* second',
...Array(10).fill('*'),
].join('\n');
const { getEditor, getMarkdown, render } = await renderRichEditorInDOM({
value: source,
});

const editor = page.getByRole('textbox');
const getListItemCounts = () =>
Array.from(
getEditor().getRootElement()?.querySelectorAll(':scope > ul') ?? [],
).map((list) => list.querySelectorAll(':scope > li').length);
const getGapParagraphCount = () =>
getEditor().getRootElement()?.querySelectorAll(':scope > p').length ?? 0;

expect(getListItemCounts()).toEqual([11, 11]);
expect(editor.getByRole('listitem')).toHaveLength(22);
expect(getGapParagraphCount()).toBe(98);
expect(editor.getByRole('listitem').nth(1)).toHaveTextContent('');
expect(editor.getByRole('listitem').nth(12)).toHaveTextContent('');

const serialized = getMarkdown();
expect(serialized).toContain('- first');
expect(serialized).toContain('* second');

await render({ value: serialized });

expect(getListItemCounts()).toEqual([11, 11]);
expect(page.getByRole('listitem')).toHaveLength(22);
expect(getGapParagraphCount()).toBe(98);
expect(getMarkdown()).toBe(serialized);
});

test('a single empty editor paragraph keeps same-kind lists separate after round-trip', async () => {
const { getEditor, getMarkdown, render } = await renderRichEditorInDOM({
value: '- first\n\n\n\n* second',
});
const root = () => getEditor().getRootElement()!;
const getTopLevelLists = () => root().querySelectorAll(':scope > ul');
const getTopLevelParagraphs = () => root().querySelectorAll(':scope > p');

expect(getTopLevelLists()).toHaveLength(2);
expect(getTopLevelParagraphs()).toHaveLength(1);

const serialized = getMarkdown();
expect(serialized).toContain('- first');
expect(serialized).toContain('* second');

await render({ value: serialized });

expect(getTopLevelLists()).toHaveLength(2);
expect(getTopLevelParagraphs()).toHaveLength(1);
expect(getMarkdown()).toBe(serialized);
});

test('a list with many empty items remains one list', async () => {
const source = ['- first', ...Array(100).fill('-'), '- last'].join('\n');
const { getEditor, getMarkdown, render } = await renderRichEditorInDOM({
value: source,
});

const getListItemCount = () =>
getEditor().getRootElement()?.querySelectorAll(':scope > ul > li').length;
expect(getEditor().getRootElement()?.querySelectorAll(':scope > ul')).toHaveLength(1);
expect(getListItemCount()).toBe(102);

const serialized = getMarkdown();
await render({ value: serialized });

expect(getEditor().getRootElement()?.querySelectorAll(':scope > ul')).toHaveLength(1);
expect(getListItemCount()).toBe(102);
expect(getMarkdown()).toBe(serialized);
});
Loading
Loading