mirror of
https://github.com/langgenius/dify.git
synced 2026-03-11 03:29:44 +08:00
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:
parent
0ac09127c7
commit
b5f62b98f9
113
web/app/components/base/prompt-editor/constants.spec.tsx
Normal file
113
web/app/components/base/prompt-editor/constants.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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]')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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]')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
162
web/app/components/base/prompt-editor/plugins/test-helpers.ts
Normal file
162
web/app/components/base/prompt-editor/plugins/test-helpers.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
17
web/app/components/base/prompt-editor/plugins/test-utils.tsx
Normal file
17
web/app/components/base/prompt-editor/plugins/test-utils.tsx
Normal 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
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user