test: add unit tests for base components-part-4 (#32452)

Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
This commit is contained in:
Poojan
2026-02-25 15:06:58 +05:30
committed by GitHub
parent 3c69bac2b1
commit 0ac09127c7
30 changed files with 3811 additions and 30 deletions

View File

@ -0,0 +1,95 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { LexicalEditor } from 'lexical'
import type { ReactElement } from 'react'
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useSelectOrDelete } from '../../hooks'
import ErrorMessageBlockComponent from './component'
import { DELETE_ERROR_MESSAGE_COMMAND, ErrorMessageBlockNode } from './index'
vi.mock('../../hooks')
const mockHasNodes = vi.fn()
const mockEditor = {
hasNodes: mockHasNodes,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
const renderWithLexicalContext = (ui: ReactElement) => {
return render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
{ui}
</LexicalComposerContext.Provider>,
)
}
describe('ErrorMessageBlockComponent', () => {
const mockRef = { current: null as HTMLDivElement | null }
beforeEach(() => {
vi.clearAllMocks()
mockHasNodes.mockReturnValue(true)
vi.mocked(useSelectOrDelete).mockReturnValue([mockRef, false])
})
describe('Rendering', () => {
it('should render error_message text and base styles when unselected', () => {
const { container } = renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)
expect(screen.getByText('error_message')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
expect(container.firstChild).toHaveClass('border-components-panel-border-subtle')
})
it('should render selected styles when node is selected', () => {
vi.mocked(useSelectOrDelete).mockReturnValue([mockRef, true])
const { container } = renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)
expect(container.firstChild).toHaveClass('border-state-accent-solid')
expect(container.firstChild).toHaveClass('bg-state-accent-hover')
})
})
describe('Interactions', () => {
it('should stop propagation when wrapper is clicked', async () => {
const user = userEvent.setup()
const onParentClick = vi.fn()
render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
<div onClick={onParentClick}>
<ErrorMessageBlockComponent nodeKey="node-1" />
</div>
</LexicalComposerContext.Provider>,
)
await user.click(screen.getByText('error_message'))
expect(onParentClick).not.toHaveBeenCalled()
})
})
describe('Hooks', () => {
it('should use selection hook and check node registration on mount', () => {
renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-xyz" />)
expect(useSelectOrDelete).toHaveBeenCalledWith('node-xyz', DELETE_ERROR_MESSAGE_COMMAND)
expect(mockHasNodes).toHaveBeenCalledWith([ErrorMessageBlockNode])
})
it('should throw when ErrorMessageBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)).toThrow(
'WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor',
)
})
})
})

View File

@ -0,0 +1,125 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { EntityMatch } from '@lexical/text'
import type { LexicalEditor, LexicalNode } from 'lexical'
import type { ReactElement } from 'react'
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { render } from '@testing-library/react'
import { $applyNodeReplacement } from 'lexical'
import { ERROR_MESSAGE_PLACEHOLDER_TEXT } from '../../constants'
import { decoratorTransform } from '../../utils'
import { CustomTextNode } from '../custom-text/node'
import ErrorMessageBlockReplacementBlock from './error-message-block-replacement-block'
import { $createErrorMessageBlockNode, ErrorMessageBlockNode } from './node'
vi.mock('@lexical/utils')
vi.mock('lexical')
vi.mock('../../utils')
vi.mock('./node')
const mockHasNodes = vi.fn()
const mockRegisterNodeTransform = vi.fn()
const mockEditor = {
hasNodes: mockHasNodes,
registerNodeTransform: mockRegisterNodeTransform,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
const renderWithLexicalContext = (ui: ReactElement) => {
return render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
{ui}
</LexicalComposerContext.Provider>,
)
}
describe('ErrorMessageBlockReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHasNodes.mockReturnValue(true)
mockRegisterNodeTransform.mockReturnValue(vi.fn())
vi.mocked(mergeRegister).mockImplementation((...cleanups) => {
return () => cleanups.forEach(cleanup => cleanup())
})
vi.mocked($createErrorMessageBlockNode).mockReturnValue({ type: 'node' } as unknown as ErrorMessageBlockNode)
vi.mocked($applyNodeReplacement).mockImplementation((node: LexicalNode) => node)
})
it('should register transform and cleanup on unmount', () => {
const transformCleanup = vi.fn()
mockRegisterNodeTransform.mockReturnValue(transformCleanup)
const { unmount, container } = renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)
expect(container.firstChild).toBeNull()
expect(mockHasNodes).toHaveBeenCalledWith([ErrorMessageBlockNode])
expect(mockRegisterNodeTransform).toHaveBeenCalledWith(CustomTextNode, expect.any(Function))
unmount()
expect(transformCleanup).toHaveBeenCalled()
})
it('should throw when ErrorMessageBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)).toThrow(
'ErrorMessageBlockNodePlugin: ErrorMessageBlockNode not registered on editor',
)
})
it('should pass matcher and creator to decoratorTransform and match placeholder text', () => {
renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
const textNode = { id: 't-1' } as unknown as LexicalNode
transformCallback(textNode)
expect(decoratorTransform).toHaveBeenCalledWith(
textNode,
expect.any(Function),
expect.any(Function),
)
const getMatch = vi.mocked(decoratorTransform).mock.calls[0][1] as (text: string) => EntityMatch | null
const match = getMatch(`hello ${ERROR_MESSAGE_PLACEHOLDER_TEXT} world`)
expect(match).toEqual({
start: 6,
end: 6 + ERROR_MESSAGE_PLACEHOLDER_TEXT.length,
})
expect(getMatch('hello world')).toBeNull()
})
it('should create replacement node and call onInsert when creator runs', () => {
const onInsert = vi.fn()
renderWithLexicalContext(<ErrorMessageBlockReplacementBlock onInsert={onInsert} />)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
transformCallback({ id: 't-1' } as unknown as LexicalNode)
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as () => ErrorMessageBlockNode
const created = createNode()
expect(onInsert).toHaveBeenCalledTimes(1)
expect($createErrorMessageBlockNode).toHaveBeenCalledTimes(1)
expect($applyNodeReplacement).toHaveBeenCalledWith({ type: 'node' })
expect(created).toEqual({ type: 'node' })
})
it('should create replacement node without onInsert callback', () => {
renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
transformCallback({ id: 't-1' } as unknown as LexicalNode)
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as () => ErrorMessageBlockNode
expect(() => createNode()).not.toThrow()
expect($createErrorMessageBlockNode).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,143 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { LexicalEditor } from 'lexical'
import type { ReactElement } from 'react'
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { render } from '@testing-library/react'
import { $insertNodes, COMMAND_PRIORITY_EDITOR } from 'lexical'
import {
DELETE_ERROR_MESSAGE_COMMAND,
ErrorMessageBlock,
ErrorMessageBlockNode,
INSERT_ERROR_MESSAGE_BLOCK_COMMAND,
} from './index'
import { $createErrorMessageBlockNode } from './node'
vi.mock('@lexical/utils')
vi.mock('lexical', async () => {
const actual = await vi.importActual('lexical')
return {
...actual,
$insertNodes: vi.fn(),
createCommand: vi.fn(name => name),
COMMAND_PRIORITY_EDITOR: 1,
}
})
vi.mock('./node')
const mockHasNodes = vi.fn()
const mockRegisterCommand = vi.fn()
const mockEditor = {
hasNodes: mockHasNodes,
registerCommand: mockRegisterCommand,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
const renderWithLexicalContext = (ui: ReactElement) => {
return render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
{ui}
</LexicalComposerContext.Provider>,
)
}
describe('ErrorMessageBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHasNodes.mockReturnValue(true)
mockRegisterCommand.mockReturnValue(vi.fn())
vi.mocked(mergeRegister).mockImplementation((...cleanups) => {
return () => cleanups.forEach(cleanup => cleanup())
})
vi.mocked($createErrorMessageBlockNode).mockReturnValue({ id: 'node' } as unknown as ErrorMessageBlockNode)
})
it('should render null and register insert and delete commands', () => {
const { container } = renderWithLexicalContext(<ErrorMessageBlock />)
expect(container.firstChild).toBeNull()
expect(mockHasNodes).toHaveBeenCalledWith([ErrorMessageBlockNode])
expect(mockRegisterCommand).toHaveBeenCalledTimes(2)
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
1,
INSERT_ERROR_MESSAGE_BLOCK_COMMAND,
expect.any(Function),
COMMAND_PRIORITY_EDITOR,
)
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
2,
DELETE_ERROR_MESSAGE_COMMAND,
expect.any(Function),
COMMAND_PRIORITY_EDITOR,
)
expect(ErrorMessageBlock.displayName).toBe('ErrorMessageBlock')
})
it('should throw when ErrorMessageBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => renderWithLexicalContext(<ErrorMessageBlock />)).toThrow(
'ERROR_MESSAGEBlockPlugin: ERROR_MESSAGEBlock not registered on editor',
)
})
it('should insert created node and call onInsert when insert command handler runs', () => {
const onInsert = vi.fn()
renderWithLexicalContext(<ErrorMessageBlock onInsert={onInsert} />)
const insertHandler = mockRegisterCommand.mock.calls[0][1] as () => boolean
const result = insertHandler()
expect($createErrorMessageBlockNode).toHaveBeenCalledTimes(1)
expect($insertNodes).toHaveBeenCalledWith([{ id: 'node' }])
expect(onInsert).toHaveBeenCalledTimes(1)
expect(result).toBe(true)
})
it('should return true on insert command without onInsert callback', () => {
renderWithLexicalContext(<ErrorMessageBlock />)
const insertHandler = mockRegisterCommand.mock.calls[0][1] as () => boolean
expect(insertHandler()).toBe(true)
expect($insertNodes).toHaveBeenCalled()
})
it('should call onDelete and return true when delete command handler runs', () => {
const onDelete = vi.fn()
renderWithLexicalContext(<ErrorMessageBlock onDelete={onDelete} />)
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
const result = deleteHandler()
expect(onDelete).toHaveBeenCalledTimes(1)
expect(result).toBe(true)
})
it('should return true on delete command without onDelete callback', () => {
renderWithLexicalContext(<ErrorMessageBlock />)
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
expect(deleteHandler()).toBe(true)
})
it('should run merged cleanup on unmount', () => {
const insertCleanup = vi.fn()
const deleteCleanup = vi.fn()
mockRegisterCommand
.mockReturnValueOnce(insertCleanup)
.mockReturnValueOnce(deleteCleanup)
const { unmount } = renderWithLexicalContext(<ErrorMessageBlock />)
unmount()
expect(insertCleanup).toHaveBeenCalledTimes(1)
expect(deleteCleanup).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,86 @@
import type { Klass, LexicalEditor, LexicalNode } from 'lexical'
import { createEditor } from 'lexical'
import { $createErrorMessageBlockNode, $isErrorMessageBlockNode, ErrorMessageBlockNode } from './node'
describe('ErrorMessageBlockNode', () => {
let editor: LexicalEditor
beforeEach(() => {
vi.clearAllMocks()
editor = createEditor({
nodes: [ErrorMessageBlockNode as unknown as Klass<LexicalNode>],
})
})
const runInEditor = (callback: () => void) => {
editor.update(callback, { discrete: true })
}
it('should expose correct static type and clone behavior', () => {
runInEditor(() => {
const original = new ErrorMessageBlockNode('node-key')
const cloned = ErrorMessageBlockNode.clone(original)
expect(ErrorMessageBlockNode.getType()).toBe('error-message-block')
expect(cloned).toBeInstanceOf(ErrorMessageBlockNode)
expect(cloned).not.toBe(original)
expect(cloned.getKey()).toBe(original.getKey())
})
})
it('should be inline and provide expected text and json payload', () => {
runInEditor(() => {
const node = new ErrorMessageBlockNode()
expect(node.isInline()).toBe(true)
expect(node.getTextContent()).toBe('{{#error_message#}}')
expect(node.exportJSON()).toEqual({
type: 'error-message-block',
version: 1,
})
})
})
it('should create dom with expected classes and never update dom', () => {
runInEditor(() => {
const node = new ErrorMessageBlockNode()
const dom = node.createDOM()
expect(dom.tagName).toBe('DIV')
expect(dom).toHaveClass('inline-flex')
expect(dom).toHaveClass('items-center')
expect(dom).toHaveClass('align-middle')
expect(node.updateDOM()).toBe(false)
})
})
it('should decorate using ErrorMessageBlockComponent with node key', () => {
runInEditor(() => {
const node = new ErrorMessageBlockNode('decorator-key')
const decorated = node.decorate()
expect(decorated.props.nodeKey).toBe('decorator-key')
})
})
it('should create and import node instances via helper APIs', () => {
runInEditor(() => {
const created = $createErrorMessageBlockNode()
const imported = ErrorMessageBlockNode.importJSON()
expect(created).toBeInstanceOf(ErrorMessageBlockNode)
expect(imported).toBeInstanceOf(ErrorMessageBlockNode)
})
})
it('should return correct type guard values for lexical and non lexical inputs', () => {
runInEditor(() => {
const node = new ErrorMessageBlockNode()
expect($isErrorMessageBlockNode(node)).toBe(true)
expect($isErrorMessageBlockNode(null)).toBe(false)
expect($isErrorMessageBlockNode(undefined)).toBe(false)
expect($isErrorMessageBlockNode({} as ErrorMessageBlockNode)).toBe(false)
})
})
})

View File

@ -0,0 +1,87 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { EntityMatch } from '@lexical/text'
import type { LexicalEditor } from 'lexical'
import type { ReactElement } from 'react'
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { render } from '@testing-library/react'
import { useLexicalTextEntity } from '../../hooks'
import VariableValueBlock from './index'
import { $createVariableValueBlockNode, VariableValueBlockNode } from './node'
vi.mock('../../hooks')
vi.mock('./node')
const mockHasNodes = vi.fn()
const mockEditor = {
hasNodes: mockHasNodes,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
const renderWithLexicalContext = (ui: ReactElement) => {
return render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
{ui}
</LexicalComposerContext.Provider>,
)
}
describe('VariableValueBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHasNodes.mockReturnValue(true)
vi.mocked($createVariableValueBlockNode).mockImplementation(
text => ({ createdText: text } as unknown as VariableValueBlockNode),
)
})
it('should render null and register lexical text entity when node is registered', () => {
const { container } = renderWithLexicalContext(<VariableValueBlock />)
expect(container.firstChild).toBeNull()
expect(mockHasNodes).toHaveBeenCalledWith([VariableValueBlockNode])
expect(useLexicalTextEntity).toHaveBeenCalledWith(
expect.any(Function),
VariableValueBlockNode,
expect.any(Function),
)
})
it('should throw when VariableValueBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => renderWithLexicalContext(<VariableValueBlock />)).toThrow(
'VariableValueBlockPlugin: VariableValueNode not registered on editor',
)
})
it('should return match offsets when placeholder exists and null when not present', () => {
renderWithLexicalContext(<VariableValueBlock />)
const getMatch = vi.mocked(useLexicalTextEntity).mock.calls[0][0] as (text: string) => EntityMatch | null
const match = getMatch('prefix {{foo_1}} suffix')
expect(match).toEqual({ start: 7, end: 16 })
expect(getMatch('prefix without variable')).toBeNull()
})
it('should create variable node from text node content in create callback', () => {
renderWithLexicalContext(<VariableValueBlock />)
const createNode = vi.mocked(useLexicalTextEntity).mock.calls[0][2] as (
textNode: { getTextContent: () => string },
) => VariableValueBlockNode
const created = createNode({
getTextContent: () => '{{account_id}}',
})
expect($createVariableValueBlockNode).toHaveBeenCalledWith('{{account_id}}')
expect(created).toEqual({ createdText: '{{account_id}}' })
})
})

View File

@ -0,0 +1,92 @@
import type { EditorConfig, Klass, LexicalEditor, LexicalNode, SerializedTextNode } from 'lexical'
import { createEditor } from 'lexical'
import {
$createVariableValueBlockNode,
$isVariableValueNodeBlock,
VariableValueBlockNode,
} from './node'
describe('VariableValueBlockNode', () => {
let editor: LexicalEditor
let config: EditorConfig
beforeEach(() => {
vi.clearAllMocks()
editor = createEditor({
nodes: [VariableValueBlockNode as unknown as Klass<LexicalNode>],
})
config = editor._config
})
const runInEditor = (callback: () => void) => {
editor.update(callback, { discrete: true })
}
it('should expose static type and clone with same text/key', () => {
runInEditor(() => {
const original = new VariableValueBlockNode('value-text', 'node-key')
const cloned = VariableValueBlockNode.clone(original)
expect(VariableValueBlockNode.getType()).toBe('variable-value-block')
expect(cloned).toBeInstanceOf(VariableValueBlockNode)
expect(cloned).not.toBe(original)
expect(cloned.getKey()).toBe('node-key')
})
})
it('should add block classes in createDOM and disallow text insertion before', () => {
runInEditor(() => {
const node = new VariableValueBlockNode('hello')
const dom = node.createDOM(config)
expect(dom).toHaveClass('inline-flex')
expect(dom).toHaveClass('items-center')
expect(dom).toHaveClass('px-0.5')
expect(dom).toHaveClass('h-[22px]')
expect(dom).toHaveClass('text-text-accent')
expect(dom).toHaveClass('rounded-[5px]')
expect(dom).toHaveClass('align-middle')
expect(node.canInsertTextBefore()).toBe(false)
})
})
it('should import serialized node and preserve text metadata in export', () => {
runInEditor(() => {
const serialized = {
detail: 2,
format: 1,
mode: 'token',
style: 'color:red;',
text: '{{profile_name}}',
type: 'text',
version: 1,
} as SerializedTextNode
const imported = VariableValueBlockNode.importJSON(serialized)
const exported = imported.exportJSON()
expect(exported).toEqual({
detail: 2,
format: 1,
mode: 'token',
style: 'color:red;',
text: '{{profile_name}}',
type: 'variable-value-block',
version: 1,
})
})
})
it('should create node with helper and support type guard checks', () => {
runInEditor(() => {
const node = $createVariableValueBlockNode('{{org_id}}')
expect(node).toBeInstanceOf(VariableValueBlockNode)
expect(node.getTextContent()).toBe('{{org_id}}')
expect($isVariableValueNodeBlock(node)).toBe(true)
expect($isVariableValueNodeBlock(null)).toBe(false)
expect($isVariableValueNodeBlock(undefined)).toBe(false)
expect($isVariableValueNodeBlock({} as LexicalNode)).toBe(false)
})
})
})

View File

@ -0,0 +1,507 @@
import type { LexicalEditor } from 'lexical'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useReactFlow, useStoreApi } from 'reactflow'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { useSelectOrDelete } from '../../hooks'
import WorkflowVariableBlockComponent from './component'
import { UPDATE_WORKFLOW_NODES_MAP } from './index'
import { WorkflowVariableBlockNode } from './node'
const { mockVarLabel, mockIsExceptionVariable, mockForcedVariableKind } = vi.hoisted(() => ({
mockVarLabel: vi.fn(),
mockIsExceptionVariable: vi.fn<(variable: string, nodeType?: BlockEnum) => boolean>(() => false),
mockForcedVariableKind: { value: '' as '' | 'env' | 'conversation' | 'rag' },
}))
vi.mock('@lexical/react/LexicalComposerContext')
vi.mock('@lexical/utils')
vi.mock('reactflow')
vi.mock('../../hooks')
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
isExceptionVariable: mockIsExceptionVariable,
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/nodes/_base/components/variable/utils')>()
return {
...actual,
isENV: (valueSelector: ValueSelector) => {
if (mockForcedVariableKind.value === 'env')
return true
return actual.isENV(valueSelector)
},
isConversationVar: (valueSelector: ValueSelector) => {
if (mockForcedVariableKind.value === 'conversation')
return true
return actual.isConversationVar(valueSelector)
},
isRagVariableVar: (valueSelector: ValueSelector) => {
if (mockForcedVariableKind.value === 'rag')
return true
return actual.isRagVariableVar(valueSelector)
},
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInEditor: (props: {
onClick: (e: React.MouseEvent) => void
errorMsg?: string
nodeTitle?: string
nodeType?: BlockEnum
notShowFullPath?: boolean
}) => {
mockVarLabel(props)
return (
<button type="button" onClick={props.onClick}>
label
</button>
)
},
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel', () => ({
default: (props: {
nodeName: string
path: string[]
varType: Type
nodeType?: BlockEnum
}) => <div data-testid="var-full-path-panel">{props.nodeName}</div>,
}))
const mockRegisterCommand = vi.fn()
const mockHasNodes = vi.fn()
const mockSetViewport = vi.fn()
const mockGetState = vi.fn()
const mockEditor = {
registerCommand: mockRegisterCommand,
hasNodes: mockHasNodes,
} as unknown as LexicalEditor
describe('WorkflowVariableBlockComponent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockForcedVariableKind.value = ''
mockHasNodes.mockReturnValue(true)
mockRegisterCommand.mockReturnValue(vi.fn())
mockGetState.mockReturnValue({ transform: [0, 0, 2] })
vi.mocked(useLexicalComposerContext).mockReturnValue([
mockEditor,
{},
] as unknown as ReturnType<typeof useLexicalComposerContext>)
vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup()))
vi.mocked(useSelectOrDelete).mockReturnValue([{ current: null }, false])
vi.mocked(useReactFlow).mockReturnValue({
setViewport: mockSetViewport,
} as unknown as ReturnType<typeof useReactFlow>)
vi.mocked(useStoreApi).mockReturnValue({
getState: mockGetState,
} as unknown as ReturnType<typeof useStoreApi>)
})
it('should throw when WorkflowVariableBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'output']}
workflowNodesMap={{}}
/>,
)).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
})
it('should render variable label and register update command', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'output']}
workflowNodesMap={{}}
/>,
)
expect(screen.getByRole('button', { name: 'label' })).toBeInTheDocument()
expect(mockHasNodes).toHaveBeenCalledWith([WorkflowVariableBlockNode])
expect(mockRegisterCommand).toHaveBeenCalledWith(
UPDATE_WORKFLOW_NODES_MAP,
expect.any(Function),
expect.any(Number),
)
})
it('should call setViewport when label is clicked and node exists', async () => {
const user = userEvent.setup()
const workflowContainer = document.createElement('div')
workflowContainer.id = 'workflow-container'
Object.defineProperty(workflowContainer, 'clientWidth', { value: 1000, configurable: true })
Object.defineProperty(workflowContainer, 'clientHeight', { value: 800, configurable: true })
document.body.appendChild(workflowContainer)
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'group', 'field']}
workflowNodesMap={{
'node-1': {
title: 'Node A',
type: BlockEnum.LLM,
width: 200,
height: 100,
position: { x: 50, y: 80 },
},
}}
/>,
)
await user.click(screen.getByRole('button', { name: 'label' }))
expect(mockSetViewport).toHaveBeenCalledWith({
x: (1000 - 400 - 200 * 2) / 2 - 50 * 2,
y: (800 - 100 * 2) / 2 - 80 * 2,
zoom: 2,
})
})
it('should render safely when node exists and getVarType is not provided', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'group', 'field']}
workflowNodesMap={{
'node-1': {
title: 'Node A',
type: BlockEnum.LLM,
width: 200,
height: 100,
position: { x: 0, y: 0 },
},
}}
/>,
)
expect(screen.getByRole('button', { name: 'label' })).toBeInTheDocument()
})
it('should pass computed varType when getVarType is provided', () => {
const getVarType = vi.fn(() => Type.number)
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'group', 'field']}
workflowNodesMap={{
'node-1': {
title: 'Node A',
type: BlockEnum.LLM,
width: 200,
height: 100,
position: { x: 0, y: 0 },
},
}}
getVarType={getVarType}
/>,
)
expect(getVarType).toHaveBeenCalledWith({
nodeId: 'node-1',
valueSelector: ['node-1', 'group', 'field'] as ValueSelector,
})
})
it('should mark env variable invalid when not found in environmentVariables', () => {
const environmentVariables: Var[] = [{ variable: 'env.valid_key', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['env', 'missing_key']}
workflowNodesMap={{}}
environmentVariables={environmentVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: expect.any(String),
}))
})
it('should keep env variable valid when environmentVariables is omitted', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['env', 'missing_key']}
workflowNodesMap={{}}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should treat env variable as valid when it exists in environmentVariables', () => {
const environmentVariables: Var[] = [{ variable: 'env.valid_key', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['env', 'valid_key']}
workflowNodesMap={{}}
environmentVariables={environmentVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should handle env selector with missing segment when environmentVariables are provided', () => {
const environmentVariables: Var[] = [{ variable: 'env.', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['env']}
workflowNodesMap={{}}
environmentVariables={environmentVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should evaluate env fallback selector tokens when classifier is forced', () => {
mockForcedVariableKind.value = 'env'
const environmentVariables: Var[] = [{ variable: '.', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={[]}
workflowNodesMap={{}}
environmentVariables={environmentVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should treat conversation variable as valid when found in conversationVariables', () => {
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['conversation', 'topic']}
workflowNodesMap={{}}
conversationVariables={conversationVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should keep conversation variable valid when conversationVariables is omitted', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['conversation', 'topic']}
workflowNodesMap={{}}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should mark conversation variable invalid when not found in conversationVariables', () => {
const conversationVariables: Var[] = [{ variable: 'conversation.other', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['conversation', 'topic']}
workflowNodesMap={{}}
conversationVariables={conversationVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: expect.any(String),
}))
})
it('should handle conversation selector with missing segment when conversationVariables are provided', () => {
const conversationVariables: Var[] = [{ variable: 'conversation.', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['conversation']}
workflowNodesMap={{}}
conversationVariables={conversationVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should evaluate conversation fallback selector tokens when classifier is forced', () => {
mockForcedVariableKind.value = 'conversation'
const conversationVariables: Var[] = [{ variable: '.', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={[]}
workflowNodesMap={{}}
conversationVariables={conversationVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should treat global variable as valid without node', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['sys', 'user_id']}
workflowNodesMap={{}}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should use rag variable validation path', () => {
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['rag', 'shared', 'answer']}
workflowNodesMap={{ rag: { title: 'Rag', type: BlockEnum.Tool } as never }}
ragVariables={ragVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should keep rag variable valid when ragVariables is omitted', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['rag', 'shared', 'answer']}
workflowNodesMap={{ rag: { title: 'Rag', type: BlockEnum.Tool } as never }}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should mark rag variable invalid when not found in ragVariables', () => {
const ragVariables: Var[] = [{ variable: 'rag.shared.other', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['rag', 'shared', 'answer']}
workflowNodesMap={{ rag: { title: 'Rag', type: BlockEnum.Tool } as never }}
ragVariables={ragVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: expect.any(String),
}))
})
it('should handle rag selector with missing segment when ragVariables are provided', () => {
const ragVariables: Var[] = [{ variable: 'rag.shared.', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['rag', 'shared']}
workflowNodesMap={{ shared: { title: 'Rag', type: BlockEnum.Tool } as never }}
ragVariables={ragVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should evaluate rag fallback selector tokens when classifier is forced', () => {
mockForcedVariableKind.value = 'rag'
const ragVariables: Var[] = [{ variable: '..', type: VarType.string }]
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={[]}
workflowNodesMap={{}}
ragVariables={ragVariables}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
it('should apply workflow node map updates through command handler', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'field']}
workflowNodesMap={{}}
/>,
)
const updateHandler = mockRegisterCommand.mock.calls[0][1] as (map: Record<string, unknown>) => boolean
let result = false
act(() => {
result = updateHandler({
'node-1': {
title: 'Updated',
type: BlockEnum.LLM,
width: 100,
height: 50,
position: { x: 0, y: 0 },
},
})
})
expect(result).toBe(true)
})
})

View File

@ -0,0 +1,204 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { LexicalEditor } from 'lexical'
import type { ReactElement } from 'react'
import type { WorkflowNodesMap } from './node'
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { render } from '@testing-library/react'
import { $insertNodes, COMMAND_PRIORITY_EDITOR } from 'lexical'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum } from '@/app/components/workflow/types'
import {
CLEAR_HIDE_MENU_TIMEOUT,
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
UPDATE_WORKFLOW_NODES_MAP,
WorkflowVariableBlock,
WorkflowVariableBlockNode,
} from './index'
import { $createWorkflowVariableBlockNode } from './node'
vi.mock('@lexical/utils')
vi.mock('lexical', async () => {
const actual = await vi.importActual('lexical')
return {
...actual,
$insertNodes: vi.fn(),
createCommand: vi.fn(name => name),
COMMAND_PRIORITY_EDITOR: 1,
}
})
vi.mock('./node')
const mockHasNodes = vi.fn()
const mockRegisterCommand = vi.fn()
const mockDispatchCommand = vi.fn()
const mockUpdate = vi.fn((callback: () => void) => callback())
const mockEditor = {
hasNodes: mockHasNodes,
registerCommand: mockRegisterCommand,
dispatchCommand: mockDispatchCommand,
update: mockUpdate,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
const renderWithLexicalContext = (ui: ReactElement) => {
return render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
{ui}
</LexicalComposerContext.Provider>,
)
}
describe('WorkflowVariableBlock', () => {
const workflowNodesMap: WorkflowNodesMap = {
'node-1': {
title: 'Node A',
type: BlockEnum.LLM,
width: 200,
height: 100,
position: { x: 10, y: 20 },
},
}
beforeEach(() => {
vi.clearAllMocks()
mockHasNodes.mockReturnValue(true)
mockRegisterCommand.mockReturnValue(vi.fn())
vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup()))
vi.mocked($createWorkflowVariableBlockNode).mockReturnValue({ id: 'workflow-node' } as unknown as WorkflowVariableBlockNode)
})
it('should render null and register insert/delete commands', () => {
const { container } = renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
/>,
)
expect(container.firstChild).toBeNull()
expect(mockHasNodes).toHaveBeenCalledWith([WorkflowVariableBlockNode])
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
1,
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
expect.any(Function),
COMMAND_PRIORITY_EDITOR,
)
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
2,
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
expect.any(Function),
COMMAND_PRIORITY_EDITOR,
)
expect(WorkflowVariableBlock.displayName).toBe('WorkflowVariableBlock')
})
it('should dispatch workflow node map update on mount', () => {
renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
/>,
)
expect(mockUpdate).toHaveBeenCalled()
expect(mockDispatchCommand).toHaveBeenCalledWith(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
})
it('should throw when WorkflowVariableBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
/>,
)).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
})
it('should insert workflow variable block node and call onInsert', () => {
const onInsert = vi.fn()
const getVarType = vi.fn(() => Type.string)
renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
onInsert={onInsert}
getVarType={getVarType}
/>,
)
const insertHandler = mockRegisterCommand.mock.calls[0][1] as (variables: string[]) => boolean
const result = insertHandler(['node-1', 'answer'])
expect(mockDispatchCommand).toHaveBeenCalledWith(CLEAR_HIDE_MENU_TIMEOUT, undefined)
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
['node-1', 'answer'],
workflowNodesMap,
getVarType,
)
expect($insertNodes).toHaveBeenCalledWith([{ id: 'workflow-node' }])
expect(onInsert).toHaveBeenCalledTimes(1)
expect(result).toBe(true)
})
it('should return true on insert when onInsert is omitted', () => {
renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
/>,
)
const insertHandler = mockRegisterCommand.mock.calls[0][1] as (variables: string[]) => boolean
expect(insertHandler(['node-1', 'answer'])).toBe(true)
})
it('should call onDelete and return true when delete handler runs', () => {
const onDelete = vi.fn()
renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
onDelete={onDelete}
/>,
)
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
const result = deleteHandler()
expect(onDelete).toHaveBeenCalledTimes(1)
expect(result).toBe(true)
})
it('should return true on delete when onDelete is omitted', () => {
renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
/>,
)
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
expect(deleteHandler()).toBe(true)
})
it('should run merged cleanup on unmount', () => {
const insertCleanup = vi.fn()
const deleteCleanup = vi.fn()
mockRegisterCommand
.mockReturnValueOnce(insertCleanup)
.mockReturnValueOnce(deleteCleanup)
const { unmount } = renderWithLexicalContext(
<WorkflowVariableBlock
workflowNodesMap={workflowNodesMap}
/>,
)
unmount()
expect(insertCleanup).toHaveBeenCalledTimes(1)
expect(deleteCleanup).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,166 @@
import type { Klass, LexicalEditor, LexicalNode } from 'lexical'
import type { Var } from '@/app/components/workflow/types'
import { createEditor } from 'lexical'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import {
$createWorkflowVariableBlockNode,
$isWorkflowVariableBlockNode,
WorkflowVariableBlockNode,
} from './node'
describe('WorkflowVariableBlockNode', () => {
let editor: LexicalEditor
beforeEach(() => {
vi.clearAllMocks()
editor = createEditor({
nodes: [WorkflowVariableBlockNode as unknown as Klass<LexicalNode>],
})
})
const runInEditor = (callback: () => void) => {
editor.update(callback, { discrete: true })
}
it('should expose type and clone with same payload', () => {
runInEditor(() => {
const getVarType = vi.fn(() => Type.string)
const original = new WorkflowVariableBlockNode(
['node-1', 'answer'],
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
'node-key',
)
const cloned = WorkflowVariableBlockNode.clone(original)
expect(WorkflowVariableBlockNode.getType()).toBe('workflow-variable-block')
expect(cloned).toBeInstanceOf(WorkflowVariableBlockNode)
expect(cloned.getKey()).toBe(original.getKey())
})
})
it('should be inline and create expected dom classes', () => {
runInEditor(() => {
const node = new WorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined)
const dom = node.createDOM()
expect(node.isInline()).toBe(true)
expect(dom.tagName).toBe('DIV')
expect(dom).toHaveClass('inline-flex')
expect(dom).toHaveClass('items-center')
expect(dom).toHaveClass('align-middle')
expect(node.updateDOM()).toBe(false)
})
})
it('should decorate with component props from node state', () => {
runInEditor(() => {
const getVarType = vi.fn(() => Type.number)
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
const node = new WorkflowVariableBlockNode(
['node-1', 'answer'],
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
'decorator-key',
environmentVariables,
conversationVariables,
ragVariables,
)
const decorated = node.decorate()
expect(decorated.props.nodeKey).toBe('decorator-key')
expect(decorated.props.variables).toEqual(['node-1', 'answer'])
expect(decorated.props.workflowNodesMap).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } })
expect(decorated.props.environmentVariables).toEqual(environmentVariables)
expect(decorated.props.conversationVariables).toEqual(conversationVariables)
expect(decorated.props.ragVariables).toEqual(ragVariables)
})
})
it('should export and import json with full payload', () => {
runInEditor(() => {
const getVarType = vi.fn(() => Type.string)
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
const node = new WorkflowVariableBlockNode(
['node-1', 'answer'],
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
undefined,
environmentVariables,
conversationVariables,
ragVariables,
)
expect(node.exportJSON()).toEqual({
type: 'workflow-variable-block',
version: 1,
variables: ['node-1', 'answer'],
workflowNodesMap: { 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
})
const imported = WorkflowVariableBlockNode.importJSON({
type: 'workflow-variable-block',
version: 1,
variables: ['node-2', 'result'],
workflowNodesMap: { 'node-2': { title: 'B', type: BlockEnum.Tool } },
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
})
expect(imported).toBeInstanceOf(WorkflowVariableBlockNode)
expect(imported.getVariables()).toEqual(['node-2', 'result'])
expect(imported.getWorkflowNodesMap()).toEqual({ 'node-2': { title: 'B', type: BlockEnum.Tool } })
})
})
it('should return getters and text content in expected format', () => {
runInEditor(() => {
const getVarType = vi.fn(() => Type.string)
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
const node = new WorkflowVariableBlockNode(
['node-1', 'answer'],
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
undefined,
environmentVariables,
conversationVariables,
ragVariables,
)
expect(node.getVariables()).toEqual(['node-1', 'answer'])
expect(node.getWorkflowNodesMap()).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } })
expect(node.getVarType()).toBe(getVarType)
expect(node.getEnvironmentVariables()).toEqual(environmentVariables)
expect(node.getConversationVariables()).toEqual(conversationVariables)
expect(node.getRagVariables()).toEqual(ragVariables)
expect(node.getTextContent()).toBe('{{#node-1.answer#}}')
})
})
it('should create node helper and type guard checks', () => {
runInEditor(() => {
const node = $createWorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined)
expect(node).toBeInstanceOf(WorkflowVariableBlockNode)
expect($isWorkflowVariableBlockNode(node)).toBe(true)
expect($isWorkflowVariableBlockNode(null)).toBe(false)
expect($isWorkflowVariableBlockNode(undefined)).toBe(false)
expect($isWorkflowVariableBlockNode({} as LexicalNode)).toBe(false)
})
})
})

View File

@ -0,0 +1,221 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { EntityMatch } from '@lexical/text'
import type { LexicalEditor, LexicalNode } from 'lexical'
import type { ReactElement } from 'react'
import type { WorkflowNodesMap } from './node'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { render } from '@testing-library/react'
import { $applyNodeReplacement } from 'lexical'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { decoratorTransform } from '../../utils'
import { CustomTextNode } from '../custom-text/node'
import { WorkflowVariableBlockNode } from './index'
import { $createWorkflowVariableBlockNode } from './node'
import WorkflowVariableBlockReplacementBlock from './workflow-variable-block-replacement-block'
vi.mock('@lexical/utils')
vi.mock('lexical')
vi.mock('../../utils')
vi.mock('./node')
const mockHasNodes = vi.fn()
const mockRegisterNodeTransform = vi.fn()
const mockEditor = {
hasNodes: mockHasNodes,
registerNodeTransform: mockRegisterNodeTransform,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
const renderWithLexicalContext = (ui: ReactElement) => {
return render(
<LexicalComposerContext.Provider value={lexicalContextValue}>
{ui}
</LexicalComposerContext.Provider>,
)
}
describe('WorkflowVariableBlockReplacementBlock', () => {
const variables: NodeOutPutVar[] = [
{
nodeId: 'env',
title: 'ENV',
vars: [{ variable: 'env.key', type: VarType.string }],
},
{
nodeId: 'conversation',
title: 'Conversation',
vars: [{ variable: 'conversation.topic', type: VarType.string }],
},
{
nodeId: 'node-1',
title: 'Node A',
vars: [
{ variable: 'output', type: VarType.string },
{ variable: 'ragVarA', type: VarType.string, isRagVariable: true },
],
},
{
nodeId: 'rag',
title: 'RAG',
vars: [{ variable: 'rag.shared.answer', type: VarType.string, isRagVariable: true }],
},
]
const workflowNodesMap: WorkflowNodesMap = {
'node-1': {
title: 'Node A',
type: BlockEnum.LLM,
width: 200,
height: 100,
position: { x: 20, y: 40 },
},
}
beforeEach(() => {
vi.clearAllMocks()
mockHasNodes.mockReturnValue(true)
mockRegisterNodeTransform.mockReturnValue(vi.fn())
vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup()))
vi.mocked($createWorkflowVariableBlockNode).mockReturnValue({ type: 'workflow-node' } as unknown as WorkflowVariableBlockNode)
vi.mocked($applyNodeReplacement).mockImplementation((node: LexicalNode) => node)
})
it('should register transform and cleanup on unmount', () => {
const transformCleanup = vi.fn()
mockRegisterNodeTransform.mockReturnValue(transformCleanup)
const { unmount, container } = renderWithLexicalContext(
<WorkflowVariableBlockReplacementBlock
workflowNodesMap={workflowNodesMap}
/>,
)
expect(container.firstChild).toBeNull()
expect(mockHasNodes).toHaveBeenCalledWith([WorkflowVariableBlockNode])
expect(mockRegisterNodeTransform).toHaveBeenCalledWith(CustomTextNode, expect.any(Function))
unmount()
expect(transformCleanup).toHaveBeenCalledTimes(1)
})
it('should throw when WorkflowVariableBlockNode is not registered', () => {
mockHasNodes.mockReturnValue(false)
expect(() => renderWithLexicalContext(
<WorkflowVariableBlockReplacementBlock
workflowNodesMap={workflowNodesMap}
/>,
)).toThrow('WorkflowVariableBlockNodePlugin: WorkflowVariableBlockNode not registered on editor')
})
it('should pass matcher and creator to decoratorTransform', () => {
renderWithLexicalContext(
<WorkflowVariableBlockReplacementBlock
workflowNodesMap={workflowNodesMap}
/>,
)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
const textNode = { id: 'text-node' } as unknown as LexicalNode
transformCallback(textNode)
expect(decoratorTransform).toHaveBeenCalledWith(
textNode,
expect.any(Function),
expect.any(Function),
)
})
it('should match variable placeholders and return null for non-placeholder text', () => {
renderWithLexicalContext(
<WorkflowVariableBlockReplacementBlock
workflowNodesMap={workflowNodesMap}
/>,
)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
transformCallback({ id: 'text-node' } as unknown as LexicalNode)
const getMatch = vi.mocked(decoratorTransform).mock.calls[0][1] as (text: string) => EntityMatch | null
const match = getMatch('prefix {{#node-1.output#}} suffix')
expect(match).toEqual({
start: 7,
end: 26,
})
expect(getMatch('plain text only')).toBeNull()
})
it('should create replacement node with mapped env/conversation/rag vars and call onInsert', () => {
const onInsert = vi.fn()
const getVarType = vi.fn(() => Type.string)
renderWithLexicalContext(
<WorkflowVariableBlockReplacementBlock
workflowNodesMap={workflowNodesMap}
onInsert={onInsert}
getVarType={getVarType}
variables={variables}
/>,
)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
transformCallback({ id: 'text-node' } as unknown as LexicalNode)
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as (
textNode: { getTextContent: () => string },
) => WorkflowVariableBlockNode
const created = createNode({
getTextContent: () => '{{#node-1.output#}}',
})
expect(onInsert).toHaveBeenCalledTimes(1)
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
['node-1', 'output'],
workflowNodesMap,
getVarType,
variables[0].vars,
variables[1].vars,
[
{ variable: 'ragVarA', type: VarType.string, isRagVariable: true },
{ variable: 'rag.shared.answer', type: VarType.string, isRagVariable: true },
],
)
expect($applyNodeReplacement).toHaveBeenCalledWith({ type: 'workflow-node' })
expect(created).toEqual({ type: 'workflow-node' })
})
it('should create replacement node without optional callbacks and variable groups', () => {
renderWithLexicalContext(
<WorkflowVariableBlockReplacementBlock
workflowNodesMap={workflowNodesMap}
/>,
)
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
transformCallback({ id: 'text-node' } as unknown as LexicalNode)
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as (
textNode: { getTextContent: () => string },
) => WorkflowVariableBlockNode
expect(() => createNode({ getTextContent: () => '{{#node-1.output#}}' })).not.toThrow()
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
['node-1', 'output'],
workflowNodesMap,
undefined,
[],
[],
undefined,
)
})
})