test: improve coverage for some test files (#32916)

Signed-off-by: edvatar <88481784+toroleapinc@users.noreply.github.com>
Signed-off-by: -LAN- <laipz8200@outlook.com>
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: majiayu000 <1835304752@qq.com>
Co-authored-by: Poojan <poojan@infocusp.com>
Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
Co-authored-by: Pandaaaa906 <ye.pandaaaa906@gmail.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: heyszt <270985384@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Ijas <ijas.ahmd.ap@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: 木之本澪 <kinomotomiovo@gmail.com>
Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
Co-authored-by: 不做了睡大觉 <64798754+stakeswky@users.noreply.github.com>
Co-authored-by: User <user@example.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: edvatar <88481784+toroleapinc@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: Leilei <138381132+Inlei@users.noreply.github.com>
Co-authored-by: HaKu <104669497+haku-ink@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: wangxiaolei <fatelei@gmail.com>
Co-authored-by: Varun Chawla <34209028+veeceey@users.noreply.github.com>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: tda <95275462+tda1017@users.noreply.github.com>
Co-authored-by: root <root@DESKTOP-KQLO90N>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: Niels Kaspers <153818647+nielskaspers@users.noreply.github.com>
Co-authored-by: hj24 <mambahj24@gmail.com>
Co-authored-by: Tyson Cung <45380903+tysoncung@users.noreply.github.com>
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com>
Co-authored-by: slegarraga <64795732+slegarraga@users.noreply.github.com>
Co-authored-by: 99 <wh2099@pm.me>
Co-authored-by: Br1an <932039080@qq.com>
Co-authored-by: L1nSn0w <l1nsn0w@qq.com>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
Co-authored-by: akkoaya <151345394+akkoaya@users.noreply.github.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
Co-authored-by: lif <1835304752@qq.com>
Co-authored-by: weiguang li <codingpunk@gmail.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: HanWenbo <124024253+hwb96@users.noreply.github.com>
Co-authored-by: Coding On Star <447357187@qq.com>
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Stable Genius <stablegenius043@gmail.com>
Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>
Co-authored-by: ふるい <46769295+Echo0ff@users.noreply.github.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
This commit is contained in:
Saumya Talwani
2026-03-06 16:29:16 +05:30
committed by GitHub
parent 09347d5e8b
commit f50e44b24a
63 changed files with 12160 additions and 587 deletions

View File

@ -0,0 +1,209 @@
import type { LexicalEditor } from 'lexical'
import { act, waitFor } from '@testing-library/react'
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isRangeSelection,
ParagraphNode,
TextNode,
} from 'lexical'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
getNodeCount,
getNodesByType,
readEditorStateValue,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
describe('test-helpers', () => {
describe('renderLexicalEditor & waitForEditorReady', () => {
it('should render the editor and wait for it', async () => {
const { getEditor } = renderLexicalEditor({
namespace: 'TestNamespace',
nodes: [ParagraphNode, TextNode],
children: null,
})
const editor = await waitForEditorReady(getEditor)
expect(editor).toBeDefined()
expect(editor).toBe(getEditor())
})
it('should throw if wait times out without editor', async () => {
await expect(waitForEditorReady(() => null)).rejects.toThrow()
})
it('should throw if editor is null after waitFor completes', async () => {
let callCount = 0
await expect(
waitForEditorReady(() => {
callCount++
// Return non-null on the last check of `waitFor` so it passes,
// then null when actually retrieving the editor
return callCount === 1 ? ({} as LexicalEditor) : null
}),
).rejects.toThrow('Editor is not available')
})
it('should surface errors through configured onError callback', async () => {
const { getEditor } = renderLexicalEditor({
namespace: 'TestNamespace',
nodes: [ParagraphNode, TextNode],
children: null,
})
const editor = await waitForEditorReady(getEditor)
expect(() => {
editor.update(() => {
throw new Error('test error')
}, { discrete: true })
}).toThrow('test error')
})
})
describe('selectRootEnd', () => {
it('should select the end of the root', async () => {
const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null })
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
await waitFor(() => {
let isRangeSelection = false
editor.getEditorState().read(() => {
const selection = $getSelection()
isRangeSelection = $isRangeSelection(selection)
})
expect(isRangeSelection).toBe(true)
})
})
})
describe('Content Reading/Writing Helpers', () => {
it('should read root text content', async () => {
const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null })
const editor = await waitForEditorReady(getEditor)
act(() => {
editor.update(() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
paragraph.append($createTextNode('Hello World'))
root.append(paragraph)
}, { discrete: true })
})
let content = ''
act(() => {
content = readRootTextContent(editor)
})
expect(content).toBe('Hello World')
})
it('should set editor root text and select end', async () => {
const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'New Text', $createTextNode)
await waitFor(() => {
let content = ''
editor.getEditorState().read(() => {
content = $getRoot().getTextContent()
})
expect(content).toBe('New Text')
})
})
})
describe('Node Selection Helpers', () => {
it('should get node count', async () => {
const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null })
const editor = await waitForEditorReady(getEditor)
act(() => {
editor.update(() => {
const root = $getRoot()
root.clear()
root.append($createParagraphNode())
root.append($createParagraphNode())
}, { discrete: true })
})
let count = 0
act(() => {
count = getNodeCount(editor, ParagraphNode)
})
expect(count).toBe(2)
})
it('should get nodes by type', async () => {
const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null })
const editor = await waitForEditorReady(getEditor)
act(() => {
editor.update(() => {
const root = $getRoot()
root.clear()
root.append($createParagraphNode())
}, { discrete: true })
})
let nodes: ParagraphNode[] = []
act(() => {
nodes = getNodesByType(editor, ParagraphNode)
})
expect(nodes).toHaveLength(1)
expect(nodes[0]).not.toBeUndefined()
})
})
describe('readEditorStateValue', () => {
it('should read primitive values from editor state', () => {
const editor = createLexicalTestEditor('test', [ParagraphNode, TextNode])
const val = readEditorStateValue(editor, () => {
return $getRoot().isEmpty()
})
expect(val).toBe(true)
})
it('should throw if value is undefined', () => {
const editor = createLexicalTestEditor('test', [ParagraphNode, TextNode])
expect(() => {
readEditorStateValue(editor, () => undefined)
}).toThrow('Failed to read editor state value')
})
})
describe('createLexicalTestEditor', () => {
it('should expose createLexicalTestEditor with onError throw', () => {
const editor = createLexicalTestEditor('custom-namespace', [ParagraphNode, TextNode])
expect(editor).toBeDefined()
expect(() => {
editor.update(() => {
throw new Error('test error')
}, { discrete: true })
}).toThrow('test error')
})
})
describe('expectInlineWrapperDom', () => {
it('should assert wrapper properties on a valid DOM element', () => {
const div = document.createElement('div')
div.classList.add('inline-flex', 'items-center', 'align-middle', 'extra1', 'extra2')
expectInlineWrapperDom(div, ['extra1', 'extra2']) // Does not throw
})
})
})

View File

@ -0,0 +1,300 @@
import type { RootNode } from 'lexical'
import { $createParagraphNode, $createTextNode, $getRoot, ParagraphNode, TextNode } from 'lexical'
import { describe, expect, it, vi } from 'vitest'
import { createTestEditor, withEditorUpdate } from './utils'
describe('Prompt Editor Test Utils', () => {
describe('createTestEditor', () => {
it('should create an editor without crashing', () => {
const editor = createTestEditor()
expect(editor).toBeDefined()
})
it('should create an editor with no nodes by default', () => {
const editor = createTestEditor()
expect(editor).toBeDefined()
})
it('should create an editor with provided nodes', () => {
const nodes = [ParagraphNode, TextNode]
const editor = createTestEditor(nodes)
expect(editor).toBeDefined()
})
it('should set up root element for the editor', () => {
const editor = createTestEditor()
// The editor should be properly initialized with a root element
expect(editor).toBeDefined()
})
it('should throw errors when they occur', () => {
const nodes = [ParagraphNode, TextNode]
const editor = createTestEditor(nodes)
expect(() => {
editor.update(() => {
throw new Error('Test error')
}, { discrete: true })
}).toThrow('Test error')
})
it('should allow multiple editors to be created independently', () => {
const editor1 = createTestEditor()
const editor2 = createTestEditor()
expect(editor1).not.toBe(editor2)
})
it('should initialize with basic node types', () => {
const nodes = [ParagraphNode, TextNode]
const editor = createTestEditor(nodes)
let content: string = ''
editor.update(() => {
const root = $getRoot()
const paragraph = $createParagraphNode()
const text = $createTextNode('Hello World')
paragraph.append(text)
root.append(paragraph)
content = root.getTextContent()
}, { discrete: true })
expect(content).toBe('Hello World')
})
})
describe('withEditorUpdate', () => {
it('should execute update function without crashing', () => {
const editor = createTestEditor([ParagraphNode, TextNode])
const updateFn = vi.fn()
withEditorUpdate(editor, updateFn)
expect(updateFn).toHaveBeenCalled()
})
it('should pass discrete: true option to editor.update', () => {
const editor = createTestEditor([ParagraphNode, TextNode])
const updateSpy = vi.spyOn(editor, 'update')
withEditorUpdate(editor, () => {
$getRoot()
})
expect(updateSpy).toHaveBeenCalledWith(expect.any(Function), { discrete: true })
})
it('should allow updating editor state', () => {
const editor = createTestEditor([ParagraphNode, TextNode])
let textContent: string = ''
withEditorUpdate(editor, () => {
const root = $getRoot()
const paragraph = $createParagraphNode()
const text = $createTextNode('Test Content')
paragraph.append(text)
root.append(paragraph)
})
withEditorUpdate(editor, () => {
textContent = $getRoot().getTextContent()
})
expect(textContent).toBe('Test Content')
})
it('should handle multiple consecutive updates', () => {
const editor = createTestEditor([ParagraphNode, TextNode])
withEditorUpdate(editor, () => {
const root = $getRoot()
const p1 = $createParagraphNode()
p1.append($createTextNode('First'))
root.append(p1)
})
withEditorUpdate(editor, () => {
const root = $getRoot()
const p2 = $createParagraphNode()
p2.append($createTextNode('Second'))
root.append(p2)
})
let content: string = ''
withEditorUpdate(editor, () => {
content = $getRoot().getTextContent()
})
expect(content).toContain('First')
expect(content).toContain('Second')
})
it('should provide access to editor state within update', () => {
const editor = createTestEditor([ParagraphNode, TextNode])
let capturedState: RootNode | null = null
withEditorUpdate(editor, () => {
const root = $getRoot()
capturedState = root
})
expect(capturedState).toBeDefined()
})
it('should execute update function immediately', () => {
const editor = createTestEditor([ParagraphNode, TextNode])
let executed = false
withEditorUpdate(editor, () => {
executed = true
})
// Update should be executed synchronously in discrete mode
expect(executed).toBe(true)
})
it('should handle complex editor operations within update', () => {
const editor = createTestEditor([ParagraphNode, TextNode])
let nodeCount: number = 0
withEditorUpdate(editor, () => {
const root = $getRoot()
for (let i = 0; i < 3; i++) {
const paragraph = $createParagraphNode()
paragraph.append($createTextNode(`Paragraph ${i}`))
root.append(paragraph)
}
// Count child nodes
nodeCount = root.getChildrenSize()
})
expect(nodeCount).toBe(3)
})
it('should allow reading editor state after update', () => {
const editor = createTestEditor([ParagraphNode, TextNode])
withEditorUpdate(editor, () => {
const root = $getRoot()
const paragraph = $createParagraphNode()
paragraph.append($createTextNode('Read Test'))
root.append(paragraph)
})
let readContent: string = ''
withEditorUpdate(editor, () => {
readContent = $getRoot().getTextContent()
})
expect(readContent).toBe('Read Test')
})
it('should handle error thrown within update function', () => {
const editor = createTestEditor([ParagraphNode, TextNode])
expect(() => {
withEditorUpdate(editor, () => {
throw new Error('Update error')
})
}).toThrow('Update error')
})
it('should preserve editor state across multiple updates', () => {
const editor = createTestEditor([ParagraphNode, TextNode])
withEditorUpdate(editor, () => {
const root = $getRoot()
const p = $createParagraphNode()
p.append($createTextNode('Persistent'))
root.append(p)
})
let persistedContent: string = ''
withEditorUpdate(editor, () => {
persistedContent = $getRoot().getTextContent()
})
expect(persistedContent).toBe('Persistent')
})
})
describe('Integration', () => {
it('should work together to create and update editor', () => {
const editor = createTestEditor([ParagraphNode, TextNode])
withEditorUpdate(editor, () => {
const root = $getRoot()
const p = $createParagraphNode()
p.append($createTextNode('Integration Test'))
root.append(p)
})
let result: string = ''
withEditorUpdate(editor, () => {
result = $getRoot().getTextContent()
})
expect(result).toBe('Integration Test')
})
it('should support multiple editors with isolated state', () => {
const editor1 = createTestEditor([ParagraphNode, TextNode])
const editor2 = createTestEditor([ParagraphNode, TextNode])
withEditorUpdate(editor1, () => {
const root = $getRoot()
const p = $createParagraphNode()
p.append($createTextNode('Editor 1'))
root.append(p)
})
withEditorUpdate(editor2, () => {
const root = $getRoot()
const p = $createParagraphNode()
p.append($createTextNode('Editor 2'))
root.append(p)
})
let content1: string = ''
let content2: string = ''
withEditorUpdate(editor1, () => {
content1 = $getRoot().getTextContent()
})
withEditorUpdate(editor2, () => {
content2 = $getRoot().getTextContent()
})
expect(content1).toBe('Editor 1')
expect(content2).toBe('Editor 2')
})
it('should handle nested paragraph and text nodes', () => {
const editor = createTestEditor([ParagraphNode, TextNode])
withEditorUpdate(editor, () => {
const root = $getRoot()
const p1 = $createParagraphNode()
const p2 = $createParagraphNode()
p1.append($createTextNode('First Para'))
p2.append($createTextNode('Second Para'))
root.append(p1)
root.append(p2)
})
let content: string = ''
withEditorUpdate(editor, () => {
content = $getRoot().getTextContent()
})
expect(content).toContain('First Para')
expect(content).toContain('Second Para')
})
})
})

View File

@ -1,112 +1,251 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { LexicalEditor } from 'lexical'
import type { JSX, RefObject } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { act, render, screen } from '@testing-library/react'
import DraggableBlockPlugin from '..'
const CONTENT_EDITABLE_TEST_ID = 'draggable-content-editable'
let namespaceCounter = 0
function renderWithEditor(anchorElem?: HTMLElement) {
render(
<LexicalComposer
initialConfig={{
namespace: `draggable-plugin-test-${namespaceCounter++}`,
onError: (error: Error) => { throw error },
}}
>
<RichTextPlugin
contentEditable={<ContentEditable data-testid={CONTENT_EDITABLE_TEST_ID} />}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
/>
<DraggableBlockPlugin anchorElem={anchorElem} />
</LexicalComposer>,
)
return screen.getByTestId(CONTENT_EDITABLE_TEST_ID)
type DraggableExperimentalProps = {
anchorElem: HTMLElement
menuRef: RefObject<HTMLDivElement>
targetLineRef: RefObject<HTMLDivElement>
menuComponent: JSX.Element | null
targetLineComponent: JSX.Element
isOnMenu: (element: HTMLElement) => boolean
onElementChanged: (element: HTMLElement | null) => void
}
function appendChildToRoot(rootElement: HTMLElement, className = '') {
const element = document.createElement('div')
element.className = className
rootElement.appendChild(element)
return element
type MouseMoveHandler = (event: MouseEvent) => void
const { draggableMockState } = vi.hoisted(() => ({
draggableMockState: {
latestProps: null as DraggableExperimentalProps | null,
},
}))
vi.mock('@lexical/react/LexicalComposerContext')
vi.mock('@lexical/react/LexicalDraggableBlockPlugin', () => ({
DraggableBlockPlugin_EXPERIMENTAL: (props: DraggableExperimentalProps) => {
draggableMockState.latestProps = props
return (
<div data-testid="draggable-plugin-experimental-mock">
{props.menuComponent}
{props.targetLineComponent}
</div>
)
},
}))
function createRootElementMock() {
let mouseMoveHandler: MouseMoveHandler | null = null
const addEventListener = vi.fn((eventName: string, handler: EventListenerOrEventListenerObject) => {
if (eventName === 'mousemove' && typeof handler === 'function')
mouseMoveHandler = handler as MouseMoveHandler
})
const removeEventListener = vi.fn()
return {
rootElement: {
addEventListener,
removeEventListener,
} as unknown as HTMLElement,
addEventListener,
removeEventListener,
getMouseMoveHandler: () => mouseMoveHandler,
}
}
function getRegisteredMouseMoveHandler(
rootMock: ReturnType<typeof createRootElementMock>,
): MouseMoveHandler {
const handler = rootMock.getMouseMoveHandler()
if (!handler)
throw new Error('Expected mousemove handler to be registered')
return handler
}
function setupEditorRoot(rootElement: HTMLElement | null) {
const editor = {
getRootElement: vi.fn(() => rootElement),
} as unknown as LexicalEditor
vi.mocked(useLexicalComposerContext).mockReturnValue([
editor,
{},
] as unknown as ReturnType<typeof useLexicalComposerContext>)
return editor
}
describe('DraggableBlockPlugin', () => {
beforeEach(() => {
vi.clearAllMocks()
draggableMockState.latestProps = null
})
describe('Rendering', () => {
it('should use body as default anchor and render target line', () => {
renderWithEditor()
const rootMock = createRootElementMock()
setupEditorRoot(rootMock.rootElement)
const targetLine = screen.getByTestId('draggable-target-line')
expect(targetLine).toBeInTheDocument()
expect(document.body.contains(targetLine)).toBe(true)
render(<DraggableBlockPlugin />)
expect(draggableMockState.latestProps?.anchorElem).toBe(document.body)
expect(screen.getByTestId('draggable-target-line')).toBeInTheDocument()
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
})
it('should render inside custom anchor element when provided', () => {
const customAnchor = document.createElement('div')
document.body.appendChild(customAnchor)
it('should render with custom anchor when provided', () => {
const rootMock = createRootElementMock()
setupEditorRoot(rootMock.rootElement)
const anchorElem = document.createElement('div')
renderWithEditor(customAnchor)
render(<DraggableBlockPlugin anchorElem={anchorElem} />)
const targetLine = screen.getByTestId('draggable-target-line')
expect(customAnchor.contains(targetLine)).toBe(true)
expect(draggableMockState.latestProps?.anchorElem).toBe(anchorElem)
expect(screen.getByTestId('draggable-target-line')).toBeInTheDocument()
})
customAnchor.remove()
it('should return early when editor root element is null', () => {
const editor = setupEditorRoot(null)
render(<DraggableBlockPlugin />)
expect(editor.getRootElement).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('draggable-target-line')).toBeInTheDocument()
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
})
})
describe('Drag Support Detection', () => {
it('should render drag menu when mouse moves over a support-drag element', async () => {
const rootElement = renderWithEditor()
const supportDragTarget = appendChildToRoot(rootElement, 'support-drag')
describe('Drag support detection', () => {
it('should show menu when target has support-drag class', () => {
const rootMock = createRootElementMock()
setupEditorRoot(rootMock.rootElement)
render(<DraggableBlockPlugin />)
const onMove = getRegisteredMouseMoveHandler(rootMock)
const target = document.createElement('div')
target.className = 'support-drag'
act(() => {
onMove({ target } as unknown as MouseEvent)
})
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
})
it('should show menu when target contains a support-drag descendant', () => {
const rootMock = createRootElementMock()
setupEditorRoot(rootMock.rootElement)
render(<DraggableBlockPlugin />)
const onMove = getRegisteredMouseMoveHandler(rootMock)
const target = document.createElement('div')
target.appendChild(Object.assign(document.createElement('span'), { className: 'support-drag' }))
act(() => {
onMove({ target } as unknown as MouseEvent)
})
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
})
it('should show menu when target is inside a support-drag ancestor', () => {
const rootMock = createRootElementMock()
setupEditorRoot(rootMock.rootElement)
render(<DraggableBlockPlugin />)
const onMove = getRegisteredMouseMoveHandler(rootMock)
const ancestor = document.createElement('div')
ancestor.className = 'support-drag'
const child = document.createElement('span')
ancestor.appendChild(child)
act(() => {
onMove({ target: child } as unknown as MouseEvent)
})
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
})
it('should hide menu when target does not support drag', () => {
const rootMock = createRootElementMock()
setupEditorRoot(rootMock.rootElement)
render(<DraggableBlockPlugin />)
const onMove = getRegisteredMouseMoveHandler(rootMock)
const supportDragTarget = document.createElement('div')
supportDragTarget.className = 'support-drag'
act(() => {
onMove({ target: supportDragTarget } as unknown as MouseEvent)
})
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
const plainTarget = document.createElement('div')
act(() => {
onMove({ target: plainTarget } as unknown as MouseEvent)
})
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
fireEvent.mouseMove(supportDragTarget)
await waitFor(() => {
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
})
})
it('should hide drag menu when support-drag target is removed and mouse moves again', async () => {
const rootElement = renderWithEditor()
const supportDragTarget = appendChildToRoot(rootElement, 'support-drag')
it('should keep menu hidden when event target becomes null', () => {
const rootMock = createRootElementMock()
setupEditorRoot(rootMock.rootElement)
render(<DraggableBlockPlugin />)
fireEvent.mouseMove(supportDragTarget)
await waitFor(() => {
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
const onMove = getRegisteredMouseMoveHandler(rootMock)
const supportDragTarget = document.createElement('div')
supportDragTarget.className = 'support-drag'
act(() => {
onMove({ target: supportDragTarget } as unknown as MouseEvent)
})
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
act(() => {
onMove({ target: null } as unknown as MouseEvent)
})
supportDragTarget.remove()
fireEvent.mouseMove(rootElement)
await waitFor(() => {
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
})
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
})
})
describe('Menu Detection Contract', () => {
it('should render menu with draggable-block-menu class and keep non-menu elements outside it', async () => {
const rootElement = renderWithEditor()
const supportDragTarget = appendChildToRoot(rootElement, 'support-drag')
describe('Forwarded callbacks', () => {
it('should forward isOnMenu and detect menu membership correctly', () => {
const rootMock = createRootElementMock()
setupEditorRoot(rootMock.rootElement)
render(<DraggableBlockPlugin />)
fireEvent.mouseMove(supportDragTarget)
const onMove = getRegisteredMouseMoveHandler(rootMock)
const supportDragTarget = document.createElement('div')
supportDragTarget.className = 'support-drag'
act(() => {
onMove({ target: supportDragTarget } as unknown as MouseEvent)
})
const menuIcon = await screen.findByTestId('draggable-menu-icon')
expect(menuIcon.closest('.draggable-block-menu')).not.toBeNull()
const renderedMenu = screen.getByTestId('draggable-menu')
const isOnMenu = draggableMockState.latestProps?.isOnMenu
if (!isOnMenu)
throw new Error('Expected isOnMenu callback')
const normalElement = document.createElement('div')
document.body.appendChild(normalElement)
expect(normalElement.closest('.draggable-block-menu')).toBeNull()
normalElement.remove()
const menuIcon = screen.getByTestId('draggable-menu-icon')
const outsideElement = document.createElement('div')
expect(isOnMenu(menuIcon)).toBe(true)
expect(isOnMenu(renderedMenu)).toBe(true)
expect(isOnMenu(outsideElement)).toBe(false)
})
it('should register and cleanup mousemove listener on mount and unmount', () => {
const rootMock = createRootElementMock()
setupEditorRoot(rootMock.rootElement)
const { unmount } = render(<DraggableBlockPlugin />)
const onMove = getRegisteredMouseMoveHandler(rootMock)
expect(rootMock.addEventListener).toHaveBeenCalledWith('mousemove', expect.any(Function))
unmount()
expect(rootMock.removeEventListener).toHaveBeenCalledWith('mousemove', onMove)
})
})
})

View File

@ -1,8 +1,10 @@
import type { LexicalCommand } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createCommand } from 'lexical'
import * as React from 'react'
import { useState } from 'react'
import ShortcutsPopupPlugin, { SHORTCUTS_EMPTY_CONTENT } from '../index'
@ -21,6 +23,9 @@ const mockDOMRect = {
toJSON: () => ({}),
}
const originalRangeGetClientRects = Range.prototype.getClientRects
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect
beforeAll(() => {
// Mock getClientRects on Range prototype
Range.prototype.getClientRects = vi.fn(() => {
@ -34,12 +39,31 @@ beforeAll(() => {
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
})
afterAll(() => {
Range.prototype.getClientRects = originalRangeGetClientRects
Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect
})
const CONTAINER_ID = 'host'
const CONTENT_EDITABLE_ID = 'ce'
const MinimalEditor: React.FC<{
type MinimalEditorProps = {
withContainer?: boolean
}> = ({ withContainer = true }) => {
hotkey?: string | string[] | string[][] | ((e: KeyboardEvent) => boolean)
children?: React.ReactNode | ((close: () => void, onInsert: (command: LexicalCommand<unknown>, params: unknown[]) => void) => React.ReactNode)
className?: string
onOpen?: () => void
onClose?: () => void
}
const MinimalEditor: React.FC<MinimalEditorProps> = ({
withContainer = true,
hotkey,
children,
className,
onOpen,
onClose,
}) => {
const initialConfig = {
namespace: 'shortcuts-popup-plugin-test',
onError: (e: Error) => {
@ -58,25 +82,35 @@ const MinimalEditor: React.FC<{
/>
<ShortcutsPopupPlugin
container={withContainer ? containerEl : undefined}
/>
hotkey={hotkey}
className={className}
onOpen={onOpen}
onClose={onClose}
>
{children}
</ShortcutsPopupPlugin>
</div>
</LexicalComposer>
)
}
/** Helper: focus the content editable and trigger a hotkey. */
function focusAndTriggerHotkey(key: string, modifiers: Partial<Record<'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey', boolean>> = { ctrlKey: true }) {
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key, ...modifiers })
}
describe('ShortcutsPopupPlugin', () => {
// ─── Basic open / close ───
it('opens on hotkey when editor is focused', async () => {
render(<MinimalEditor />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true }) // 模拟 Ctrl+/
focusAndTriggerHotkey('/')
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('does not open when editor is not focused', async () => {
render(<MinimalEditor />)
// 未聚焦
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
@ -85,10 +119,7 @@ describe('ShortcutsPopupPlugin', () => {
it('closes on Escape', async () => {
render(<MinimalEditor />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
focusAndTriggerHotkey('/')
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
@ -111,24 +142,370 @@ describe('ShortcutsPopupPlugin', () => {
})
})
// ─── Container / portal ───
it('portals into provided container when container is set', async () => {
render(<MinimalEditor withContainer />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
const host = screen.getByTestId(CONTAINER_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
focusAndTriggerHotkey('/')
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
expect(host).toContainElement(portalContent)
})
it('falls back to document.body when container is not provided', async () => {
render(<MinimalEditor withContainer={false} />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
focusAndTriggerHotkey('/')
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
expect(document.body).toContainElement(portalContent)
})
// ─── matchHotkey: string hotkey ───
it('matches a string hotkey like "mod+/"', async () => {
render(<MinimalEditor hotkey="mod+/" />)
focusAndTriggerHotkey('/', { metaKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('matches ctrl+/ when hotkey is "mod+/" (mod matches ctrl or meta)', async () => {
render(<MinimalEditor hotkey="mod+/" />)
focusAndTriggerHotkey('/', { ctrlKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
// ─── matchHotkey: string[] hotkey ───
it('matches when hotkey is a string array like ["mod", "/"]', async () => {
render(<MinimalEditor hotkey={['mod', '/']} />)
focusAndTriggerHotkey('/', { ctrlKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
// ─── matchHotkey: string[][] (nested) hotkey ───
it('matches when hotkey is a nested array (any combo matches)', async () => {
render(<MinimalEditor hotkey={[['ctrl', 'k'], ['meta', 'j']]} />)
focusAndTriggerHotkey('k', { ctrlKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('matches the second combo in a nested array', async () => {
render(<MinimalEditor hotkey={[['ctrl', 'k'], ['meta', 'j']]} />)
focusAndTriggerHotkey('j', { metaKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('does not match nested array when no combo matches', async () => {
render(<MinimalEditor hotkey={[['ctrl', 'k'], ['meta', 'j']]} />)
focusAndTriggerHotkey('x', { ctrlKey: true })
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
// ─── matchHotkey: function hotkey ───
it('matches when hotkey is a custom function returning true', async () => {
const customMatcher = (e: KeyboardEvent) => e.key === 'F1'
render(<MinimalEditor hotkey={customMatcher} />)
focusAndTriggerHotkey('F1', {})
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('does not match when custom function returns false', async () => {
const customMatcher = (e: KeyboardEvent) => e.key === 'F1'
render(<MinimalEditor hotkey={customMatcher} />)
focusAndTriggerHotkey('F2', {})
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
// ─── matchHotkey: modifier aliases ───
it('matches meta/cmd/command aliases', async () => {
render(<MinimalEditor hotkey="cmd+k" />)
focusAndTriggerHotkey('k', { metaKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('matches "command" alias for meta', async () => {
render(<MinimalEditor hotkey="command+k" />)
focusAndTriggerHotkey('k', { metaKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('does not match meta alias when meta is not pressed', async () => {
render(<MinimalEditor hotkey="cmd+k" />)
focusAndTriggerHotkey('k', {})
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
it('matches alt/option alias', async () => {
render(<MinimalEditor hotkey="alt+a" />)
focusAndTriggerHotkey('a', { altKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('does not match alt alias when alt is not pressed', async () => {
render(<MinimalEditor hotkey="alt+a" />)
focusAndTriggerHotkey('a', {})
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
it('matches shift alias', async () => {
render(<MinimalEditor hotkey="shift+s" />)
focusAndTriggerHotkey('s', { shiftKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('does not match shift alias when shift is not pressed', async () => {
render(<MinimalEditor hotkey="shift+s" />)
focusAndTriggerHotkey('s', {})
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
it('matches ctrl alias', async () => {
render(<MinimalEditor hotkey="ctrl+b" />)
focusAndTriggerHotkey('b', { ctrlKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('does not match ctrl alias when ctrl is not pressed', async () => {
render(<MinimalEditor hotkey="ctrl+b" />)
focusAndTriggerHotkey('b', {})
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
// ─── matchHotkey: space key normalization ───
it('normalizes space key to "space" for matching', async () => {
render(<MinimalEditor hotkey="ctrl+space" />)
focusAndTriggerHotkey(' ', { ctrlKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
// ─── matchHotkey: key mismatch ───
it('does not match when expected key does not match pressed key', async () => {
render(<MinimalEditor hotkey="ctrl+z" />)
focusAndTriggerHotkey('x', { ctrlKey: true })
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
// ─── Children rendering ───
it('renders children as ReactNode when provided', async () => {
render(
<MinimalEditor>
<div data-testid="custom-content">My Content</div>
</MinimalEditor>,
)
focusAndTriggerHotkey('/')
expect(await screen.findByTestId('custom-content')).toBeInTheDocument()
expect(screen.getByText('My Content')).toBeInTheDocument()
})
it('renders children as render function and provides close/onInsert', async () => {
const TEST_COMMAND = createCommand<unknown>('TEST_COMMAND')
const childrenFn = vi.fn((close: () => void, onInsert: (cmd: LexicalCommand<unknown>, params: unknown[]) => void) => (
<div>
<button type="button" data-testid="close-btn" onClick={close}>Close</button>
<button type="button" data-testid="insert-btn" onClick={() => onInsert(TEST_COMMAND, ['param1'])}>Insert</button>
</div>
))
render(
<MinimalEditor>
{childrenFn}
</MinimalEditor>,
)
focusAndTriggerHotkey('/')
// Children render function should have been called
expect(await screen.findByTestId('close-btn')).toBeInTheDocument()
expect(screen.getByTestId('insert-btn')).toBeInTheDocument()
})
it('renders SHORTCUTS_EMPTY_CONTENT when children is undefined', async () => {
render(<MinimalEditor />)
focusAndTriggerHotkey('/')
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
// ─── handleInsert callback ───
it('calls close after insert via children render function', async () => {
const TEST_COMMAND = createCommand<unknown>('TEST_INSERT_COMMAND')
render(
<MinimalEditor>
{(close: () => void, onInsert: (cmd: LexicalCommand<unknown>, params: unknown[]) => void) => (
<div>
<button type="button" data-testid="insert-btn" onClick={() => onInsert(TEST_COMMAND, ['value'])}>Insert</button>
</div>
)}
</MinimalEditor>,
)
focusAndTriggerHotkey('/')
const insertBtn = await screen.findByTestId('insert-btn')
fireEvent.click(insertBtn)
// After insert, the popup should close
await waitFor(() => {
expect(screen.queryByTestId('insert-btn')).not.toBeInTheDocument()
})
})
it('calls close via children render function close callback', async () => {
render(
<MinimalEditor>
{(close: () => void) => (
<button type="button" data-testid="close-via-fn" onClick={close}>Close</button>
)}
</MinimalEditor>,
)
focusAndTriggerHotkey('/')
const closeBtn = await screen.findByTestId('close-via-fn')
fireEvent.click(closeBtn)
await waitFor(() => {
expect(screen.queryByTestId('close-via-fn')).not.toBeInTheDocument()
})
})
// ─── onOpen / onClose callbacks ───
it('calls onOpen when popup opens', async () => {
const onOpen = vi.fn()
render(<MinimalEditor onOpen={onOpen} />)
focusAndTriggerHotkey('/')
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
expect(onOpen).toHaveBeenCalledTimes(1)
})
it('calls onClose when popup closes', async () => {
const onClose = vi.fn()
render(<MinimalEditor onClose={onClose} />)
focusAndTriggerHotkey('/')
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1)
})
})
// ─── className prop ───
it('applies custom className to floating popup', async () => {
render(<MinimalEditor className="custom-popup-class" />)
focusAndTriggerHotkey('/')
const content = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const floatingDiv = content.closest('div')
expect(floatingDiv).toHaveClass('custom-popup-class')
})
// ─── mousedown inside portal should not close ───
it('does not close on mousedown inside the portal', async () => {
render(
<MinimalEditor>
<div data-testid="portal-inner">Inner content</div>
</MinimalEditor>,
)
focusAndTriggerHotkey('/')
const inner = await screen.findByTestId('portal-inner')
fireEvent.mouseDown(inner)
// Should still be open
await waitFor(() => {
expect(screen.getByTestId('portal-inner')).toBeInTheDocument()
})
})
it('prevents default and stops propagation on Escape when open', async () => {
render(<MinimalEditor />)
focusAndTriggerHotkey('/')
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const preventDefaultSpy = vi.fn()
const stopPropagationSpy = vi.fn()
// Use a custom event to capture preventDefault/stopPropagation calls
const escEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
Object.defineProperty(escEvent, 'preventDefault', { value: preventDefaultSpy })
Object.defineProperty(escEvent, 'stopPropagation', { value: stopPropagationSpy })
document.dispatchEvent(escEvent)
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
expect(preventDefaultSpy).toHaveBeenCalledTimes(1)
expect(stopPropagationSpy).toHaveBeenCalledTimes(1)
})
// ─── Zero-rect fallback in openPortal ───
it('handles zero-size range rects by falling back to node bounding rect', async () => {
// Temporarily override getClientRects to return zero-size rect
const zeroRect = { x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0, toJSON: () => ({}) }
const originalGetClientRects = Range.prototype.getClientRects
const originalGetBoundingClientRect = Range.prototype.getBoundingClientRect
Range.prototype.getClientRects = vi.fn(() => {
const rectList = [zeroRect] as unknown as DOMRectList
Object.defineProperty(rectList, 'length', { value: 1 })
Object.defineProperty(rectList, 'item', { value: () => zeroRect })
return rectList
})
Range.prototype.getBoundingClientRect = vi.fn(() => zeroRect as DOMRect)
render(<MinimalEditor />)
focusAndTriggerHotkey('/')
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
// Restore
Range.prototype.getClientRects = originalGetClientRects
Range.prototype.getBoundingClientRect = originalGetBoundingClientRect
})
it('handles empty getClientRects by using getBoundingClientRect fallback', async () => {
const originalGetClientRects = Range.prototype.getClientRects
const originalGetBoundingClientRect = Range.prototype.getBoundingClientRect
Range.prototype.getClientRects = vi.fn(() => {
const rectList = [] as unknown as DOMRectList
Object.defineProperty(rectList, 'length', { value: 0 })
Object.defineProperty(rectList, 'item', { value: () => null })
return rectList
})
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
render(<MinimalEditor />)
focusAndTriggerHotkey('/')
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
Range.prototype.getClientRects = originalGetClientRects
Range.prototype.getBoundingClientRect = originalGetBoundingClientRect
})
// ─── Combined modifier hotkeys ───
it('matches hotkey with multiple modifiers: ctrl+shift+k', async () => {
render(<MinimalEditor hotkey="ctrl+shift+k" />)
focusAndTriggerHotkey('k', { ctrlKey: true, shiftKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('matches "option" alias for alt', async () => {
render(<MinimalEditor hotkey="option+o" />)
focusAndTriggerHotkey('o', { altKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('does not match mod hotkey when neither ctrl nor meta is pressed', async () => {
render(<MinimalEditor hotkey="mod+k" />)
focusAndTriggerHotkey('k', {})
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
})