test: add unit tests for prompt editor's component picker block plugin. (#32412)

This commit is contained in:
mahammadasim
2026-02-24 10:12:57 +05:30
committed by GitHub
parent 6e531fe44f
commit 0070891114
5 changed files with 2173 additions and 0 deletions

View File

@@ -0,0 +1,633 @@
import type { EventEmitter } from 'ahooks/lib/useEventEmitter'
import type { LexicalEditor } from 'lexical'
import type {
ContextBlockType,
CurrentBlockType,
ErrorMessageBlockType,
LastRunBlockType,
QueryBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from '../../types'
import type { NodeOutPutVar, Var } from '@/app/components/workflow/types'
import type { EventEmitterValue } from '@/context/event-emitter'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$setSelection,
KEY_ESCAPE_COMMAND,
} from 'lexical'
import * as React from 'react'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { VarType } from '@/app/components/workflow/types'
import { EventEmitterContextProvider, useEventEmitterContextContext } from '@/context/event-emitter'
import { INSERT_CONTEXT_BLOCK_COMMAND } from '../context-block'
import { INSERT_CURRENT_BLOCK_COMMAND } from '../current-block'
import { INSERT_ERROR_MESSAGE_BLOCK_COMMAND } from '../error-message-block'
import { INSERT_LAST_RUN_BLOCK_COMMAND } from '../last-run-block'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '../workflow-variable-block'
import ComponentPicker from './index'
// Mock Range.getClientRects / getBoundingClientRect for Lexical menu positioning in JSDOM.
// This mirrors the pattern used by other prompt-editor plugin tests in this repo.
const mockDOMRect = {
x: 100,
y: 100,
width: 100,
height: 20,
top: 100,
right: 200,
bottom: 120,
left: 100,
toJSON: () => ({}),
}
beforeAll(() => {
Range.prototype.getClientRects = vi.fn(() => {
const rectList = [mockDOMRect] as unknown as DOMRectList
Object.defineProperty(rectList, 'length', { value: 1 })
Object.defineProperty(rectList, 'item', { value: (index: number) => (index === 0 ? mockDOMRect : null) })
return rectList
})
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
})
// ─── Typed factories (no `any` / `never`) ────────────────────────────────────
function makeContextBlock(overrides: Partial<ContextBlockType> = {}): ContextBlockType {
return { show: true, selectable: true, ...overrides }
}
function makeQueryBlock(overrides: Partial<QueryBlockType> = {}): QueryBlockType {
return { show: true, selectable: true, ...overrides }
}
function makeVariableBlock(overrides: Partial<VariableBlockType> = {}): VariableBlockType {
return { show: true, variables: [], ...overrides }
}
function makeCurrentBlock(overrides: Partial<CurrentBlockType> = {}): CurrentBlockType {
return { show: true, generatorType: GeneratorType.prompt, ...overrides }
}
function makeErrorMessageBlock(overrides: Partial<ErrorMessageBlockType> = {}): ErrorMessageBlockType {
return { show: true, ...overrides }
}
function makeLastRunBlock(overrides: Partial<LastRunBlockType> = {}): LastRunBlockType {
return { show: true, ...overrides }
}
function makeWorkflowNodeVar(variable: string, type: VarType, children?: Var['children']): Var {
return { variable, type, children }
}
function makeWorkflowVarNode(nodeId: string, title: string, vars: Var[]): NodeOutPutVar {
return { nodeId, title, vars }
}
function makeWorkflowVariableBlock(
overrides: Partial<WorkflowVariableBlockType> = {},
variables: NodeOutPutVar[] = [],
): WorkflowVariableBlockType {
return { show: true, variables, ...overrides }
}
// ─── Test harness ────────────────────────────────────────────────────────────
type Captures = {
editor: LexicalEditor | null
eventEmitter: EventEmitter<EventEmitterValue> | null
}
type ReactFiber = {
child: ReactFiber | null
sibling: ReactFiber | null
return: ReactFiber | null
memoizedState?: unknown
}
type ReactHook = {
memoizedState?: unknown
next?: ReactHook | null
}
const CaptureEditorAndEmitter: React.FC<{ captures: Captures }> = ({ captures }) => {
const [editor] = useLexicalComposerContext()
const { eventEmitter } = useEventEmitterContextContext()
React.useEffect(() => {
captures.editor = editor
}, [captures, editor])
React.useEffect(() => {
captures.eventEmitter = eventEmitter
}, [captures, eventEmitter])
return null
}
const CONTENT_EDITABLE_TEST_ID = 'component-picker-ce'
const MinimalEditor: React.FC<{
triggerString: string
contextBlock?: ContextBlockType
queryBlock?: QueryBlockType
variableBlock?: VariableBlockType
workflowVariableBlock?: WorkflowVariableBlockType
currentBlock?: CurrentBlockType
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
captures: Captures
}> = ({
triggerString,
contextBlock,
queryBlock,
variableBlock,
workflowVariableBlock,
currentBlock,
errorMessageBlock,
lastRunBlock,
captures,
}) => {
const initialConfig = React.useMemo(() => ({
namespace: `component-picker-test-${Math.random().toString(16).slice(2)}`,
onError: (e: Error) => {
throw e
},
}), [])
return (
<EventEmitterContextProvider>
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={<ContentEditable data-testid={CONTENT_EDITABLE_TEST_ID} />}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
/>
<CaptureEditorAndEmitter captures={captures} />
<ComponentPicker
triggerString={triggerString}
contextBlock={contextBlock}
queryBlock={queryBlock}
variableBlock={variableBlock}
workflowVariableBlock={workflowVariableBlock}
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
/>
</LexicalComposer>
</EventEmitterContextProvider>
)
}
async function waitForEditor(captures: Captures): Promise<LexicalEditor> {
await waitFor(() => {
expect(captures.editor).not.toBeNull()
})
return captures.editor as LexicalEditor
}
async function waitForEventEmitter(captures: Captures): Promise<NonNullable<Captures['eventEmitter']>> {
await waitFor(() => {
expect(captures.eventEmitter).not.toBeNull()
})
return captures.eventEmitter as NonNullable<Captures['eventEmitter']>
}
async function setEditorText(editor: LexicalEditor, text: string, selectEnd: boolean): Promise<void> {
await act(async () => {
editor.update(() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
const textNode = $createTextNode(text)
paragraph.append(textNode)
root.append(paragraph)
if (selectEnd)
textNode.selectEnd()
})
})
}
function readEditorText(editor: LexicalEditor): string {
return editor.getEditorState().read(() => $getRoot().getTextContent())
}
function getReactFiberFromDom(dom: Element): ReactFiber | null {
const key = Object.keys(dom).find(k => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'))
if (!key)
return null
return (dom as unknown as Record<string, unknown>)[key] as ReactFiber
}
function findHookRefPointingToElement(root: ReactFiber, element: Element): { current: unknown } | null {
const visit = (fiber: ReactFiber | null): { current: unknown } | null => {
if (!fiber)
return null
let hook = fiber.memoizedState as ReactHook | null | undefined
while (hook) {
const state = hook.memoizedState
if (state && typeof state === 'object' && 'current' in state) {
const ref = state as { current: unknown }
if (ref.current === element)
return ref
}
hook = hook.next
}
return visit(fiber.child) || visit(fiber.sibling)
}
return visit(root)
}
async function flushNextTick(): Promise<void> {
// Used to flush 0ms setTimeout work scheduled by renderMenu (refs.setReference guard).
await act(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0))
})
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('ComponentPicker (component-picker-block/index.tsx)', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
it('does not render a menu when there are no options and workflowVariableBlock is not shown (renderMenu returns null)', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render(<MinimalEditor triggerString="{" captures={captures} />)
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
// Menu should not appear because renderMenu exits early without an anchor + content.
await waitFor(() => {
expect(screen.queryByText('common.promptEditor.context.item.title')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('workflow.common.searchVar')).not.toBeInTheDocument()
})
})
it('renders prompt options in a portal and removes the trigger TextNode when selecting a normal option (nodeToRemove && key truthy)', async () => {
const user = userEvent.setup()
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
queryBlock={makeQueryBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
const dispatchSpy = vi.spyOn(editor, 'dispatchCommand')
// Open the typeahead menu by inserting the trigger character at the caret.
await setEditorText(editor, '{', true)
// The i18n mock returns "common.<key>" for { ns: 'common' }.
const contextTitle = await screen.findByText('common.promptEditor.context.item.title')
expect(contextTitle).toBeInTheDocument()
// Hover over another menu item to trigger `onSetHighlight` -> `setHighlightedIndex(index)`.
const queryTitle = await screen.findByText('common.promptEditor.query.item.title')
const queryItem = queryTitle.closest('[tabindex="-1"]')
expect(queryItem).not.toBeNull()
await user.hover(queryItem as HTMLElement)
// Flush the 0ms timer in renderMenu that calls refs.setReference(anchor).
await flushNextTick()
fireEvent.click(contextTitle)
// Selecting an option should dispatch a command (from the real option implementation).
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_CONTEXT_BLOCK_COMMAND, undefined)
// The trigger character should be removed from editor content via `nodeToRemove.remove()`.
await waitFor(() => {
expect(readEditorText(editor)).not.toContain('{')
})
})
it('does not remove the trigger when selecting an option with an empty key (nodeToRemove && key falsy)', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
variableBlock={makeVariableBlock({
show: true,
// Edge case: an empty variable name produces a MenuOption key of '' (falsy),
// which drives the `nodeToRemove && selectedOption?.key` condition to false.
variables: [{ name: 'empty', value: '' }],
})}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
// There is no accessible "option" role here (menu items are plain divs).
// We locate menu items by `tabindex="-1"` inside the listbox.
const listbox = await screen.findByRole('listbox', { name: /typeahead menu/i })
const menuItems = Array.from(listbox.querySelectorAll('[tabindex="-1"]'))
// Expect at least: (1) our empty variable option, (2) the "add variable" option.
expect(menuItems.length).toBeGreaterThanOrEqual(2)
expect(within(listbox).getByText('common.promptEditor.variable.modal.add')).toBeInTheDocument()
fireEvent.click(menuItems[0] as HTMLElement)
// Since the key is falsy, ComponentPicker should NOT call nodeToRemove.remove().
// The trigger remains in editor content.
await waitFor(() => {
expect(readEditorText(editor)).toContain('{')
})
})
it('subscribes to EventEmitter and dispatches INSERT_VARIABLE_VALUE_BLOCK_COMMAND only for matching messages', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
const eventEmitter = await waitForEventEmitter(captures)
const dispatchSpy = vi.spyOn(editor, 'dispatchCommand')
// Non-object emissions (string) should be ignored by the subscription callback.
eventEmitter.emit('some-string')
expect(dispatchSpy).not.toHaveBeenCalledWith(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, expect.any(String))
// Mismatched type should be ignored.
eventEmitter.emit({ type: 'OTHER', payload: 'x' })
expect(dispatchSpy).not.toHaveBeenCalledWith(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, '{{x}}')
// Matching type should dispatch with {{payload}} wrapping.
eventEmitter.emit({ type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND as unknown as string, payload: 'foo' })
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, '{{foo}}')
})
it('handles workflow variable selection: flat vars (current/error_message/last_run) and closes on Escape from search input', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
{ nodeId: 'custom-flat', title: 'custom-flat', isFlat: true, vars: [makeWorkflowNodeVar('custom_flat', VarType.string)] },
makeWorkflowVarNode('node-output', 'Node Output', [
makeWorkflowNodeVar('output', VarType.string),
]),
])
render((
<MinimalEditor
triggerString="{"
workflowVariableBlock={workflowVariableBlock}
currentBlock={makeCurrentBlock({ generatorType: GeneratorType.prompt })}
errorMessageBlock={makeErrorMessageBlock()}
lastRunBlock={makeLastRunBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
const dispatchSpy = vi.spyOn(editor, 'dispatchCommand')
// Open menu and select current (flat).
await setEditorText(editor, '{', true)
await flushNextTick()
const currentLabel = await screen.findByText('current_prompt')
await act(async () => {
fireEvent.click(currentLabel)
})
await flushNextTick()
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_CURRENT_BLOCK_COMMAND, GeneratorType.prompt)
// Re-open menu and select error_message (flat).
await setEditorText(editor, '{', true)
await flushNextTick()
const errorMessageLabel = await screen.findByText('error_message')
await act(async () => {
fireEvent.click(errorMessageLabel)
})
await flushNextTick()
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_ERROR_MESSAGE_BLOCK_COMMAND, null)
// Re-open menu and select last_run (flat).
await setEditorText(editor, '{', true)
await flushNextTick()
const lastRunLabel = await screen.findByText('last_run')
await act(async () => {
fireEvent.click(lastRunLabel)
})
await flushNextTick()
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_LAST_RUN_BLOCK_COMMAND, null)
// Re-open menu and press Escape in the VarReferenceVars search input to exercise handleClose().
await setEditorText(editor, '{', true)
await flushNextTick()
const searchInput = await screen.findByPlaceholderText('workflow.common.searchVar')
await act(async () => {
fireEvent.keyDown(searchInput, { key: 'Escape' })
})
await flushNextTick()
expect(dispatchSpy).toHaveBeenCalledWith(KEY_ESCAPE_COMMAND, expect.any(KeyboardEvent))
// Re-open menu and select a flat var that is not handled by the special-case list.
// This covers the "no-op" path in the `isFlat` branch.
dispatchSpy.mockClear()
await setEditorText(editor, '{', true)
await flushNextTick()
const customFlatLabel = await screen.findByText('custom_flat')
await act(async () => {
fireEvent.click(customFlatLabel)
})
await flushNextTick()
expect(dispatchSpy).not.toHaveBeenCalled()
})
it('handles workflow variable selection for nested fields: sys.query, sys.files, and normal paths', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const user = userEvent.setup()
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
makeWorkflowVarNode('node-1', 'Node 1', [
makeWorkflowNodeVar('sys.query', VarType.object, [makeWorkflowNodeVar('q', VarType.string)]),
makeWorkflowNodeVar('sys.files', VarType.object, [makeWorkflowNodeVar('f', VarType.string)]),
makeWorkflowNodeVar('output', VarType.object, [makeWorkflowNodeVar('x', VarType.string)]),
]),
])
render((
<MinimalEditor
triggerString="{"
workflowVariableBlock={workflowVariableBlock}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
const dispatchSpy = vi.spyOn(editor, 'dispatchCommand')
const openPickerAndSelectField = async (variableTitle: string, fieldName: string) => {
await setEditorText(editor, '{', true)
await screen.findByPlaceholderText('workflow.common.searchVar')
await act(async () => { /* flush effects */ })
const label = document.querySelector(`[title="${variableTitle}"]`)
expect(label).not.toBeNull()
const row = (label as HTMLElement).parentElement?.parentElement
expect(row).not.toBeNull()
// `ahooks/useHover` listens for native `mouseenter` / `mouseleave`. `user.hover` triggers
// a realistic event sequence that reliably hits those listeners in JSDOM.
await user.hover(row as HTMLElement)
const field = await screen.findByText(fieldName)
fireEvent.mouseDown(field)
await user.unhover(row as HTMLElement)
}
await openPickerAndSelectField('sys.query', 'q')
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['sys.query'])
await waitFor(() => expect(readEditorText(editor)).not.toContain('{'))
await openPickerAndSelectField('sys.files', 'f')
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['sys.files'])
await waitFor(() => expect(readEditorText(editor)).not.toContain('{'))
await openPickerAndSelectField('output', 'x')
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'output', 'x'])
await waitFor(() => expect(readEditorText(editor)).not.toContain('{'))
})
it('skips removing the trigger when selection is null (needRemove is null) and still dispatches', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
{ nodeId: 'current', title: 'current_prompt', isFlat: true, vars: [makeWorkflowNodeVar('current', VarType.string)] },
])
render((
<MinimalEditor
triggerString="{"
workflowVariableBlock={workflowVariableBlock}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
const dispatchSpy = vi.spyOn(editor, 'dispatchCommand')
await setEditorText(editor, '{', true)
const currentLabel = await screen.findByText('current_prompt')
// Force selection to null and click within the same act() to avoid the typeahead UI unmounting
// before the click handler fires.
await act(async () => {
editor.update(() => {
$setSelection(null)
})
currentLabel.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_CURRENT_BLOCK_COMMAND, undefined)
await waitFor(() => expect(readEditorText(editor)).toContain('{'))
})
it('covers the anchor-ref guard when anchorElementRef.current becomes null before the scheduled callback runs', async () => {
// `@lexical/react` keeps `anchorElementRef.current` as a stable element reference, which means the
// "anchor is null" path is hard to reach through normal interactions in JSDOM.
//
// To reach 100% branch coverage for `index.tsx`, we:
// 1) Pause timers so the scheduled callback doesn't run immediately.
// 2) Find the `useRef` hook object used by LexicalTypeaheadMenuPlugin that points at `#typeahead-menu`.
// 3) Set that ref's `.current = null` before advancing timers.
//
// This avoids mocking third-party modules while still exercising the guard.
vi.useFakeTimers()
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="{"
contextBlock={makeContextBlock()}
captures={captures}
/>
))
await act(async () => { /* flush effects */ })
expect(captures.editor).not.toBeNull()
const editor = captures.editor as LexicalEditor
await setEditorText(editor, '{', true)
const typeaheadMenu = document.getElementById('typeahead-menu')
expect(typeaheadMenu).not.toBeNull()
const ce = screen.getByTestId(CONTENT_EDITABLE_TEST_ID)
const fiber = getReactFiberFromDom(ce)
expect(fiber).not.toBeNull()
const root = (() => {
let cur = fiber as ReactFiber
while (cur.return)
cur = cur.return
return cur
})()
const anchorRef = findHookRefPointingToElement(root, typeaheadMenu as Element)
expect(anchorRef).not.toBeNull()
anchorRef!.current = null
await act(async () => {
vi.runOnlyPendingTimers()
})
vi.useRealTimers()
})
it('renders the workflow-variable divider when workflowVariableBlock is shown and options are non-empty', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
makeWorkflowVarNode('node-1', 'Node 1', [
makeWorkflowNodeVar('output', VarType.string),
]),
])
render((
<MinimalEditor
triggerString="{"
workflowVariableBlock={workflowVariableBlock}
contextBlock={makeContextBlock()}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '{', true)
// Both sections are present.
expect(await screen.findByPlaceholderText('workflow.common.searchVar')).toBeInTheDocument()
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
// With a single option group, the only divider should be the workflow-var/options separator.
expect(document.querySelectorAll('.bg-divider-subtle')).toHaveLength(1)
})
})

View File

@@ -0,0 +1,123 @@
import { render } from '@testing-library/react'
import { Fragment } from 'react'
import { PickerBlockMenuOption } from './menu'
describe('PickerBlockMenuOption', () => {
// Define the render props type locally to match the component's internal type accurately
type MenuOptionRenderProps = {
isSelected: boolean
onSelect: () => void
onSetHighlight: () => void
queryString: string | null
}
const mockRender = vi.fn((props: MenuOptionRenderProps) => (
<div data-testid="menu-item">
{props.isSelected ? 'Selected' : 'Not Selected'}
{props.queryString && ` Query: ${props.queryString}`}
</div>
))
const mockOnSelect = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Constructor and Initialization', () => {
it('should correctly initialize with provided key and group', () => {
const option = new PickerBlockMenuOption({
key: 'test-key',
group: 'test-group',
render: mockRender,
})
// Check inheritance from MenuOption (key)
expect(option.key).toBe('test-key')
// Check custom property (group)
expect(option.group).toBe('test-group')
})
it('should initialize without group when not provided', () => {
const option = new PickerBlockMenuOption({
key: 'test-key-no-group',
render: mockRender,
})
expect(option.key).toBe('test-key-no-group')
expect(option.group).toBeUndefined()
})
})
describe('onSelectMenuOption', () => {
it('should call the provided onSelect callback', () => {
const option = new PickerBlockMenuOption({
key: 'test-key',
onSelect: mockOnSelect,
render: mockRender,
})
option.onSelectMenuOption()
expect(mockOnSelect).toHaveBeenCalledTimes(1)
})
it('should handle cases where onSelect is not provided (optional chaining)', () => {
const option = new PickerBlockMenuOption({
key: 'test-key',
render: mockRender,
})
// This covers the branch where this.data.onSelect is undefined
expect(() => option.onSelectMenuOption()).not.toThrow()
})
})
describe('renderMenuOption', () => {
it('should call the render function with correct props and return the element', () => {
const option = new PickerBlockMenuOption({
key: 'test-key',
render: mockRender,
})
const renderProps: MenuOptionRenderProps = {
isSelected: true,
onSelect: vi.fn(),
onSetHighlight: vi.fn(),
queryString: 'search-string',
}
// Execute renderMenuOption
const renderedElement = option.renderMenuOption(renderProps)
// Use RTL to verify the rendered output
const { getByTestId, getByText } = render(renderedElement)
// Assertions
expect(mockRender).toHaveBeenCalledWith(renderProps)
expect(getByTestId('menu-item')).toBeInTheDocument()
expect(getByText('Selected Query: search-string')).toBeInTheDocument()
})
it('should use Fragment with the correct key as the wrapper', () => {
// In React testing, verifying the key of a Fragment directly from the element can be tricky,
// but we can verify the structure and that it renders correctly.
const option = new PickerBlockMenuOption({
key: 'fragment-key',
render: mockRender,
})
const renderProps: MenuOptionRenderProps = {
isSelected: false,
onSelect: vi.fn(),
onSetHighlight: vi.fn(),
queryString: null,
}
const element = option.renderMenuOption(renderProps)
// Verify the element type is Fragment (rendered output doesn't show Fragment in DOM)
// but we can check the JSX structure if needed.
expect(element.type).toBe(Fragment)
expect(element.key).toBe('fragment-key')
})
})
})

View File

@@ -0,0 +1,131 @@
import { createEvent, fireEvent, render, screen } from '@testing-library/react'
import { PromptMenuItem } from './prompt-option'
describe('PromptMenuItem', () => {
const defaultProps = {
icon: <span data-testid="test-icon">icon</span>,
title: 'Test Option',
isSelected: false,
onClick: vi.fn(),
onMouseEnter: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render the icon and title correctly', () => {
render(<PromptMenuItem {...defaultProps} />)
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
expect(screen.getByText('Test Option')).toBeInTheDocument()
})
it('should have the correct display name', () => {
expect(PromptMenuItem.displayName).toBe('PromptMenuItem')
})
})
describe('Styling and States', () => {
it('should apply selected styles when isSelected is true and not disabled', () => {
const { container } = render(<PromptMenuItem {...defaultProps} isSelected={true} />)
const menuDiv = container.firstChild as HTMLElement
expect(menuDiv.className).toContain('!bg-state-base-hover')
expect(menuDiv.className).toContain('cursor-pointer')
expect(menuDiv.className).not.toContain('cursor-not-allowed')
})
it('should apply disabled styles and ignore isSelected when disabled is true', () => {
const { container } = render(
<PromptMenuItem {...defaultProps} isSelected={true} disabled={true} />,
)
const menuDiv = container.firstChild as HTMLElement
expect(menuDiv.className).toContain('cursor-not-allowed')
expect(menuDiv.className).toContain('opacity-30')
expect(menuDiv.className).not.toContain('!bg-state-base-hover')
})
it('should render with default styles when not selected and not disabled', () => {
const { container } = render(<PromptMenuItem {...defaultProps} />)
const menuDiv = container.firstChild as HTMLElement
expect(menuDiv.className).toContain('cursor-pointer')
expect(menuDiv.className).not.toContain('!bg-state-base-hover')
expect(menuDiv.className).not.toContain('cursor-not-allowed')
})
})
describe('Interactions', () => {
describe('onClick', () => {
it('should call onClick when not disabled', () => {
render(<PromptMenuItem {...defaultProps} />)
fireEvent.click(screen.getByText('Test Option'))
expect(defaultProps.onClick).toHaveBeenCalledTimes(1)
})
it('should NOT call onClick when disabled', () => {
render(<PromptMenuItem {...defaultProps} disabled={true} />)
fireEvent.click(screen.getByText('Test Option'))
expect(defaultProps.onClick).not.toHaveBeenCalled()
})
})
describe('onMouseEnter', () => {
it('should call onMouseEnter when not disabled', () => {
render(<PromptMenuItem {...defaultProps} />)
fireEvent.mouseEnter(screen.getByText('Test Option'))
expect(defaultProps.onMouseEnter).toHaveBeenCalledTimes(1)
})
it('should NOT call onMouseEnter when disabled', () => {
render(<PromptMenuItem {...defaultProps} disabled={true} />)
fireEvent.mouseEnter(screen.getByText('Test Option'))
expect(defaultProps.onMouseEnter).not.toHaveBeenCalled()
})
})
describe('onMouseDown', () => {
it('should prevent default and stop propagation', () => {
render(<PromptMenuItem {...defaultProps} />)
const element = screen.getByText('Test Option').parentElement!
// Use createEvent to properly spy on preventDefault and stopPropagation
const mouseDownEvent = createEvent.mouseDown(element)
const preventDefault = vi.fn()
const stopPropagation = vi.fn()
mouseDownEvent.preventDefault = preventDefault
mouseDownEvent.stopPropagation = stopPropagation
fireEvent(element, mouseDownEvent)
expect(preventDefault).toHaveBeenCalled()
expect(stopPropagation).toHaveBeenCalled()
})
})
})
describe('Reference Management', () => {
it('should call setRefElement with the div element if provided', () => {
const setRefElement = vi.fn()
const { container } = render(
<PromptMenuItem {...defaultProps} setRefElement={setRefElement} />,
)
const menuDiv = container.firstChild
expect(setRefElement).toHaveBeenCalledWith(menuDiv)
})
})
})

View File

@@ -0,0 +1,124 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { VariableMenuItem } from './variable-option'
describe('VariableMenuItem', () => {
const defaultProps = {
title: 'Variable Name',
isSelected: false,
queryString: null,
onClick: vi.fn(),
onMouseEnter: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render the title correctly', () => {
render(<VariableMenuItem {...defaultProps} />)
expect(screen.getByText('Variable Name')).toBeInTheDocument()
expect(screen.getByTitle('Variable Name')).toBeInTheDocument()
})
it('should render the icon when provided', () => {
render(
<VariableMenuItem
{...defaultProps}
icon={<span data-testid="test-icon">icon</span>}
/>,
)
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
})
it('should render the extra element when provided', () => {
render(
<VariableMenuItem
{...defaultProps}
extraElement={<span data-testid="extra">extra</span>}
/>,
)
expect(screen.getByTestId('extra')).toBeInTheDocument()
})
it('should apply selection styles when isSelected is true', () => {
const { container } = render(<VariableMenuItem {...defaultProps} isSelected={true} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('bg-state-base-hover')
})
})
describe('Highlighting Logic (queryString)', () => {
it('should not highlight anything when queryString is null', () => {
render(<VariableMenuItem {...defaultProps} queryString={null} />)
const titleContainer = screen.getByTitle('Variable Name')
// Ensure no highlighted span with text exists
expect(titleContainer.querySelector('.text-text-accent')?.textContent).toBe('')
})
it('should highlight matching text case-insensitively', () => {
render(<VariableMenuItem {...defaultProps} title="User Name" queryString="user" />)
const highlighted = screen.getByText('User')
expect(highlighted).toHaveClass('text-text-accent')
const titleContainer = screen.getByTitle('User Name')
expect(titleContainer.textContent).toBe('User Name')
})
it('should handle partial match in the middle of the string', () => {
render(<VariableMenuItem {...defaultProps} title="System Variable" queryString="tem" />)
const highlighted = screen.getByText('tem')
expect(highlighted).toHaveClass('text-text-accent')
const titleContainer = screen.getByTitle('System Variable')
expect(titleContainer.textContent).toBe('System Variable')
expect(titleContainer.innerHTML).toContain('Sys')
expect(titleContainer.innerHTML).toContain(' Variable')
})
it('should handle no match gracefully', () => {
render(<VariableMenuItem {...defaultProps} title="Variable" queryString="xyz" />)
expect(screen.getByText('Variable')).toBeInTheDocument()
const titleContainer = screen.getByTitle('Variable')
expect(titleContainer.querySelector('.text-text-accent')?.textContent).toBe('')
})
})
describe('Events', () => {
it('should trigger onClick when clicked', () => {
render(<VariableMenuItem {...defaultProps} />)
fireEvent.click(screen.getByTitle('Variable Name'))
expect(defaultProps.onClick).toHaveBeenCalledTimes(1)
})
it('should trigger onMouseEnter when mouse enters', () => {
render(<VariableMenuItem {...defaultProps} />)
fireEvent.mouseEnter(screen.getByTitle('Variable Name'))
expect(defaultProps.onMouseEnter).toHaveBeenCalledTimes(1)
})
it('should prevent default and stop propagation onMouseDown', () => {
render(<VariableMenuItem {...defaultProps} />)
const mousedownEvent = new MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
})
const preventDefaultSpy = vi.spyOn(mousedownEvent, 'preventDefault')
const stopPropagationSpy = vi.spyOn(mousedownEvent, 'stopPropagation')
fireEvent(screen.getByTitle('Variable Name'), mousedownEvent)
expect(preventDefaultSpy).toHaveBeenCalled()
expect(stopPropagationSpy).toHaveBeenCalled()
})
})
describe('Ref handling', () => {
it('should call setRefElement with the element', () => {
const setRefElement = vi.fn()
render(<VariableMenuItem {...defaultProps} setRefElement={setRefElement} />)
expect(setRefElement).toHaveBeenCalledWith(expect.any(HTMLDivElement))
})
})
})