mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 18:05:11 +00:00
test: add unit tests for prompt editor's component picker block plugin. (#32412)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user