test: add unit tests for base-components-part-5 (#32457)

Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
This commit is contained in:
Poojan 2026-02-25 19:43:10 +05:30 committed by GitHub
parent 0ac09127c7
commit b5f62b98f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 5046 additions and 0 deletions

View File

@ -0,0 +1,113 @@
import { SupportUploadFileTypes } from '../../workflow/types'
import {
checkHasContextBlock,
checkHasHistoryBlock,
checkHasQueryBlock,
checkHasRequestURLBlock,
CONTEXT_PLACEHOLDER_TEXT,
CURRENT_PLACEHOLDER_TEXT,
ERROR_MESSAGE_PLACEHOLDER_TEXT,
FILE_EXTS,
getInputVars,
HISTORY_PLACEHOLDER_TEXT,
LAST_RUN_PLACEHOLDER_TEXT,
PRE_PROMPT_PLACEHOLDER_TEXT,
QUERY_PLACEHOLDER_TEXT,
REQUEST_URL_PLACEHOLDER_TEXT,
UPDATE_DATASETS_EVENT_EMITTER,
UPDATE_HISTORY_EVENT_EMITTER,
} from './constants'
describe('prompt-editor constants', () => {
describe('placeholder and event constants', () => {
it('should expose expected placeholder constants', () => {
expect(CONTEXT_PLACEHOLDER_TEXT).toBe('{{#context#}}')
expect(HISTORY_PLACEHOLDER_TEXT).toBe('{{#histories#}}')
expect(QUERY_PLACEHOLDER_TEXT).toBe('{{#query#}}')
expect(REQUEST_URL_PLACEHOLDER_TEXT).toBe('{{#url#}}')
expect(CURRENT_PLACEHOLDER_TEXT).toBe('{{#current#}}')
expect(ERROR_MESSAGE_PLACEHOLDER_TEXT).toBe('{{#error_message#}}')
expect(LAST_RUN_PLACEHOLDER_TEXT).toBe('{{#last_run#}}')
expect(PRE_PROMPT_PLACEHOLDER_TEXT).toBe('{{#pre_prompt#}}')
})
it('should expose expected event emitter constants', () => {
expect(UPDATE_DATASETS_EVENT_EMITTER).toBe('prompt-editor-context-block-update-datasets')
expect(UPDATE_HISTORY_EVENT_EMITTER).toBe('prompt-editor-history-block-update-role')
})
})
describe('check block helpers', () => {
it('should detect context placeholder only when present', () => {
expect(checkHasContextBlock('')).toBe(false)
expect(checkHasContextBlock('plain text')).toBe(false)
expect(checkHasContextBlock(`before ${CONTEXT_PLACEHOLDER_TEXT} after`)).toBe(true)
})
it('should detect history placeholder only when present', () => {
expect(checkHasHistoryBlock('')).toBe(false)
expect(checkHasHistoryBlock('plain text')).toBe(false)
expect(checkHasHistoryBlock(`before ${HISTORY_PLACEHOLDER_TEXT} after`)).toBe(true)
})
it('should detect query placeholder only when present', () => {
expect(checkHasQueryBlock('')).toBe(false)
expect(checkHasQueryBlock('plain text')).toBe(false)
expect(checkHasQueryBlock(`before ${QUERY_PLACEHOLDER_TEXT} after`)).toBe(true)
})
it('should detect request url placeholder only when present', () => {
expect(checkHasRequestURLBlock('')).toBe(false)
expect(checkHasRequestURLBlock('plain text')).toBe(false)
expect(checkHasRequestURLBlock(`before ${REQUEST_URL_PLACEHOLDER_TEXT} after`)).toBe(true)
})
})
describe('getInputVars', () => {
it('should return empty array for invalid or empty input', () => {
expect(getInputVars('')).toEqual([])
expect(getInputVars('plain text without vars')).toEqual([])
expect(getInputVars(null as unknown as string)).toEqual([])
})
it('should ignore placeholders that are not input vars', () => {
const text = `a ${CONTEXT_PLACEHOLDER_TEXT} b ${QUERY_PLACEHOLDER_TEXT} c`
expect(getInputVars(text)).toEqual([])
})
it('should parse regular input vars with dotted selectors', () => {
const text = 'value {{#node123.result.answer#}} and {{#abc.def#}}'
expect(getInputVars(text)).toEqual([
['node123', 'result', 'answer'],
['abc', 'def'],
])
})
it('should strip numeric node id for sys selector vars', () => {
const text = 'value {{#1711617514996.sys.query#}}'
expect(getInputVars(text)).toEqual([
['sys', 'query'],
])
})
it('should keep selector unchanged when sys prefix is not numeric id', () => {
const text = 'value {{#abc.sys.query#}}'
expect(getInputVars(text)).toEqual([
['abc', 'sys', 'query'],
])
})
})
describe('file extension map', () => {
it('should expose expected file extensions for each supported type', () => {
expect(FILE_EXTS[SupportUploadFileTypes.image]).toContain('PNG')
expect(FILE_EXTS[SupportUploadFileTypes.document]).toContain('PDF')
expect(FILE_EXTS[SupportUploadFileTypes.audio]).toContain('MP3')
expect(FILE_EXTS[SupportUploadFileTypes.video]).toContain('MP4')
})
})
})

View File

@ -0,0 +1,110 @@
import type { RefObject } from 'react'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { CurrentBlockNode, DELETE_CURRENT_BLOCK_COMMAND } from '.'
import { CustomTextNode } from '../custom-text/node'
import CurrentBlockComponent from './component'
const { mockUseSelectOrDelete } = vi.hoisted(() => ({
mockUseSelectOrDelete: vi.fn(),
}))
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
}))
const createHookReturn = (isSelected: boolean): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, isSelected]
}
const renderComponent = (props?: {
isSelected?: boolean
withNode?: boolean
onParentClick?: () => void
generatorType?: GeneratorType
}) => {
const {
isSelected = false,
withNode = true,
onParentClick,
generatorType = GeneratorType.prompt,
} = props ?? {}
mockUseSelectOrDelete.mockReturnValue(createHookReturn(isSelected))
return render(
<LexicalComposer
initialConfig={{
namespace: 'current-block-component-test',
onError: (error: Error) => {
throw error
},
nodes: withNode ? [CustomTextNode, CurrentBlockNode] : [CustomTextNode],
}}
>
<div onClick={onParentClick}>
<CurrentBlockComponent nodeKey="current-node" generatorType={generatorType} />
</div>
</LexicalComposer>,
)
}
describe('CurrentBlockComponent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render prompt label and selected classes when generator type is prompt and selected', () => {
const { container } = renderComponent({
generatorType: GeneratorType.prompt,
isSelected: true,
})
const wrapper = container.querySelector('.group\\/wrap')
expect(screen.getByText('current_prompt')).toBeInTheDocument()
expect(wrapper).toHaveClass('border-state-accent-solid')
expect(wrapper).toHaveClass('bg-state-accent-hover')
})
it('should render code label and default classes when generator type is code and not selected', () => {
const { container } = renderComponent({
generatorType: GeneratorType.code,
isSelected: false,
})
const wrapper = container.querySelector('.group\\/wrap')
expect(screen.getByText('current_code')).toBeInTheDocument()
expect(wrapper).toHaveClass('border-components-panel-border-subtle')
expect(wrapper).toHaveClass('bg-components-badge-white-to-dark')
})
it('should wire useSelectOrDelete with node key and delete command', () => {
renderComponent({ generatorType: GeneratorType.prompt })
expect(mockUseSelectOrDelete).toHaveBeenCalledWith('current-node', DELETE_CURRENT_BLOCK_COMMAND)
})
})
describe('Interactions', () => {
it('should stop click propagation from wrapper', async () => {
const user = userEvent.setup()
const onParentClick = vi.fn()
renderComponent({ onParentClick, generatorType: GeneratorType.prompt })
await user.click(screen.getByText('current_prompt'))
expect(onParentClick).not.toHaveBeenCalled()
})
})
describe('Node registration guard', () => {
it('should throw when current block node is not registered on editor', () => {
expect(() => {
renderComponent({ withNode: false })
}).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
})
})
})

View File

@ -0,0 +1,118 @@
import type { LexicalEditor } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, waitFor } from '@testing-library/react'
import { $nodesOfType } from 'lexical'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { CURRENT_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readEditorStateValue,
renderLexicalEditor,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
import CurrentBlockReplacementBlock from './current-block-replacement-block'
import { CurrentBlockNode } from './index'
const renderReplacementPlugin = (props?: {
generatorType?: GeneratorType
onInsert?: () => void
}) => {
const {
generatorType = GeneratorType.prompt,
onInsert,
} = props ?? {}
return renderLexicalEditor({
namespace: 'current-block-replacement-plugin-test',
nodes: [CustomTextNode, CurrentBlockNode],
children: (
<CurrentBlockReplacementBlock generatorType={generatorType} onInsert={onInsert} />
),
})
}
const getCurrentNodeGeneratorTypes = (editor: LexicalEditor) => {
return readEditorStateValue(editor, () => {
return $nodesOfType(CurrentBlockNode).map(node => node.getGeneratorType())
})
}
describe('CurrentBlockReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Replacement behavior', () => {
it('should replace placeholder text and call onInsert when placeholder exists', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({
generatorType: GeneratorType.prompt,
onInsert,
})
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, `prefix ${CURRENT_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, CurrentBlockNode)).toBe(1)
})
expect(getCurrentNodeGeneratorTypes(editor)).toEqual([GeneratorType.prompt])
expect(onInsert).toHaveBeenCalledTimes(1)
})
it('should not replace text when placeholder is missing', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({
generatorType: GeneratorType.prompt,
onInsert,
})
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'plain text without current placeholder', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, CurrentBlockNode)).toBe(0)
})
expect(onInsert).not.toHaveBeenCalled()
})
it('should replace placeholder without onInsert callback', async () => {
const { getEditor } = renderReplacementPlugin({
generatorType: GeneratorType.code,
})
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, CURRENT_PLACEHOLDER_TEXT, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, CurrentBlockNode)).toBe(1)
})
expect(getCurrentNodeGeneratorTypes(editor)).toEqual([GeneratorType.code])
})
})
describe('Node registration guard', () => {
it('should throw when current block node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'current-block-replacement-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<CurrentBlockReplacementBlock generatorType={GeneratorType.prompt} />
</LexicalComposer>,
)
}).toThrow('CurrentBlockNodePlugin: CurrentBlockNode not registered on editor')
})
})
})

View File

@ -0,0 +1,168 @@
import type { LexicalEditor } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { $nodesOfType } from 'lexical'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { CURRENT_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readEditorStateValue,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import {
CurrentBlock,
CurrentBlockNode,
DELETE_CURRENT_BLOCK_COMMAND,
INSERT_CURRENT_BLOCK_COMMAND,
} from './index'
const renderCurrentBlock = (props?: {
generatorType?: GeneratorType
onInsert?: () => void
onDelete?: () => void
}) => {
const {
generatorType = GeneratorType.prompt,
onInsert,
onDelete,
} = props ?? {}
return renderLexicalEditor({
namespace: 'current-block-plugin-test',
nodes: [CustomTextNode, CurrentBlockNode],
children: (
<CurrentBlock generatorType={generatorType} onInsert={onInsert} onDelete={onDelete} />
),
})
}
const getCurrentNodeGeneratorTypes = (editor: LexicalEditor) => {
return readEditorStateValue(editor, () => {
return $nodesOfType(CurrentBlockNode).map(node => node.getGeneratorType())
})
}
describe('CurrentBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Command handling', () => {
it('should insert current block and call onInsert when insert command is dispatched', async () => {
const onInsert = vi.fn()
const { getEditor } = renderCurrentBlock({
generatorType: GeneratorType.prompt,
onInsert,
})
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_CURRENT_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onInsert).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(CURRENT_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, CurrentBlockNode)).toBe(1)
expect(getCurrentNodeGeneratorTypes(editor)).toEqual([GeneratorType.prompt])
})
it('should insert current block without onInsert callback', async () => {
const { getEditor } = renderCurrentBlock({
generatorType: GeneratorType.code,
})
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_CURRENT_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(CURRENT_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, CurrentBlockNode)).toBe(1)
expect(getCurrentNodeGeneratorTypes(editor)).toEqual([GeneratorType.code])
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderCurrentBlock({ onDelete })
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_CURRENT_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should handle delete command without onDelete callback', async () => {
const { getEditor } = renderCurrentBlock()
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_CURRENT_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle', () => {
it('should unregister insert and delete commands when unmounted', async () => {
const { getEditor, unmount } = renderCurrentBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let insertHandled = true
let deleteHandled = true
act(() => {
insertHandled = editor.dispatchCommand(INSERT_CURRENT_BLOCK_COMMAND, undefined)
deleteHandled = editor.dispatchCommand(DELETE_CURRENT_BLOCK_COMMAND, undefined)
})
expect(insertHandled).toBe(false)
expect(deleteHandled).toBe(false)
})
it('should throw when current block node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'current-block-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<CurrentBlock generatorType={GeneratorType.prompt} />
</LexicalComposer>,
)
}).toThrow('CURRENTBlockPlugin: CURRENTBlock not registered on editor')
})
})
})

View File

@ -0,0 +1,195 @@
import { act } from '@testing-library/react'
import {
$createParagraphNode,
$getRoot,
} from 'lexical'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
} from '../test-helpers'
import CurrentBlockComponent from './component'
import {
$createCurrentBlockNode,
$isCurrentBlockNode,
CurrentBlockNode,
} from './node'
const createTestEditor = () => {
return createLexicalTestEditor('current-block-node-test', [CurrentBlockNode])
}
const appendNodeToRoot = (node: CurrentBlockNode) => {
const paragraph = $createParagraphNode()
paragraph.append(node)
$getRoot().append(paragraph)
}
describe('CurrentBlockNode', () => {
describe('Node metadata', () => {
it('should expose current block type, inline behavior, and text content', () => {
const editor = createTestEditor()
let isInline = false
let textContent = ''
let generatorType!: GeneratorType
act(() => {
editor.update(() => {
const node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
isInline = node.isInline()
textContent = node.getTextContent()
generatorType = node.getGeneratorType()
})
})
expect(CurrentBlockNode.getType()).toBe('current-block')
expect(isInline).toBe(true)
expect(textContent).toBe('{{#current#}}')
expect(generatorType).toBe(GeneratorType.prompt)
})
it('should clone with the same key and generator type', () => {
const editor = createTestEditor()
let originalKey = ''
let clonedKey = ''
let clonedGeneratorType!: GeneratorType
act(() => {
editor.update(() => {
const node = $createCurrentBlockNode(GeneratorType.code)
appendNodeToRoot(node)
const cloned = CurrentBlockNode.clone(node)
originalKey = node.getKey()
clonedKey = cloned.getKey()
clonedGeneratorType = cloned.getGeneratorType()
})
})
expect(clonedKey).toBe(originalKey)
expect(clonedGeneratorType).toBe(GeneratorType.code)
})
})
describe('DOM behavior', () => {
it('should create inline wrapper DOM with expected classes', () => {
const editor = createTestEditor()
let node!: CurrentBlockNode
act(() => {
editor.update(() => {
node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
})
})
const dom = node.createDOM()
expectInlineWrapperDom(dom)
})
it('should not update DOM', () => {
const editor = createTestEditor()
let node!: CurrentBlockNode
act(() => {
editor.update(() => {
node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
})
})
expect(node.updateDOM()).toBe(false)
})
})
describe('Serialization and decoration', () => {
it('should export and import JSON with generator type', () => {
const editor = createTestEditor()
let serialized!: ReturnType<CurrentBlockNode['exportJSON']>
let importedSerialized!: ReturnType<CurrentBlockNode['exportJSON']>
act(() => {
editor.update(() => {
const node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
serialized = node.exportJSON()
const imported = CurrentBlockNode.importJSON({
type: 'current-block',
version: 1,
generatorType: GeneratorType.code,
})
appendNodeToRoot(imported)
importedSerialized = imported.exportJSON()
})
})
expect(serialized).toEqual({
type: 'current-block',
version: 1,
generatorType: GeneratorType.prompt,
})
expect(importedSerialized).toEqual({
type: 'current-block',
version: 1,
generatorType: GeneratorType.code,
})
})
it('should decorate with current block component and props', () => {
const editor = createTestEditor()
let nodeKey = ''
let element!: ReturnType<CurrentBlockNode['decorate']>
act(() => {
editor.update(() => {
const node = $createCurrentBlockNode(GeneratorType.code)
appendNodeToRoot(node)
nodeKey = node.getKey()
element = node.decorate()
})
})
expect(element.type).toBe(CurrentBlockComponent)
expect(element.props).toEqual({
nodeKey,
generatorType: GeneratorType.code,
})
})
})
describe('Helpers', () => {
it('should create current block node instance from factory', () => {
const editor = createTestEditor()
let node!: CurrentBlockNode
act(() => {
editor.update(() => {
node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
})
})
expect(node).toBeInstanceOf(CurrentBlockNode)
})
it('should identify current block nodes using type guard helper', () => {
const editor = createTestEditor()
let node!: CurrentBlockNode
act(() => {
editor.update(() => {
node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
})
})
expect($isCurrentBlockNode(node)).toBe(true)
expect($isCurrentBlockNode(null)).toBe(false)
expect($isCurrentBlockNode(undefined)).toBe(false)
})
})
})

View File

@ -0,0 +1,205 @@
import type { Dispatch, RefObject, SetStateAction } from 'react'
import type { RoleName } from './index'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UPDATE_HISTORY_EVENT_EMITTER } from '../../constants'
import HistoryBlockComponent from './component'
import { DELETE_HISTORY_BLOCK_COMMAND } from './index'
type HistoryEventPayload = {
type?: string
payload?: RoleName
}
type HistorySubscriptionHandler = (payload: HistoryEventPayload) => void
const { mockUseSelectOrDelete, mockUseTrigger, mockUseEventEmitterContextContext } = vi.hoisted(() => ({
mockUseSelectOrDelete: vi.fn(),
mockUseTrigger: vi.fn(),
mockUseEventEmitterContextContext: vi.fn(),
}))
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
useTrigger: (...args: unknown[]) => mockUseTrigger(...args),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
}))
const createRoleName = (overrides?: Partial<RoleName>): RoleName => ({
user: 'user-role',
assistant: 'assistant-role',
...overrides,
})
const createSelectHookReturn = (isSelected: boolean): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, isSelected]
}
const createTriggerHookReturn = (
open: boolean,
setOpen: Dispatch<SetStateAction<boolean>> = vi.fn() as unknown as Dispatch<SetStateAction<boolean>>,
): [RefObject<HTMLDivElement | null>, boolean, Dispatch<SetStateAction<boolean>>] => {
return [{ current: null }, open, setOpen]
}
describe('HistoryBlockComponent', () => {
let subscribedHandler: HistorySubscriptionHandler | null
beforeEach(() => {
vi.clearAllMocks()
subscribedHandler = null
mockUseSelectOrDelete.mockReturnValue(createSelectHookReturn(false))
mockUseTrigger.mockReturnValue(createTriggerHookReturn(false))
const subscribeToHistoryEvents = (handler: HistorySubscriptionHandler) => {
subscribedHandler = handler
}
mockUseEventEmitterContextContext.mockReturnValue({
eventEmitter: {
useSubscription: subscribeToHistoryEvents,
},
})
})
it('should render title and register select or delete hook with node key', () => {
render(
<HistoryBlockComponent
nodeKey="history-node-1"
onEditRole={vi.fn()}
/>,
)
expect(mockUseSelectOrDelete).toHaveBeenCalledWith('history-node-1', DELETE_HISTORY_BLOCK_COMMAND)
expect(screen.getByText('common.promptEditor.history.item.title')).toBeInTheDocument()
})
it('should apply selected and opened classes when selected and popup is open', () => {
mockUseSelectOrDelete.mockReturnValue(createSelectHookReturn(true))
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
const { container } = render(
<HistoryBlockComponent
nodeKey="history-node-2"
onEditRole={vi.fn()}
/>,
)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('!border-[#F670C7]')
expect(wrapper).toHaveClass('bg-[#FCE7F6]')
})
it('should render modal content when popup is open', () => {
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
render(
<HistoryBlockComponent
nodeKey="history-node-3"
roleName={createRoleName()}
onEditRole={vi.fn()}
/>,
)
expect(screen.getByText('user-role')).toBeInTheDocument()
expect(screen.getByText('assistant-role')).toBeInTheDocument()
expect(screen.getByText('common.promptEditor.history.modal.user')).toBeInTheDocument()
expect(screen.getByText('common.promptEditor.history.modal.assistant')).toBeInTheDocument()
})
it('should call onEditRole when edit action is clicked', async () => {
const user = userEvent.setup()
const onEditRole = vi.fn()
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
render(
<HistoryBlockComponent
nodeKey="history-node-4"
roleName={createRoleName()}
onEditRole={onEditRole}
/>,
)
await user.click(screen.getByText('common.promptEditor.history.modal.edit'))
expect(onEditRole).toHaveBeenCalledTimes(1)
})
it('should update local role names when update history event is received', () => {
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
render(
<HistoryBlockComponent
nodeKey="history-node-5"
roleName={createRoleName({
user: 'old-user',
assistant: 'old-assistant',
})}
onEditRole={vi.fn()}
/>,
)
expect(screen.getByText('old-user')).toBeInTheDocument()
expect(screen.getByText('old-assistant')).toBeInTheDocument()
expect(subscribedHandler).not.toBeNull()
act(() => {
subscribedHandler?.({
type: UPDATE_HISTORY_EVENT_EMITTER,
payload: {
user: 'new-user',
assistant: 'new-assistant',
},
})
})
expect(screen.getByText('new-user')).toBeInTheDocument()
expect(screen.getByText('new-assistant')).toBeInTheDocument()
})
it('should ignore non history update events from event emitter', () => {
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
render(
<HistoryBlockComponent
nodeKey="history-node-6"
roleName={createRoleName({
user: 'kept-user',
assistant: 'kept-assistant',
})}
onEditRole={vi.fn()}
/>,
)
expect(subscribedHandler).not.toBeNull()
act(() => {
subscribedHandler?.({
type: 'other-event',
payload: {
user: 'updated-user',
assistant: 'updated-assistant',
},
})
})
expect(screen.getByText('kept-user')).toBeInTheDocument()
expect(screen.getByText('kept-assistant')).toBeInTheDocument()
})
it('should render when event emitter is unavailable', () => {
mockUseEventEmitterContextContext.mockReturnValue({
eventEmitter: undefined,
})
render(
<HistoryBlockComponent
nodeKey="history-node-7"
onEditRole={vi.fn()}
/>,
)
expect(screen.getByText('common.promptEditor.history.item.title')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,118 @@
import type { LexicalEditor } from 'lexical'
import type { RoleName } from './index'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, waitFor } from '@testing-library/react'
import { $nodesOfType } from 'lexical'
import { HISTORY_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readEditorStateValue,
renderLexicalEditor,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
import HistoryBlockReplacementBlock from './history-block-replacement-block'
import { HistoryBlockNode } from './node'
const createRoleName = (overrides?: Partial<RoleName>): RoleName => ({
user: 'user-role',
assistant: 'assistant-role',
...overrides,
})
const renderReplacementPlugin = (props?: {
history?: RoleName
onEditRole?: () => void
onInsert?: () => void
}) => {
return renderLexicalEditor({
namespace: 'history-block-replacement-plugin-test',
nodes: [CustomTextNode, HistoryBlockNode],
children: (
<HistoryBlockReplacementBlock
history={props?.history}
onEditRole={props?.onEditRole}
onInsert={props?.onInsert}
/>
),
})
}
const getFirstNodeRoleName = (editor: LexicalEditor) => {
return readEditorStateValue(editor, () => {
const node = $nodesOfType(HistoryBlockNode)[0]
return node?.getRoleName() ?? null
})
}
describe('HistoryBlockReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should replace history placeholder and call onInsert', async () => {
const onInsert = vi.fn()
const history = createRoleName()
const onEditRole = vi.fn()
const { getEditor } = renderReplacementPlugin({
onInsert,
history,
onEditRole,
})
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, `prefix ${HISTORY_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, HistoryBlockNode)).toBe(1)
})
expect(onInsert).toHaveBeenCalledTimes(1)
expect(getFirstNodeRoleName(editor)).toEqual(history)
})
it('should not replace text when history placeholder is absent', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'plain text without history placeholder', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, HistoryBlockNode)).toBe(0)
})
expect(onInsert).not.toHaveBeenCalled()
})
it('should replace history placeholder without onInsert callback', async () => {
const { getEditor } = renderReplacementPlugin()
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, HISTORY_PLACEHOLDER_TEXT, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, HistoryBlockNode)).toBe(1)
})
})
it('should throw when history node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'history-block-replacement-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<HistoryBlockReplacementBlock />
</LexicalComposer>,
)
}).toThrow('HistoryBlockNodePlugin: HistoryBlockNode not registered on editor')
})
})

View File

@ -0,0 +1,172 @@
import type { LexicalEditor } from 'lexical'
import type { RoleName } from './index'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { $nodesOfType } from 'lexical'
import { HISTORY_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readEditorStateValue,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import {
DELETE_HISTORY_BLOCK_COMMAND,
HistoryBlock,
HistoryBlockNode,
INSERT_HISTORY_BLOCK_COMMAND,
} from './index'
const createRoleName = (overrides?: Partial<RoleName>): RoleName => ({
user: 'user-role',
assistant: 'assistant-role',
...overrides,
})
const renderHistoryBlock = (props?: {
history?: RoleName
onEditRole?: () => void
onInsert?: () => void
onDelete?: () => void
}) => {
return renderLexicalEditor({
namespace: 'history-block-plugin-test',
nodes: [CustomTextNode, HistoryBlockNode],
children: (
<HistoryBlock
history={props?.history}
onEditRole={props?.onEditRole}
onInsert={props?.onInsert}
onDelete={props?.onDelete}
/>
),
})
}
const getFirstNodeRoleName = (editor: LexicalEditor) => {
return readEditorStateValue(editor, () => {
const node = $nodesOfType(HistoryBlockNode)[0]
return node?.getRoleName() ?? null
})
}
describe('HistoryBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should insert history block and call onInsert when insert command is dispatched', async () => {
const onInsert = vi.fn()
const onEditRole = vi.fn()
const history = createRoleName()
const { getEditor } = renderHistoryBlock({ onInsert, onEditRole, history })
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onInsert).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(HISTORY_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, HistoryBlockNode)).toBe(1)
expect(getFirstNodeRoleName(editor)).toEqual(history)
})
it('should insert history block with default props when insert command is dispatched', async () => {
const { getEditor } = renderHistoryBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(HISTORY_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, HistoryBlockNode)).toBe(1)
expect(getFirstNodeRoleName(editor)).toEqual({
user: '',
assistant: '',
})
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderHistoryBlock({ onDelete })
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_HISTORY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should handle delete command without onDelete callback', async () => {
const { getEditor } = renderHistoryBlock()
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_HISTORY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
})
it('should unregister insert and delete commands when unmounted', async () => {
const { getEditor, unmount } = renderHistoryBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let insertHandled = true
let deleteHandled = true
act(() => {
insertHandled = editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined)
deleteHandled = editor.dispatchCommand(DELETE_HISTORY_BLOCK_COMMAND, undefined)
})
expect(insertHandled).toBe(false)
expect(deleteHandled).toBe(false)
})
it('should throw when history node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'history-block-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<HistoryBlock />
</LexicalComposer>,
)
}).toThrow('HistoryBlockPlugin: HistoryBlock not registered on editor')
})
})

View File

@ -0,0 +1,168 @@
import type { SerializedNode as SerializedHistoryBlockNode } from './node'
import { act } from '@testing-library/react'
import { $getNodeByKey, $getRoot } from 'lexical'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
} from '../test-helpers'
import HistoryBlockComponent from './component'
import {
$createHistoryBlockNode,
$isHistoryBlockNode,
HistoryBlockNode,
} from './node'
const createRoleName = (overrides?: { user?: string, assistant?: string }) => ({
user: 'user-role',
assistant: 'assistant-role',
...overrides,
})
const createTestEditor = () => {
return createLexicalTestEditor('history-block-node-test', [HistoryBlockNode])
}
const createNodeInEditor = () => {
const editor = createTestEditor()
const roleName = createRoleName()
const onEditRole = vi.fn()
let node!: HistoryBlockNode
let nodeKey = ''
act(() => {
editor.update(() => {
node = $createHistoryBlockNode(roleName, onEditRole)
$getRoot().append(node)
nodeKey = node.getKey()
})
})
return { editor, node, nodeKey, roleName, onEditRole }
}
describe('HistoryBlockNode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expose history block type and inline behavior', () => {
const { node } = createNodeInEditor()
expect(HistoryBlockNode.getType()).toBe('history-block')
expect(node.isInline()).toBe(true)
expect(node.getTextContent()).toBe('{{#histories#}}')
})
it('should clone into a new history block node with same role and handler', () => {
const { editor, node, nodeKey } = createNodeInEditor()
let cloned!: HistoryBlockNode
act(() => {
editor.update(() => {
const currentNode = $getNodeByKey(nodeKey) as HistoryBlockNode
cloned = HistoryBlockNode.clone(currentNode)
})
})
expect(cloned).toBeInstanceOf(HistoryBlockNode)
expect(cloned).not.toBe(node)
})
it('should create inline wrapper DOM with expected classes', () => {
const { node } = createNodeInEditor()
const dom = node.createDOM()
expectInlineWrapperDom(dom)
})
it('should not update DOM', () => {
const { node } = createNodeInEditor()
expect(node.updateDOM()).toBe(false)
})
it('should decorate with history block component and expected props', () => {
const { editor, nodeKey, roleName, onEditRole } = createNodeInEditor()
let element!: React.JSX.Element
act(() => {
editor.update(() => {
const currentNode = $getNodeByKey(nodeKey) as HistoryBlockNode
element = currentNode.decorate()
})
})
expect(element.type).toBe(HistoryBlockComponent)
expect(element.props.nodeKey).toBe(nodeKey)
expect(element.props.roleName).toEqual(roleName)
expect(element.props.onEditRole).toBe(onEditRole)
})
it('should export and import JSON with role and edit handler', () => {
const { editor, nodeKey, roleName, onEditRole } = createNodeInEditor()
let serialized!: SerializedHistoryBlockNode
let imported!: HistoryBlockNode
let importedKey = ''
const payload: SerializedHistoryBlockNode = {
type: 'history-block',
version: 1,
roleName,
onEditRole,
}
act(() => {
editor.update(() => {
const currentNode = $getNodeByKey(nodeKey) as HistoryBlockNode
serialized = currentNode.exportJSON()
})
})
act(() => {
editor.update(() => {
imported = HistoryBlockNode.importJSON(payload)
$getRoot().append(imported)
importedKey = imported.getKey()
expect(imported.getRoleName()).toEqual(roleName)
expect(imported.getOnEditRole()).toBe(onEditRole)
})
})
expect(serialized.type).toBe('history-block')
expect(serialized.version).toBe(1)
expect(serialized.roleName).toEqual(roleName)
expect(typeof serialized.onEditRole).toBe('function')
expect(imported).toBeInstanceOf(HistoryBlockNode)
expect(importedKey).not.toBe('')
})
it('should identify history block nodes using type guard', () => {
const { node } = createNodeInEditor()
expect($isHistoryBlockNode(node)).toBe(true)
expect($isHistoryBlockNode(null)).toBe(false)
expect($isHistoryBlockNode(undefined)).toBe(false)
})
it('should create a history block node instance from factory', () => {
const editor = createTestEditor()
const roleName = createRoleName({
user: 'custom-user',
assistant: 'custom-assistant',
})
const onEditRole = vi.fn()
let node!: HistoryBlockNode
act(() => {
editor.update(() => {
node = $createHistoryBlockNode(roleName, onEditRole)
expect(node.getRoleName()).toEqual(roleName)
expect(node.getOnEditRole()).toBe(onEditRole)
})
})
expect(node).toBeInstanceOf(HistoryBlockNode)
})
})

View File

@ -0,0 +1,153 @@
import type { RefObject } from 'react'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType } from '@/app/components/workflow/types'
import HITLInputComponent from './component'
const { mockUseSelectOrDelete } = vi.hoisted(() => ({
mockUseSelectOrDelete: vi.fn(),
}))
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
}))
vi.mock('./component-ui', () => ({
default: ({ formInput, onChange }: { formInput?: FormInputItem, onChange: (payload: FormInputItem) => void }) => {
const basePayload: FormInputItem = formInput ?? {
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
}
return (
<div>
<button
type="button"
onClick={() => onChange(basePayload)}
>
emit-same-name
</button>
<button
type="button"
onClick={() => onChange({
...basePayload,
output_variable_name: 'renamed_name',
})}
>
emit-rename
</button>
<button
type="button"
onClick={() => onChange({
...basePayload,
default: {
type: 'constant',
selector: [],
value: 'updated',
},
})}
>
emit-update
</button>
</div>
)
},
}))
const createHookReturn = (): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, false]
}
const createInput = (overrides?: Partial<FormInputItem>): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
...overrides,
})
describe('HITLInputComponent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSelectOrDelete.mockReturnValue(createHookReturn())
})
it('should append payload when matching form input does not exist', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HITLInputComponent
nodeKey="node-key-1"
nodeId="node-1"
varName="user_name"
formInputs={[]}
onChange={onChange}
onRename={vi.fn()}
onRemove={vi.fn()}
workflowNodesMap={{}}
/>,
)
await user.click(screen.getByRole('button', { name: 'emit-same-name' }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0]).toHaveLength(1)
expect(onChange.mock.calls[0][0][0].output_variable_name).toBe('user_name')
})
it('should replace payload when variable name is renamed', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HITLInputComponent
nodeKey="node-key-2"
nodeId="node-2"
varName="user_name"
formInputs={[createInput()]}
onChange={onChange}
onRename={vi.fn()}
onRemove={vi.fn()}
workflowNodesMap={{}}
/>,
)
await user.click(screen.getByRole('button', { name: 'emit-rename' }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0][0].output_variable_name).toBe('renamed_name')
})
it('should update existing payload when variable name stays the same', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HITLInputComponent
nodeKey="node-key-3"
nodeId="node-3"
varName="user_name"
formInputs={[createInput()]}
onChange={onChange}
onRename={vi.fn()}
onRemove={vi.fn()}
workflowNodesMap={{}}
/>,
)
await user.click(screen.getByRole('button', { name: 'emit-update' }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0][0].default.value).toBe('updated')
expect(onChange.mock.calls[0][0][0].output_variable_name).toBe('user_name')
})
})

View File

@ -0,0 +1,250 @@
import type { LexicalEditor } from 'lexical'
import type { GetVarType } from '../../types'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { NodeOutPutVar, Var } from '@/app/components/workflow/types'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, waitFor } from '@testing-library/react'
import { $nodesOfType } from 'lexical'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import {
BlockEnum,
InputVarType,
} from '@/app/components/workflow/types'
import { CustomTextNode } from '../custom-text/node'
import {
getNodesByType,
readEditorStateValue,
renderLexicalEditor,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
import HITLInputReplacementBlock from './hitl-input-block-replacement-block'
import { HITLInputNode } from './node'
const createWorkflowNodesMap = () => ({
'node-1': {
title: 'Start Node',
type: BlockEnum.Start,
height: 100,
width: 120,
position: { x: 0, y: 0 },
},
})
const createFormInput = (): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
})
const createVariables = (): NodeOutPutVar[] => {
return [
{
nodeId: 'env',
title: 'Env',
vars: [{ variable: 'env.api_key', type: 'string' } as Var],
},
{
nodeId: 'conversation',
title: 'Conversation',
vars: [{ variable: 'conversation.user_id', type: 'number' } as Var],
},
{
nodeId: 'rag',
title: 'RAG',
vars: [{ variable: 'rag.shared.file_name', type: 'string', isRagVariable: true } as Var],
},
{
nodeId: 'node-1',
title: 'Node 1',
vars: [
{ variable: 'node-1.ignore_me', type: 'string', isRagVariable: false } as Var,
{ variable: 'node-1.doc_name', type: 'string', isRagVariable: true } as Var,
],
},
]
}
const renderReplacementPlugin = (props?: {
variables?: NodeOutPutVar[]
readonly?: boolean
getVarType?: GetVarType
formInputs?: FormInputItem[] | null
}) => {
const formInputs = props?.formInputs === null ? undefined : (props?.formInputs ?? [createFormInput()])
return renderLexicalEditor({
namespace: 'hitl-input-replacement-plugin-test',
nodes: [CustomTextNode, HITLInputNode],
children: (
<HITLInputReplacementBlock
nodeId="node-1"
formInputs={formInputs}
onFormInputsChange={vi.fn()}
onFormInputItemRename={vi.fn()}
onFormInputItemRemove={vi.fn()}
workflowNodesMap={createWorkflowNodesMap()}
variables={props?.variables}
getVarType={props?.getVarType}
readonly={props?.readonly}
/>
),
})
}
type HITLInputNodeSnapshot = {
variableName: string
nodeId: string
getVarType: GetVarType | undefined
readonly: boolean
environmentVariables: Var[]
conversationVariables: Var[]
ragVariables: Var[]
formInputsLength: number
}
const readFirstHITLInputNodeSnapshot = (editor: LexicalEditor): HITLInputNodeSnapshot | null => {
return readEditorStateValue(editor, () => {
const node = $nodesOfType(HITLInputNode)[0]
if (!node)
return null
return {
variableName: node.getVariableName(),
nodeId: node.getNodeId(),
getVarType: node.getGetVarType(),
readonly: node.getReadonly(),
environmentVariables: node.getEnvironmentVariables(),
conversationVariables: node.getConversationVariables(),
ragVariables: node.getRagVariables(),
formInputsLength: node.getFormInputs().length,
}
})
}
describe('HITLInputReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Replacement behavior', () => {
it('should replace matched output token with hitl input node and map variables from all supported sources', async () => {
const getVarType: GetVarType = () => Type.string
const { getEditor } = renderReplacementPlugin({
variables: createVariables(),
readonly: true,
getVarType,
})
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'before {{#$output.user_name#}} after', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodesByType(editor, HITLInputNode)).toHaveLength(1)
})
const node = readFirstHITLInputNodeSnapshot(editor)
expect(node).not.toBeNull()
if (!node)
throw new Error('Expected HITLInputNode snapshot')
expect(node.variableName).toBe('user_name')
expect(node.nodeId).toBe('node-1')
expect(node.getVarType).toBe(getVarType)
expect(node.readonly).toBe(true)
expect(node.environmentVariables).toEqual([{ variable: 'env.api_key', type: 'string' }])
expect(node.conversationVariables).toEqual([{ variable: 'conversation.user_id', type: 'number' }])
expect(node.ragVariables).toEqual([
{ variable: 'rag.shared.file_name', type: 'string', isRagVariable: true },
{ variable: 'node-1.doc_name', type: 'string', isRagVariable: true },
])
})
it('should not replace text when no hitl output token exists', async () => {
const { getEditor } = renderReplacementPlugin({
variables: createVariables(),
})
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'plain text without replacement token', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodesByType(editor, HITLInputNode)).toHaveLength(0)
})
})
it('should replace token with empty env conversation and rag lists when variables are not provided', async () => {
const { getEditor } = renderReplacementPlugin()
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, '{{#$output.user_name#}}', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodesByType(editor, HITLInputNode)).toHaveLength(1)
})
const node = readFirstHITLInputNodeSnapshot(editor)
expect(node).not.toBeNull()
if (!node)
throw new Error('Expected HITLInputNode snapshot')
expect(node.environmentVariables).toEqual([])
expect(node.conversationVariables).toEqual([])
expect(node.ragVariables).toEqual([])
expect(node.readonly).toBe(false)
})
it('should replace token with empty form inputs when formInputs is undefined', async () => {
const { getEditor } = renderReplacementPlugin({ formInputs: null })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, '{{#$output.user_name#}}', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodesByType(editor, HITLInputNode)).toHaveLength(1)
})
const node = readFirstHITLInputNodeSnapshot(editor)
expect(node).not.toBeNull()
if (!node)
throw new Error('Expected HITLInputNode snapshot')
expect(node.formInputsLength).toBe(0)
})
})
describe('Node registration guard', () => {
it('should throw when hitl input node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'hitl-input-replacement-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<HITLInputReplacementBlock
nodeId="node-1"
formInputs={[createFormInput()]}
onFormInputsChange={vi.fn()}
onFormInputItemRename={vi.fn()}
onFormInputItemRemove={vi.fn()}
workflowNodesMap={createWorkflowNodesMap()}
/>
</LexicalComposer>,
)
}).toThrow('HITLInputNodePlugin: HITLInputNode not registered on editor')
})
})
})

View File

@ -0,0 +1,241 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { act, render, waitFor } from '@testing-library/react'
import {
COMMAND_PRIORITY_EDITOR,
} from 'lexical'
import { useEffect } from 'react'
import {
BlockEnum,
InputVarType,
} from '@/app/components/workflow/types'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import {
DELETE_HITL_INPUT_BLOCK_COMMAND,
HITLInputBlock,
HITLInputNode,
INSERT_HITL_INPUT_BLOCK_COMMAND,
UPDATE_WORKFLOW_NODES_MAP,
} from './index'
type UpdateWorkflowNodesMapPluginProps = {
onUpdate: (payload: unknown) => void
}
const UpdateWorkflowNodesMapPlugin = ({ onUpdate }: UpdateWorkflowNodesMapPluginProps) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return editor.registerCommand(
UPDATE_WORKFLOW_NODES_MAP,
(payload: unknown) => {
onUpdate(payload)
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [editor, onUpdate])
return null
}
const createWorkflowNodesMap = (title: string) => ({
'node-1': {
title,
type: BlockEnum.Start,
height: 100,
width: 120,
position: { x: 0, y: 0 },
},
})
const createFormInput = (): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
})
const createInsertPayload = () => ({
variableName: 'user_name',
nodeId: 'node-1',
formInputs: [createFormInput()],
onFormInputsChange: vi.fn(),
onFormInputItemRename: vi.fn(),
onFormInputItemRemove: vi.fn(),
})
const renderHITLInputBlock = (props?: {
onInsert?: () => void
onDelete?: () => void
workflowNodesMap?: ReturnType<typeof createWorkflowNodesMap>
onWorkflowMapUpdate?: (payload: unknown) => void
}) => {
const workflowNodesMap = props?.workflowNodesMap ?? createWorkflowNodesMap('First Node')
return renderLexicalEditor({
namespace: 'hitl-input-block-plugin-test',
nodes: [CustomTextNode, HITLInputNode],
children: (
<>
{props?.onWorkflowMapUpdate && <UpdateWorkflowNodesMapPlugin onUpdate={props.onWorkflowMapUpdate} />}
<HITLInputBlock
nodeId="node-1"
formInputs={[createFormInput()]}
onFormInputItemRename={vi.fn()}
onFormInputItemRemove={vi.fn()}
workflowNodesMap={workflowNodesMap}
onInsert={props?.onInsert}
onDelete={props?.onDelete}
/>
</>
),
})
}
describe('HITLInputBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Workflow map command dispatch', () => {
it('should dispatch UPDATE_WORKFLOW_NODES_MAP when mounted', async () => {
const onWorkflowMapUpdate = vi.fn()
const workflowNodesMap = createWorkflowNodesMap('Map Node')
renderHITLInputBlock({
workflowNodesMap,
onWorkflowMapUpdate,
})
await waitFor(() => {
expect(onWorkflowMapUpdate).toHaveBeenCalledWith(workflowNodesMap)
})
})
})
describe('Command handling', () => {
it('should insert hitl input block and call onInsert when insert command is dispatched', async () => {
const onInsert = vi.fn()
const { getEditor } = renderHITLInputBlock({ onInsert })
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload())
})
expect(handled).toBe(true)
expect(onInsert).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(readRootTextContent(editor)).toContain('{{#$output.user_name#}}')
})
expect(getNodeCount(editor, HITLInputNode)).toBe(1)
})
it('should insert hitl input block without onInsert callback', async () => {
const { getEditor } = renderHITLInputBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload())
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toContain('{{#$output.user_name#}}')
})
expect(getNodeCount(editor, HITLInputNode)).toBe(1)
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderHITLInputBlock({ onDelete })
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_HITL_INPUT_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should handle delete command without onDelete callback', async () => {
const { getEditor } = renderHITLInputBlock()
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_HITL_INPUT_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle', () => {
it('should unregister insert and delete commands when unmounted', async () => {
const { getEditor, unmount } = renderHITLInputBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let insertHandled = true
let deleteHandled = true
act(() => {
insertHandled = editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload())
deleteHandled = editor.dispatchCommand(DELETE_HITL_INPUT_BLOCK_COMMAND, undefined)
})
expect(insertHandled).toBe(false)
expect(deleteHandled).toBe(false)
})
it('should throw when hitl input node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'hitl-input-block-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<HITLInputBlock
nodeId="node-1"
formInputs={[createFormInput()]}
onFormInputItemRename={vi.fn()}
onFormInputItemRemove={vi.fn()}
workflowNodesMap={createWorkflowNodesMap('Map Node')}
/>
</LexicalComposer>,
)
}).toThrow('HITLInputBlockPlugin: HITLInputBlock not registered on editor')
})
})
})

View File

@ -0,0 +1,277 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType } from '@/app/components/workflow/types'
import InputField from './input-field'
type VarReferencePickerProps = {
onChange: (value: string[]) => void
}
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: (props: VarReferencePickerProps) => {
return (
<button type="button" onClick={() => props.onChange(['node-a', 'var-a'])}>
pick-variable
</button>
)
},
}))
const createPayload = (overrides?: Partial<FormInputItem>): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'valid_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
...overrides,
})
describe('InputField', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should disable save and show validation error when variable name is invalid', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-1"
isEdit
payload={createPayload()}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
const inputs = screen.getAllByRole('textbox')
await user.clear(inputs[0])
await user.type(inputs[0], 'invalid name')
expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameInvalid')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
await user.keyboard('{Control>}{Enter}{/Control}')
expect(onChange).not.toHaveBeenCalled()
})
it('should call onChange when saving a valid payload in edit mode', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-2"
isEdit
payload={createPayload()}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0]).toEqual(createPayload())
})
it('should call onCancel when cancel button is clicked', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(
<InputField
nodeId="node-3"
isEdit={false}
payload={createPayload()}
onChange={vi.fn()}
onCancel={onCancel}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should use default payload when payload is not provided', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-default-payload"
isEdit={false}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
const nameInput = screen.getAllByRole('textbox')[0]
await user.type(nameInput, 'generated_name')
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0]).toEqual({
type: InputVarType.paragraph,
output_variable_name: 'generated_name',
default: {
type: 'constant',
selector: [],
value: '',
},
})
})
it('should save in create mode on Ctrl+Enter and include updated default constant value', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-4"
isEdit={false}
payload={createPayload({
default: {
type: 'constant',
selector: [],
value: '',
},
})}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.keyboard('{Tab}')
const inputs = screen.getAllByRole('textbox')
await user.type(inputs[1], 'constant-default')
await user.keyboard('{Control>}{Enter}{/Control}')
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0].default).toEqual({
type: 'constant',
selector: [],
value: 'constant-default',
})
})
it('should switch to variable mode when type switch is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-4-1"
isEdit={false}
payload={createPayload({
default: {
type: 'constant',
selector: [],
value: 'preset',
},
})}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0].default.type).toBe('variable')
})
it('should switch to constant mode when variable mode type switch is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-5-1"
isEdit={false}
payload={createPayload({
default: {
type: 'variable',
selector: ['node-y', 'var-y'],
value: '',
},
})}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useConstantInstead/i))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0].default.type).toBe('constant')
})
it('should update default selector when variable picker is used', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-5"
isEdit={false}
payload={createPayload({
default: {
type: 'variable',
selector: ['node-x', 'old'],
value: '',
},
})}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByText('pick-variable'))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0].default).toEqual({
type: 'variable',
selector: ['node-a', 'var-a'],
value: '',
})
})
it('should initialize default config when missing and selector is selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const payloadWithoutDefault = {
...createPayload(),
default: undefined,
} as unknown as FormInputItem
render(
<InputField
nodeId="node-6"
isEdit={false}
payload={payloadWithoutDefault}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.keyboard('{Tab}')
await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0].default).toEqual({
type: 'variable',
selector: [],
value: '',
})
})
})

View File

@ -0,0 +1,235 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Var } from '@/app/components/workflow/types'
import { act } from '@testing-library/react'
import {
BlockEnum,
InputVarType,
} from '@/app/components/workflow/types'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
} from '../test-helpers'
import HITLInputBlockComponent from './component'
import {
$createHITLInputNode,
$isHITLInputNode,
HITLInputNode,
} from './node'
const createFormInput = (): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
})
const createNodeProps = () => {
return {
variableName: 'user_name',
nodeId: 'node-1',
formInputs: [createFormInput()],
onFormInputsChange: vi.fn(),
onFormInputItemRename: vi.fn(),
onFormInputItemRemove: vi.fn(),
workflowNodesMap: {
'node-1': {
title: 'Node 1',
type: BlockEnum.Start,
height: 100,
width: 100,
position: { x: 0, y: 0 },
},
},
getVarType: vi.fn(),
environmentVariables: [{ variable: 'env.var_a', type: 'string' }] as Var[],
conversationVariables: [{ variable: 'conversation.var_b', type: 'number' }] as Var[],
ragVariables: [{ variable: 'rag.shared.var_c', type: 'string', isRagVariable: true }] as Var[],
readonly: true,
}
}
const createTestEditor = () => {
return createLexicalTestEditor('hitl-input-node-test', [HITLInputNode])
}
describe('HITLInputNode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expose node metadata and configured properties through getters', () => {
const editor = createTestEditor()
const props = createNodeProps()
expect(HITLInputNode.getType()).toBe('hitl-input-block')
act(() => {
editor.update(() => {
const node = $createHITLInputNode(
props.variableName,
props.nodeId,
props.formInputs,
props.onFormInputsChange,
props.onFormInputItemRename,
props.onFormInputItemRemove,
props.workflowNodesMap,
props.getVarType,
props.environmentVariables,
props.conversationVariables,
props.ragVariables,
props.readonly,
)
expect(node.isInline()).toBe(true)
expect(node.isIsolated()).toBe(true)
expect(node.isTopLevel()).toBe(true)
expect(node.getVariableName()).toBe(props.variableName)
expect(node.getNodeId()).toBe(props.nodeId)
expect(node.getFormInputs()).toEqual(props.formInputs)
expect(node.getOnFormInputsChange()).toBe(props.onFormInputsChange)
expect(node.getOnFormInputItemRename()).toBe(props.onFormInputItemRename)
expect(node.getOnFormInputItemRemove()).toBe(props.onFormInputItemRemove)
expect(node.getWorkflowNodesMap()).toEqual(props.workflowNodesMap)
expect(node.getGetVarType()).toBe(props.getVarType)
expect(node.getEnvironmentVariables()).toEqual(props.environmentVariables)
expect(node.getConversationVariables()).toEqual(props.conversationVariables)
expect(node.getRagVariables()).toEqual(props.ragVariables)
expect(node.getReadonly()).toBe(true)
expect(node.getTextContent()).toBe('{{#$output.user_name#}}')
})
})
})
it('should return default fallback values for optional properties', () => {
const editor = createTestEditor()
const props = createNodeProps()
act(() => {
editor.update(() => {
const node = $createHITLInputNode(
props.variableName,
props.nodeId,
props.formInputs,
props.onFormInputsChange,
props.onFormInputItemRename,
props.onFormInputItemRemove,
props.workflowNodesMap,
)
expect(node.getEnvironmentVariables()).toEqual([])
expect(node.getConversationVariables()).toEqual([])
expect(node.getRagVariables()).toEqual([])
expect(node.getReadonly()).toBe(false)
})
})
})
it('should clone, serialize, import and decorate correctly', () => {
const editor = createTestEditor()
const props = createNodeProps()
act(() => {
editor.update(() => {
const node = $createHITLInputNode(
props.variableName,
props.nodeId,
props.formInputs,
props.onFormInputsChange,
props.onFormInputItemRename,
props.onFormInputItemRemove,
props.workflowNodesMap,
props.getVarType,
props.environmentVariables,
props.conversationVariables,
props.ragVariables,
props.readonly,
)
const serialized = node.exportJSON()
const cloned = HITLInputNode.clone(node)
const imported = HITLInputNode.importJSON(serialized)
expect(cloned).toBeInstanceOf(HITLInputNode)
expect(cloned.getKey()).toBe(node.getKey())
expect(cloned).not.toBe(node)
expect(imported).toBeInstanceOf(HITLInputNode)
const element = node.decorate()
expect(element.type).toBe(HITLInputBlockComponent)
expect(element.props.nodeKey).toBe(node.getKey())
expect(element.props.varName).toBe('user_name')
})
})
})
it('should fallback to empty form inputs when imported payload omits formInputs', () => {
const editor = createTestEditor()
const props = createNodeProps()
act(() => {
editor.update(() => {
const source = $createHITLInputNode(
props.variableName,
props.nodeId,
props.formInputs,
props.onFormInputsChange,
props.onFormInputItemRename,
props.onFormInputItemRemove,
props.workflowNodesMap,
props.getVarType,
props.environmentVariables,
props.conversationVariables,
props.ragVariables,
props.readonly,
)
const payload = {
...source.exportJSON(),
formInputs: undefined as unknown as FormInputItem[],
}
const imported = HITLInputNode.importJSON(payload)
const cloned = HITLInputNode.clone(imported)
expect(imported.getFormInputs()).toEqual([])
expect(cloned.getFormInputs()).toEqual([])
})
})
})
it('should create and update DOM and support helper type guard', () => {
const editor = createTestEditor()
const props = createNodeProps()
act(() => {
editor.update(() => {
const node = $createHITLInputNode(
props.variableName,
props.nodeId,
props.formInputs,
props.onFormInputsChange,
props.onFormInputItemRename,
props.onFormInputItemRemove,
props.workflowNodesMap,
props.getVarType,
props.environmentVariables,
props.conversationVariables,
props.ragVariables,
props.readonly,
)
const dom = node.createDOM()
expectInlineWrapperDom(dom, ['w-[calc(100%-1px)]', 'support-drag'])
expect(node.updateDOM()).toBe(false)
expect($isHITLInputNode(node)).toBe(true)
})
})
expect($isHITLInputNode(null)).toBe(false)
expect($isHITLInputNode(undefined)).toBe(false)
})
})

View File

@ -0,0 +1,126 @@
import type { Var } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import PrePopulate from './pre-populate'
const { mockVarReferencePicker } = vi.hoisted(() => ({
mockVarReferencePicker: vi.fn(),
}))
type VarReferencePickerProps = {
onChange: (value: string[]) => void
filterVar: (v: Var) => boolean
}
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: (props: VarReferencePickerProps) => {
mockVarReferencePicker(props)
return (
<button type="button" onClick={() => props.onChange(['node-1', 'var-1'])}>
pick-variable
</button>
)
},
}))
describe('PrePopulate', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should show placeholder initially and switch out of placeholder on Tab key', async () => {
const user = userEvent.setup()
render(
<PrePopulate
nodeId="node-1"
isVariable={false}
value=""
/>,
)
expect(screen.getByText('nodes.humanInput.insertInputField.prePopulateFieldPlaceholder')).toBeInTheDocument()
await user.keyboard('{Tab}')
expect(screen.queryByText('nodes.humanInput.insertInputField.prePopulateFieldPlaceholder')).not.toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should update constant value and toggle to variable mode when type switch is clicked', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
const onIsVariableChange = vi.fn()
const Wrapper = () => {
const [value, setValue] = useState('initial value')
return (
<PrePopulate
nodeId="node-1"
isVariable={false}
value={value}
onValueChange={(next) => {
onValueChange(next)
setValue(next)
}}
onIsVariableChange={onIsVariableChange}
/>
)
}
render(
<Wrapper />,
)
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'next')
await user.click(screen.getByText('workflow.nodes.humanInput.insertInputField.useVarInstead'))
expect(onValueChange).toHaveBeenLastCalledWith('next')
expect(onIsVariableChange).toHaveBeenCalledWith(true)
})
it('should render variable picker mode and propagate selected value selector', async () => {
const user = userEvent.setup()
const onValueSelectorChange = vi.fn()
const onIsVariableChange = vi.fn()
render(
<PrePopulate
nodeId="node-2"
isVariable
valueSelector={['node-2', 'existing']}
onValueSelectorChange={onValueSelectorChange}
onIsVariableChange={onIsVariableChange}
/>,
)
await user.click(screen.getByText('pick-variable'))
await user.click(screen.getByText('workflow.nodes.humanInput.insertInputField.useConstantInstead'))
expect(onValueSelectorChange).toHaveBeenCalledWith(['node-1', 'var-1'])
expect(onIsVariableChange).toHaveBeenCalledWith(false)
})
it('should pass variable type filter to picker that allows string number and secret', () => {
render(
<PrePopulate
nodeId="node-3"
isVariable
valueSelector={['node-3', 'existing']}
/>,
)
const pickerProps = mockVarReferencePicker.mock.calls[0][0] as VarReferencePickerProps
const allowString = pickerProps.filterVar({ type: 'string' } as Var)
const allowNumber = pickerProps.filterVar({ type: 'number' } as Var)
const allowSecret = pickerProps.filterVar({ type: 'secret' } as Var)
const blockObject = pickerProps.filterVar({ type: 'object' } as Var)
expect(allowString).toBe(true)
expect(allowNumber).toBe(true)
expect(allowSecret).toBe(true)
expect(blockObject).toBe(false)
})
})

View File

@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TagLabel from './tag-label'
describe('TagLabel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render edit icon label and trigger click handler when type is edit', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
const { container } = render(
<TagLabel type="edit" onClick={onClick}>
Edit
</TagLabel>,
)
await user.click(screen.getByText('Edit'))
expect(onClick).toHaveBeenCalledTimes(1)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should render variable icon label when type is variable', () => {
const { container } = render(
<TagLabel type="variable">
Variable
</TagLabel>,
)
expect(screen.getByText('Variable')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TypeSwitch from './type-switch'
describe('TypeSwitch', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render use variable text when isVariable is false and toggle to true on click', async () => {
const user = userEvent.setup()
const onIsVariableChange = vi.fn()
render(
<TypeSwitch isVariable={false} onIsVariableChange={onIsVariableChange} />,
)
const trigger = screen.getByText('workflow.nodes.humanInput.insertInputField.useVarInstead')
await user.click(trigger)
expect(onIsVariableChange).toHaveBeenCalledWith(true)
})
it('should render use constant text when isVariable is true and toggle to false on click', async () => {
const user = userEvent.setup()
const onIsVariableChange = vi.fn()
render(
<TypeSwitch isVariable onIsVariableChange={onIsVariableChange} />,
)
const trigger = screen.getByText('workflow.nodes.humanInput.insertInputField.useConstantInstead')
await user.click(trigger)
expect(onIsVariableChange).toHaveBeenCalledWith(false)
})
})

View File

@ -0,0 +1,208 @@
import type { LexicalEditor } from 'lexical'
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { Var } from '@/app/components/workflow/types'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, screen, waitFor } from '@testing-library/react'
import {
$getRoot,
} from 'lexical'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import {
BlockEnum,
} from '@/app/components/workflow/types'
import { CaptureEditorPlugin } from '../test-utils'
import { UPDATE_WORKFLOW_NODES_MAP } from '../workflow-variable-block'
import { HITLInputNode } from './node'
import HITLInputVariableBlockComponent from './variable-block'
const createWorkflowNodesMap = (title = 'Node One'): WorkflowNodesMap => ({
'node-1': {
title,
type: BlockEnum.LLM,
height: 100,
width: 120,
position: { x: 0, y: 0 },
},
'node-rag': {
title: 'Retriever',
type: BlockEnum.LLM,
height: 100,
width: 120,
position: { x: 0, y: 0 },
},
})
const hasErrorIcon = (container: HTMLElement) => {
return container.querySelector('svg.text-text-destructive') !== null
}
const renderVariableBlock = (props: {
variables: string[]
workflowNodesMap?: WorkflowNodesMap
getVarType?: (payload: { nodeId: string, valueSelector: string[] }) => Type
environmentVariables?: Var[]
conversationVariables?: Var[]
ragVariables?: Var[]
}) => {
let editor: LexicalEditor | null = null
const setEditor = (value: LexicalEditor) => {
editor = value
}
const utils = render(
<LexicalComposer
initialConfig={{
namespace: 'hitl-input-variable-block-test',
onError: (error: Error) => {
throw error
},
nodes: [HITLInputNode],
}}
>
<HITLInputVariableBlockComponent
variables={props.variables}
workflowNodesMap={props.workflowNodesMap ?? createWorkflowNodesMap()}
getVarType={props.getVarType}
environmentVariables={props.environmentVariables}
conversationVariables={props.conversationVariables}
ragVariables={props.ragVariables}
/>
<CaptureEditorPlugin onReady={setEditor} />
</LexicalComposer>,
)
return {
...utils,
getEditor: () => editor,
}
}
describe('HITLInputVariableBlockComponent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Node guard', () => {
it('should throw when hitl input node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'hitl-input-variable-block-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [],
}}
>
<HITLInputVariableBlockComponent
variables={['node-1', 'output']}
workflowNodesMap={createWorkflowNodesMap()}
/>
</LexicalComposer>,
)
}).toThrow('HITLInputNodePlugin: HITLInputNode not registered on editor')
})
})
describe('Workflow map updates', () => {
it('should update local workflow node map when UPDATE_WORKFLOW_NODES_MAP command is dispatched', async () => {
const { container, getEditor } = renderVariableBlock({
variables: ['node-1', 'output'],
workflowNodesMap: {},
})
expect(screen.queryByText('Node One')).not.toBeInTheDocument()
expect(hasErrorIcon(container)).toBe(true)
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
let handled = false
act(() => {
editor!.update(() => {
$getRoot().selectEnd()
})
handled = editor!.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, createWorkflowNodesMap())
})
expect(handled).toBe(true)
await waitFor(() => {
expect(screen.getByText('Node One')).toBeInTheDocument()
})
})
})
describe('Validation branches', () => {
it('should show invalid state for env variable when environment list does not contain selector', () => {
const { container } = renderVariableBlock({
variables: ['env', 'api_key'],
workflowNodesMap: {},
environmentVariables: [],
})
expect(hasErrorIcon(container)).toBe(true)
})
it('should keep conversation variable valid when selector exists in conversation variables', () => {
const { container } = renderVariableBlock({
variables: ['conversation', 'session_id'],
workflowNodesMap: {},
conversationVariables: [{ variable: 'conversation.session_id', type: 'string' } as Var],
})
expect(hasErrorIcon(container)).toBe(false)
})
it('should keep global system variable valid without workflow node mapping', () => {
const { container } = renderVariableBlock({
variables: ['sys', 'global_name'],
workflowNodesMap: {},
})
expect(screen.getByText('sys.global_name')).toBeInTheDocument()
expect(hasErrorIcon(container)).toBe(false)
})
})
describe('Tooltip payload', () => {
it('should call getVarType with rag selector and use rag node id mapping', () => {
const getVarType = vi.fn(() => Type.number)
const { container } = renderVariableBlock({
variables: ['rag', 'node-rag', 'chunk'],
workflowNodesMap: createWorkflowNodesMap(),
ragVariables: [{ variable: 'rag.node-rag.chunk', type: 'string', isRagVariable: true } as Var],
getVarType,
})
expect(screen.getByText('chunk')).toBeInTheDocument()
expect(hasErrorIcon(container)).toBe(false)
expect(getVarType).toHaveBeenCalledWith({
nodeId: 'rag',
valueSelector: ['rag', 'node-rag', 'chunk'],
})
})
it('should use shortened display name for deep non-rag selectors', () => {
const getVarType = vi.fn(() => Type.string)
renderVariableBlock({
variables: ['node-1', 'parent', 'child'],
workflowNodesMap: createWorkflowNodesMap(),
getVarType,
})
expect(screen.getByText('child')).toBeInTheDocument()
expect(screen.queryByText('parent.child')).not.toBeInTheDocument()
expect(getVarType).toHaveBeenCalledWith({
nodeId: 'node-1',
valueSelector: ['node-1', 'parent', 'child'],
})
})
})
})

View File

@ -0,0 +1,94 @@
import type { RefObject } from 'react'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LastRunBlockNode } from '.'
import { CustomTextNode } from '../custom-text/node'
import LastRunBlockComponent from './component'
const { mockUseSelectOrDelete } = vi.hoisted(() => ({
mockUseSelectOrDelete: vi.fn(),
}))
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
}))
const createHookReturn = (isSelected: boolean): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, isSelected]
}
const renderComponent = (props?: {
isSelected?: boolean
withNode?: boolean
onParentClick?: () => void
}) => {
const {
isSelected = false,
withNode = true,
onParentClick,
} = props ?? {}
mockUseSelectOrDelete.mockReturnValue(createHookReturn(isSelected))
return render(
<LexicalComposer
initialConfig={{
namespace: 'last-run-block-component-test',
onError: (error: Error) => {
throw error
},
nodes: withNode ? [CustomTextNode, LastRunBlockNode] : [CustomTextNode],
}}
>
<div onClick={onParentClick}>
<LastRunBlockComponent nodeKey="last-run-node" />
</div>
</LexicalComposer>,
)
}
describe('LastRunBlockComponent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render last run label and apply selected classes when selected', () => {
const { container } = renderComponent({ isSelected: true })
const wrapper = container.querySelector('.group\\/wrap')
expect(screen.getByText('last_run')).toBeInTheDocument()
expect(wrapper).toHaveClass('border-state-accent-solid')
expect(wrapper).toHaveClass('bg-state-accent-hover')
})
it('should apply default classes when not selected', () => {
const { container } = renderComponent({ isSelected: false })
const wrapper = container.querySelector('.group\\/wrap')
expect(wrapper).toHaveClass('border-components-panel-border-subtle')
expect(wrapper).toHaveClass('bg-components-badge-white-to-dark')
})
})
describe('Interactions', () => {
it('should stop click propagation from wrapper', async () => {
const user = userEvent.setup()
const onParentClick = vi.fn()
renderComponent({ onParentClick })
await user.click(screen.getByText('last_run'))
expect(onParentClick).not.toHaveBeenCalled()
})
})
describe('Node registration guard', () => {
it('should throw when last run node is not registered on editor', () => {
expect(() => {
renderComponent({ withNode: false })
}).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
})
})
})

View File

@ -0,0 +1,144 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { LAST_RUN_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import {
DELETE_LAST_RUN_COMMAND,
INSERT_LAST_RUN_BLOCK_COMMAND,
LastRunBlock,
LastRunBlockNode,
} from './index'
const renderLastRunBlock = (props?: {
onInsert?: () => void
onDelete?: () => void
}) => {
return renderLexicalEditor({
namespace: 'last-run-block-plugin-test',
nodes: [CustomTextNode, LastRunBlockNode],
children: (
<LastRunBlock {...(props ?? {})} />
),
})
}
describe('LastRunBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Command handling', () => {
it('should insert last run block and call onInsert when insert command is dispatched', async () => {
const onInsert = vi.fn()
const { getEditor } = renderLastRunBlock({ onInsert })
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_LAST_RUN_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onInsert).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(LAST_RUN_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, LastRunBlockNode)).toBe(1)
})
it('should insert last run block without onInsert callback', async () => {
const { getEditor } = renderLastRunBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_LAST_RUN_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(LAST_RUN_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, LastRunBlockNode)).toBe(1)
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderLastRunBlock({ onDelete })
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_LAST_RUN_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should handle delete command without onDelete callback', async () => {
const { getEditor } = renderLastRunBlock()
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_LAST_RUN_COMMAND, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle', () => {
it('should unregister insert and delete commands when unmounted', async () => {
const { getEditor, unmount } = renderLastRunBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let insertHandled = true
let deleteHandled = true
act(() => {
insertHandled = editor.dispatchCommand(INSERT_LAST_RUN_BLOCK_COMMAND, undefined)
deleteHandled = editor.dispatchCommand(DELETE_LAST_RUN_COMMAND, undefined)
})
expect(insertHandled).toBe(false)
expect(deleteHandled).toBe(false)
})
it('should throw when last run node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'last-run-block-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<LastRunBlock />
</LexicalComposer>,
)
}).toThrow('Last_RunBlockPlugin: Last_RunBlock not registered on editor')
})
})
})

View File

@ -0,0 +1,92 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, waitFor } from '@testing-library/react'
import { LAST_RUN_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
renderLexicalEditor,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
import { LastRunBlockNode } from './index'
import LastRunReplacementBlock from './last-run-block-replacement-block'
const renderReplacementPlugin = (props?: {
onInsert?: () => void
}) => {
return renderLexicalEditor({
namespace: 'last-run-block-replacement-plugin-test',
nodes: [CustomTextNode, LastRunBlockNode],
children: (
<LastRunReplacementBlock {...(props ?? {})} />
),
})
}
describe('LastRunReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Replacement behavior', () => {
it('should replace placeholder text with last run block and call onInsert', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, `prefix ${LAST_RUN_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, LastRunBlockNode)).toBe(1)
})
expect(onInsert).toHaveBeenCalledTimes(1)
})
it('should not replace text when placeholder is missing', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'plain text without placeholder', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, LastRunBlockNode)).toBe(0)
})
expect(onInsert).not.toHaveBeenCalled()
})
it('should replace placeholder text without onInsert callback', async () => {
const { getEditor } = renderReplacementPlugin()
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, LAST_RUN_PLACEHOLDER_TEXT, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, LastRunBlockNode)).toBe(1)
})
})
})
describe('Node registration guard', () => {
it('should throw when last run node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'last-run-block-replacement-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<LastRunReplacementBlock />
</LexicalComposer>,
)
}).toThrow('LastRunMessageBlockNodePlugin: LastRunMessageBlockNode not registered on editor')
})
})
})

View File

@ -0,0 +1,114 @@
import { act } from '@testing-library/react'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
} from '../test-helpers'
import LastRunBlockComponent from './component'
import {
$createLastRunBlockNode,
$isLastRunBlockNode,
LastRunBlockNode,
} from './node'
const createTestEditor = () => {
return createLexicalTestEditor('last-run-block-node-test', [LastRunBlockNode])
}
const createNodeInEditor = () => {
const editor = createTestEditor()
let node!: LastRunBlockNode
act(() => {
editor.update(() => {
node = $createLastRunBlockNode()
})
})
return { editor, node }
}
describe('LastRunBlockNode', () => {
describe('Node metadata', () => {
it('should expose last run block type and inline behavior', () => {
const { node } = createNodeInEditor()
expect(LastRunBlockNode.getType()).toBe('last-run-block')
expect(node.isInline()).toBe(true)
expect(node.getTextContent()).toBe('{{#last_run#}}')
})
it('should clone with the same key', () => {
const { editor, node } = createNodeInEditor()
let cloned!: LastRunBlockNode
act(() => {
editor.update(() => {
cloned = LastRunBlockNode.clone(node)
})
})
expect(cloned).toBeInstanceOf(LastRunBlockNode)
expect(cloned.getKey()).toBe(node.getKey())
expect(cloned).not.toBe(node)
})
})
describe('DOM behavior', () => {
it('should create inline wrapper DOM with expected classes', () => {
const { node } = createNodeInEditor()
const dom = node.createDOM()
expectInlineWrapperDom(dom)
})
it('should not update DOM', () => {
const { node } = createNodeInEditor()
expect(node.updateDOM()).toBe(false)
})
})
describe('Serialization and decoration', () => {
it('should export and import JSON', () => {
const { editor, node } = createNodeInEditor()
const serialized = node.exportJSON()
let imported!: LastRunBlockNode
act(() => {
editor.update(() => {
imported = LastRunBlockNode.importJSON()
})
})
expect(serialized).toEqual({
type: 'last-run-block',
version: 1,
})
expect(imported).toBeInstanceOf(LastRunBlockNode)
})
it('should decorate with last run block component and node key', () => {
const { node } = createNodeInEditor()
const element = node.decorate()
expect(element.type).toBe(LastRunBlockComponent)
expect(element.props).toEqual({ nodeKey: node.getKey() })
})
})
describe('Helpers', () => {
it('should create last run block node instance from factory', () => {
const { node } = createNodeInEditor()
expect(node).toBeInstanceOf(LastRunBlockNode)
})
it('should identify last run block nodes using type guard helper', () => {
const { node } = createNodeInEditor()
expect($isLastRunBlockNode(node)).toBe(true)
expect($isLastRunBlockNode(null)).toBe(false)
expect($isLastRunBlockNode(undefined)).toBe(false)
})
})
})

View File

@ -0,0 +1,281 @@
import type { LexicalEditor } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import {
BLUR_COMMAND,
COMMAND_PRIORITY_EDITOR,
FOCUS_COMMAND,
KEY_ESCAPE_COMMAND,
} from 'lexical'
import OnBlurBlock from './on-blur-or-focus-block'
import { CaptureEditorPlugin } from './test-utils'
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
const renderOnBlurBlock = (props?: {
onBlur?: () => void
onFocus?: () => void
}) => {
let editor: LexicalEditor | null = null
const setEditor = (value: LexicalEditor) => {
editor = value
}
const utils = render(
<LexicalComposer
initialConfig={{
namespace: 'on-blur-block-plugin-test',
onError: (error: Error) => {
throw error
},
}}
>
<OnBlurBlock onBlur={props?.onBlur} onFocus={props?.onFocus} />
<CaptureEditorPlugin onReady={setEditor} />
</LexicalComposer>,
)
return {
...utils,
getEditor: () => editor,
}
}
const createBlurEvent = (relatedTarget?: HTMLElement): FocusEvent => {
return new FocusEvent('blur', { relatedTarget: relatedTarget ?? null })
}
const createFocusEvent = (): FocusEvent => {
return new FocusEvent('focus')
}
describe('OnBlurBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Focus and blur handling', () => {
it('should call onFocus when focus command is dispatched', async () => {
const onFocus = vi.fn()
const { getEditor } = renderOnBlurBlock({ onFocus })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
let handled = false
act(() => {
handled = editor!.dispatchCommand(FOCUS_COMMAND, createFocusEvent())
})
expect(handled).toBe(true)
expect(onFocus).toHaveBeenCalledTimes(1)
})
it('should call onBlur and dispatch escape after delay when blur target is not var-search-input', async () => {
const onBlur = vi.fn()
const { getEditor } = renderOnBlurBlock({ onBlur })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
vi.useFakeTimers()
const onEscape = vi.fn(() => true)
const unregister = editor!.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_EDITOR,
)
let handled = false
act(() => {
handled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('button')))
})
expect(handled).toBe(true)
expect(onBlur).toHaveBeenCalledTimes(1)
expect(onEscape).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(200)
})
expect(onEscape).toHaveBeenCalledTimes(1)
unregister()
vi.useRealTimers()
})
it('should dispatch delayed escape when onBlur callback is not provided', async () => {
const { getEditor } = renderOnBlurBlock()
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
vi.useFakeTimers()
const onEscape = vi.fn(() => true)
const unregister = editor!.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_EDITOR,
)
act(() => {
editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(onEscape).toHaveBeenCalledTimes(1)
unregister()
vi.useRealTimers()
})
it('should skip onBlur and delayed escape when blur target is var-search-input', async () => {
const onBlur = vi.fn()
const { getEditor } = renderOnBlurBlock({ onBlur })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
vi.useFakeTimers()
const target = document.createElement('input')
target.classList.add('var-search-input')
const onEscape = vi.fn(() => true)
const unregister = editor!.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_EDITOR,
)
let handled = false
act(() => {
handled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(target))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(handled).toBe(true)
expect(onBlur).not.toHaveBeenCalled()
expect(onEscape).not.toHaveBeenCalled()
unregister()
vi.useRealTimers()
})
it('should handle focus command when onFocus callback is not provided', async () => {
const { getEditor } = renderOnBlurBlock()
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
let handled = false
act(() => {
handled = editor!.dispatchCommand(FOCUS_COMMAND, createFocusEvent())
})
expect(handled).toBe(true)
})
})
describe('Clear timeout command', () => {
it('should clear scheduled escape timeout when clear command is dispatched', async () => {
const { getEditor } = renderOnBlurBlock({ onBlur: vi.fn() })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
vi.useFakeTimers()
const onEscape = vi.fn(() => true)
const unregister = editor!.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_EDITOR,
)
act(() => {
editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
})
act(() => {
editor!.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(onEscape).not.toHaveBeenCalled()
unregister()
vi.useRealTimers()
})
it('should handle clear command when no timeout is scheduled', async () => {
const { getEditor } = renderOnBlurBlock()
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
let handled = false
act(() => {
handled = editor!.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle cleanup', () => {
it('should unregister commands when component unmounts', async () => {
const { getEditor, unmount } = renderOnBlurBlock()
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
unmount()
let blurHandled = true
let focusHandled = true
let clearHandled = true
act(() => {
blurHandled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
focusHandled = editor!.dispatchCommand(FOCUS_COMMAND, createFocusEvent())
clearHandled = editor!.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
})
expect(blurHandled).toBe(false)
expect(focusHandled).toBe(false)
expect(clearHandled).toBe(false)
})
})
})

View File

@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react'
import Placeholder from './placeholder'
describe('Placeholder', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render translated default placeholder text when value is not provided', () => {
render(<Placeholder />)
expect(screen.getByText('common.promptEditor.placeholder')).toBeInTheDocument()
})
it('should render provided value instead of translated default text', () => {
render(<Placeholder value={<span>custom placeholder</span>} />)
expect(screen.getByText('custom placeholder')).toBeInTheDocument()
expect(screen.queryByText('common.promptEditor.placeholder')).not.toBeInTheDocument()
})
})
describe('Class names', () => {
it('should apply compact text classes when compact is true', () => {
const { container } = render(<Placeholder compact />)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('text-[13px]')
expect(wrapper).toHaveClass('leading-5')
expect(wrapper).not.toHaveClass('leading-6')
})
it('should apply default text classes when compact is false', () => {
const { container } = render(<Placeholder compact={false} />)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('text-sm')
expect(wrapper).toHaveClass('leading-6')
expect(wrapper).not.toHaveClass('leading-5')
})
it('should merge additional className when provided', () => {
const { container } = render(<Placeholder className="custom-class" />)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('custom-class')
})
})
})

View File

@ -0,0 +1,51 @@
import type { RefObject } from 'react'
import { render, screen } from '@testing-library/react'
import QueryBlockComponent from './component'
import { DELETE_QUERY_BLOCK_COMMAND } from './index'
const { mockUseSelectOrDelete } = vi.hoisted(() => ({
mockUseSelectOrDelete: vi.fn(),
}))
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
}))
describe('QueryBlockComponent', () => {
const createHookReturn = (isSelected: boolean): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, isSelected]
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render query title and register select or delete hook with node key', () => {
mockUseSelectOrDelete.mockReturnValue(createHookReturn(false))
render(<QueryBlockComponent nodeKey="query-node-1" />)
expect(mockUseSelectOrDelete).toHaveBeenCalledWith('query-node-1', DELETE_QUERY_BLOCK_COMMAND)
expect(screen.getByText('common.promptEditor.query.item.title')).toBeInTheDocument()
})
it('should apply selected border class when the block is selected', () => {
mockUseSelectOrDelete.mockReturnValue(createHookReturn(true))
const { container } = render(<QueryBlockComponent nodeKey="query-node-2" />)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('!border-[#FD853A]')
})
it('should not apply selected border class when the block is not selected', () => {
mockUseSelectOrDelete.mockReturnValue(createHookReturn(false))
const { container } = render(<QueryBlockComponent nodeKey="query-node-3" />)
const wrapper = container.firstElementChild
expect(wrapper).not.toHaveClass('!border-[#FD853A]')
})
})
})

View File

@ -0,0 +1,144 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { QUERY_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import {
DELETE_QUERY_BLOCK_COMMAND,
INSERT_QUERY_BLOCK_COMMAND,
QueryBlock,
QueryBlockNode,
} from './index'
const renderQueryBlock = (props: {
onInsert?: () => void
onDelete?: () => void
} = {}) => {
return renderLexicalEditor({
namespace: 'query-block-plugin-test',
nodes: [CustomTextNode, QueryBlockNode],
children: (
<QueryBlock {...props} />
),
})
}
describe('QueryBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Command handling', () => {
it('should insert query block and call onInsert when insert command is dispatched', async () => {
const onInsert = vi.fn()
const { getEditor } = renderQueryBlock({ onInsert })
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onInsert).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(QUERY_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, QueryBlockNode)).toBe(1)
})
it('should insert query block without onInsert callback', async () => {
const { getEditor } = renderQueryBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(QUERY_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, QueryBlockNode)).toBe(1)
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderQueryBlock({ onDelete })
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_QUERY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should handle delete command without onDelete callback', async () => {
const { getEditor } = renderQueryBlock()
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_QUERY_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle', () => {
it('should unregister insert and delete commands when unmounted', async () => {
const { getEditor, unmount } = renderQueryBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let insertHandled = true
let deleteHandled = true
act(() => {
insertHandled = editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined)
deleteHandled = editor.dispatchCommand(DELETE_QUERY_BLOCK_COMMAND, undefined)
})
expect(insertHandled).toBe(false)
expect(deleteHandled).toBe(false)
})
it('should throw when query node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'query-block-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<QueryBlock />
</LexicalComposer>,
)
}).toThrow('QueryBlockPlugin: QueryBlock not registered on editor')
})
})
})

View File

@ -0,0 +1,113 @@
import { act } from '@testing-library/react'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
} from '../test-helpers'
import QueryBlockComponent from './component'
import {
$createQueryBlockNode,
$isQueryBlockNode,
QueryBlockNode,
} from './node'
describe('QueryBlockNode', () => {
const createTestEditor = () => {
return createLexicalTestEditor('query-block-node-test', [QueryBlockNode])
}
const createNodeInEditor = () => {
const editor = createTestEditor()
let node!: QueryBlockNode
act(() => {
editor.update(() => {
node = $createQueryBlockNode()
})
})
return { editor, node }
}
describe('Node metadata', () => {
it('should expose query block type and inline behavior', () => {
const { node } = createNodeInEditor()
expect(QueryBlockNode.getType()).toBe('query-block')
expect(node.isInline()).toBe(true)
expect(node.getTextContent()).toBe('{{#query#}}')
})
it('should clone into a new query block node', () => {
const { editor, node } = createNodeInEditor()
let cloned!: QueryBlockNode
act(() => {
editor.update(() => {
cloned = QueryBlockNode.clone()
})
})
expect(cloned).toBeInstanceOf(QueryBlockNode)
expect(cloned).not.toBe(node)
})
})
describe('DOM behavior', () => {
it('should create inline wrapper DOM with expected classes', () => {
const { node } = createNodeInEditor()
const dom = node.createDOM()
expectInlineWrapperDom(dom)
})
it('should not update DOM', () => {
const { node } = createNodeInEditor()
expect(node.updateDOM()).toBe(false)
})
})
describe('Serialization and decoration', () => {
it('should export and import JSON', () => {
const { editor, node } = createNodeInEditor()
const serialized = node.exportJSON()
let imported!: QueryBlockNode
act(() => {
editor.update(() => {
imported = QueryBlockNode.importJSON()
})
})
expect(serialized).toEqual({
type: 'query-block',
version: 1,
})
expect(imported).toBeInstanceOf(QueryBlockNode)
})
it('should decorate with query block component and node key', () => {
const { node } = createNodeInEditor()
const element = node.decorate()
expect(element.type).toBe(QueryBlockComponent)
expect(element.props).toEqual({ nodeKey: node.getKey() })
})
})
describe('Helpers', () => {
it('should create query block node instance from factory', () => {
const { node } = createNodeInEditor()
expect(node).toBeInstanceOf(QueryBlockNode)
})
it('should identify query block nodes using type guard', () => {
const { node } = createNodeInEditor()
expect($isQueryBlockNode(node)).toBe(true)
expect($isQueryBlockNode(null)).toBe(false)
expect($isQueryBlockNode(undefined)).toBe(false)
})
})
})

View File

@ -0,0 +1,92 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, waitFor } from '@testing-library/react'
import { QUERY_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
renderLexicalEditor,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
import { QueryBlockNode } from './index'
import QueryBlockReplacementBlock from './query-block-replacement-block'
const renderReplacementPlugin = (props: {
onInsert?: () => void
} = {}) => {
return renderLexicalEditor({
namespace: 'query-block-replacement-plugin-test',
nodes: [CustomTextNode, QueryBlockNode],
children: (
<QueryBlockReplacementBlock {...props} />
),
})
}
describe('QueryBlockReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Replacement behavior', () => {
it('should replace placeholder text with query block and call onInsert', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, `prefix ${QUERY_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, QueryBlockNode)).toBe(1)
})
expect(onInsert).toHaveBeenCalledTimes(1)
})
it('should not replace text when placeholder is missing', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'plain text without placeholder', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, QueryBlockNode)).toBe(0)
})
expect(onInsert).not.toHaveBeenCalled()
})
it('should replace placeholder text without onInsert callback', async () => {
const { getEditor } = renderReplacementPlugin()
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, QUERY_PLACEHOLDER_TEXT, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, QueryBlockNode)).toBe(1)
})
})
})
describe('Node registration guard', () => {
it('should throw when query node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'query-block-replacement-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<QueryBlockReplacementBlock />
</LexicalComposer>,
)
}).toThrow('QueryBlockNodePlugin: QueryBlockNode not registered on editor')
})
})
})

View File

@ -0,0 +1,53 @@
import type { RefObject } from 'react'
import { render, screen } from '@testing-library/react'
import RequestURLBlockComponent from './component'
import { DELETE_REQUEST_URL_BLOCK_COMMAND } from './index'
const { mockUseSelectOrDelete } = vi.hoisted(() => ({
mockUseSelectOrDelete: vi.fn(),
}))
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
}))
describe('RequestURLBlockComponent', () => {
const createHookReturn = (isSelected: boolean): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, isSelected]
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render request URL title and register select or delete hook with node key', () => {
mockUseSelectOrDelete.mockReturnValue(createHookReturn(false))
render(<RequestURLBlockComponent nodeKey="node-1" />)
expect(mockUseSelectOrDelete).toHaveBeenCalledWith('node-1', DELETE_REQUEST_URL_BLOCK_COMMAND)
expect(screen.getByText('common.promptEditor.requestURL.item.title')).toBeInTheDocument()
})
it('should apply selected border classes when the block is selected', () => {
mockUseSelectOrDelete.mockReturnValue(createHookReturn(true))
const { container } = render(<RequestURLBlockComponent nodeKey="node-2" />)
const wrapper = container.firstElementChild
expect(wrapper).toHaveClass('!border-[#7839ee]')
expect(wrapper).toHaveClass('hover:!border-[#7839ee]')
})
it('should not apply selected border classes when the block is not selected', () => {
mockUseSelectOrDelete.mockReturnValue(createHookReturn(false))
const { container } = render(<RequestURLBlockComponent nodeKey="node-3" />)
const wrapper = container.firstElementChild
expect(wrapper).not.toHaveClass('!border-[#7839ee]')
expect(wrapper).not.toHaveClass('hover:!border-[#7839ee]')
})
})
})

View File

@ -0,0 +1,144 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { REQUEST_URL_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import {
DELETE_REQUEST_URL_BLOCK_COMMAND,
INSERT_REQUEST_URL_BLOCK_COMMAND,
RequestURLBlock,
RequestURLBlockNode,
} from './index'
const renderRequestURLBlock = (props: {
onInsert?: () => void
onDelete?: () => void
} = {}) => {
return renderLexicalEditor({
namespace: 'request-url-block-plugin-test',
nodes: [CustomTextNode, RequestURLBlockNode],
children: (
<RequestURLBlock {...props} />
),
})
}
describe('RequestURLBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Command handling', () => {
it('should insert request URL block and call onInsert when insert command is dispatched', async () => {
const onInsert = vi.fn()
const { getEditor } = renderRequestURLBlock({ onInsert })
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onInsert).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(REQUEST_URL_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, RequestURLBlockNode)).toBe(1)
})
it('should insert request URL block without onInsert callback', async () => {
const { getEditor } = renderRequestURLBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe(REQUEST_URL_PLACEHOLDER_TEXT)
})
expect(getNodeCount(editor, RequestURLBlockNode)).toBe(1)
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderRequestURLBlock({ onDelete })
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_REQUEST_URL_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should handle delete command without onDelete callback', async () => {
const { getEditor } = renderRequestURLBlock()
const editor = await waitForEditorReady(getEditor)
let handled = false
act(() => {
handled = editor.dispatchCommand(DELETE_REQUEST_URL_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
})
})
describe('Lifecycle', () => {
it('should unregister insert and delete commands when unmounted', async () => {
const { getEditor, unmount } = renderRequestURLBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let insertHandled = true
let deleteHandled = true
act(() => {
insertHandled = editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined)
deleteHandled = editor.dispatchCommand(DELETE_REQUEST_URL_BLOCK_COMMAND, undefined)
})
expect(insertHandled).toBe(false)
expect(deleteHandled).toBe(false)
})
it('should throw when request URL node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'request-url-block-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<RequestURLBlock />
</LexicalComposer>,
)
}).toThrow('RequestURLBlockPlugin: RequestURLBlock not registered on editor')
})
})
})

View File

@ -0,0 +1,114 @@
import { act } from '@testing-library/react'
import {
createLexicalTestEditor,
expectInlineWrapperDom,
} from '../test-helpers'
import RequestURLBlockComponent from './component'
import {
$createRequestURLBlockNode,
$isRequestURLBlockNode,
RequestURLBlockNode,
} from './node'
describe('RequestURLBlockNode', () => {
const createTestEditor = () => {
return createLexicalTestEditor('request-url-block-node-test', [RequestURLBlockNode])
}
const createNodeInEditor = () => {
const editor = createTestEditor()
let node!: RequestURLBlockNode
act(() => {
editor.update(() => {
node = $createRequestURLBlockNode()
})
})
return { editor, node }
}
describe('Node metadata', () => {
it('should expose request URL block type and inline behavior', () => {
const { node } = createNodeInEditor()
expect(RequestURLBlockNode.getType()).toBe('request-url-block')
expect(node.isInline()).toBe(true)
expect(node.getTextContent()).toBe('{{#url#}}')
})
it('should clone with the same key', () => {
const { editor, node } = createNodeInEditor()
let cloned!: RequestURLBlockNode
act(() => {
editor.update(() => {
cloned = RequestURLBlockNode.clone(node)
})
})
expect(cloned).toBeInstanceOf(RequestURLBlockNode)
expect(cloned.getKey()).toBe(node.getKey())
expect(cloned).not.toBe(node)
})
})
describe('DOM behavior', () => {
it('should create inline wrapper DOM with expected classes', () => {
const { node } = createNodeInEditor()
const dom = node.createDOM()
expectInlineWrapperDom(dom)
})
it('should not update DOM', () => {
const { node } = createNodeInEditor()
expect(node.updateDOM()).toBe(false)
})
})
describe('Serialization and decoration', () => {
it('should export and import JSON', () => {
const { editor, node } = createNodeInEditor()
const serialized = node.exportJSON()
let imported!: RequestURLBlockNode
act(() => {
editor.update(() => {
imported = RequestURLBlockNode.importJSON()
})
})
expect(serialized).toEqual({
type: 'request-url-block',
version: 1,
})
expect(imported).toBeInstanceOf(RequestURLBlockNode)
})
it('should decorate with request URL block component and node key', () => {
const { node } = createNodeInEditor()
const element = node.decorate()
expect(element.type).toBe(RequestURLBlockComponent)
expect(element.props).toEqual({ nodeKey: node.getKey() })
})
})
describe('Helpers', () => {
it('should create request URL block node instance from factory', () => {
const { node } = createNodeInEditor()
expect(node).toBeInstanceOf(RequestURLBlockNode)
})
it('should identify request URL block nodes using type guard', () => {
const { node } = createNodeInEditor()
expect($isRequestURLBlockNode(node)).toBe(true)
expect($isRequestURLBlockNode(null)).toBe(false)
expect($isRequestURLBlockNode(undefined)).toBe(false)
})
})
})

View File

@ -0,0 +1,92 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, waitFor } from '@testing-library/react'
import { REQUEST_URL_PLACEHOLDER_TEXT } from '../../constants'
import { CustomTextNode } from '../custom-text/node'
import {
getNodeCount,
renderLexicalEditor,
setEditorRootText,
waitForEditorReady,
} from '../test-helpers'
import { RequestURLBlockNode } from './index'
import RequestURLBlockReplacementBlock from './request-url-block-replacement-block'
const renderReplacementPlugin = (props: {
onInsert?: () => void
} = {}) => {
return renderLexicalEditor({
namespace: 'request-url-block-replacement-plugin-test',
nodes: [CustomTextNode, RequestURLBlockNode],
children: (
<RequestURLBlockReplacementBlock {...props} />
),
})
}
describe('RequestURLBlockReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Replacement behavior', () => {
it('should replace placeholder text with request URL block and call onInsert', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, `prefix ${REQUEST_URL_PLACEHOLDER_TEXT} suffix`, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, RequestURLBlockNode)).toBe(1)
})
expect(onInsert).toHaveBeenCalledTimes(1)
})
it('should not replace text when placeholder is missing', async () => {
const onInsert = vi.fn()
const { getEditor } = renderReplacementPlugin({ onInsert })
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, 'plain text without placeholder', text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, RequestURLBlockNode)).toBe(0)
})
expect(onInsert).not.toHaveBeenCalled()
})
it('should replace placeholder text without onInsert callback', async () => {
const { getEditor } = renderReplacementPlugin()
const editor = await waitForEditorReady(getEditor)
setEditorRootText(editor, REQUEST_URL_PLACEHOLDER_TEXT, text => new CustomTextNode(text))
await waitFor(() => {
expect(getNodeCount(editor, RequestURLBlockNode)).toBe(1)
})
})
})
describe('Node registration guard', () => {
it('should throw when request URL node is not registered on editor', () => {
expect(() => {
render(
<LexicalComposer
initialConfig={{
namespace: 'request-url-block-replacement-plugin-missing-node-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<RequestURLBlockReplacementBlock />
</LexicalComposer>,
)
}).toThrow('RequestURLBlockNodePlugin: RequestURLBlockNode not registered on editor')
})
})
})

View File

@ -0,0 +1,162 @@
import type {
Klass,
LexicalEditor,
LexicalNode,
} from 'lexical'
import type { ReactNode } from 'react'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import {
$createParagraphNode,
$getRoot,
$nodesOfType,
createEditor,
} from 'lexical'
import { createElement } from 'react'
import { expect } from 'vitest'
import { CaptureEditorPlugin } from './test-utils'
type RenderLexicalEditorProps = {
namespace: string
nodes?: Array<Klass<LexicalNode>>
children: ReactNode
}
type RenderLexicalEditorResult = ReturnType<typeof render> & {
getEditor: () => LexicalEditor | null
}
export const renderLexicalEditor = ({
namespace,
nodes = [],
children,
}: RenderLexicalEditorProps): RenderLexicalEditorResult => {
let editor: LexicalEditor | null = null
const utils = render(createElement(
LexicalComposer,
{
initialConfig: {
namespace,
onError: (error: Error) => {
throw error
},
nodes,
},
},
children,
createElement(CaptureEditorPlugin, {
onReady: (value) => {
editor = value
},
}),
))
return {
...utils,
getEditor: () => editor,
}
}
export const waitForEditorReady = async (getEditor: () => LexicalEditor | null): Promise<LexicalEditor> => {
await waitFor(() => {
if (!getEditor())
throw new Error('Editor is not ready yet')
})
const editor = getEditor()
if (!editor)
throw new Error('Editor is not available')
return editor
}
export const selectRootEnd = (editor: LexicalEditor) => {
act(() => {
editor.update(() => {
$getRoot().selectEnd()
})
})
}
export const readRootTextContent = (editor: LexicalEditor): string => {
let content = ''
editor.getEditorState().read(() => {
content = $getRoot().getTextContent()
})
return content
}
export const getNodeCount = <T extends LexicalNode>(editor: LexicalEditor, nodeType: Klass<T>): number => {
let count = 0
editor.getEditorState().read(() => {
count = $nodesOfType(nodeType).length
})
return count
}
export const getNodesByType = <T extends LexicalNode>(editor: LexicalEditor, nodeType: Klass<T>): T[] => {
let nodes: T[] = []
editor.getEditorState().read(() => {
nodes = $nodesOfType(nodeType)
})
return nodes
}
export const readEditorStateValue = <T>(editor: LexicalEditor, reader: () => T): T => {
let value: T | undefined
editor.getEditorState().read(() => {
value = reader()
})
if (value === undefined)
throw new Error('Failed to read editor state value')
return value
}
export const setEditorRootText = (
editor: LexicalEditor,
text: string,
createTextNode: (text: string) => LexicalNode,
) => {
act(() => {
editor.update(() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
paragraph.append(createTextNode(text))
root.append(paragraph)
paragraph.selectEnd()
})
})
}
export const createLexicalTestEditor = (namespace: string, nodes: Array<Klass<LexicalNode>>) => {
return createEditor({
namespace,
onError: (error: Error) => {
throw error
},
nodes,
})
}
export const expectInlineWrapperDom = (dom: HTMLElement, extraClasses: string[] = []) => {
expect(dom.tagName).toBe('DIV')
expect(dom).toHaveClass('inline-flex')
expect(dom).toHaveClass('items-center')
expect(dom).toHaveClass('align-middle')
extraClasses.forEach((className) => {
expect(dom).toHaveClass(className)
})
}

View File

@ -0,0 +1,17 @@
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect } from 'react'
type CaptureEditorPluginProps = {
onReady: (editor: LexicalEditor) => void
}
export const CaptureEditorPlugin = ({ onReady }: CaptureEditorPluginProps) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
onReady(editor)
}, [editor, onReady])
return null
}

View File

@ -0,0 +1,58 @@
import type { LexicalEditor } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { render, screen, waitFor } from '@testing-library/react'
import { CaptureEditorPlugin } from './test-utils'
import TreeViewPlugin from './tree-view'
const { mockTreeView } = vi.hoisted(() => ({
mockTreeView: vi.fn(),
}))
vi.mock('@lexical/react/LexicalTreeView', () => ({
TreeView: (props: unknown) => {
mockTreeView(props)
return <div data-testid="lexical-tree-view" />
},
}))
describe('TreeViewPlugin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render lexical tree view with expected classes and current editor', async () => {
let editor: LexicalEditor | null = null
render(
<LexicalComposer
initialConfig={{
namespace: 'tree-view-plugin-test',
onError: (error: Error) => {
throw error
},
}}
>
<TreeViewPlugin />
<CaptureEditorPlugin onReady={(value) => {
editor = value
}}
/>
</LexicalComposer>,
)
await waitFor(() => {
expect(editor).not.toBeNull()
})
expect(screen.getByTestId('lexical-tree-view')).toBeInTheDocument()
const firstCallProps = mockTreeView.mock.calls[0][0] as Record<string, unknown>
expect(firstCallProps.editor).toBe(editor)
expect(firstCallProps.viewClassName).toBe('tree-view-output')
expect(firstCallProps.treeTypeButtonClassName).toBe('debug-treetype-button')
expect(firstCallProps.timeTravelPanelClassName).toBe('debug-timetravel-panel')
expect(firstCallProps.timeTravelButtonClassName).toBe('debug-timetravel-button')
expect(firstCallProps.timeTravelPanelSliderClassName).toBe('debug-timetravel-panel-slider')
expect(firstCallProps.timeTravelPanelButtonClassName).toBe('debug-timetravel-panel-button')
})
})

View File

@ -0,0 +1,212 @@
import type { LexicalEditor } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { act, render, waitFor } from '@testing-library/react'
import { $getRoot, COMMAND_PRIORITY_EDITOR } from 'lexical'
import { CustomTextNode } from './custom-text/node'
import { CaptureEditorPlugin } from './test-utils'
import UpdateBlock, {
PROMPT_EDITOR_INSERT_QUICKLY,
PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
} from './update-block'
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
const { mockUseEventEmitterContextContext } = vi.hoisted(() => ({
mockUseEventEmitterContextContext: vi.fn(),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
}))
type TestEvent = {
type: string
instanceId?: string
payload?: string
}
const readEditorText = (editor: LexicalEditor) => {
let content = ''
editor.getEditorState().read(() => {
content = $getRoot().getTextContent()
})
return content
}
const selectRootEnd = (editor: LexicalEditor) => {
act(() => {
editor.update(() => {
$getRoot().selectEnd()
})
})
}
const setup = (props?: {
instanceId?: string
withEventEmitter?: boolean
}) => {
const callbacks: Array<(event: TestEvent) => void> = []
const eventEmitter = props?.withEventEmitter === false
? null
: {
useSubscription: vi.fn((callback: (event: TestEvent) => void) => {
callbacks.push(callback)
}),
}
mockUseEventEmitterContextContext.mockReturnValue({ eventEmitter })
let editor: LexicalEditor | null = null
const onReady = (value: LexicalEditor) => {
editor = value
}
render(
<LexicalComposer
initialConfig={{
namespace: 'update-block-plugin-test',
onError: (error: Error) => {
throw error
},
nodes: [CustomTextNode],
}}
>
<UpdateBlock instanceId={props?.instanceId} />
<CaptureEditorPlugin onReady={onReady} />
</LexicalComposer>,
)
const emit = (event: TestEvent) => {
act(() => {
callbacks.forEach(callback => callback(event))
})
}
return {
callbacks,
emit,
eventEmitter,
getEditor: () => editor,
}
}
describe('UpdateBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Subscription setup', () => {
it('should register two subscriptions when event emitter is available', () => {
const { callbacks, eventEmitter } = setup({ instanceId: 'instance-1' })
expect(eventEmitter).not.toBeNull()
expect(eventEmitter?.useSubscription).toHaveBeenCalledTimes(2)
expect(callbacks).toHaveLength(2)
})
it('should render without subscriptions when event emitter is null', () => {
const { callbacks, eventEmitter } = setup({ withEventEmitter: false })
expect(eventEmitter).toBeNull()
expect(callbacks).toHaveLength(0)
})
})
describe('Update value event', () => {
it('should update editor state when update event matches instance id', async () => {
const { emit, getEditor } = setup({ instanceId: 'instance-1' })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
emit({
type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
instanceId: 'instance-1',
payload: 'updated text',
})
await waitFor(() => {
expect(readEditorText(editor!)).toBe('updated text')
})
})
it('should ignore update event when instance id does not match', async () => {
const { emit, getEditor } = setup({ instanceId: 'instance-1' })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
emit({
type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
instanceId: 'instance-2',
payload: 'should not apply',
})
await waitFor(() => {
expect(readEditorText(editor!)).toBe('')
})
})
})
describe('Quick insert event', () => {
it('should insert slash and dispatch clear command when quick insert event matches instance id', async () => {
const { emit, getEditor } = setup({ instanceId: 'instance-1' })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
selectRootEnd(editor!)
const clearCommandHandler = vi.fn(() => true)
const unregister = editor!.registerCommand(
CLEAR_HIDE_MENU_TIMEOUT,
clearCommandHandler,
COMMAND_PRIORITY_EDITOR,
)
emit({
type: PROMPT_EDITOR_INSERT_QUICKLY,
instanceId: 'instance-1',
})
await waitFor(() => {
expect(readEditorText(editor!)).toBe('/')
})
expect(clearCommandHandler).toHaveBeenCalledTimes(1)
unregister()
})
it('should ignore quick insert event when instance id does not match', async () => {
const { emit, getEditor } = setup({ instanceId: 'instance-1' })
await waitFor(() => {
expect(getEditor()).not.toBeNull()
})
const editor = getEditor()
expect(editor).not.toBeNull()
selectRootEnd(editor!)
emit({
type: PROMPT_EDITOR_INSERT_QUICKLY,
instanceId: 'instance-2',
})
await waitFor(() => {
expect(readEditorText(editor!)).toBe('')
})
})
})
})

View File

@ -0,0 +1,89 @@
import { act, waitFor } from '@testing-library/react'
import { CustomTextNode } from '../custom-text/node'
import {
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
waitForEditorReady,
} from '../test-helpers'
import VariableBlock, {
INSERT_VARIABLE_BLOCK_COMMAND,
INSERT_VARIABLE_VALUE_BLOCK_COMMAND,
} from './index'
const renderVariableBlock = () => {
return renderLexicalEditor({
namespace: 'variable-block-plugin-test',
nodes: [CustomTextNode],
children: (
<VariableBlock />
),
})
}
describe('VariableBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Command handling', () => {
it('should insert an opening brace when INSERT_VARIABLE_BLOCK_COMMAND is dispatched', async () => {
const { getEditor } = renderVariableBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_VARIABLE_BLOCK_COMMAND, undefined)
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe('{')
})
})
it('should insert provided value when INSERT_VARIABLE_VALUE_BLOCK_COMMAND is dispatched', async () => {
const { getEditor } = renderVariableBlock()
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
let handled = false
act(() => {
handled = editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, 'user.name')
})
expect(handled).toBe(true)
await waitFor(() => {
expect(readRootTextContent(editor)).toBe('user.name')
})
})
})
describe('Lifecycle cleanup', () => {
it('should unregister command handlers when the plugin unmounts', async () => {
const { getEditor, unmount } = renderVariableBlock()
const editor = await waitForEditorReady(getEditor)
unmount()
let variableHandled = true
let valueHandled = true
act(() => {
variableHandled = editor.dispatchCommand(INSERT_VARIABLE_BLOCK_COMMAND, undefined)
valueHandled = editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, 'ignored')
})
expect(variableHandled).toBe(false)
expect(valueHandled).toBe(false)
})
})
})