diff --git a/web/app/components/base/prompt-editor/constants.spec.tsx b/web/app/components/base/prompt-editor/constants.spec.tsx new file mode 100644 index 0000000000..862c386383 --- /dev/null +++ b/web/app/components/base/prompt-editor/constants.spec.tsx @@ -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') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/current-block/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/current-block/component.spec.tsx new file mode 100644 index 0000000000..e2669af862 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/current-block/component.spec.tsx @@ -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, 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( + { + throw error + }, + nodes: withNode ? [CustomTextNode, CurrentBlockNode] : [CustomTextNode], + }} + > +
+ +
+
, + ) +} + +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') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/current-block/current-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/current-block/current-block-replacement-block.spec.tsx new file mode 100644 index 0000000000..16b75834fe --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/current-block/current-block-replacement-block.spec.tsx @@ -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: ( + + ), + }) +} + +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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + , + ) + }).toThrow('CurrentBlockNodePlugin: CurrentBlockNode not registered on editor') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/current-block/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/current-block/index.spec.tsx new file mode 100644 index 0000000000..39085c5925 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/current-block/index.spec.tsx @@ -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: ( + + ), + }) +} + +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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + , + ) + }).toThrow('CURRENTBlockPlugin: CURRENTBlock not registered on editor') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/current-block/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/current-block/node.spec.tsx new file mode 100644 index 0000000000..26063fb8a7 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/current-block/node.spec.tsx @@ -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 + let importedSerialized!: ReturnType + + 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 + + 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) + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/history-block/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/history-block/component.spec.tsx new file mode 100644 index 0000000000..5ba2f92b0e --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/history-block/component.spec.tsx @@ -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 => ({ + user: 'user-role', + assistant: 'assistant-role', + ...overrides, +}) + +const createSelectHookReturn = (isSelected: boolean): [RefObject, boolean] => { + return [{ current: null }, isSelected] +} + +const createTriggerHookReturn = ( + open: boolean, + setOpen: Dispatch> = vi.fn() as unknown as Dispatch>, +): [RefObject, boolean, Dispatch>] => { + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(screen.getByText('common.promptEditor.history.item.title')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/history-block/history-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/history-block/history-block-replacement-block.spec.tsx new file mode 100644 index 0000000000..af74f39a1d --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/history-block/history-block-replacement-block.spec.tsx @@ -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 => ({ + 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: ( + + ), + }) +} + +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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + , + ) + }).toThrow('HistoryBlockNodePlugin: HistoryBlockNode not registered on editor') + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/history-block/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/history-block/index.spec.tsx new file mode 100644 index 0000000000..e41a8f7c63 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/history-block/index.spec.tsx @@ -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 => ({ + 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: ( + + ), + }) +} + +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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + , + ) + }).toThrow('HistoryBlockPlugin: HistoryBlock not registered on editor') + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/history-block/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/history-block/node.spec.tsx new file mode 100644 index 0000000000..b8603ef4fe --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/history-block/node.spec.tsx @@ -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) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.spec.tsx new file mode 100644 index 0000000000..eb76728939 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.spec.tsx @@ -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 ( +
+ + + +
+ ) + }, +})) + +const createHookReturn = (): [RefObject, boolean] => { + return [{ current: null }, false] +} + +const createInput = (overrides?: Partial): 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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') + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.spec.tsx new file mode 100644 index 0000000000..d01cab70c2 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.spec.tsx @@ -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: ( + + ), + }) +} + +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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + , + ) + }).toThrow('HITLInputNodePlugin: HITLInputNode not registered on editor') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.spec.tsx new file mode 100644 index 0000000000..dc94b0b319 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.spec.tsx @@ -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 + 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 && } + + + ), + }) +} + +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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + , + ) + }).toThrow('HITLInputBlockPlugin: HITLInputBlock not registered on editor') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.spec.tsx new file mode 100644 index 0000000000..b7518e8895 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.spec.tsx @@ -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 ( + + ) + }, +})) + +const createPayload = (overrides?: Partial): 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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: '', + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.spec.tsx new file mode 100644 index 0000000000..ef2a0e0c51 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.spec.tsx @@ -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) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.spec.tsx new file mode 100644 index 0000000000..be95aea062 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.spec.tsx @@ -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 ( + + ) + }, +})) + +describe('PrePopulate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should show placeholder initially and switch out of placeholder on Tab key', async () => { + const user = userEvent.setup() + render( + , + ) + + 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 ( + { + onValueChange(next) + setValue(next) + }} + onIsVariableChange={onIsVariableChange} + /> + ) + } + + render( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/tag-label.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/tag-label.spec.tsx new file mode 100644 index 0000000000..c39b66e545 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/tag-label.spec.tsx @@ -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( + + Edit + , + ) + + 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( + + Variable + , + ) + + expect(screen.getByText('Variable')).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.spec.tsx new file mode 100644 index 0000000000..b3d1376eb9 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.spec.tsx @@ -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( + , + ) + + 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( + , + ) + + const trigger = screen.getByText('workflow.nodes.humanInput.insertInputField.useConstantInstead') + await user.click(trigger) + + expect(onIsVariableChange).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.spec.tsx new file mode 100644 index 0000000000..727bc664d3 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.spec.tsx @@ -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( + { + throw error + }, + nodes: [HITLInputNode], + }} + > + + + , + ) + + 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( + { + throw error + }, + nodes: [], + }} + > + + , + ) + }).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'], + }) + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/component.spec.tsx new file mode 100644 index 0000000000..29da9e4e9c --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/last-run-block/component.spec.tsx @@ -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, 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( + { + throw error + }, + nodes: withNode ? [CustomTextNode, LastRunBlockNode] : [CustomTextNode], + }} + > +
+ +
+
, + ) +} + +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') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/index.spec.tsx new file mode 100644 index 0000000000..7a28bf847d --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/last-run-block/index.spec.tsx @@ -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: ( + + ), + }) +} + +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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + , + ) + }).toThrow('Last_RunBlockPlugin: Last_RunBlock not registered on editor') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/last-run-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/last-run-block-replacement-block.spec.tsx new file mode 100644 index 0000000000..ba144c9e5f --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/last-run-block/last-run-block-replacement-block.spec.tsx @@ -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: ( + + ), + }) +} + +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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + , + ) + }).toThrow('LastRunMessageBlockNodePlugin: LastRunMessageBlockNode not registered on editor') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/node.spec.tsx new file mode 100644 index 0000000000..dcc75b56c6 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/last-run-block/node.spec.tsx @@ -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) + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/on-blur-or-focus-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/on-blur-or-focus-block.spec.tsx new file mode 100644 index 0000000000..54acb0267a --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/on-blur-or-focus-block.spec.tsx @@ -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( + { + throw error + }, + }} + > + + + , + ) + + 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) + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/placeholder.spec.tsx b/web/app/components/base/prompt-editor/plugins/placeholder.spec.tsx new file mode 100644 index 0000000000..2386b355b0 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/placeholder.spec.tsx @@ -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() + + expect(screen.getByText('common.promptEditor.placeholder')).toBeInTheDocument() + }) + + it('should render provided value instead of translated default text', () => { + render(custom placeholder} />) + + 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() + 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() + 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() + const wrapper = container.firstElementChild + + expect(wrapper).toHaveClass('custom-class') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/query-block/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/query-block/component.spec.tsx new file mode 100644 index 0000000000..28f439cafe --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/query-block/component.spec.tsx @@ -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, 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() + + 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() + 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() + const wrapper = container.firstElementChild + + expect(wrapper).not.toHaveClass('!border-[#FD853A]') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/query-block/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/query-block/index.spec.tsx new file mode 100644 index 0000000000..08f6109b7c --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/query-block/index.spec.tsx @@ -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: ( + + ), + }) +} + +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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + , + ) + }).toThrow('QueryBlockPlugin: QueryBlock not registered on editor') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/query-block/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/query-block/node.spec.tsx new file mode 100644 index 0000000000..e91714e098 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/query-block/node.spec.tsx @@ -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) + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/query-block/query-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/query-block/query-block-replacement-block.spec.tsx new file mode 100644 index 0000000000..379128df2e --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/query-block/query-block-replacement-block.spec.tsx @@ -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: ( + + ), + }) +} + +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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + , + ) + }).toThrow('QueryBlockNodePlugin: QueryBlockNode not registered on editor') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/component.spec.tsx new file mode 100644 index 0000000000..f1b97d9417 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/request-url-block/component.spec.tsx @@ -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, 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() + + 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() + 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() + const wrapper = container.firstElementChild + + expect(wrapper).not.toHaveClass('!border-[#7839ee]') + expect(wrapper).not.toHaveClass('hover:!border-[#7839ee]') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/index.spec.tsx new file mode 100644 index 0000000000..431acdb0df --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/request-url-block/index.spec.tsx @@ -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: ( + + ), + }) +} + +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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + , + ) + }).toThrow('RequestURLBlockPlugin: RequestURLBlock not registered on editor') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/node.spec.tsx new file mode 100644 index 0000000000..aa8a661512 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/request-url-block/node.spec.tsx @@ -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) + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/request-url-block-replacement-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/request-url-block-replacement-block.spec.tsx new file mode 100644 index 0000000000..77c78d0e50 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/request-url-block/request-url-block-replacement-block.spec.tsx @@ -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: ( + + ), + }) +} + +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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + , + ) + }).toThrow('RequestURLBlockNodePlugin: RequestURLBlockNode not registered on editor') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/test-helpers.ts b/web/app/components/base/prompt-editor/plugins/test-helpers.ts new file mode 100644 index 0000000000..ef82f3dba3 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/test-helpers.ts @@ -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> + children: ReactNode +} + +type RenderLexicalEditorResult = ReturnType & { + 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 => { + 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 = (editor: LexicalEditor, nodeType: Klass): number => { + let count = 0 + + editor.getEditorState().read(() => { + count = $nodesOfType(nodeType).length + }) + + return count +} + +export const getNodesByType = (editor: LexicalEditor, nodeType: Klass): T[] => { + let nodes: T[] = [] + + editor.getEditorState().read(() => { + nodes = $nodesOfType(nodeType) + }) + + return nodes +} + +export const readEditorStateValue = (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>) => { + 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) + }) +} diff --git a/web/app/components/base/prompt-editor/plugins/test-utils.tsx b/web/app/components/base/prompt-editor/plugins/test-utils.tsx new file mode 100644 index 0000000000..9e04d9a8dc --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/test-utils.tsx @@ -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 +} diff --git a/web/app/components/base/prompt-editor/plugins/tree-view.spec.tsx b/web/app/components/base/prompt-editor/plugins/tree-view.spec.tsx new file mode 100644 index 0000000000..cc32ab6ea3 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/tree-view.spec.tsx @@ -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
+ }, +})) + +describe('TreeViewPlugin', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render lexical tree view with expected classes and current editor', async () => { + let editor: LexicalEditor | null = null + + render( + { + throw error + }, + }} + > + + { + editor = value + }} + /> + , + ) + + await waitFor(() => { + expect(editor).not.toBeNull() + }) + expect(screen.getByTestId('lexical-tree-view')).toBeInTheDocument() + + const firstCallProps = mockTreeView.mock.calls[0][0] as Record + + 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') + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/update-block.spec.tsx b/web/app/components/base/prompt-editor/plugins/update-block.spec.tsx new file mode 100644 index 0000000000..f5576c4109 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/update-block.spec.tsx @@ -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( + { + throw error + }, + nodes: [CustomTextNode], + }} + > + + + , + ) + + 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('') + }) + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/variable-block/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/variable-block/index.spec.tsx new file mode 100644 index 0000000000..f835ec07ef --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/variable-block/index.spec.tsx @@ -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: ( + + ), + }) +} + +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) + }) + }) +})